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

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