From a597763393b54f169233ee45bc1f8f9b280a4f6b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 3 Aug 2026 16:33:23 +0800 Subject: [PATCH] fix(session): type turn/end error as one structured failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/core-data-structures/session.md | 6 ++++-- docs/core-data-structures/session.zh.md | 6 ++++-- .../subagent-continuable/session.1.jsonl | 2 +- packages/acp/acp/src/index.ts | 2 +- packages/acp/acp/tests/codec.spec.ts | 2 +- .../runtime/src/client/sessions/session.ts | 4 +--- .../runtime/tests/request-inspection.spec.ts | 2 +- packages/client/runtime/tests/session.spec.ts | 9 +++++---- packages/core/agent-loop/src/agent.ts | 19 +++++++++++++++---- .../tests/contract-regressions.spec.ts | 8 ++++---- .../agent-loop/tests/coverage-edges.spec.ts | 4 ++-- packages/core/agent-loop/tests/loop.spec.ts | 2 +- .../tests/request-reconstruction.spec.ts | 9 ++++----- .../core/agent-loop/tests/tool-calls.spec.ts | 2 +- packages/core/agent/src/types.ts | 5 ++++- packages/core/session/src/types.ts | 7 +++++-- packages/core/session/tests/fork.spec.ts | 2 +- packages/core/session/tests/invariant.spec.ts | 2 +- packages/llm/llm-retry/tests/retry.spec.ts | 6 +++++- .../tests/transport-recovery.spec.ts | 8 ++++++-- .../session-query-sqlite/tests/sqlite.spec.ts | 2 +- .../session-query/src/extraction.ts | 2 +- .../tests/search-helpers.spec.ts | 4 ++-- .../tests/subagent-dsh-sdk.spec.ts | 2 +- .../llm-replay/tests/llm-replay.spec.ts | 2 +- .../session-telemetry-otel/tests/otel.spec.ts | 2 +- .../session-telemetry/tests/telemetry.spec.ts | 2 +- packages/ui/tui/src/index.ts | 2 +- packages/ui/tui/tests/tui.snapshot.ts | 4 ++-- packages/ui/tui/tests/tui.spec.ts | 6 +++--- 30 files changed, 81 insertions(+), 54 deletions(-) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index facedfcd08..fa357a97d0 100644 --- a/docs/core-data-structures/session.md +++ b/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' } /** diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index f60ec4341b..c7ab306654 100644 --- a/docs/core-data-structures/session.zh.md +++ b/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' } /** diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index bf5ae0ca4b..f9b92f5cd8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/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"}}}} diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index fb8088bf42..a794c52901 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -146,7 +146,7 @@ export function apply(ctx: Context, config: AcpConfig): void { inflight: NonNullable, reason: Extract, ): 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, diff --git a/packages/acp/acp/tests/codec.spec.ts b/packages/acp/acp/tests/codec.spec.ts index 4d37cc1c23..0317898e22 100644 --- a/packages/acp/acp/tests/codec.spec.ts +++ b/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) }) diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index af77e1656d..85c58198e0 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/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++ } diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts index 509219cd5b..4e9094f9f5 100644 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ b/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' } }, }), ])) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 638e95646d..803a65e45e 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/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({ diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 84cb5c3c7a..b9d3217419 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/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 { + 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 { diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 652fb96679..c16b1a1000 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/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' } } }, }) }) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 1eb4373257..51488c9328 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/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('') + expect(errorChain(end.data.reason.error.message)).not.toBe('') } }) }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 68a1b0dd77..5e2890fb9a 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/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 diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 9624fc796a..6e7d0fe15a 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/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 () => { diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 7f141eb2a5..10bf60875a 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/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' } } }, }) }) }) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 77d3761e26..d83315ba7e 100644 --- a/packages/core/agent/src/types.ts +++ b/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. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 719c42a86c..1e910fe001 100644 --- a/packages/core/session/src/types.ts +++ b/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' } /** diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index d8f012c21c..efa6358e47 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/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' }, diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index 1595212f37..61f017e275 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/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() }) diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index a552166eca..6ff39aca71 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/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 () => { diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 79d63d6ba4..7ce8224171 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/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') + } }) }) diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 36da11e99f..7d74c8b0b0 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/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 } }) diff --git a/packages/session-query/session-query/src/extraction.ts b/packages/session-query/session-query/src/extraction.ts index 6f89a4ff4b..d01ccb7aa3 100644 --- a/packages/session-query/session-query/src/extraction.ts +++ b/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': diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index a511994992..03748aeabb 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/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'], diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index 73bc92866f..5603e0e9a0 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/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') }) diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 2ef7e0a518..2f65f53e52 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/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) }) diff --git a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts b/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts index ea93d604b5..8313a486f2 100644 --- a/packages/telemetry/session-telemetry-otel/tests/otel.spec.ts +++ b/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) diff --git a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts b/packages/telemetry/session-telemetry/tests/telemetry.spec.ts index 7280221e2c..6fe4bbf207 100644 --- a/packages/telemetry/session-telemetry/tests/telemetry.spec.ts +++ b/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'], diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 1cd5e7adab..259b2a7df6 100644 --- a/packages/ui/tui/src/index.ts +++ b/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 } diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 0051278060..57a1f7a696 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/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' }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index fb2bd8772c..b363cfc6e3 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/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' } }, })