Jelajahi Sumber

fix(session): type turn/end error as one structured failure

TurnEndReasonMap.error now carries a single `error: LlmFailure` field:
an LlmError keeps its structured facts, any other error flattens to
errorChain text under the UNKNOWN code. Consumers read message/code
directly instead of defending against an unknown union — this also fixes
errorChain() rendering structured failures as '[object Object]' in the
TUI and ACP error paths. Document the turn-stopping contract: a
concludesTurn result never short-circuits already-submitted next-step
work (same-step additionalContexts or racing steering still runs), data
decides.
_Kerman 2 bulan lalu
induk
melakukan
a597763393
30 mengubah file dengan 81 tambahan dan 54 penghapusan
  1. 4 2
      docs/core-data-structures/session.md
  2. 4 2
      docs/core-data-structures/session.zh.md
  3. 1 1
      examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl
  4. 1 1
      packages/acp/acp/src/index.ts
  5. 1 1
      packages/acp/acp/tests/codec.spec.ts
  6. 1 3
      packages/client/runtime/src/client/sessions/session.ts
  7. 1 1
      packages/client/runtime/tests/request-inspection.spec.ts
  8. 5 4
      packages/client/runtime/tests/session.spec.ts
  9. 15 4
      packages/core/agent-loop/src/agent.ts
  10. 4 4
      packages/core/agent-loop/tests/contract-regressions.spec.ts
  11. 2 2
      packages/core/agent-loop/tests/coverage-edges.spec.ts
  12. 1 1
      packages/core/agent-loop/tests/loop.spec.ts
  13. 4 5
      packages/core/agent-loop/tests/request-reconstruction.spec.ts
  14. 1 1
      packages/core/agent-loop/tests/tool-calls.spec.ts
  15. 4 1
      packages/core/agent/src/types.ts
  16. 5 2
      packages/core/session/src/types.ts
  17. 1 1
      packages/core/session/tests/fork.spec.ts
  18. 1 1
      packages/core/session/tests/invariant.spec.ts
  19. 5 1
      packages/llm/llm-retry/tests/retry.spec.ts
  20. 6 2
      packages/llm/llm-retry/tests/transport-recovery.spec.ts
  21. 1 1
      packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
  22. 1 1
      packages/session-query/session-query/src/extraction.ts
  23. 2 2
      packages/session-query/session-query/tests/search-helpers.spec.ts
  24. 1 1
      packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts
  25. 1 1
      packages/support/llm-replay/tests/llm-replay.spec.ts
  26. 1 1
      packages/telemetry/session-telemetry-otel/tests/otel.spec.ts
  27. 1 1
      packages/telemetry/session-telemetry/tests/telemetry.spec.ts
  28. 1 1
      packages/ui/tui/src/index.ts
  29. 2 2
      packages/ui/tui/tests/tui.snapshot.ts
  30. 3 3
      packages/ui/tui/tests/tui.spec.ts

+ 4 - 2
docs/core-data-structures/session.md

