|
|
@@ -1,14 +1,22 @@
|
|
|
-/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
|
|
|
+/** Direct one-shot Agent driving, exact Session adoption, machine-readable projection, and exit mapping. */
|
|
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
+import { brandString } from '@deepseek-ai/dsh-brand'
|
|
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
|
|
-import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
|
|
+import type {
|
|
|
+ Agent,
|
|
|
+ AgentHandle,
|
|
|
+ AssistantStreamFrame,
|
|
|
+ CreateAgentOptions,
|
|
|
+ ResumeAgentOptions,
|
|
|
+} from '@deepseek-ai/dsh-agent'
|
|
|
import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
|
|
|
-import { LlmAttemptId, createAssistantMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
+import { LlmAttemptId, ToolCallId, createAssistantMessage, createToolResultMessage, type StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
|
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
|
|
-import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
|
|
+import type { Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
|
|
+import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
|
|
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
|
|
|
import { apply, Config, internals } from '../src/index.ts'
|
|
|
|
|
|
@@ -20,6 +28,22 @@ interface Script {
|
|
|
afterPrompt(session: Session, message: UserMessage, agent: Agent): Promise<void> | void
|
|
|
}
|
|
|
|
|
|
+/** Observation stub returned by the `--session-id` query path. */
|
|
|
+interface ObservationStub {
|
|
|
+ header: { cwd: string; origin?: string; parentSession?: string }
|
|
|
+ [Symbol.dispose](): void
|
|
|
+}
|
|
|
+
|
|
|
+/** Runner invocation options layered over the scripted Agent factory. */
|
|
|
+interface BenchOptions {
|
|
|
+ task?: string
|
|
|
+ useStdin?: boolean
|
|
|
+ readStdin?: () => Promise<string>
|
|
|
+ sessionId?: string
|
|
|
+ json?: boolean
|
|
|
+ observe?: () => Promise<ObservationStub>
|
|
|
+}
|
|
|
+
|
|
|
const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
|
|
|
|
|
|
function startFrames(agent: Agent, turn = 1, step = 1): void {
|
|
|
@@ -74,7 +98,7 @@ function appendTurn(
|
|
|
}
|
|
|
|
|
|
/** Mount the real registries around a small scripted Agent factory. */
|
|
|
-async function bench(script: Script): Promise<{
|
|
|
+async function bench(script: Script, options: BenchOptions = {}): Promise<{
|
|
|
ctx: Context
|
|
|
output(): { out: string; err: string; order: string[] }
|
|
|
run(): Promise<{ code: number; out: string; err: string; order: string[] }>
|
|
|
@@ -83,42 +107,60 @@ async function bench(script: Script): Promise<{
|
|
|
let out = ''
|
|
|
let err = ''
|
|
|
const order: string[] = []
|
|
|
+
|
|
|
+ const mount = async (
|
|
|
+ ownerCtx: Context,
|
|
|
+ session: Session,
|
|
|
+ createOptions: CreateAgentOptions | ResumeAgentOptions,
|
|
|
+ ): Promise<Agent> => {
|
|
|
+ const inbox = createInboxStub()
|
|
|
+ let idle = Promise.resolve()
|
|
|
+ const agent: Agent = {
|
|
|
+ id: session.id,
|
|
|
+ options: createOptions.agentOptions ?? {},
|
|
|
+ session,
|
|
|
+ inbox,
|
|
|
+ status: 'idle',
|
|
|
+ ctx: ownerCtx,
|
|
|
+ cancel: () => {},
|
|
|
+ runMaintenance: () => Promise.reject(new Error('not used')),
|
|
|
+ send: () => {},
|
|
|
+ followup: (message: UserMessage) => {
|
|
|
+ agent.inbox.append('next-turn', message)
|
|
|
+ idle = Promise.resolve().then(() => script.afterPrompt(session, message, agent))
|
|
|
+ },
|
|
|
+ steer: () => {},
|
|
|
+ inject: () => {},
|
|
|
+ whenIdle: () => idle,
|
|
|
+ }
|
|
|
+ await createOptions.setup?.(ownerCtx, agent)
|
|
|
+ ctx.agents.register(agent)
|
|
|
+ return agent
|
|
|
+ }
|
|
|
+
|
|
|
await ctx.plugin(SessionStore)
|
|
|
await ctx.plugin(SessionProjectionRegistry)
|
|
|
await ctx.plugin(AgentRegistry)
|
|
|
await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' })
|
|
|
ctx.agents.setFactory({
|
|
|
- async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
|
|
|
- const session = ctx.sessions.create(options.sessionId, {
|
|
|
- ...options.meta === undefined ? {} : { meta: options.meta },
|
|
|
+ async createAgent(ownerCtx: Context, createOptions: CreateAgentOptions): Promise<AgentHandle> {
|
|
|
+ const session = ctx.sessions.create(createOptions.sessionId, {
|
|
|
+ ...createOptions.meta === undefined ? {} : { meta: createOptions.meta },
|
|
|
})
|
|
|
- const inbox = createInboxStub()
|
|
|
- let idle = Promise.resolve()
|
|
|
- const agent: Agent = {
|
|
|
- id: session.id,
|
|
|
- options: options.agentOptions ?? {},
|
|
|
- session,
|
|
|
- inbox,
|
|
|
- status: 'idle',
|
|
|
- ctx: ownerCtx,
|
|
|
- cancel: () => {},
|
|
|
- runMaintenance: () => Promise.reject(new Error('not used')),
|
|
|
- send: () => {},
|
|
|
- followup: (message: UserMessage) => {
|
|
|
- agent.inbox.append('next-turn', message)
|
|
|
- idle = Promise.resolve().then(() => script.afterPrompt(session, message, agent))
|
|
|
- },
|
|
|
- steer: () => {},
|
|
|
- inject: () => {},
|
|
|
- whenIdle: () => idle,
|
|
|
- }
|
|
|
- await options.setup?.(ownerCtx, agent)
|
|
|
script.before?.(session)
|
|
|
- ctx.agents.register(agent)
|
|
|
+ const agent = await mount(ownerCtx, session, createOptions)
|
|
|
+ return { agent, dispose: () => Promise.resolve() }
|
|
|
+ },
|
|
|
+ async resume(ownerCtx: Context, resumeOptions: ResumeAgentOptions): Promise<AgentHandle> {
|
|
|
+ const session = ctx.sessions.get(resumeOptions.resumeSessionId)
|
|
|
+ if (session === undefined) throw new Error(`no attached Session ${resumeOptions.resumeSessionId}`)
|
|
|
+ const agent = await mount(ownerCtx, session, resumeOptions)
|
|
|
return { agent, dispose: () => Promise.resolve() }
|
|
|
},
|
|
|
- resume: () => Promise.reject(new Error('not used')),
|
|
|
})
|
|
|
+ if (options.observe !== undefined) {
|
|
|
+ ctx.provide('sessionQuery', { observeSession: () => options.observe!() } as never)
|
|
|
+ }
|
|
|
return {
|
|
|
ctx,
|
|
|
output: () => ({ out, err, order: [...order] }),
|
|
|
@@ -126,10 +168,15 @@ async function bench(script: Script): Promise<{
|
|
|
ctx.on('session/flush', () => { order.push('flush') })
|
|
|
internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
|
|
|
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
|
|
+ if (options.readStdin !== undefined) internals.readStdin = options.readStdin
|
|
|
const exited = new Promise<number>((resolve) => {
|
|
|
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
|
|
|
})
|
|
|
- apply(ctx, { task: 'do the thing' })
|
|
|
+ apply(ctx, {
|
|
|
+ ...options.useStdin === true ? {} : { task: options.task ?? 'do the thing' },
|
|
|
+ ...options.sessionId === undefined ? {} : { sessionId: options.sessionId },
|
|
|
+ ...options.json === undefined ? {} : { json: options.json },
|
|
|
+ })
|
|
|
return { code: await exited, out, err, order }
|
|
|
},
|
|
|
}
|
|
|
@@ -375,6 +422,174 @@ describe('headless runner', () => {
|
|
|
await test.ctx.fiber.dispose()
|
|
|
})
|
|
|
|
|
|
+ it('reads the task from stdin when the invocation omits one', async () => {
|
|
|
+ const test = await bench({
|
|
|
+ afterPrompt(session, message) { appendTurn(session, 1, message, 'stdin answer', true) },
|
|
|
+ }, {
|
|
|
+ useStdin: true,
|
|
|
+ readStdin: () => Promise.resolve('task from stdin'),
|
|
|
+ })
|
|
|
+ expect(await test.run()).toMatchObject({ code: 0, out: 'stdin answer\n', err: '' })
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects an empty stdin task', async () => {
|
|
|
+ const test = await bench({ afterPrompt: () => {} }, {
|
|
|
+ useStdin: true,
|
|
|
+ readStdin: () => Promise.resolve(' \n'),
|
|
|
+ })
|
|
|
+ expect(await test.run()).toMatchObject({
|
|
|
+ code: 1,
|
|
|
+ err: 'dsh: a task is required, for example: dsh --profile headless "run the tests"\n',
|
|
|
+ })
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('creates the exact requested Session when the query reports it missing', async () => {
|
|
|
+ const seen: string[] = []
|
|
|
+ const test = await bench({
|
|
|
+ afterPrompt(session, message) {
|
|
|
+ seen.push(session.id)
|
|
|
+ appendTurn(session, 1, message, 'created', true)
|
|
|
+ },
|
|
|
+ }, {
|
|
|
+ sessionId: 'session-exact',
|
|
|
+ observe: () => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')),
|
|
|
+ })
|
|
|
+ expect(await test.run()).toMatchObject({ code: 0, out: 'created\n', err: '' })
|
|
|
+ expect(seen).toEqual(['session-exact'])
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resumes the persisted Session when the query finds it', async () => {
|
|
|
+ const test = await bench({
|
|
|
+ afterPrompt(session, message) { appendTurn(session, 1, message, 'resumed answer', true) },
|
|
|
+ }, {
|
|
|
+ sessionId: 'session-exact',
|
|
|
+ observe: () => Promise.resolve({
|
|
|
+ header: { cwd: process.cwd(), origin: 'user' },
|
|
|
+ [Symbol.dispose]() {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ const session = test.ctx.sessions.create(brandString<SessionId>('session-exact'), { meta: { cwd: process.cwd() } })
|
|
|
+ const history = {
|
|
|
+ role: 'user', content: [{ type: 'text', text: 'earlier' }], source: { kind: 'user' }, id: 'history',
|
|
|
+ } as UserMessage
|
|
|
+ appendTurn(session, 0, history, 'earlier answer', true)
|
|
|
+ const before = session.seq
|
|
|
+ expect(await test.run()).toMatchObject({ code: 0, out: 'resumed answer\n', err: '' })
|
|
|
+ expect(session.seq).toBeGreaterThan(before)
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a persisted Session recorded in another working directory', async () => {
|
|
|
+ const test = await bench({ afterPrompt: () => {} }, {
|
|
|
+ sessionId: 'session-exact',
|
|
|
+ observe: () => Promise.resolve({
|
|
|
+ header: { cwd: '/somewhere/else', origin: 'user' },
|
|
|
+ [Symbol.dispose]() {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ const result = await test.run()
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ expect(result.err).toContain('was recorded in "/somewhere/else"')
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a persisted Session owned by a subagent', async () => {
|
|
|
+ const test = await bench({ afterPrompt: () => {} }, {
|
|
|
+ sessionId: 'session-exact',
|
|
|
+ observe: () => Promise.resolve({
|
|
|
+ header: { cwd: process.cwd(), origin: 'subagent' },
|
|
|
+ [Symbol.dispose]() {},
|
|
|
+ }),
|
|
|
+ })
|
|
|
+ const result = await test.run()
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ expect(result.err).toContain('belongs to a subagent')
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('requires the Session query service for an exact Session identity', async () => {
|
|
|
+ const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact' })
|
|
|
+ const result = await test.run()
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ expect(result.err).toContain('requires the sessionQuery service')
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('projects the run as ordered newline-delimited events in --json mode', async () => {
|
|
|
+ const test = await bench({
|
|
|
+ afterPrompt(session, message, agent) {
|
|
|
+ session.append('turn/start', { turn: 1 })
|
|
|
+ session.append('step/start', { turn: 1, step: 1 })
|
|
|
+ session.append('user/message', message, { surfaceOp: 'append' })
|
|
|
+ startFrames(agent)
|
|
|
+ emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'thinking hard' })
|
|
|
+ emitChunk(agent, { type: 'text-delta', index: 0, text: 'answer' })
|
|
|
+ session.append('assistant/message', {
|
|
|
+ stream: [],
|
|
|
+ turn: 1,
|
|
|
+ step: 1,
|
|
|
+ usage: { inputTokens: 3, outputTokens: 4 },
|
|
|
+ message: createAssistantMessage({
|
|
|
+ content: [
|
|
|
+ { type: 'text', text: 'answer' },
|
|
|
+ { type: 'tool-call', id: ToolCallId('call-1'), name: 'bash', arguments: '{"command":"ls"}' },
|
|
|
+ ],
|
|
|
+ source: { provider: 'test-provider', model: 'test-model' },
|
|
|
+ }),
|
|
|
+ }, { surfaceOp: 'append' })
|
|
|
+ session.append('tool/call', {
|
|
|
+ turn: 1, step: 1, callId: ToolCallId('call-1'), name: 'bash', arguments: '{"command":"ls"}',
|
|
|
+ })
|
|
|
+ session.append('tool/result', {
|
|
|
+ turn: 1,
|
|
|
+ step: 1,
|
|
|
+ message: createToolResultMessage({
|
|
|
+ callId: ToolCallId('call-1'),
|
|
|
+ content: [{ type: 'text', text: 'a.txt' }],
|
|
|
+ isError: false,
|
|
|
+ }),
|
|
|
+ }, { surfaceOp: 'append' })
|
|
|
+ session.append('step/end', { turn: 1, step: 1 })
|
|
|
+ session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
|
+ },
|
|
|
+ }, { json: true })
|
|
|
+ const result = await test.run()
|
|
|
+ const events = result.out.trim().split('\n').map(line => JSON.parse(line) as Record<string, unknown>)
|
|
|
+ expect(events.map(event => event.type)).toEqual([
|
|
|
+ 'session', 'status', 'status', 'thinking', 'text',
|
|
|
+ 'tool_call', 'tool_result', 'status', 'status', 'final',
|
|
|
+ ])
|
|
|
+ expect(events[0]).toMatchObject({ type: 'session', cwd: process.cwd() })
|
|
|
+ expect(typeof events[0]?.sessionId).toBe('string')
|
|
|
+ expect(events[1]).toMatchObject({ type: 'status', phase: 'turn_start', turn: 1 })
|
|
|
+ expect(events[3]).toMatchObject({ type: 'thinking', text: 'thinking hard' })
|
|
|
+ expect(events[4]).toMatchObject({ type: 'text', text: 'answer' })
|
|
|
+ expect(events[5]).toMatchObject({ type: 'tool_call', callId: 'call-1', tool: 'bash', input: { command: 'ls' } })
|
|
|
+ expect(events[6]).toMatchObject({ type: 'tool_result', callId: 'call-1', status: 'completed', result: 'a.txt' })
|
|
|
+ expect(events[7]).toMatchObject({ type: 'status', phase: 'step_end', usage: { inputTokens: 3, outputTokens: 4 } })
|
|
|
+ expect(events[8]).toMatchObject({ type: 'status', phase: 'turn_end', reason: { kind: 'completed' } })
|
|
|
+ expect(events[9]).toMatchObject({ type: 'final', text: 'answer' })
|
|
|
+ expect(result.err).toBe('')
|
|
|
+ expect(result.code).toBe(0)
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports a direct failure as an error event in --json mode', async () => {
|
|
|
+ const test = await bench({ afterPrompt: () => {} }, {
|
|
|
+ useStdin: true,
|
|
|
+ readStdin: () => Promise.resolve(''),
|
|
|
+ json: true,
|
|
|
+ })
|
|
|
+ const result = await test.run()
|
|
|
+ expect(result.code).toBe(1)
|
|
|
+ expect(JSON.parse(result.out.trim())).toMatchObject({ type: 'error' })
|
|
|
+ expect(result.err).toContain('a task is required')
|
|
|
+ await test.ctx.fiber.dispose()
|
|
|
+ })
|
|
|
+
|
|
|
it('reports a direct Agent creation failure', async () => {
|
|
|
const ctx = new Context()
|
|
|
let err = ''
|
|
|
@@ -442,8 +657,9 @@ describe('headless runner', () => {
|
|
|
expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
|
|
|
})
|
|
|
|
|
|
- it('validates config: the task is required', () => {
|
|
|
- expect(() => new Config({} as never)).toThrow()
|
|
|
- expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
|
|
|
+ it('validates config: the task and run options are optional', () => {
|
|
|
+ expect(new Config({})).toEqual({})
|
|
|
+ expect(new Config({ task: 'x', sessionId: 'session-x', json: true }))
|
|
|
+ .toEqual({ task: 'x', sessionId: 'session-x', json: true })
|
|
|
})
|
|
|
})
|