From 7ba60845d6b1eaec90d3cc9e45a05c38b2410714 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 30 Jul 2026 18:05:23 +0800 Subject: [PATCH] test(agent-loop): update lifecycle regression contracts --- .../agent-loop/tests/agent-initiator.spec.ts | 4 +- .../tests/config-session-id.spec.ts | 7 +- .../tests/contract-regressions.spec.ts | 190 ++++++------------ .../agent-loop/tests/coverage-edges.spec.ts | 55 +---- .../core/agent-loop/tests/tool-order.spec.ts | 14 +- 5 files changed, 79 insertions(+), 191 deletions(-) diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index f3d784679f..3f5cdac7c1 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -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() diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 7640820e81..c8dc6eb70d 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -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') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 70682656d7..a19bbfa9de 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -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(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' } }]) }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 1ee67e70b8..5235a81145 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -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 } - 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 diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 6f6ce8c263..e8a13f6a80 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -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) }) })