@@ -522,9 +522,11 @@ interface TurnEndReasonMap {
 
   blocked: { kind: 'blocked' }
   /**
-   * The turn failed.
+   * The turn failed. `error` is always a structured failure: the `LlmError`
+   * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
+   * flattened from any other error.
    */
-  error: { kind: 'error'; error: unknown }
+  error: { kind: 'error'; error: LlmFailure }
   /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
   'max-tokens': { kind: 'max-tokens' }
   /**

+ 4 - 2
docs/core-data-structures/session.zh.md

@@ -526,9 +526,11 @@ interface TurnEndReasonMap {
 
   blocked: { kind: 'blocked' }
   /**
-   * The turn failed.
+   * The turn failed. `error` is always a structured failure: the `LlmError`
+   * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
+   * flattened from any other error.
    */
-  error: { kind: 'error'; error: unknown }
+  error: { kind: 'error'; error: LlmFailure }
   /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
   'max-tokens': { kind: 'max-tokens' }
   /**

+ 1 - 1
examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl

@@ -37,4 +37,4 @@
 {"type":"step/start","seq":35,"time":1785731000386,"data":{"turn":3,"step":1}}
 {"type":"user/message","seq":36,"time":1785731000386,"data":{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"fda43b7e-8d31-449a-a0ef-d7b17e945f86"},"surfaceOp":"append"}
 {"type":"step/end","seq":37,"time":1785731000391,"data":{"turn":3,"step":1}}
-{"type":"turn/end","seq":38,"time":1785731000391,"data":{"turn":3,"step":1,"reason":{"kind":"error","error":"snapshot disk full"}}}
+{"type":"turn/end","seq":38,"time":1785731000391,"data":{"turn":3,"step":1,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}}

+ 1 - 1
packages/acp/acp/src/index.ts

@@ -146,7 +146,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
     inflight: NonNullable<SessionRecord['inflight']>,
     reason: Extract<TurnEndReason, { kind: 'error' }>,
   ): void => {
-    inflight.reject(internalError(`turn failed: ${errorChain(reason.error)}`))
+    inflight.reject(internalError(`turn failed: ${reason.error.message}`))
   }
 
   // Emit only committed assistant text. Raw chunks, reasoning, tools, plans,

+ 1 - 1
packages/acp/acp/tests/codec.spec.ts

@@ -9,7 +9,7 @@ describe('ACP codec', () => {
     [{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
     [{ kind: 'interrupted' }, 'cancelled'],
     [{ kind: 'blocked' }, 'end_turn'],
-    [{ kind: 'error', error: 'failed' }, 'end_turn'],
+    [{ kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } }, 'end_turn'],
   ] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
     expect(turnEndToStopReason(reason)).toBe(expected)
   })

+ 1 - 3
packages/client/runtime/src/client/sessions/session.ts

@@ -829,8 +829,6 @@ export class Session implements SessionFace {
           && !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
         ) {
           const failure = event.data.reason.error
-          const code = failure !== null && typeof failure === 'object' && 'code' in failure
-            && typeof failure.code === 'string' ? failure.code : undefined
           this.derivedNodes.push({
             kind: 'turn-error',
             seq: event.seq,
@@ -838,7 +836,7 @@ export class Session implements SessionFace {
             turn: event.data.turn,
             step: event.data.step,
             message: displayFailureMessage(failure),
-            ...(code === undefined ? {} : { code }),
+            code: failure.code,
           })
           this.derivedRev++
         }

+ 1 - 1
packages/client/runtime/tests/request-inspection.spec.ts

@@ -233,7 +233,7 @@ describe('inspectRequests', () => {
       }),
       at(2, 'step/start', { turn: 2, step: 1 }),
       at(3, 'turn/end', {
-        turn: 2, step: 1, reason: { kind: 'error', error: 'plugin exploded' },
+        turn: 2, step: 1, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } },
       }),
     ]))
 

+ 5 - 4
packages/client/runtime/tests/session.spec.ts

@@ -234,7 +234,7 @@ describe('live event path', () => {
       ev.stepStart(12, 2, 1),
       at(13, {
         type: 'turn/end',
-        data: { turn: 2, step: 1, reason: { kind: 'error', error: 'plugin exploded' } },
+        data: { turn: 2, step: 1, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } } },
       }),
     ]
     for (const event of failedTurns) feed(event)
@@ -242,9 +242,10 @@ describe('live event path', () => {
     const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
     expect(errors).toMatchObject([
       { seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
-      { seq: 13, turn: 2, step: 1, message: 'plugin exploded' },
+      // Every failed turn carries a structured failure; unstructured errors
+      // flatten to the UNKNOWN code.
+      { seq: 13, turn: 2, step: 1, code: 'UNKNOWN', message: 'plugin exploded' },
     ])
-    expect('code' in errors[1]!).toBe(false)
 
     const replay = makeSession()
     replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
@@ -410,7 +411,7 @@ describe('live event path', () => {
     feed(ev.retry(7, 1))
     feed(at(8, {
       type: 'turn/end',
-      data: { turn: 1, step: 0, reason: { kind: 'error', error: 'retry failed' } },
+      data: { turn: 1, step: 0, reason: { kind: 'error', error: { message: 'retry failed', code: 'UNKNOWN' } } },
     }))
 
     expect(session.getSnapshot().nodes.at(-1)).toMatchObject({

+ 15 - 4
packages/core/agent-loop/src/agent.ts

@@ -60,6 +60,20 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
 }
 
 /** Drives one session through turn and step boundaries. */
+/**
+ * Shape a caught turn error into its durable reason: every failure is a
+ * structured LlmFailure — an `LlmError` keeps its facts, anything else
+ * flattens to `errorChain` text under the `UNKNOWN` code.
+ */
+function turnErrorReason(error: unknown): Extract<TurnEndReason, { kind: 'error' }> {
+  return {
+    kind: 'error',
+    error: error instanceof LlmError
+      ? error.failure
+      : { message: errorChain(error), code: 'UNKNOWN' },
+  }
+}
+
 export class ReactLoopAgent implements Agent {
   readonly inbox: Inbox
   private phase: Phase
@@ -279,10 +293,7 @@ export class ReactLoopAgent implements Agent {
         turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
         throw error
       }
-      turnEnds = {
-        kind: 'error',
-        error: error instanceof LlmError ? error.failure : errorChain(error),
-      }
+      turnEnds = turnErrorReason(error)
       this.throwError(error)
     } finally {
       try {

+ 4 - 4
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -339,7 +339,7 @@ describe('plugin exceptions are contained', () => {
     send(agent, 'first')
     await waitForIdle(ctx, agent)
     expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
-      data: { step: 1, reason: { kind: 'error', error: 'broken continuation plugin' } },
+      data: { step: 1, reason: { kind: 'error', error: { message: 'broken continuation plugin', code: 'UNKNOWN' } } },
     })
 
     // the loop is still alive: a second send works normally
@@ -426,10 +426,10 @@ describe('adapter registration, routing, and accepted-input ownership', () => {
     await waitForIdle(ctx, agent)
     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
+      ? turnEnd.data.reason.error.message
       : undefined).toContain('has no provider/model')
     expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
-      ? turnEnd.data.reason.error
+      ? turnEnd.data.reason.error.message
       : undefined).toContain('agent/request')
   })
 
@@ -762,7 +762,7 @@ describe('turn and step boundary recovery', () => {
       errors: 1,
     })
     expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
-      data: { step: 0, reason: { kind: 'error', error: 'reject step-start before commit' } },
+      data: { step: 0, reason: { kind: 'error', error: { message: 'reject step-start before commit', code: 'UNKNOWN' } } },
     })
   })
 

