Переглянути джерело

test(agent-loop): update lifecycle regression contracts

_Kerman 2 місяців тому
батько
коміт
7ba60845d6

+ 2 - 2
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()

+ 5 - 2
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')

+ 57 - 133
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<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' } }])
   })

+ 9 - 46
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<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

+ 6 - 8
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)
   })
 })