fix(claude): surface an API error a result frame reports instead of settling the turn on it

The SDK models an API failure as a SUCCESS-subtype result whose `result` string
is the user-facing error text, with no assistant frame behind it. The translator
suppressed every catalogued result subtype as turn bookkeeping, so that turn
tombstoned its lifecycle and showed the user a completed, empty reply with no
sign anything had failed.

Suppression is now by meaning. A result reporting a failure routes to the
bounded provider-error surface, leading with the provider's own sentence and
keeping the raw frame behind the row's disclosure; ordinary successful results
stay off the timeline as before. A turn the user aborted also stays suppressed:
its interrupt frame already says so, and its execution diagnostic would only be
noise on every stop.

Claude-Session: https://claude.ai/code/session_01BSmXgkWSsNHft8jFkdBFG9
This commit is contained in:
Merge Sim
2026-09-02 01:53:34 -07:00
parent 1cbf2f3234
commit cb08dc6c41
3 changed files with 100 additions and 5 deletions
@@ -353,6 +353,56 @@ describe('Claude structured journal translation', () => {
).toEqual(['turn-lifecycle:user-replay-1', 'turn-lifecycle:user-interrupt'])
})
it('surfaces an API error carried by a success-subtype result with no assistant frame', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(message('user', 'user-1', [{ type: 'text', text: 'summarize this' }]))
// The SDK models this as a SUCCESS-subtype result whose `result` string is the
// user-facing API error. Suppressing it as ordinary turn bookkeeping ends the
// turn with nothing shown at all.
translator.handle(
resultFrame('success', {
is_error: true,
result: 'API Error: 529 upstream overloaded',
stop_reason: null,
terminal_reason: 'api_error'
})
)
expect(providerFrameKinds(state.items)).toEqual(['message:result:success'])
expect(state.items.at(-1)?.body).toMatchObject({
kind: 'status',
text: 'API Error: 529 upstream overloaded'
})
// The turn still settles: the error is an extra row, not a stuck lifecycle.
expect(
state.tombstones.flatMap((identity) =>
identity.provider === 'legacy' ? [identity.recordId] : []
)
).toEqual(['turn-lifecycle:user-1'])
})
it('keeps an ordinary successful result off the timeline', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(resultFrame('success', { is_error: false, result: 'done', errors: [] }))
expect(providerFrameKinds(state.items)).toEqual([])
})
it('surfaces the reason an error-subtype result stopped the turn', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
translator.handle(
resultFrame('error_max_turns', { is_error: true, errors: ['turn limit reached'] })
)
expect(providerFrameKinds(state.items)).toEqual(['message:result:error_max_turns'])
})
it('keeps an unmodeled result subtype on the bounded provider fallback', () => {
const state = sinkState()
const translator = createClaudeJournalTranslator({ sink: state.sink })
@@ -34,6 +34,7 @@ import {
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
import {
claudeProviderFrameKind,
claudeResultFailure,
createClaudeProviderFrameFallback,
isModeledClaudeContent,
isSettledClaudeResultKind
@@ -281,8 +282,10 @@ export function createClaudeJournalTranslator(
// The turn is over: a block still awaiting its final keeps its flushed text.
streamedBlocks.clear()
const kind = claudeProviderFrameKind(event.message)
if (!isSettledClaudeResultKind(kind)) {
providerFallback.append(kind, event.message)
// Ordinary turn bookkeeping stays suppressed; a reported failure never does.
const failure = claudeResultFailure(event.message)
if (failure || !isSettledClaudeResultKind(kind)) {
providerFallback.append(kind, event.message, failure?.text)
}
} else if (event.type === 'message') {
if (!handleMessage(event.message)) {
@@ -1,4 +1,8 @@
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import { CLAUDE_STREAM_JSON_FRAME_KINDS } from '../native-chat/agent-session-wire/claude-stream-json-frame-schema'
import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame'
import { claudeRecord, claudeText } from './claude-structured-item-translation'
@@ -20,6 +24,40 @@ export function isSettledClaudeResultKind(kind: string): boolean {
return SETTLED_RESULT_KINDS.has(kind)
}
/**
* The failure a result frame carries that the turn's own frames never showed.
*
* Suppression is by meaning, not by kind. The SDK models an API failure as a
* SUCCESS-subtype result whose `result` string IS the error text and which has
* no assistant frame behind it, so keying on the subtype tombstones the turn and
* shows the user a completed, empty reply. A turn the user aborted is the
* opposite: its interrupt frame already says so, and the diagnostic in `errors`
* would only be noise.
*/
export function claudeResultFailure(
message: Record<string, unknown>
): { text: string | null } | null {
if (message.is_error !== true) {
return null
}
const terminalReason = claudeText(message.terminal_reason)
if (terminalReason === 'aborted_streaming' || terminalReason === 'aborted_tools') {
return null
}
const result = claudeText(message.result)?.trim()
if (result) {
return { text: result }
}
const errors = Array.isArray(message.errors)
? message.errors.flatMap((entry) => {
const text = claudeText(entry)?.trim()
return text ? [text] : []
})
: []
// Nothing readable to lead with, but a reported failure still gets its row.
return { text: errors.length > 0 ? errors.join('\n') : null }
}
export function isModeledClaudeContent(value: unknown): boolean {
const part = claudeRecord(value)
if (!part) {
@@ -46,22 +84,26 @@ export function createClaudeProviderFrameFallback(
sink: StructuredAgentSessionEventSink,
acquisitionId: string
): {
append: (kind: string, payload: unknown) => void
/** `displayText` leads the row when Claude knows the sentence the frame itself does not name. */
append: (kind: string, payload: unknown, displayText?: string | null) => void
} {
let sequence = 0
return {
append: (kind, payload) => {
append: (kind, payload, displayText) => {
sequence += 1
const translated = unhandledProviderFrameJournalItem('claude', kind, payload)
if (!translated) {
return
}
const bounded = displayText
? boundInlineText(displayText, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
: null
sink.appendItem(
{
provider: 'orca',
clientMessageId: `provider-frame:claude:${acquisitionId}:${sequence}`
},
translated.body,
bounded ? { ...translated.body, text: bounded } : translated.body,
translated.blobs
)
sink.publish()