mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 00:02:05 +00:00
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user