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:
@@ -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 {
|
||||
|
||||
@@ -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' } } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' } } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user