diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index b25a6770008..c11be13b90e 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -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 }) diff --git a/src/main/claude/claude-structured-journal-translation.ts b/src/main/claude/claude-structured-journal-translation.ts index c1e9b6b291a..c4a4acbfd9f 100644 --- a/src/main/claude/claude-structured-journal-translation.ts +++ b/src/main/claude/claude-structured-journal-translation.ts @@ -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)) { diff --git a/src/main/claude/claude-structured-provider-fallback.ts b/src/main/claude/claude-structured-provider-fallback.ts index 5074ff49558..afa9125e98f 100644 --- a/src/main/claude/claude-structured-provider-fallback.ts +++ b/src/main/claude/claude-structured-provider-fallback.ts @@ -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 +): { 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()