test(agent-loop): update lifecycle regression contracts

This commit is contained in:
_Kerman
2026-07-30 18:05:23 +08:00
parent 97111cb5b3
commit 7ba60845d6
5 changed files with 79 additions and 191 deletions

View File

@@ -198,7 +198,7 @@ describe('AgentLoop initiator scope', () => {
expect(firstSignal).toBeDefined()
expect(new Set([...signals, ...adapter.requests.slice(0, 2).map(request => request.signal!)])).toEqual(new Set([firstSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).not.toBe(firstSignal)
expect(admissionSignals[0]).toBe(firstSignal)
signals = []
admissionSignals = []
@@ -209,7 +209,7 @@ describe('AgentLoop initiator scope', () => {
expect(secondSignal).toBeDefined()
expect(new Set([...signals, adapter.requests[2]!.signal!])).toEqual(new Set([secondSignal]))
expect(admissionSignals).toHaveLength(1)
expect(admissionSignals[0]).not.toBe(secondSignal)
expect(admissionSignals[0]).toBe(secondSignal)
expect(secondSignal).not.toBe(firstSignal)
expect(ctx.agents.currentInitiator()).toBeUndefined()
await ctx.fiber.dispose()

View File

@@ -126,8 +126,9 @@ describe('config-driven session id', () => {
dirs.push(root)
const ctx = await makeCoreContext()
await ctx.plugin(SessionPersistenceJsonl, { root })
ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('saved')]))
const sessionId = SessionId('config-exact-overlap')
const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] }
const config = { agents: [{ id: 'main', sessionId, provider: 'mock', model: 'mock' }] }
const firstLoop = await ctx.plugin(AgentLoop, config)
await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined()
const first = ctx.agents.get(sessionId) as Agent
@@ -138,7 +139,9 @@ describe('config-driven session id', () => {
cleanupStarted.resolve(undefined)
await cleanupGate.promise
})
first.inject(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'plugin', plugin: 'test' } }))
const idle = waitForIdle(ctx, first)
first.followup(createUserMessage({ content: [{ type: 'text', text: 'persist before replacement' }], source: { kind: 'user' } }))
await idle
await ctx.sessions.flush(first.session)
expect(JSON.stringify((await ctx.sessionPersistence.inspect(sessionId)).events))
.toContain('persist before replacement')

View File

