diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b4fd678a4594..11110bf389d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2676,6 +2676,66 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + for (const terminalReason of [ + "api_error", + "malformed_tool_use_exhausted", + "budget_exhausted", + "structured_output_retry_exhausted", + "tool_deferred_unavailable", + "turn_setup_failed", + "blocking_limit", + "rapid_refill_breaker", + "prompt_too_long", + "image_error", + "model_error", + "overloaded_status", + ] as const) { + it.effect(`fails a success-shaped Claude result with ${terminalReason}`, () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-structured-terminal-failure"), + text: "Complete the task.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000205", + result: "Provider failure details.", + isError: false, + ...(terminalReason === "overloaded_status" + ? { apiErrorStatus: 529 } + : { terminalReason }), + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.isNotEmpty(terminal.failure.message); + assert.isFalse( + harness.events.some( + (event) => + event.type === "message.updated" && + event.message.text === "Provider failure details.", + ), + ); + assert.equal( + terminal.failure.code, + terminalReason === "overloaded_status" ? "api_error_529" : terminalReason, + ); + }).pipe(Effect.provide(Layer.merge(idAllocatorLayer, NodeServices.layer))), + ), + ); + } + const providerThreadRosterEvents = (events: ReadonlyArray) => events.filter( (event): event is Extract => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a5d40f87e378..c1d4f1cabac9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2127,7 +2127,10 @@ function providerFailureFromResult( const apiErrorStatus = message.api_error_status ?? null; return makeProviderFailure({ message: listedError ?? structuredError ?? failureHint ?? message.result, - code: apiErrorStatus === null ? "sdk_result_error" : `api_error_${apiErrorStatus}`, + code: + apiErrorStatus === null + ? (message.terminal_reason ?? "sdk_result_error") + : `api_error_${apiErrorStatus}`, class: "provider_error", retryable: apiErrorStatus === 429 || apiErrorStatus === 529 ? true : null, }); @@ -4994,10 +4997,12 @@ export function makeClaudeAdapterV2( }); } - // An is_error result's text is the error message; it belongs on the - // terminal-failure item, not on a synthetic assistant message. + // Failed result text belongs on the terminal-failure item, including + // structured failures whose SDK result still has is_error=false. const resultText = - message.type === "result" && message.subtype === "success" && message.is_error + message.type === "result" && + ((message.subtype === "success" && message.is_error) || + terminalStatusFromResult(message) === "failed") ? null : resultTextFromSdkMessage(message); if (