The web runtime creates its dev-mode client-hmr row in the root tree after Loader settlement with plain loader.create, deleting the vendored Entry.enableRuntime state machine and dsh-cmdline's enableRow export. Include declares the existing EntryGroup.key tree-carrier marker instead of the EntryConfigResolver protocol (its own path stays literal; nothing used a dynamic path). The launcher recognizes no app row: SIGTERM exits 0 on every surface, every boot watches its user patch layers, and the headless runner exits through ctx.appExit, deleting ctx.headlessIo. Also restores the vendor README rescope entry to the position the rescope-vendor exact-edit anchor requires, fixing the master hygiene regression.
253 lines
9.4 KiB
TypeScript
253 lines
9.4 KiB
TypeScript
/** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
|
|
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
|
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
|
|
import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
|
|
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
|
|
import SessionStore from '@deepseek-ai/dsh-session'
|
|
import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
|
|
import { apply, Config, internals } from '../src/index.ts'
|
|
|
|
const originalInternals = { ...internals }
|
|
afterEach(() => { Object.assign(internals, originalInternals) })
|
|
|
|
interface Script {
|
|
before?(session: Session): void
|
|
afterPrompt(session: Session, message: UserMessage): Promise<void> | void
|
|
}
|
|
|
|
function appendTurn(
|
|
session: Session,
|
|
turn: number,
|
|
message: UserMessage,
|
|
text: string | undefined,
|
|
completed: boolean,
|
|
): void {
|
|
session.append('turn/start', { turn })
|
|
session.append('step/start', { turn, step: 1 })
|
|
session.append('user/message', message, { surfaceOp: 'append' })
|
|
if (text !== undefined) {
|
|
session.append('assistant/message', {
|
|
turn,
|
|
step: 1,
|
|
message: createAssistantMessage({
|
|
content: [{ type: 'text', text }],
|
|
source: { provider: 'test-provider', model: 'test-model' },
|
|
}),
|
|
}, { surfaceOp: 'append' })
|
|
}
|
|
session.append('step/end', { turn, step: 1 })
|
|
session.append('turn/end', {
|
|
turn,
|
|
reason: completed
|
|
? { kind: 'completed' }
|
|
: { kind: 'aborted', reason: { kind: 'user' } },
|
|
})
|
|
}
|
|
|
|
/** Mount the real registries around a small scripted Agent factory. */
|
|
async function bench(script: Script): Promise<{
|
|
ctx: Context
|
|
run(): Promise<{ code: number; out: string; err: string; order: string[] }>
|
|
}> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(AgentRegistry)
|
|
await ctx.plugin(AgentDefaultModelService, { 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 },
|
|
})
|
|
let idle = Promise.resolve()
|
|
const agent = {} as Agent
|
|
const agentCtx = ownerCtx.extend({ agent })
|
|
Object.assign(agent, {
|
|
id: session.id,
|
|
options: options.agentOptions ?? {},
|
|
session,
|
|
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
|
|
status: 'idle',
|
|
ctx: agentCtx,
|
|
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))
|
|
},
|
|
steer: () => {},
|
|
inject: () => {},
|
|
whenIdle: () => idle,
|
|
} satisfies Partial<Agent>)
|
|
await options.setup?.(agentCtx)
|
|
script.before?.(session)
|
|
ctx.agents.register(agent)
|
|
return { agent, dispose: () => Promise.resolve() }
|
|
},
|
|
resume: () => Promise.reject(new Error('not used')),
|
|
})
|
|
return {
|
|
ctx,
|
|
run: async () => {
|
|
let out = ''
|
|
let err = ''
|
|
const order: string[] = []
|
|
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 } }
|
|
const exited = new Promise<number>((resolve) => {
|
|
ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
|
|
})
|
|
apply(ctx, { task: 'do the thing' })
|
|
return { code: await exited, out, err, order }
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('headless runner', () => {
|
|
it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => {
|
|
const test = await bench({
|
|
before(session) {
|
|
const setupMessage = {
|
|
role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
|
|
} as UserMessage
|
|
appendTurn(session, 0, setupMessage, 'pre-task noise', true)
|
|
},
|
|
async afterPrompt(session, message) {
|
|
await Promise.resolve()
|
|
appendTurn(session, 1, message, '', true)
|
|
appendTurn(session, 2, message, 'final answer', true)
|
|
},
|
|
})
|
|
const result = await test.run()
|
|
expect(result).toEqual({
|
|
code: 0,
|
|
out: 'final answer\n',
|
|
err: '',
|
|
order: ['flush', 'exit'],
|
|
})
|
|
await test.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('waits for asynchronously appended events instead of racing Agent idleness', async () => {
|
|
const test = await bench({
|
|
afterPrompt: async (session, message) => {
|
|
await new Promise(resolve => setTimeout(resolve, 5))
|
|
appendTurn(session, 1, message, 'race-free answer', true)
|
|
},
|
|
})
|
|
expect(await test.run()).toMatchObject({ code: 0, out: 'race-free answer\n', err: '' })
|
|
await test.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('exits 1 when the final turn does not complete', async () => {
|
|
const test = await bench({
|
|
afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
|
|
})
|
|
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
|
|
await test.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('prints the durable model failure when the final turn ends in error', async () => {
|
|
const test = await bench({
|
|
afterPrompt(session, message) {
|
|
session.append('turn/start', { turn: 1 })
|
|
session.append('step/start', { turn: 1, step: 1 })
|
|
session.append('user/message', message, { surfaceOp: 'append' })
|
|
session.append('step/end', { turn: 1, step: 1 })
|
|
session.append('turn/end', {
|
|
turn: 1,
|
|
reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
|
|
})
|
|
},
|
|
})
|
|
expect(await test.run()).toMatchObject({
|
|
code: 1,
|
|
out: '\n',
|
|
err: 'dsh: SERVER: provider unavailable\n',
|
|
})
|
|
await test.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('exits 1 when the owned interval contains no turn', async () => {
|
|
const test = await bench({ afterPrompt: () => {} })
|
|
expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
|
|
await test.ctx.fiber.dispose()
|
|
})
|
|
|
|
it('reports a direct Agent creation failure', async () => {
|
|
const ctx = new Context()
|
|
let err = ''
|
|
internals.stdout = { write: () => true }
|
|
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
|
const exited = new Promise<number>((resolve) => {
|
|
ctx.provide('appExit', resolve)
|
|
})
|
|
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
|
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
|
ctx.provide('agents', { create: () => Promise.reject(new Error('factory exploded')) } as never)
|
|
apply(ctx, { task: 't' })
|
|
expect(await exited).toBe(1)
|
|
expect(err).toBe('dsh: factory exploded\n')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('stringifies a non-Error Agent creation failure', async () => {
|
|
const ctx = new Context()
|
|
let err = ''
|
|
internals.stdout = { write: () => true }
|
|
internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
|
|
const exited = new Promise<number>((resolve) => {
|
|
ctx.provide('appExit', resolve)
|
|
})
|
|
ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
|
ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
|
|
const rejected = {
|
|
then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void {
|
|
reject('factory exploded')
|
|
},
|
|
}
|
|
ctx.provide('agents', { create: () => rejected } as never)
|
|
apply(ctx, { task: 't' })
|
|
expect(await exited).toBe(1)
|
|
expect(err).toBe('dsh: factory exploded\n')
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('abandons a run when the tree is disposed during Loader settlement', async () => {
|
|
const ctx = new Context()
|
|
let exited = false
|
|
internals.stdout = { write: () => true }
|
|
internals.stderr = { write: () => true }
|
|
ctx.provide('appExit', () => { exited = true })
|
|
const services = ctx.plugin((child: Context) => {
|
|
child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
|
|
child.provide('sessions', {} as never)
|
|
child.provide('agents', {} as never)
|
|
})
|
|
await services
|
|
let release: () => void
|
|
const settlement = new Promise<void>((resolve) => { release = resolve })
|
|
ctx.provide('loader', { await: () => settlement } as never)
|
|
apply(ctx, { task: 't' })
|
|
await services.dispose()
|
|
release!()
|
|
await new Promise(resolve => setTimeout(resolve, 10))
|
|
expect(exited).toBe(false)
|
|
await ctx.fiber.dispose()
|
|
})
|
|
|
|
it('fails loud without the launcher-provided exit request', () => {
|
|
const ctx = new Context()
|
|
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' })
|
|
})
|
|
})
|