+ 2 - 2
packages/core/agent-loop/tests/coverage-edges.spec.ts

@@ -155,7 +155,7 @@ describe('thrown-value propagation', () => {
     await waitForIdle(ctx, agent)
     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
+      ? turnEnd.data.reason.error.message
       : undefined).toBe('[object Object]')
   })
 })
@@ -464,7 +464,7 @@ describe('unrenderable failure settlement', () => {
     if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
       // The durable failure keeps the adapter facts' message, not the
       // unrenderable chain.
-      expect(errorChain(end.data.reason.error)).not.toBe('<unrenderable value>')
+      expect(errorChain(end.data.reason.error.message)).not.toBe('<unrenderable value>')
     }
   })
 })

+ 1 - 1
packages/core/agent-loop/tests/loop.spec.ts

@@ -235,7 +235,7 @@ describe('agent loop', () => {
     const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
     expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
     expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
-      ? turnEnd.data.reason.error
+      ? turnEnd.data.reason.error.message
       : '').toContain('no value for this assembly')
 
     // The loop survived: a waterfall listener rescues {{cwd}} and the SAME

+ 4 - 5
packages/core/agent-loop/tests/request-reconstruction.spec.ts

@@ -361,10 +361,9 @@ describe('request stability across the loop', () => {
       expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
         data: {
           step: 1,
-          reason: {
-            kind: 'error',
-            error: failure instanceof LlmError ? failure.failure : failure.message,
-          },
+          reason: failure instanceof LlmError
+            ? { kind: 'error', error: failure.failure }
+            : { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } },
         },
       })
       expect(adapter.requests).toHaveLength(0)