@@ -82,15 +82,15 @@ describe('addressable inbox operations', () => {
send(agent, 'remove me')
send(agent, 'edit me')
const pending = agent.inbox.nextTurn
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
expect(pending.map(inboxText)).toEqual(['first', 'remove me', 'edit me'])
const remove = pending[0]!
const edit = pending[1]!
expect(agent.inbox.splice('next-turn', 1, 1, [freezeMessage({
const remove = pending[1]!
const edit = pending[2]!
expect(agent.inbox.splice('next-turn', 2, 1, [freezeMessage({
...edit,
content: [{ type: 'text', text: 'edited' }],
})])).toEqual([edit])
expect(agent.inbox.splice('next-turn', 0, 1, [])).toEqual([remove])
expect(agent.inbox.splice('next-turn', 1, 1, [])).toEqual([remove])
const idle = waitForIdle(ctx, agent)
release.resolve(undefined)
@@ -128,7 +128,7 @@ describe('assistant replay provenance', () => {
})
describe('abort during tool execution ends the turn', () => {
it('records context accepted before a tool-step abort in the same turn', async () => {
it('records context finalized after a tool-step abort in the next turn', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'aborter', {})])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-abort-injection'), { provider: 'mock', model: 'mock' })
@@ -159,7 +159,7 @@ describe('abort during tool execution ends the turn', () => {
|| (event.type === 'user/message' && event.data.source.kind === 'plugin')
|| event.type === 'step/end' || event.type === 'turn/end')
.map(event => event.type))
.toEqual(['tool/result', 'user/message', 'step/end', 'turn/end'])
.toEqual(['tool/result', 'step/end', 'turn/end', 'user/message', 'step/end', 'turn/end'])
expect(events
.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'plugin'
? [event.data.content]
@@ -281,6 +281,7 @@ describe('abort during tool execution ends the turn', () => {
{ type: 'finish', reason: { kind: 'tool-calls' } },
] satisfies StreamChunk[],
textResponse('later turn'),
textResponse('context accepted'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-historical-tool-pair'), { provider: 'mock', model: 'mock' })
@@ -304,8 +305,9 @@ describe('abort during tool execution ends the turn', () => {
send(agent, 'leave an unmatched historical call')
await waitForIdle(ctx, agent)
ctx.on('agent/step', (subject, turn) => {
const disposeInjection = ctx.on('agent/step', (subject, turn) => {
if (subject === agent && turn === 2) {
disposeInjection()
agent.inject(createUserMessage({ content: [{ type: 'text', text: 'new turn context' }], source: { kind: 'plugin', plugin: 'test' } }))
}
})
@@ -317,7 +319,7 @@ describe('abort during tool execution ends the turn', () => {
? [event.data.content]
: [])[0])
.toEqual([{ type: 'text', text: 'new turn context' }])
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('new turn context')
expect(JSON.stringify(adapter.requests[2]?.messages)).toContain('new turn context')
})
})
@@ -346,64 +348,6 @@ describe('steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from a step/end session-event listener forces a SAME-TURN next step', async () => {
// Assert the same-turn shape; content alone cannot distinguish re-enqueue.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steeredOnce = false
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
steeredOnce = true
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'goal reminder from step/end' }], source: { kind: 'user' } }))
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const events = [...agent.session.events]
expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
// Same-turn steering precedes the second step.
const steeringIdx = events.findIndex(e => e.type === 'steering/message')
const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
expect(steeringIdx).toBeGreaterThanOrEqual(0)
expect(steeringIdx).toBeLessThan(step2Idx)
// and it reached the next model request.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
})
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const turns: number[] = []
let steeredOnce = false
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && !steeredOnce) {
steeredOnce = true
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } }))
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// the loop chains directly into turn 2 (status never returns to idle in
// between), so the first idle transition means both turns are complete
expect(turns).toEqual([1, 2])
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('too late for this turn')
})
})
describe('plugin exceptions are contained', () => {
@@ -420,14 +364,11 @@ describe('plugin exceptions are contained', () => {
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['broken continuation plugin'])
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: 'broken continuation plugin' } },
})
// the loop is still alive: a second send works normally
send(agent, 'second')
@@ -509,16 +450,15 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('has no provider/model')
expect(errors[0]!.message).toContain('agent/request')
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error
: undefined).toContain('has no provider/model')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error
: undefined).toContain('agent/request')
})
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
@@ -574,9 +514,8 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
['content', 'id', 'role', 'source'],
])
expect(targets).toEqual(['next-turn', 'next-step'])
// The drain appends the durable steering/message with the caller's source
// intact — the log, not a transient emit, is where consumers read it.
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.message.source] : [])
const steeringSources = agent.session.events.flatMap(e =>
e.type === 'user/message' && e.data.source.kind === 'plugin' ? [e.data.source] : [])
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
})
@@ -660,13 +599,11 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, failure }])
expect(reasons).toEqual([{ kind: 'error', error: failure }])
const events = [...agent.session.events]
// The durable failure lives on turn/end.reason (with the failing step), not
// a standalone error event.
const turnEnd = events.find(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, failure })
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', error: failure })
// A failed step must not synthesize an assistant message.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -685,7 +622,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'model stream aborted', code: 'ABORTED' } }])
expect(reasons).toEqual([{ kind: 'error', error: { message: 'model stream aborted', code: 'ABORTED' } }])
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -703,7 +640,7 @@ describe('a finish-error stream chunk ends the turn as error, not completed', ()
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', step: 1, failure: { message: 'codeless failure', code: 'UNKNOWN' } }])
expect(reasons).toEqual([{ kind: 'error', error: { message: 'codeless failure', code: 'UNKNOWN' } }])
})
})
@@ -793,8 +730,8 @@ describe('turn and step boundary recovery', () => {
expect(stepEndIdx).toBeLessThan(turnEndIdx)
})
it('a pre-commit turn/start rejection leaves no turn state for the next prompt', async () => {
const adapter = new MockAdapter([textResponse('after recovery')])
it('a pre-commit turn/start rejection leaves no durable turn state', async () => {
const adapter = new MockAdapter([])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-turnstart-veto'), { provider: 'mock', model: 'mock' })
let rejected = false
@@ -814,23 +751,10 @@ describe('turn and step boundary recovery', () => {
send(agent, 'rejected')
await waitForIdle(ctx, agent)
// The rejected turn left nothing behind: no events, no admitted prompt.
expect(agent.session.events).toEqual([])
expect(agent.session.events.some(event => event.type === 'turn/start'
|| event.type === 'user/message')).toBe(false)
expect(errors.map(error => error.message)).toEqual(['reject turn-start before commit'])
// The next prompt reuses the never-committed turn number and carries only
// its own admitted content — invariants (mounted) accept the log.
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(boundaryCounts(agent)).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1 })
const turnStart = agent.session.events.find(event => event.type === 'turn/start')
expect(turnStart?.type === 'turn/start' && turnStart.data.turn).toBe(1)
const prompts = agent.session.events.filter(event => event.type === 'user/message')
expect(prompts.map(event => event.type === 'user/message' && event.data.content)).toEqual([
[{ type: 'text', text: 'go' }],
])
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(0)
})
it('a pre-commit step/start validation failure does not invent a step boundary', async () => {
@@ -846,11 +770,6 @@ describe('turn and step boundary recovery', () => {
throw new Error('reject step-start before commit')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -862,10 +781,12 @@ describe('turn and step boundary recovery', () => {
stepEnd: 0,
errors: 1,
})
expect(errors.map(error => error.message)).toEqual(['reject step-start before commit'])
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
data: { reason: { kind: 'error', error: 'reject step-start before commit' } },
})
})
it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => {
it('a step/end validation failure surfaces the resulting open-step invariant', async () => {
const adapter = new MockAdapter([textResponse('completed before close validation')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { provider: 'mock', model: 'mock' })
@@ -887,13 +808,15 @@ describe('turn and step boundary recovery', () => {
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(errors.map(error => error.message)).toEqual(['reject first step-end'])
expect(errors.map(error => error.message)).toEqual([
'invariant violated by "@deepseek-ai/dsh-session": turn/end 1 while step 1 is still open',
])
expect(boundaryCounts(agent)).toMatchObject({
turnStart: 1,
turnEnd: 1,
turnEnd: 0,
stepStart: 1,
stepEnd: 1,
errors: 1,
stepEnd: 0,
errors: 0,
})
})
@@ -917,8 +840,7 @@ describe('turn and step boundary recovery', () => {
expect(c.stepStart).toBe(c.stepEnd)
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({
kind: 'error',
step: 1,
failure: { message: 'provider 500', code: 'SERVER' },
error: { message: 'provider 500', code: 'SERVER' },
})
// loop survives: a second turn runs to completion (invariants oracle would
@@ -990,9 +912,7 @@ describe('turn and step boundary recovery', () => {
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
// No step opened (the throw was before step/start) and disposal is not a
// failure, so no agent/error for the contained throw.
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'step/start')).toBe(true)
expect(errorEmits).toHaveLength(0)
})
@@ -1092,7 +1012,7 @@ describe('turn and step boundary recovery', () => {
expect(e.some(x => x.type === 'step/end')).toBe(true)
expect(e.some(x => x.type === 'turn/end')).toBe(true)
expect(e.at(-1)?.type).toBe('turn/end')
expect(errors.map(error => error.message)).toEqual(['provider 500'])
expect(errors).toEqual([])
// loop survives.
send(agent, 'again')
@@ -1176,7 +1096,7 @@ describe('tool result call identity', () => {
})
describe('disposal and cancellation during pre-step assembly', () => {
it('disposal during system-prompt assembly drops the about-to-start step as disposed', { timeout: 30000 }, async () => {
it('disposal during system-prompt assembly closes the started step as disposed', { timeout: 30000 }, async () => {
// Start disposal, then release assembly. Do not await disposal first: it
// waits for the blocked driver to exit.
const adapter = new MockAdapter(['hang'])
@@ -1225,11 +1145,12 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
})
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
it('cancel during system-prompt assembly closes the started step as aborted', { timeout: 30000 }, async () => {
const adapter = new MockAdapter([textResponse('should not appear')])
let releaseAssemble!: () => void
const blocker = new Promise<void>(r => void (releaseAssemble = r))
@@ -1272,7 +1193,8 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
@@ -1316,15 +1238,16 @@ describe('disposal and cancellation during pre-step assembly', () => {
await disposalDone
await driverDone(agent)
// After the agent/step listeners finish, the post-listener cancel/dispose check
// catches disposal. The step was never opened, no LLM call was made.
// After the agent/step listeners finish, the post-listener cancel/dispose
// check catches disposal before any LLM call.
const e = [...agent.session.events]
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
// Disposal wins the post-listener check — reason is `disposed`.
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'disposed' } })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// The durable turn/end record is the authoritative turn-boundary signal
// (turn boundaries have no agent/* mirror).
@@ -1372,7 +1295,8 @@ describe('disposal and cancellation during pre-step assembly', () => {
expect(e.filter(x => x.type === 'turn/end')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted', reason: { kind: 'user' } })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.filter(x => x.type === 'step/start')).toHaveLength(1)
expect(e.filter(x => x.type === 'step/end')).toHaveLength(1)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }])
})

View File

@@ -127,19 +127,14 @@ describe('thrown-value propagation', () => {
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]).toBe('naked string error')
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests).toHaveLength(0)
const starts = agent.session.events.filter(event => event.type === 'turn/start')
const ends = agent.session.events.filter(event => event.type === 'turn/end')
const messages = agent.session.events.filter(event => event.type === 'user/message')
expect(starts).toHaveLength(1)
// The rejected turn/start committed nothing, so the survivor reuses turn 1
// and the rejected prompt does not leak into it.
expect(starts[0]?.type === 'turn/start' && starts[0].data.turn).toBe(1)
expect(ends).toHaveLength(1)
expect(messages).toHaveLength(1)
expect(messages[0]?.type === 'user/message' && messages[0].data.content).toEqual([
{ type: 'text', text: 'survives as the next item' },
])
expect(starts).toHaveLength(0)
expect(ends).toHaveLength(0)
expect(messages).toHaveLength(0)
expect(agent.inbox.nextTurn).toHaveLength(1)
})
it('preserves non-Error throws from the agent/request waterfall', async () => {
@@ -156,22 +151,17 @@ describe('thrown-value propagation', () => {
return next()
})
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errors[0]).toEqual({ code: 500 })
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
? turnEnd.data.reason.error
: undefined).toEqual({ code: 500 })
: undefined).toBe('[object Object]')
})
})
describe('coded error data emission', () => {
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
describe('durable error rendering', () => {
it('renders a coded error thrown from a plugin', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
@@ -185,19 +175,13 @@ describe('coded error data emission', () => {
return next()
})
const errors: unknown[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(errors).toHaveLength(1)
expect(errorChain(errors[0])).toBe('server overloaded')
// turn-end error reason includes the code
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect(turnEnd.data.reason.error).toMatchObject({ code: 'RATE_LIMIT' })
expect(turnEnd.data.reason.error).toBe('server overloaded')
}
})
})
@@ -483,27 +467,6 @@ describe('unrenderable failure settlement', () => {
})
describe('driver bookkeeping edges', () => {
it('a deferred wake settles when replacement activity rejects', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('rejected-deferred-wake'), {
provider: 'mock',
model: 'mock',
})
ctx.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'agent/inbox/spliced'
|| event.data.target !== 'next-turn' || event.data.inserted.length === 0) return
agent.cancel({ kind: 'user' })
const mutable = agent as Agent & { done: Promise<void> }
mutable.done = Promise.reject(new Error('replacement rejected'))
})
send(agent, 'cancel before wake')
await expect(agent.whenIdle()).resolves.toBeUndefined()
expect(agent.session.events).toEqual([])
})
it('a request failure that concludes recovery after step/end closed keeps the boundary balanced', async () => {
const { LlmError } = await import('@deepseek-ai/dsh-llm')
// The failure finish-chunk path returns request-failed AFTER step() has

View File

@@ -98,19 +98,17 @@ describe('loop-level canonical tool order', () => {
const adapter = new MockAdapter([textResponse('never sent')])
const ctx = await harness(adapter, ['ghost', TOOL_ORDER_REST])
registerNamed(ctx, 'alpha')
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => {
if (error instanceof Error) errors.push(error)
})
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(errors.map(e => e.message)).toEqual(['toolOrder lists unregistered tool "ghost"; known tools: alpha'])
expect(foldRequestHeader(agent.session.events)).toBeUndefined()
const end = agent.session.events.find(e => e.type === 'turn/end')
expect(end?.type === 'turn/end' && end.data.reason).toMatchObject({ kind: 'error', step: 1 })
// The turn is balanced (turn/start → turn/end) with no step events inside.
expect(agent.session.events.some(e => e.type === 'step/start')).toBe(false)
expect(end?.type === 'turn/end' && end.data.reason).toEqual({
kind: 'error',
error: 'toolOrder lists unregistered tool "ghost"; known tools: alpha',
})
expect(agent.session.events.filter(e => e.type === 'step/start')).toHaveLength(1)
expect(agent.session.events.filter(e => e.type === 'step/end')).toHaveLength(1)
})
})