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

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