@@ -505,7 +504,7 @@ describe('request stability across the loop', () => {
     const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
     expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } })
     if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new Error()
-    expect(turnEnd.data.reason.error).toMatch(/not extensible|frozen|read only|readonly/i)
+    expect(turnEnd.data.reason.error.message).toMatch(/not extensible|frozen|read only|readonly/i)
   })
 
   it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => {

+ 1 - 1
packages/core/agent-loop/tests/tool-calls.spec.ts

@@ -683,7 +683,7 @@ describe('tool-call scheduler: failure quiescence', () => {
     expect(turnEndBeforeDrain).toBeUndefined()
     expect(gated.pending()).toEqual([])
     expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
-      data: { step: 1, reason: { kind: 'error', error: schedulerError.message } },
+      data: { step: 1, reason: { kind: 'error', error: { message: schedulerError.message, code: 'UNKNOWN' } } },
     })
   })
 })

+ 4 - 1
packages/core/agent/src/types.ts

@@ -278,7 +278,10 @@ declare module 'cordis' {
      * re-reads its inbox: fresh steering runs another step, none closes the
      * turn. Data decides, so listener order cannot change the outcome. The
      * inverse control (stop a tool loop early) is data too: a tool result
-     * carrying `concludesTurn` ends the turn at its step.
+     * carrying `concludesTurn` ends the turn at its step. The conclusion
+     * never short-circuits already-submitted next-step work: same-step
+     * `additionalContexts` or racing steering still runs, and the turn
+     * closes only when that inbox drains.
      * @param agent - the agent whose turn is at its stop boundary.
      * @param turn - the turn about to close.
      * @param signal - the current turn's explicit abort signal.

+ 5 - 2
packages/core/session/src/types.ts

@@ -4,6 +4,7 @@ import type {
   CallId,
   LlmCallConfig,
   LlmCallConfigAdapterDefaults,
+  LlmFailure,
   StreamChunk,
   TokenUsage,
   ToolResultMessage,
@@ -109,9 +110,11 @@ export interface TurnEndReasonMap {
 
   blocked: { kind: 'blocked' }
   /**
-   * The turn failed.
+   * The turn failed. `error` is always a structured failure: the `LlmError`
+   * facts verbatim, or `{ message: errorChain(error), code: 'UNKNOWN' }`
+   * flattened from any other error.
    */
-  error: { kind: 'error'; error: unknown }
+  error: { kind: 'error'; error: LlmFailure }
   /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
   'max-tokens': { kind: 'max-tokens' }
   /**

+ 1 - 1
packages/core/session/tests/fork.spec.ts

@@ -139,7 +139,7 @@ describe('SessionStore.fork', () => {
     const reasons: TurnEndReason[] = [
       { kind: 'completed' },
       { kind: 'aborted', reason: { kind: 'user' } },
-      { kind: 'error', error: 'model failed' },
+      { kind: 'error', error: { message: 'model failed', code: 'UNKNOWN' } },
       { kind: 'aborted', reason: { kind: 'disposed' } },
       { kind: 'max-tokens' },
       { kind: 'interrupted' },

+ 1 - 1
packages/core/session/tests/invariant.spec.ts

@@ -331,7 +331,7 @@ describe('session-log invariants', () => {
       unresolved.append('step/start', { turn: 1, step: 1 })
       unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
       unresolved.append('step/end', { turn: 1, step: 1 })
-      unresolved.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
+      unresolved.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
     }).not.toThrow()
   })
 

+ 5 - 1
packages/llm/llm-retry/tests/retry.spec.ts

@@ -448,10 +448,14 @@ describe('provider-routed retry policy', () => {
 
     expect(adapter.requests).toHaveLength(0)
     expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
-    expect(agent.session.events.at(-1)).toMatchObject({
+    const end = agent.session.events.at(-1)
+    expect(end).toMatchObject({
       type: 'turn/end',
       data: { step: 1, reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
     })
+    if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
+      expect(end.data.reason.error.message).toContain('no adapter registered for provider')
+    }
   })
 
   it('selects policy by the failed request provider', async () => {

+ 6 - 2
packages/llm/llm-retry/tests/transport-recovery.spec.ts

@@ -193,7 +193,7 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
     expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
     expect(agent.session.events.at(-1)).toMatchObject({
       type: 'turn/end',
-      data: { step: 1, reason: { kind: 'error', error: { code: 'STREAM_CLOSED' } } },
+      data: { step: 1, reason: { kind: 'error', error: { message: 'SSE stream ended without [DONE]', code: 'STREAM_CLOSED' } } },
     })
   })
 
@@ -233,9 +233,13 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
     expect(server.requests).toHaveLength(3)
     expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
     expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
-    expect(agent.session.events.at(-1)).toMatchObject({
+    const end = agent.session.events.at(-1)
+    expect(end).toMatchObject({
       type: 'turn/end',
       data: { step: 1, reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
     })
+    if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
+      expect(end.data.reason.error.message).toContain('DeepSeek API request to')
+    }
   })
 })

+ 1 - 1
packages/session-query/session-query-sqlite/tests/sqlite.spec.ts

@@ -340,7 +340,7 @@ describe('SQLite session search', () => {
       { type: 'user/message', seq: 2, time: 12, data: createUserMessage({
         content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
       }), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
-      { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'error', error: 'needle failure' } } },
+      { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'needle failure', code: 'UNKNOWN' } } } },
     ]
     ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
     ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } })

+ 1 - 1
packages/session-query/session-query/src/extraction.ts

@@ -44,7 +44,7 @@ export function extractSessionEventText(event: SessionEvent): string {
 function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
   switch (reason.kind) {
     case 'error':
-      return joinText(['error', reason.error instanceof Error ? reason.error.message : String(reason.error)])
+      return joinText(['error', reason.error.message])
     case 'aborted':
       return 'aborted'
     case 'max-tokens':

+ 2 - 2
packages/session-query/session-query/tests/search-helpers.spec.ts

@@ -119,8 +119,8 @@ describe('session-query semantic extraction', () => {
 
   it('extracts meaningful turn outcomes and skips structural or unknown events', () => {
     const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
-      [{ kind: 'error', error: new Error('boom') }, 'error\nboom'],
-      [{ kind: 'error', error: 'provider boom' }, 'error\nprovider boom'],
+      [{ kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } }, 'error\nboom'],
+      [{ kind: 'error', error: { message: 'provider boom', code: 'UNKNOWN' } }, 'error\nprovider boom'],
       [{ kind: 'aborted', reason: { kind: 'user' } }, 'aborted'],
       [{ kind: 'aborted', reason: { kind: 'disposed' } }, 'aborted'],
       [{ kind: 'max-tokens' }, 'max-tokens'],

+ 1 - 1
packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts

@@ -74,7 +74,7 @@ describe('sdkStopReason', () => {
     expect(sdkStopReason({ kind: 'completed' })).toBe('completed')
     expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens')
     expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'user' } })).toBe('aborted')
-    expect(sdkStopReason({ kind: 'error', error: new Error('x') })).toBe('error')
+    expect(sdkStopReason({ kind: 'error', error: { message: 'x', code: 'UNKNOWN' } })).toBe('error')
     expect(sdkStopReason({ kind: 'interrupted' })).toBe('error')
     expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'disposed' } })).toBe('aborted')
   })

+ 1 - 1
packages/support/llm-replay/tests/llm-replay.spec.ts

@@ -183,7 +183,7 @@ describe('deriveReplayScript', () => {
     const events: SessionEvent[] = [
       chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
       chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }),
-      { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, step: 1, reason: { kind: 'error', error: 'x' } } },
+      { type: 'turn/end', seq: 3, time: 0, data: { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'x', code: 'UNKNOWN' } } } },
     ]
     expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s)
   })

+ 1 - 1
packages/telemetry/session-telemetry-otel/tests/otel.spec.ts

@@ -113,7 +113,7 @@ describe('TelemetryOtel wire', () => {
     const { ctx, fiber } = await boot(url)
     const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
     session.append('turn/start', { turn: 1 })
-    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: 'boom' } })
+    session.append('turn/end', { turn: 1, step: 0, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
     await fiber.dispose()
 
     expect(captures.length).toBeGreaterThan(0)

+ 1 - 1
packages/telemetry/session-telemetry/tests/telemetry.spec.ts

@@ -126,7 +126,7 @@ describe('TelemetryCoordinator capture', () => {
       }),
     }, { surfaceOp: 'append' })
     session.append('telemetry-test/opaque', { payload: { nested: [] } })
-    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'boom' } })
+    session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } } })
     const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
     expect(severities).toEqual([
       ['turn/start', 'info'],

+ 1 - 1
packages/ui/tui/src/index.ts

@@ -831,7 +831,7 @@ export function createTuiChat(
               liveErrors.delete(key)
               alreadyReported = true
             }
-            const message = errorChain(reason.error)
+            const message = reason.error.message
             if (!alreadyReported) appendNotice(message, 'error')
             break
           }

+ 2 - 2
packages/ui/tui/tests/tui.snapshot.ts

@@ -641,7 +641,7 @@ describe('TUI terminal-state snapshots', () => {
           todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
         })
         session.append('step/end', { turn: 1, step: 1 })
-        session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: `Unsafe turn error ${CONTROL_PROBE}` },
+        session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: `Unsafe turn error ${CONTROL_PROBE}`, code: 'UNKNOWN' } },
         })
       },
     }, { columns: 100, rows: 34 })
@@ -801,7 +801,7 @@ describe('TUI terminal-state snapshots', () => {
       harness.terminal.send('\r')
       agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
       harness.session.append('step/end', { turn: 1, step: 1 })
-      harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'provider stream failed after partial output' },
+      harness.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'provider stream failed after partial output', code: 'UNKNOWN' } },
       })
       harness.session.append('turn/start', { turn: 2 })
       harness.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'interrupted' },

+ 3 - 3
packages/ui/tui/tests/tui.spec.ts

@@ -466,7 +466,7 @@ describe('goodbye message and /resume', () => {
 
   it.each([
     [{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
-    [{ kind: 'error', step: 1, error: 'failed' }, 'error'],
+    [{ kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } }, 'error'],
     [{ kind: 'aborted', reason: { kind: 'disposed' } }, 'disposed'],
     [{ kind: 'max-tokens' }, 'max tokens'],
     [{ kind: 'interrupted' }, 'interrupted'],
@@ -3981,9 +3981,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
     agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
     agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
     events.session.append('step/end', { turn: 1, step: 1 })
-    events.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: 'live failure' } })
+    events.session.append('turn/end', { turn: 1, step: 1, reason: { kind: 'error', error: { message: 'live failure', code: 'UNKNOWN' } } })
     events.session.append('turn/start', { turn: 2 })
-    events.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'error', error: 'durable failure' } })
+    events.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'error', error: { message: 'durable failure', code: 'UNKNOWN' } } })
     events.session.append('turn/start', { turn: 3 })
     events.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } },
     })