| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- /** 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 from '@deepseek-ai/dsh-agent'
- import type { Agent, AgentHandle, AssistantStreamFrame, CreateAgentOptions } 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 SessionStore from '@deepseek-ai/dsh-session'
- import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
- import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
- import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
- 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, agent: Agent): Promise<void> | void
- }
- const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
- function startFrames(agent: Agent, turn = 1, step = 1): void {
- const state = { attemptId: LlmAttemptId(`${agent.id}:test`), revision: 1, index: 0 }
- frameStates.set(agent, state)
- agent.ctx.emit('agent/assistant-stream', {
- agent,
- frame: {
- type: 'start', attemptId: state.attemptId, revision: state.revision, turn, step,
- },
- })
- }
- function emitChunk(agent: Agent, chunk: StreamChunk): void {
- const state = frameStates.get(agent)
- if (state === undefined) throw new Error('test Assistant frames have not started')
- const frame: AssistantStreamFrame = {
- type: 'chunk', attemptId: state.attemptId, revision: ++state.revision,
- index: state.index++, time: Date.now(), chunk,
- }
- agent.ctx.emit('agent/assistant-stream', { agent, frame })
- }
- 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', {
- stream: [],
- 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
- output(): { out: string; err: string; order: string[] }
- run(): Promise<{ code: number; out: string; err: string; order: string[] }>
- }> {
- const ctx = new Context()
- let out = ''
- let err = ''
- const order: string[] = []
- 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 },
- })
- 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)
- return { agent, dispose: () => Promise.resolve() }
- },
- resume: () => Promise.reject(new Error('not used')),
- })
- return {
- ctx,
- output: () => ({ out, err, order: [...order] }),
- run: async () => {
- 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('ignores durable inbox events before the first owned turn', async () => {
- const test = await bench({
- afterPrompt(session, message) {
- session.append('agent/inbox/spliced', {
- target: 'next-turn',
- start: 0,
- inserted: [message],
- })
- appendTurn(session, 1, message, 'answer after inbox activity', true)
- },
- })
- expect(await test.run()).toMatchObject({
- code: 0,
- out: 'answer after inbox activity\n',
- err: '',
- })
- 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('streams reasoning before the Agent becomes idle and terminates its stderr line', async () => {
- const reasoningAppended = Promise.withResolvers<undefined>()
- const release = Promise.withResolvers<undefined>()
- const test = await bench({
- async 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: 'block-start', index: 0, blockType: 'reasoning' })
- emitChunk(agent, { type: 'reasoning-delta', index: 0, text: '' })
- emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'checking the workspace' })
- emitChunk(agent, { type: 'reasoning-delta', index: 0, text: ' safely\n' })
- emitChunk(agent, { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } })
- emitChunk(agent, { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } })
- emitChunk(agent, { type: 'block-start', index: 1, blockType: 'reasoning' })
- emitChunk(agent, { type: 'reasoning-delta', index: 1, text: 'second pass\n' })
- reasoningAppended.resolve(undefined)
- await release.promise
- emitChunk(agent, { type: 'block-start', index: 2, blockType: 'text' })
- emitChunk(agent, { type: 'text-delta', index: 2, text: 'done' })
- emitChunk(agent, { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } })
- session.append('assistant/message', {
- stream: [],
- turn: 1,
- step: 1,
- message: createAssistantMessage({
- content: [{ type: 'text', text: 'done' }],
- source: { provider: 'test-provider', model: 'test-model' },
- }),
- }, { surfaceOp: 'append' })
- session.append('step/end', { turn: 1, step: 1 })
- session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- },
- })
- const running = test.run()
- await reasoningAppended.promise
- const other = test.ctx.sessions.create()
- other.append('turn/start', { turn: 1 })
- other.append('step/start', { turn: 1, step: 1 })
- test.ctx.emit('agent/assistant-stream', {
- agent: { session: other } as Agent,
- frame: {
- type: 'chunk', attemptId: LlmAttemptId('other'), revision: 1,
- index: 0, time: Date.now(), chunk: { type: 'reasoning-delta', index: 0, text: 'other session' },
- },
- })
- const streamed = test.output()
- release.resolve(undefined)
- const result = await running
- expect(streamed).toEqual({
- out: '',
- err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
- order: [],
- })
- expect(result).toEqual({
- code: 0,
- out: 'done\n',
- err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
- order: ['flush', 'exit'],
- })
- await test.ctx.fiber.dispose()
- })
- it('closes an unterminated reasoning line as soon as the attempt ends', async () => {
- const reasoningAppended = Promise.withResolvers<undefined>()
- const releaseEnd = Promise.withResolvers<undefined>()
- const ended = Promise.withResolvers<undefined>()
- const finish = Promise.withResolvers<undefined>()
- const test = await bench({
- async 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: 'unfinished reasoning' })
- reasoningAppended.resolve(undefined)
- await releaseEnd.promise
- const state = frameStates.get(agent)
- if (state === undefined) throw new Error('test Assistant frames have not started')
- agent.ctx.emit('agent/assistant-stream', {
- agent,
- frame: {
- type: 'end', attemptId: state.attemptId, revision: ++state.revision,
- index: state.index, outcome: { kind: 'abandoned' },
- },
- })
- ended.resolve(undefined)
- await finish.promise
- session.append('step/end', { turn: 1, step: 1 })
- session.append('turn/end', {
- turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
- })
- },
- })
- const running = test.run()
- await reasoningAppended.promise
- expect(test.output().err).toBe('dsh: reasoning:\nunfinished reasoning')
- releaseEnd.resolve(undefined)
- await ended.promise
- expect(test.output().err).toBe('dsh: reasoning:\nunfinished reasoning\n')
- finish.resolve(undefined)
- await expect(running).resolves.toMatchObject({ code: 1 })
- 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('separates an unterminated reasoning prefix from the terminal model failure', 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: 'trying recovery' })
- 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: reasoning:\ntrying recovery\ndsh: 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('fails when an event below the captured Session length cannot be read', async () => {
- let capturedLength = 0
- const test = await bench({
- afterPrompt(session, message) {
- appendTurn(session, 1, message, 'unreachable', true)
- capturedLength = session.seq
- Object.defineProperty(session, 'eventAt', { value: () => undefined })
- },
- })
- const result = await test.run()
- expect(capturedLength).toBeGreaterThan(0)
- expect(result).toMatchObject({
- code: 1,
- out: '',
- err: `dsh: headless summary cannot read seq 0 below captured length ${String(capturedLength)}\n`,
- })
- 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' })
- })
- })
|