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.
This commit is contained in:
_Kerman
2026-08-03 16:33:23 +08:00
parent 8b975edf44
commit a597763393
30 changed files with 81 additions and 54 deletions

View File

@@ -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' }
/**

View File

@@ -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' }
/**

View File

@@ -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"}}}}

View File

@@ -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,

View File

@@ -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)
})

View File

@@ -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++
}

View File

@@ -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' } },
}),
]))

View File

@@ -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({

View File

@@ -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 {

View File

@@ -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' } } },
})
})

View File

@@ -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>')
}
})
})

View File

@@ -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

View File

@@ -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 () => {

View File

@@ -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' } } },
})
})
})

View File

@@ -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.

View File

@@ -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' }
/**

View File

@@ -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' },

View File

@@ -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()
})

View File

@@ -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 () => {

View File

@@ -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')
}
})
})

View File

@@ -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 } })

View File

@@ -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':

View File

@@ -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'],

View File

@@ -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')
})

View File

@@ -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)
})

View File

@@ -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)

View File

@@ -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'],

View File

@@ -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
}

View File

@@ -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' },

View File

@@ -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' } },
})