fix(test): address cli demo cleanup review feedback
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/examples/README.md
|
||||
README.md: 36d1c0d0ddb7a3840af10e6a69ea407d3471c661
|
||||
README.zh.md: 9886abe8eedc27a6c62728b93fd63748b2c7dea6
|
||||
README.md: 2d672dcc307bb280cf3803f29128eba4988a8da0
|
||||
README.zh.md: e827e7cff4ff9d6521e5889e48270e06641ef38c
|
||||
|
||||
@@ -10,7 +10,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
|
||||
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle |
|
||||
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime |
|
||||
|
||||
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run` rather than a package in this directory.
|
||||
`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation front door, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it.
|
||||
|
||||
These packages are not product API. Product seams and front doors remain in their owning groups; demo bundles select concrete compositions.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP 自动化应用组合包 |
|
||||
| [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 |
|
||||
|
||||
`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行归 `dsh run` 所有,而不再由本目录中的 package 提供。
|
||||
`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行归 `dsh run` 所有;本目录没有任何包提供该功能。
|
||||
|
||||
这些包不是产品 API。产品 seam 与前端入口仍位于各自的归属组;演示组合包只选择具体组合。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/support/loader-smoke/README.md
|
||||
README.md: 73610ce50ebac4c6fc7bb9135f7b41b347c60685
|
||||
README.zh.md: 9254cd1592d4bf3f119d350f04165228db87b1cf
|
||||
README.md: e5a33beb95f4e5940364cf309c8ea5fea60686f1
|
||||
README.zh.md: 1f3c6235175b3cfdcefec111eac39b2361b0e05b
|
||||
|
||||
@@ -6,15 +6,17 @@ Shared subprocess harness for tests that boot an app and `cordis.yml` through th
|
||||
|
||||
`runLoaderSmoke` accepts bin and config paths, optional complete bin arguments, environment overrides, stdin, pre-run setup, and pre-cleanup inspection. It owns the isolated cwd, DSH homes, diagnostics, deadline, termination, EOF, and cleanup; it returns both streams after a zero exit and rejects with both streams on failure.
|
||||
|
||||
`runFixtureTurn` drives one task through exactly one configured root agent, forwards canonical events after that task reaches the durable inbox, flushes the session, and returns the final assistant text plus accumulated usage. Example-local drivers retain configuration, rendering, and assertion ownership.
|
||||
|
||||
This is support-tier test infrastructure, not product API.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this test-only harness boots example processes and inspects their streams without changing an assembled model request.
|
||||
None, as the test harness submits only the consuming test's ordinary user task and delegates prompt and tool composition to the loaded tree.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
None beyond the loaded tree; the helper neither changes the request prefix nor retains state across runs.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -6,15 +6,17 @@
|
||||
|
||||
`runLoaderSmoke` 接受可执行文件路径和配置路径、可选的完整可执行文件参数、环境变量覆盖、标准输入、运行前准备和清理前检查。它负责隔离工作目录、DSH 主目录、诊断、截止时间、终止、EOF 和清理;进程以零状态退出后返回两个流,失败时则返回拒绝并附带两个流。
|
||||
|
||||
`runFixtureTurn` 通过恰好一个已配置的根 agent(智能体)驱动一项任务,在该任务进入持久收件箱后转发规范事件,刷写会话,并返回最终 assistant 文本和累计用量。示例本地 driver 继续负责配置、渲染和断言。
|
||||
|
||||
这是支持层测试基础设施,而非产品 API。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。该测试专用 harness 启动示例进程并检查它们的流,不会改变组装后的模型请求。
|
||||
无,因为测试 harness 仅提交调用方测试的普通用户任务,并将提示词和工具组装交由已加载的插件树负责。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
除已加载树本身的影响外,无其他影响;该 helper 既不更改请求前缀,也不跨运行保留状态。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-loader-smoke",
|
||||
"description": "Shared subprocess harness for keyless real-Loader example smoke tests",
|
||||
"description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -29,11 +29,17 @@
|
||||
"tsx": "^4.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
100
packages/support/loader-smoke/src/agent-turn.ts
Normal file
100
packages/support/loader-smoke/src/agent-turn.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Test-only direct-agent turn driver shared by assembled Loader fixtures.
|
||||
* @module @deepseek-ai/dsh-loader-smoke/agent-turn
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Result envelope consumed only by snapshot and composition tests. */
|
||||
export interface FixtureTurnResult {
|
||||
readonly type: 'result'
|
||||
readonly sessionId: string
|
||||
readonly output: string
|
||||
readonly usage?: TokenUsage
|
||||
}
|
||||
|
||||
/** Options for one fixture turn against exactly one configured root agent. */
|
||||
export interface FixtureTurnOptions {
|
||||
readonly task: string
|
||||
readonly onEvent?: (sessionId: string, event: SessionEvent) => void
|
||||
}
|
||||
|
||||
function addUsage(total: TokenUsage | undefined, step: TokenUsage): TokenUsage {
|
||||
const next: TokenUsage = {
|
||||
inputTokens: (total?.inputTokens ?? 0) + step.inputTokens,
|
||||
outputTokens: (total?.outputTokens ?? 0) + step.outputTokens,
|
||||
}
|
||||
for (const key of ['cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const) {
|
||||
if (total?.[key] !== undefined || step[key] !== undefined) next[key] = (total?.[key] ?? 0) + (step[key] ?? 0)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string | undefined {
|
||||
const blocks = event.data.message.content.filter(block => block.type === 'text')
|
||||
return blocks.length === 0 ? undefined : blocks.map(block => block.text).join('')
|
||||
}
|
||||
|
||||
function onlyRootAgent(ctx: Context): Agent {
|
||||
const agents = ctx.get('agents')?.roots() ?? []
|
||||
const [agent] = agents
|
||||
if (agent === undefined || agents.length !== 1) {
|
||||
throw new Error(`fixture turn requires exactly one top-level agent, found ${agents.length}`)
|
||||
}
|
||||
return agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive one task from its durable inbox receipt through whole-agent idle.
|
||||
* @param ctx - settled Loader context with exactly one configured root agent.
|
||||
* @param options - task and optional canonical-event observer.
|
||||
* @returns the final assistant text and accumulated model usage.
|
||||
*/
|
||||
export async function runFixtureTurn(ctx: Context, options: FixtureTurnOptions): Promise<FixtureTurnResult> {
|
||||
const agent = onlyRootAgent(ctx)
|
||||
await agent.whenIdle()
|
||||
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: options.task }],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
let received = false
|
||||
let output = ''
|
||||
const usageByStep = new Map<string, TokenUsage>()
|
||||
const disposeListener = ctx.on('session/event', (session, event) => {
|
||||
if (session !== agent.session) return
|
||||
if (!received) {
|
||||
if (event.type !== 'agent/inbox/spliced'
|
||||
|| !event.data.inserted.some(inserted => inserted.id === message.id)) return
|
||||
received = true
|
||||
}
|
||||
options.onEvent?.(session.id, event)
|
||||
if (event.type === 'assistant/chunk' && event.data.chunk.type === 'usage') {
|
||||
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.chunk.usage)
|
||||
}
|
||||
if (event.type === 'assistant/message') {
|
||||
output = assistantText(event) ?? output
|
||||
if (event.data.usage !== undefined) {
|
||||
usageByStep.set(`${event.data.turn}/${event.data.step}`, event.data.usage)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
agent.followup(message)
|
||||
await agent.whenIdle()
|
||||
} finally {
|
||||
disposeListener()
|
||||
}
|
||||
await ctx.sessions.flush(agent.session)
|
||||
const usage = [...usageByStep.values()].reduce<TokenUsage | undefined>(addUsage, undefined)
|
||||
return {
|
||||
type: 'result',
|
||||
sessionId: agent.session.id,
|
||||
output,
|
||||
...usage === undefined ? {} : { usage },
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { execa } from 'execa'
|
||||
|
||||
export {
|
||||
runFixtureTurn,
|
||||
type FixtureTurnOptions,
|
||||
type FixtureTurnResult,
|
||||
} from './agent-turn.ts'
|
||||
|
||||
const DEFAULT_PROCESS_TIMEOUT_MS = 30_000
|
||||
|
||||
/** Vitest deadline that leaves room for the subprocess-owned 30-second diagnostic timeout. */
|
||||
|
||||
160
packages/support/loader-smoke/tests/agent-turn.spec.ts
Normal file
160
packages/support/loader-smoke/tests/agent-turn.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runFixtureTurn } from '../src/agent-turn.ts'
|
||||
|
||||
type Listener = (session: unknown, event: SessionEvent) => void
|
||||
|
||||
const event = (value: object): SessionEvent => value as unknown as SessionEvent
|
||||
|
||||
function turnHarness(): {
|
||||
readonly ctx: Context
|
||||
readonly session: { readonly id: string }
|
||||
readonly foreignSession: object
|
||||
readonly emit: (session: unknown, value: object) => void
|
||||
readonly setFollowup: (callback: (message: { readonly id: unknown }) => void) => void
|
||||
readonly whenIdle: ReturnType<typeof vi.fn>
|
||||
readonly disposeListener: ReturnType<typeof vi.fn>
|
||||
readonly flush: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const session = { id: 'fixture-session' }
|
||||
const foreignSession = {}
|
||||
let listener: Listener | undefined
|
||||
let followup = (_message: { readonly id: unknown }): void => {}
|
||||
const whenIdle = vi.fn(async () => {})
|
||||
const disposeListener = vi.fn()
|
||||
const flush = vi.fn(async () => {})
|
||||
const agent = {
|
||||
session,
|
||||
whenIdle,
|
||||
followup: vi.fn((message: { readonly id: unknown }) => { followup(message) }),
|
||||
}
|
||||
const ctx = {
|
||||
get: (name: string) => name === 'agents' ? { roots: () => [agent] } : undefined,
|
||||
on: (_name: string, callback: Listener) => {
|
||||
listener = callback
|
||||
return disposeListener
|
||||
},
|
||||
sessions: { flush },
|
||||
} as unknown as Context
|
||||
return {
|
||||
ctx,
|
||||
session,
|
||||
foreignSession,
|
||||
emit: (target, value) => { listener?.(target, event(value)) },
|
||||
setFollowup: (callback) => { followup = callback },
|
||||
whenIdle,
|
||||
disposeListener,
|
||||
flush,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runFixtureTurn', () => {
|
||||
it.each([
|
||||
['no agent registry', undefined, 0],
|
||||
['multiple roots', { roots: () => [{}, {}] }, 2],
|
||||
])('rejects %s', async (_label, registry, count) => {
|
||||
const ctx = { get: () => registry } as unknown as Context
|
||||
await expect(runFixtureTurn(ctx, { task: 'ignored' }))
|
||||
.rejects.toThrow(`fixture turn requires exactly one top-level agent, found ${count}`)
|
||||
})
|
||||
|
||||
it('observes only the owned interval and returns its final text and deduplicated usage', async () => {
|
||||
const harness = turnHarness()
|
||||
const observed: SessionEvent[] = []
|
||||
harness.setFollowup((message) => {
|
||||
harness.emit(harness.foreignSession, {
|
||||
type: 'assistant/message', seq: 0, time: 0, data: { message: { content: [] } },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'step/start', seq: 0, time: 0, data: { turn: 1, step: 1 },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'agent/inbox/spliced', seq: 1, time: 1, data: { inserted: [{ id: 'other' }] },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'agent/inbox/spliced', seq: 2, time: 2, data: { inserted: [message] },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/chunk', seq: 3, time: 3,
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', text: 'partial' } },
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/chunk', seq: 4, time: 4,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 2, outputTokens: 3, reasoningTokens: 1 } },
|
||||
},
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/message', seq: 5, time: 5,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { content: [{ type: 'text', text: 'final answer' }] },
|
||||
usage: { inputTokens: 4, outputTokens: 5, cacheReadTokens: 6 },
|
||||
},
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/chunk', seq: 6, time: 6,
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 2,
|
||||
chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, cacheWriteTokens: 7, reasoningTokens: 2 } },
|
||||
},
|
||||
})
|
||||
harness.emit(harness.session, {
|
||||
type: 'assistant/message', seq: 7, time: 7,
|
||||
data: { turn: 1, step: 2, message: { content: [{ type: 'tool-call' }] } },
|
||||
})
|
||||
harness.emit(harness.foreignSession, {
|
||||
type: 'assistant/message', seq: 8, time: 8, data: { message: { content: [] } },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(runFixtureTurn(harness.ctx, {
|
||||
task: 'prove the fixture',
|
||||
onEvent: (_sessionId, current) => { observed.push(current) },
|
||||
})).resolves.toEqual({
|
||||
type: 'result',
|
||||
sessionId: 'fixture-session',
|
||||
output: 'final answer',
|
||||
usage: {
|
||||
inputTokens: 5,
|
||||
outputTokens: 7,
|
||||
cacheReadTokens: 6,
|
||||
cacheWriteTokens: 7,
|
||||
reasoningTokens: 2,
|
||||
},
|
||||
})
|
||||
expect(observed.map(current => current.seq)).toEqual([2, 3, 4, 5, 6, 7])
|
||||
expect(harness.whenIdle).toHaveBeenCalledTimes(2)
|
||||
expect(harness.flush).toHaveBeenCalledWith(harness.session)
|
||||
expect(harness.disposeListener).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('omits usage when the interval records none', async () => {
|
||||
const harness = turnHarness()
|
||||
harness.setFollowup((message) => {
|
||||
harness.emit(harness.session, {
|
||||
type: 'agent/inbox/spliced', seq: 0, time: 0, data: { inserted: [message] },
|
||||
})
|
||||
})
|
||||
|
||||
await expect(runFixtureTurn(harness.ctx, { task: 'no model step' })).resolves.toEqual({
|
||||
type: 'result',
|
||||
sessionId: 'fixture-session',
|
||||
output: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('always removes its listener when the turn fails', async () => {
|
||||
const harness = turnHarness()
|
||||
harness.whenIdle.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('turn failed'))
|
||||
|
||||
await expect(runFixtureTurn(harness.ctx, { task: 'fail' })).rejects.toThrow('turn failed')
|
||||
expect(harness.disposeListener).toHaveBeenCalledOnce()
|
||||
expect(harness.flush).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,15 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user