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:
@@ -522,9 +522,11 @@ interface TurnEndReasonMap {
|
|||||||
|
|
||||||
blocked: { kind: 'blocked' }
|
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. */
|
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||||
'max-tokens': { kind: 'max-tokens' }
|
'max-tokens': { kind: 'max-tokens' }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -526,9 +526,11 @@ interface TurnEndReasonMap {
|
|||||||
|
|
||||||
blocked: { kind: 'blocked' }
|
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. */
|
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||||
'max-tokens': { kind: 'max-tokens' }
|
'max-tokens': { kind: 'max-tokens' }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,4 +37,4 @@
|
|||||||
{"type":"step/start","seq":35,"time":1785731000386,"data":{"turn":3,"step":1}}
|
{"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":"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":"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"}}}}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
|||||||
inflight: NonNullable<SessionRecord['inflight']>,
|
inflight: NonNullable<SessionRecord['inflight']>,
|
||||||
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
reason: Extract<TurnEndReason, { kind: 'error' }>,
|
||||||
): void => {
|
): 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,
|
// Emit only committed assistant text. Raw chunks, reasoning, tools, plans,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ describe('ACP codec', () => {
|
|||||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
|
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
|
||||||
[{ kind: 'interrupted' }, 'cancelled'],
|
[{ kind: 'interrupted' }, 'cancelled'],
|
||||||
[{ kind: 'blocked' }, 'end_turn'],
|
[{ 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) => {
|
] satisfies Array<[TurnEndReason, string]>)('maps %o to %s', (reason, expected) => {
|
||||||
expect(turnEndToStopReason(reason)).toBe(expected)
|
expect(turnEndToStopReason(reason)).toBe(expected)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -829,8 +829,6 @@ export class Session implements SessionFace {
|
|||||||
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
|
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
|
||||||
) {
|
) {
|
||||||
const failure = event.data.reason.error
|
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({
|
this.derivedNodes.push({
|
||||||
kind: 'turn-error',
|
kind: 'turn-error',
|
||||||
seq: event.seq,
|
seq: event.seq,
|
||||||
@@ -838,7 +836,7 @@ export class Session implements SessionFace {
|
|||||||
turn: event.data.turn,
|
turn: event.data.turn,
|
||||||
step: event.data.step,
|
step: event.data.step,
|
||||||
message: displayFailureMessage(failure),
|
message: displayFailureMessage(failure),
|
||||||
...(code === undefined ? {} : { code }),
|
code: failure.code,
|
||||||
})
|
})
|
||||||
this.derivedRev++
|
this.derivedRev++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ describe('inspectRequests', () => {
|
|||||||
}),
|
}),
|
||||||
at(2, 'step/start', { turn: 2, step: 1 }),
|
at(2, 'step/start', { turn: 2, step: 1 }),
|
||||||
at(3, 'turn/end', {
|
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' } },
|
||||||
}),
|
}),
|
||||||
]))
|
]))
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe('live event path', () => {
|
|||||||
ev.stepStart(12, 2, 1),
|
ev.stepStart(12, 2, 1),
|
||||||
at(13, {
|
at(13, {
|
||||||
type: 'turn/end',
|
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)
|
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')
|
const errors = session.getSnapshot().nodes.filter(node => node.kind === 'turn-error')
|
||||||
expect(errors).toMatchObject([
|
expect(errors).toMatchObject([
|
||||||
{ seq: 9, turn: 1, step: 0, code: 'AUTH', message: 'API key is invalid' },
|
{ 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()
|
const replay = makeSession()
|
||||||
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...failedTurns])
|
||||||
@@ -410,7 +411,7 @@ describe('live event path', () => {
|
|||||||
feed(ev.retry(7, 1))
|
feed(ev.retry(7, 1))
|
||||||
feed(at(8, {
|
feed(at(8, {
|
||||||
type: 'turn/end',
|
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({
|
expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ function requestProposal(header: EpochHeader): LlmCallConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Drives one session through turn and step boundaries. */
|
/** 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 {
|
export class ReactLoopAgent implements Agent {
|
||||||
readonly inbox: Inbox
|
readonly inbox: Inbox
|
||||||
private phase: Phase
|
private phase: Phase
|
||||||
@@ -279,10 +293,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
|
turnEnds = { kind: 'aborted', reason: signal.reason as AgentCancelCause }
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
turnEnds = {
|
turnEnds = turnErrorReason(error)
|
||||||
kind: 'error',
|
|
||||||
error: error instanceof LlmError ? error.failure : errorChain(error),
|
|
||||||
}
|
|
||||||
this.throwError(error)
|
this.throwError(error)
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -339,7 +339,7 @@ describe('plugin exceptions are contained', () => {
|
|||||||
send(agent, 'first')
|
send(agent, 'first')
|
||||||
await waitForIdle(ctx, agent)
|
await waitForIdle(ctx, agent)
|
||||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
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
|
// 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)
|
await waitForIdle(ctx, agent)
|
||||||
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
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')
|
: undefined).toContain('has no provider/model')
|
||||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||||
? turnEnd.data.reason.error
|
? turnEnd.data.reason.error.message
|
||||||
: undefined).toContain('agent/request')
|
: undefined).toContain('agent/request')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -762,7 +762,7 @@ describe('turn and step boundary recovery', () => {
|
|||||||
errors: 1,
|
errors: 1,
|
||||||
})
|
})
|
||||||
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
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)
|
await waitForIdle(ctx, agent)
|
||||||
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
|
||||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error'
|
||||||
? turnEnd.data.reason.error
|
? turnEnd.data.reason.error.message
|
||||||
: undefined).toBe('[object Object]')
|
: undefined).toBe('[object Object]')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -464,7 +464,7 @@ describe('unrenderable failure settlement', () => {
|
|||||||
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
|
if (end?.type === 'turn/end' && end.data.reason.kind === 'error') {
|
||||||
// The durable failure keeps the adapter facts' message, not the
|
// The durable failure keeps the adapter facts' message, not the
|
||||||
// unrenderable chain.
|
// 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')
|
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).toBe('error')
|
||||||
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === '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')
|
: '').toContain('no value for this assembly')
|
||||||
|
|
||||||
// The loop survived: a waterfall listener rescues {{cwd}} and the SAME
|
// 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({
|
expect(agent.session.events.findLast(event => event.type === 'turn/end')).toMatchObject({
|
||||||
data: {
|
data: {
|
||||||
step: 1,
|
step: 1,
|
||||||
reason: {
|
reason: failure instanceof LlmError
|
||||||
kind: 'error',
|
? { kind: 'error', error: failure.failure }
|
||||||
error: failure instanceof LlmError ? failure.failure : failure.message,
|
: { kind: 'error', error: { message: failure.message, code: 'UNKNOWN' } },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
expect(adapter.requests).toHaveLength(0)
|
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')
|
const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
|
||||||
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } })
|
expect(turnEnd).toMatchObject({ data: { reason: { kind: 'error' } } })
|
||||||
if (turnEnd?.type !== 'turn/end' || turnEnd.data.reason.kind !== 'error') throw new 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 () => {
|
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(turnEndBeforeDrain).toBeUndefined()
|
||||||
expect(gated.pending()).toEqual([])
|
expect(gated.pending()).toEqual([])
|
||||||
expect(events(agent).findLast(event => event.type === 'turn/end')).toMatchObject({
|
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' } } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -278,7 +278,10 @@ declare module 'cordis' {
|
|||||||
* re-reads its inbox: fresh steering runs another step, none closes the
|
* re-reads its inbox: fresh steering runs another step, none closes the
|
||||||
* turn. Data decides, so listener order cannot change the outcome. 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
|
* 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 agent - the agent whose turn is at its stop boundary.
|
||||||
* @param turn - the turn about to close.
|
* @param turn - the turn about to close.
|
||||||
* @param signal - the current turn's explicit abort signal.
|
* @param signal - the current turn's explicit abort signal.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CallId,
|
CallId,
|
||||||
LlmCallConfig,
|
LlmCallConfig,
|
||||||
LlmCallConfigAdapterDefaults,
|
LlmCallConfigAdapterDefaults,
|
||||||
|
LlmFailure,
|
||||||
StreamChunk,
|
StreamChunk,
|
||||||
TokenUsage,
|
TokenUsage,
|
||||||
ToolResultMessage,
|
ToolResultMessage,
|
||||||
@@ -109,9 +110,11 @@ export interface TurnEndReasonMap {
|
|||||||
|
|
||||||
blocked: { kind: 'blocked' }
|
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. */
|
/** At least one step reached its output-token ceiling, even if a plugin continued the turn. */
|
||||||
'max-tokens': { kind: 'max-tokens' }
|
'max-tokens': { kind: 'max-tokens' }
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe('SessionStore.fork', () => {
|
|||||||
const reasons: TurnEndReason[] = [
|
const reasons: TurnEndReason[] = [
|
||||||
{ kind: 'completed' },
|
{ kind: 'completed' },
|
||||||
{ kind: 'aborted', reason: { kind: 'user' } },
|
{ kind: 'aborted', reason: { kind: 'user' } },
|
||||||
{ kind: 'error', error: 'model failed' },
|
{ kind: 'error', error: { message: 'model failed', code: 'UNKNOWN' } },
|
||||||
{ kind: 'aborted', reason: { kind: 'disposed' } },
|
{ kind: 'aborted', reason: { kind: 'disposed' } },
|
||||||
{ kind: 'max-tokens' },
|
{ kind: 'max-tokens' },
|
||||||
{ kind: 'interrupted' },
|
{ kind: 'interrupted' },
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ describe('session-log invariants', () => {
|
|||||||
unresolved.append('step/start', { turn: 1, step: 1 })
|
unresolved.append('step/start', { turn: 1, step: 1 })
|
||||||
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
unresolved.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
|
||||||
unresolved.append('step/end', { turn: 1, step: 1 })
|
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()
|
}).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -448,10 +448,14 @@ describe('provider-routed retry policy', () => {
|
|||||||
|
|
||||||
expect(adapter.requests).toHaveLength(0)
|
expect(adapter.requests).toHaveLength(0)
|
||||||
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
|
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',
|
type: 'turn/end',
|
||||||
data: { step: 1, reason: { kind: 'error', error: { code: 'NO_ADAPTER' } } },
|
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 () => {
|
it('selects policy by the failed request provider', async () => {
|
||||||
|
|||||||
@@ -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.some(event => event.type === 'llm/retry')).toBe(false)
|
||||||
expect(agent.session.events.at(-1)).toMatchObject({
|
expect(agent.session.events.at(-1)).toMatchObject({
|
||||||
type: 'turn/end',
|
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(server.requests).toHaveLength(3)
|
||||||
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(1)
|
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.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',
|
type: 'turn/end',
|
||||||
data: { step: 1, reason: { kind: 'error', error: { code: 'TRANSPORT' } } },
|
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')
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -340,7 +340,7 @@ describe('SQLite session search', () => {
|
|||||||
{ type: 'user/message', seq: 2, time: 12, data: createUserMessage({
|
{ type: 'user/message', seq: 2, time: 12, data: createUserMessage({
|
||||||
content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
|
content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' },
|
||||||
}), surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
}), 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('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
|
||||||
ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } })
|
ctx.sessions.create(SessionId('b'), { seed: messageEvents('needle peer', 12), meta: { createdAt: 20 } })
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function extractSessionEventText(event: SessionEvent): string {
|
|||||||
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
|
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
|
||||||
switch (reason.kind) {
|
switch (reason.kind) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return joinText(['error', reason.error instanceof Error ? reason.error.message : String(reason.error)])
|
return joinText(['error', reason.error.message])
|
||||||
case 'aborted':
|
case 'aborted':
|
||||||
return 'aborted'
|
return 'aborted'
|
||||||
case 'max-tokens':
|
case 'max-tokens':
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ describe('session-query semantic extraction', () => {
|
|||||||
|
|
||||||
it('extracts meaningful turn outcomes and skips structural or unknown events', () => {
|
it('extracts meaningful turn outcomes and skips structural or unknown events', () => {
|
||||||
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
|
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
|
||||||
[{ kind: 'error', error: new Error('boom') }, 'error\nboom'],
|
[{ kind: 'error', error: { message: 'boom', code: 'UNKNOWN' } }, 'error\nboom'],
|
||||||
[{ kind: 'error', error: 'provider boom' }, 'error\nprovider boom'],
|
[{ kind: 'error', error: { message: 'provider boom', code: 'UNKNOWN' } }, 'error\nprovider boom'],
|
||||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'aborted'],
|
[{ kind: 'aborted', reason: { kind: 'user' } }, 'aborted'],
|
||||||
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'aborted'],
|
[{ kind: 'aborted', reason: { kind: 'disposed' } }, 'aborted'],
|
||||||
[{ kind: 'max-tokens' }, 'max-tokens'],
|
[{ kind: 'max-tokens' }, 'max-tokens'],
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ describe('sdkStopReason', () => {
|
|||||||
expect(sdkStopReason({ kind: 'completed' })).toBe('completed')
|
expect(sdkStopReason({ kind: 'completed' })).toBe('completed')
|
||||||
expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens')
|
expect(sdkStopReason({ kind: 'max-tokens' })).toBe('max-tokens')
|
||||||
expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'user' } })).toBe('aborted')
|
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: 'interrupted' })).toBe('error')
|
||||||
expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'disposed' } })).toBe('aborted')
|
expect(sdkStopReason({ kind: 'aborted', reason: { kind: 'disposed' } })).toBe('aborted')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ describe('deriveReplayScript', () => {
|
|||||||
const events: SessionEvent[] = [
|
const events: SessionEvent[] = [
|
||||||
chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
|
chunkEvent(1, 1, 1, { type: 'block-start', index: 0, blockType: 'text' }),
|
||||||
chunkEvent(2, 1, 1, { type: 'text-delta', index: 0, text: 'par' }),
|
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)
|
expect(() => deriveReplayScript(events)).toThrow(/without a finish chunk.*replay\.override\.json/s)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ describe('TelemetryOtel wire', () => {
|
|||||||
const { ctx, fiber } = await boot(url)
|
const { ctx, fiber } = await boot(url)
|
||||||
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
|
const session = ctx.sessions.create(SessionId('wire'), { meta: { cwd: '/tmp/w' } })
|
||||||
session.append('turn/start', { turn: 1 })
|
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()
|
await fiber.dispose()
|
||||||
|
|
||||||
expect(captures.length).toBeGreaterThan(0)
|
expect(captures.length).toBeGreaterThan(0)
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe('TelemetryCoordinator capture', () => {
|
|||||||
}),
|
}),
|
||||||
}, { surfaceOp: 'append' })
|
}, { surfaceOp: 'append' })
|
||||||
session.append('telemetry-test/opaque', { payload: { nested: [] } })
|
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])
|
const severities = backend.ledger().map(r => [r.attributes['event.type'], r.severity])
|
||||||
expect(severities).toEqual([
|
expect(severities).toEqual([
|
||||||
['turn/start', 'info'],
|
['turn/start', 'info'],
|
||||||
|
|||||||
@@ -831,7 +831,7 @@ export function createTuiChat(
|
|||||||
liveErrors.delete(key)
|
liveErrors.delete(key)
|
||||||
alreadyReported = true
|
alreadyReported = true
|
||||||
}
|
}
|
||||||
const message = errorChain(reason.error)
|
const message = reason.error.message
|
||||||
if (!alreadyReported) appendNotice(message, 'error')
|
if (!alreadyReported) appendNotice(message, 'error')
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -641,7 +641,7 @@ describe('TUI terminal-state snapshots', () => {
|
|||||||
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }],
|
||||||
})
|
})
|
||||||
session.append('step/end', { turn: 1, step: 1 })
|
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 })
|
}, { columns: 100, rows: 34 })
|
||||||
@@ -801,7 +801,7 @@ describe('TUI terminal-state snapshots', () => {
|
|||||||
harness.terminal.send('\r')
|
harness.terminal.send('\r')
|
||||||
agentEvents(harness.ctx, harness.agent).emit('agent/error', 1, 1, new Error('provider stream failed after partial output'))
|
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('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/start', { turn: 2 })
|
||||||
harness.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'interrupted' },
|
harness.session.append('turn/end', { turn: 2, step: 0, reason: { kind: 'interrupted' },
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ describe('goodbye message and /resume', () => {
|
|||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[{ kind: 'aborted', reason: { kind: 'user' } }, 'cancelled'],
|
[{ 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: 'aborted', reason: { kind: 'disposed' } }, 'disposed'],
|
||||||
[{ kind: 'max-tokens' }, 'max tokens'],
|
[{ kind: 'max-tokens' }, 'max tokens'],
|
||||||
[{ kind: 'interrupted' }, 'interrupted'],
|
[{ kind: 'interrupted' }, 'interrupted'],
|
||||||
@@ -3981,9 +3981,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
|
agentEvents(events.ctx, unrelatedAgent).emit('agent/disposed')
|
||||||
agentEvents(events.ctx, events.agent).emit('agent/error', 1, 1, new Error('live failure'))
|
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('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/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/start', { turn: 3 })
|
||||||
events.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
events.session.append('turn/end', { turn: 3, step: 0, reason: { kind: 'aborted', reason: { kind: 'user' } },
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user