From b3eb8808ecf51c6349dab89f89cef2de588ed5e5 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 09:33:01 -0500 Subject: [PATCH 1/5] fix(claude): safeguard refusals show their error once Claude Code reports an API failure as an assistant frame with `error` set and again as the turn's result. The adapter turned the frame into a reply, so the refusal showed as an assistant message and as the turn's failure. The frame now waits until the turn ends and is dropped when the failure carries the same text. Fixes #24171 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 59 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 23 ++++++++ 2 files changed, 82 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0c8b2ad588ac..470971e616a9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3935,6 +3935,65 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #24171 + it.effect.each([ + { + name: "drops the CLI's API error frame when the failure repeats it", + text: "API Error: Claude Opus 5.5's safeguards flagged this message (https://www.anthropic.com/legal/aup).", + apiErrorStatus: 400, + shown: false, + }, + { + name: "keeps the CLI's API error frame when the failure says something else", + text: "API Error: 429 Specific rate limit detail.", + apiErrorStatus: 429, + shown: true, + }, + ])("$name", (input) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-api-error-frame"), + text: "Continue.", + attachments: [], + }), + ); + yield* Queue.offerAll(harness.sdkMessages, [ + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000610", + error: "invalid_request", + text: input.text, + }), + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000611", + result: input.text, + isError: true, + apiErrorStatus: input.apiErrorStatus, + terminalReason: "api_error", + }), + ]); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "failed"); + if (terminal.status !== "failed") return; + assert.equal(terminal.failure.message === input.text, !input.shown); + const assistantTexts = harness.events.flatMap((event) => + event.type === "message.updated" && event.message.role === "assistant" + ? [event.message.text] + : [], + ); + assert.deepEqual(assistantTexts, input.shown ? [input.text] : []); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + const lastProviderTurn = (events: ReadonlyArray) => { const event = events.findLast((candidate) => candidate.type === "provider_turn.updated"); if (event?.type !== "provider_turn.updated") { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e0868918eaa..36ba7dd7a807 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3415,6 +3415,10 @@ interface ActiveClaudeTurnContext { emittedNativeItemIds: Set; // The CLI repeats a notice that ends a prompt as the turn's result text. noticeTexts: Set; + // The CLI reports an API failure as an assistant frame and again as the + // turn's result. The frame waits for the turn's end so the failure can + // show it once. + apiErrorFrame: { readonly nativeItemId: string; readonly text: string } | null; }; readonly reasoning: { messageId: string | null; @@ -6706,6 +6710,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( subagentToolCalls.delete(toolCall.nativeItemId); } + const apiErrorFrame = input.context.assistant.apiErrorFrame; + input.context.assistant.apiErrorFrame = null; + if ( + apiErrorFrame !== null && + input.failure?.message !== makeProviderFailure({ message: apiErrorFrame.text }).message + ) { + yield* emitAssistantTextArtifacts({ context: input.context, ...apiErrorFrame }); + } + if ( input.context.assistant.emittedNativeItemIds.size === 0 && input.context.assistant.fallbackText.length > 0 @@ -8485,6 +8498,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } if (assistantText !== null && assistantText.text.length > 0) { + const heldApiError = context.assistant.apiErrorFrame; + context.assistant.apiErrorFrame = null; + if (heldApiError !== null) { + yield* emitAssistantTextArtifacts({ context, ...heldApiError }); + } + if (message.type === "assistant" && message.error !== undefined) { + context.assistant.apiErrorFrame = assistantText; + return; + } yield* emitAssistantTextArtifacts({ context, nativeItemId: assistantText.nativeItemId, @@ -9516,6 +9538,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fallbackNativeItemId: `assistant:${turnInput.runId}`, emittedNativeItemIds: new Set(), noticeTexts: new Set(), + apiErrorFrame: null, }, reasoning: { messageId: null, From 398ddcccadad222a1a49d99d5dc0bb4c07b9db48 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 15:46:44 +0000 Subject: [PATCH 2/5] fix(claude): deduplicate truncated api error frames --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 8 +++++++- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 083d18b0dc37..0256360ef61f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5108,6 +5108,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: false, }, + { + name: "drops the CLI's API error frame when the repeated failure is truncated", + text: `API Error: ${"safeguard detail ".repeat(40)}`.trim(), + apiErrorStatus: 400, + shown: false, + }, { name: "keeps the CLI's API error frame when the failure says something else", text: "API Error: 429 Specific rate limit detail.", @@ -5145,7 +5151,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const terminal = yield* Queue.take(harness.terminalReceipts); assert.equal(terminal.status, "failed"); if (terminal.status !== "failed") return; - assert.equal(terminal.failure.message === input.text, !input.shown); + assert.equal(terminal.failure.message === input.text.slice(0, 500).trim(), !input.shown); const assistantTexts = harness.events.flatMap((event) => event.type === "message.updated" && event.message.role === "assistant" ? [event.message.text] diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2ba3451002aa..b71e6ab992f9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7303,7 +7303,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context.assistant.apiErrorFrame = null; if ( apiErrorFrame !== null && - input.failure?.message !== makeProviderFailure({ message: apiErrorFrame.text }).message + input.failure?.message !== + makeProviderFailure({ message: apiErrorFrame.text }).message.slice(0, 500).trim() ) { yield* emitAssistantTextArtifacts({ context: input.context, ...apiErrorFrame }); } From dc9eba2fd4d37f228e69bd43388404d4ec731231 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 15:59:37 +0000 Subject: [PATCH 3/5] fix(claude): preserve error frames on interrupted turns --- .../Adapters/ClaudeAdapterV2.test.ts | 18 ++++++++++++++---- .../Adapters/ClaudeAdapterV2.ts | 5 +++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0256360ef61f..d7bd9b8ce2da 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5120,6 +5120,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 429, shown: true, }, + { + name: "keeps the CLI's API error frame when the turn is interrupted", + text: "API Error: rejected", + apiErrorStatus: 400, + terminalReason: "aborted_streaming" as const, + shown: true, + }, ])("$name", (input) => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -5144,14 +5151,17 @@ describe("ClaudeAdapterV2 background wake turns", () => { result: input.text, isError: true, apiErrorStatus: input.apiErrorStatus, - terminalReason: "api_error", + terminalReason: "terminalReason" in input ? input.terminalReason : "api_error", }), ]); const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "failed"); - if (terminal.status !== "failed") return; - assert.equal(terminal.failure.message === input.text.slice(0, 500).trim(), !input.shown); + assert.equal(terminal.status, "terminalReason" in input ? "interrupted" : "failed"); + if (terminal.status === "failed") { + assert.equal(terminal.failure.message === input.text.slice(0, 500).trim(), !input.shown); + } else { + assert.equal(terminal.failure, null); + } const assistantTexts = harness.events.flatMap((event) => event.type === "message.updated" && event.message.role === "assistant" ? [event.message.text] diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b71e6ab992f9..f379e2ddb441 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7303,8 +7303,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context.assistant.apiErrorFrame = null; if ( apiErrorFrame !== null && - input.failure?.message !== - makeProviderFailure({ message: apiErrorFrame.text }).message.slice(0, 500).trim() + (input.status !== "failed" || + input.failure?.message !== + makeProviderFailure({ message: apiErrorFrame.text }).message.slice(0, 500).trim()) ) { yield* emitAssistantTextArtifacts({ context: input.context, ...apiErrorFrame }); } From 020c2ee85ab5b2d6c0eb4cff918a7bcdc2ba55fe Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 11:30:43 +0000 Subject: [PATCH 4/5] fix(claude): an API error that differs from the final result stays visible --- .../Adapters/ClaudeAdapterV2.test.ts | 261 ++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 60 ++-- packages/provider-core/src/server/failure.ts | 5 + 3 files changed, 294 insertions(+), 32 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4a78e8831f33..e50c17312eeb 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -96,6 +96,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; +import { claudeSignedOutMessage } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -7649,6 +7650,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const longConfigDir = `/claude/${"config/".repeat(600)}`; // regression: #24171 it.effect.each([ { @@ -7663,6 +7665,175 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: false, }, + { + name: "drops the CLI's API error frame when a long 403 failure repeats it", + text: `API Error: 403 ${"access detail ".repeat(40)}`.trim(), + apiErrorStatus: 403, + shown: false, + }, + { + name: "keeps the CLI's API error frame when only the truncated failure matches it", + text: `API Error: ${"safeguard detail ".repeat(40)}first ending`, + result: `API Error: ${"safeguard detail ".repeat(40)}second ending`, + apiErrorStatus: 400, + shown: true, + }, + { + name: "keeps a 500-character API error frame when the longer result goes on", + text: `API Error: ${"boundary detail ".repeat(31)}`.slice(0, 500), + result: `${`API Error: ${"boundary detail ".repeat(31)}`.slice(0, 500)} and more`, + apiErrorStatus: 400, + shown: true, + }, + { + name: "drops the CLI's API error frame when the result adds a trailing newline", + text: "API Error: rejected", + result: "API Error: rejected\n", + apiErrorStatus: 400, + shown: false, + }, + { + name: "drops the CLI's API error frame when the result has a control character", + text: "API Error: rejected detail", + result: "API Error: rejected\u0000detail", + apiErrorStatus: 400, + shown: false, + }, + { + name: "drops the CLI's API error frame when a listed error has a control character", + text: "API Error: rejected detail", + errors: ["API Error: rejected\u0007detail"], + apiErrorStatus: 400, + shown: false, + }, + { + name: "drops the CLI's API error frame when a full-length listed error has a control character", + text: `API Error: ${"x".repeat(4096 - "API Error: ".length)}`, + errors: [`API Error:\u0000${"x".repeat(4096 - "API Error: ".length)}`], + failure: `API Error: ${"x".repeat(4096 - "API Error: ".length)}`, + apiErrorStatus: 400, + shown: false, + }, + { + name: "keeps the CLI's API error frame when it equals only a capped listed error", + text: `API Error: ${"x".repeat(4094 - "API Error: ".length)}…`, + failure: `API Error: ${"x".repeat(4094 - "API Error: ".length)}…`, + errors: [`API Error: ${"x".repeat(4094 - "API Error: ".length)}\u{1F600}tail`], + apiErrorStatus: 400, + shown: true, + }, + { + name: "drops the CLI's API error frame when a listed error repeats it under a longer result", + text: `API Error: ${"x".repeat(489)}`, + result: `API Error: ${"x".repeat(489)} hidden tail`, + errors: [`API Error: ${"x".repeat(489)}`], + failure: `API Error: ${"x".repeat(489)}`, + apiErrorStatus: 400, + shown: false, + }, + { + name: "keeps the CLI's API error frame when a listed error shows only its first 500 characters", + text: `API Error: ${"x".repeat(489)} hidden tail`, + errors: [`API Error: ${"x".repeat(489)}`], + failure: `API Error: ${"x".repeat(489)}`, + apiErrorStatus: 400, + shown: true, + }, + { + name: "keeps the CLI's API error frame when the result has a different secret", + text: "API Error: Bearer frame-value rejected", + result: "API Error: Bearer result-value rejected", + apiErrorStatus: 400, + shown: true, + }, + { + name: "drops the CLI's API error frame when its snapshot repeats and the failure repeats it", + text: "API Error: Claude Opus 5.5's safeguards flagged this message (https://www.anthropic.com/legal/aup).", + repeatedSnapshot: true, + apiErrorStatus: 400, + shown: false, + }, + { + name: "drops the CLI's API error frame when an error result lists it", + text: "API Error: Request could not be completed.", + subtype: "error_during_execution", + errors: ["API Error: Request could not be completed."], + apiErrorStatus: 400, + shown: false, + }, + { + name: "keeps the CLI's API error frame when the endings differ beyond the failure cap", + text: `API Error: ${"detail ".repeat(700)}first ending`, + result: `API Error: ${"detail ".repeat(700)}second ending`, + apiErrorStatus: 400, + shown: true, + }, + { + name: "keeps the CLI's API error frame when a 403 result differs beyond the failure cap", + text: `API Error: 403 ${"access detail ".repeat(300)}first ending`, + result: `API Error: 403 ${"access detail ".repeat(300)}second ending`, + apiErrorStatus: 403, + shown: true, + }, + { + name: "keeps the CLI's API error frame when an error result differs beyond the failure cap", + text: `API Error: ${"detail ".repeat(700)}first ending`, + subtype: "error_during_execution", + errors: [`API Error: ${"detail ".repeat(700)}second ending`], + apiErrorStatus: 400, + shown: true, + }, + { + name: "keeps the CLI's API error frame when a listed error differs beyond the failure cap", + text: `API Error: ${"detail ".repeat(700)}first ending`, + errors: [`API Error: ${"detail ".repeat(700)}second ending`], + apiErrorStatus: 400, + shown: true, + }, + { + name: "keeps the CLI's API error frame when joined diagnostics differ beyond the failure cap", + text: `[ede_diagnostic] ${"detail ".repeat(700)}first ending`, + subtype: "error_during_execution", + errors: [`[ede_diagnostic] ${"detail ".repeat(700)}second ending`], + apiErrorStatus: 400, + terminalReason: null, + shown: true, + }, + { + name: "keeps the CLI's API error frame when a long login hint differs beyond the failure cap", + text: claudeSignedOutMessage({ + configDir: `${longConfigDir}frame`, + cwd: NodePath.resolve("/workspace"), + }), + environment: { CLAUDE_CONFIG_DIR: `${longConfigDir}login` }, + frameError: "authentication_failed" as const, + apiErrorStatus: 401, + shown: true, + }, + { + name: "keeps the CLI's API error frame when a custom endpoint's version error is reworded", + text: "API Error: 400 Claude Code 2.1.280 does not support this model; version 2.1.290 or newer is required. Run 'claude update', or update the Claude desktop app, then try again.", + environment: { + CLAUDE_CONFIG_DIR: "/synthetic/no-claude-config", + ANTHROPIC_BASE_URL: "https://gateway.example.test/v1", + }, + installed: "2.1.322", + apiErrorStatus: 400, + shown: true, + }, + { + name: "keeps the CLI's API error frame when a compaction error differs beyond the failure cap", + text: `API Error: ${"detail ".repeat(700)}first ending`, + compactError: `API Error: ${"detail ".repeat(700)}second ending`, + shown: true, + }, + { + name: "emits the held API error frame before later assistant text", + text: "API Error: 400 Request rejected.", + followingText: "The request was rejected.", + apiErrorStatus: 400, + shown: true, + }, { name: "keeps the CLI's API error frame when the failure says something else", text: "API Error: 429 Specific rate limit detail.", @@ -7678,7 +7849,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, ])("$name", (input) => Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const harness = yield* makeWakeHarnessWithOptions( + "environment" in input ? { environment: input.environment } : undefined, + ); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -7689,25 +7862,74 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); + if ("installed" in input) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "00000000-0000-4000-8000-000000000615", + session_id: WAKE_NATIVE_SESSION, + claude_code_version: input.installed, + }), + ); + } + const result = makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000611", + result: "result" in input ? input.result : input.text, + ...("subtype" in input ? { subtype: input.subtype } : {}), + ...("errors" in input ? { errors: input.errors } : {}), + isError: true, + ...("apiErrorStatus" in input ? { apiErrorStatus: input.apiErrorStatus } : {}), + terminalReason: "terminalReason" in input ? input.terminalReason : "api_error", + }); yield* Queue.offerAll(harness.sdkMessages, [ - makeAssistantErrorFrame({ - uuid: "00000000-0000-4000-8000-000000000610", - error: "invalid_request", - text: input.text, - }), - makeResultFrame({ - uuid: "00000000-0000-4000-8000-000000000611", - result: input.text, - isError: true, - apiErrorStatus: input.apiErrorStatus, - terminalReason: "terminalReason" in input ? input.terminalReason : "api_error", - }), + ...("compactError" in input + ? [ + claudeSdkFrame({ + type: "rate_limit_event", + rate_limit_info: { status: "rejected", rateLimitType: "five_hour", resetsAt: 0 }, + uuid: "00000000-0000-4000-8000-000000000613", + session_id: WAKE_NATIVE_SESSION, + }), + claudeSdkFrame({ + type: "system", + subtype: "status", + status: null, + compact_result: "failed", + compact_error: input.compactError, + uuid: "00000000-0000-4000-8000-000000000614", + session_id: WAKE_NATIVE_SESSION, + }), + ] + : []), + ...Array.from({ length: "repeatedSnapshot" in input ? 2 : 1 }, () => + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000610", + error: "frameError" in input ? input.frameError : "invalid_request", + text: input.text, + }), + ), + ...("followingText" in input + ? [ + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000612", + text: input.followingText, + }), + ] + : []), + "compactError" in input ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, ]); const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "terminalReason" in input ? "interrupted" : "failed"); + assert.equal( + terminal.status, + "terminalReason" in input && input.terminalReason === "aborted_streaming" + ? "interrupted" + : "failed", + ); if (terminal.status === "failed") { - assert.equal(terminal.failure.message === input.text.slice(0, 500).trim(), !input.shown); + if (!input.shown) assert.isTrue(input.text.startsWith(terminal.failure.message)); + if ("failure" in input) assert.equal(terminal.failure.message, input.failure); } else { assert.equal(terminal.failure, null); } @@ -7716,7 +7938,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [event.message.text] : [], ); - assert.deepEqual(assistantTexts, input.shown ? [input.text] : []); + assert.deepEqual( + assistantTexts, + !input.shown + ? [] + : "followingText" in input + ? [input.text, input.followingText] + : [input.text], + ); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index a58daa4b4bd2..77e0e11d46aa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -225,6 +225,7 @@ import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure, makeProviderRetryTurnItem, + uncappedProviderFailureMessage, } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; @@ -4178,6 +4179,13 @@ const awaitClaudeUserInputAnswers = Effect.fn("awaitClaudeUserInputAnswers")(fun return yield* Effect.raceFirst(Deferred.await(answers), cancellation); }); +/** True when a failure built from `source` shows `frameText` whole: the same text, raw or as the failure normalizes it before the cap. */ +function failureRepeatsFrame(source: string | undefined, frameText: string): boolean { + if (source === undefined) return false; + const frame = frameText.trim(); + return source.trim() === frame || uncappedProviderFailureMessage(source) === frame; +} + /** Exclude CLI-internal "[ede_diagnostic] ..." telemetry from error banners. */ function resultUserFacingError(result: SDKResultMessage): string | undefined { const errors = "errors" in result && Array.isArray(result.errors) ? result.errors : []; @@ -4421,7 +4429,7 @@ function providerFailureFromResult( receivedAt: DateTime.Utc, failureHint?: string, usageLimited = false, -): OrchestrationV2ProviderFailure | null { +): { readonly failure: OrchestrationV2ProviderFailure; readonly source: string } | null { const gatewayResetAt = failureHint === undefined && message.subtype === "success" && @@ -4436,13 +4444,15 @@ function providerFailureFromResult( ) : undefined; if (gatewayResetAt !== undefined) { - return makeProviderFailure({ + const source = "Claude API rate limit reached. Try again later."; + const failure = makeProviderFailure({ class: "usage_limit", - message: "Claude API rate limit reached. Try again later.", + message: source, code: "api_error_429", retryable: true, resetAt: gatewayResetAt, }); + return { failure, source }; } const failureClass = (message.subtype === "success" && message.api_error_status === 429) || usageLimited @@ -4461,11 +4471,13 @@ function providerFailureFromResult( ? "Claude API rate limit reached. Try again later." : terminalResultError(message.terminal_reason, failureHint ?? apiErrorMessage); if (message.subtype !== "success") { - return makeProviderFailure({ - message: listedError ?? structuredError ?? message.errors.join("\n"), + const source = listedError ?? structuredError ?? message.errors.join("\n"); + const failure = makeProviderFailure({ + message: source, code: message.subtype, class: failureClass, }); + return { failure, source }; } if (!message.is_error && structuredError === undefined) { return null; @@ -4479,8 +4491,9 @@ function providerFailureFromResult( receivedAt, ) : undefined; - return makeProviderFailure({ - message: listedError ?? structuredError ?? failureHint ?? message.result, + const source = listedError ?? structuredError ?? failureHint ?? message.result; + const failure = makeProviderFailure({ + message: source, code: apiErrorStatus === null ? (message.terminal_reason ?? "sdk_result_error") @@ -4489,6 +4502,14 @@ function providerFailureFromResult( retryable: apiErrorStatus === 429 || apiErrorStatus === 529 ? true : null, resetAt: resetAt ?? null, }); + // The 500-character API error excerpt supplies the message only when nothing ranked above it does. + const fromExcerpt = + listedError === undefined && + failureHint === undefined && + apiErrorMessage !== undefined && + apiErrorStatus !== 429 && + apiErrorStatus !== 529; + return { failure, source: fromExcerpt ? message.result : source }; } function providerFailureFromApiRetry(message: SDKAPIRetryMessage): OrchestrationV2ProviderFailure { @@ -8605,6 +8626,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; readonly completedAt: DateTime.Utc; readonly failure?: OrchestrationV2ProviderFailure; + readonly failureSource?: string; readonly threadDisposition?: "reusable" | "broken"; readonly result?: SDKResultMessage; }) { @@ -8697,8 +8719,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if ( apiErrorFrame !== null && (input.status !== "failed" || - input.failure?.message !== - makeProviderFailure({ message: apiErrorFrame.text }).message.slice(0, 500).trim()) + !failureRepeatsFrame(input.failureSource, apiErrorFrame.text)) ) { yield* emitAssistantTextArtifacts({ context: input.context, ...apiErrorFrame }); } @@ -10986,7 +11007,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (assistantText !== null && assistantText.text.length > 0) { const heldApiError = context.assistant.apiErrorFrame; context.assistant.apiErrorFrame = null; - if (heldApiError !== null) { + // A repeated snapshot of the held frame is that frame again, not later text. + if (heldApiError !== null && heldApiError.nativeItemId !== assistantText.nativeItemId) { yield* emitAssistantTextArtifacts({ context, ...heldApiError }); } if (message.type === "assistant" && message.error !== undefined) { @@ -11172,20 +11194,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resetTimes.length > 0 && resetTimes.every((time) => time !== null) ? resetTimes.reduce((latest, time) => (time! > latest ? time! : latest), "") : null; - const resultFailure = interrupted + const compactionMessage = limitedCompaction?.message ?? failureHint; + const builtFailure = interrupted ? null : limitedCompaction !== null - ? makeProviderFailure({ - class: "usage_limit", - message: limitedCompaction.message ?? failureHint, - code: "compaction_failed", - }) + ? { + failure: makeProviderFailure({ + class: "usage_limit", + message: compactionMessage, + code: "compaction_failed", + }), + source: compactionMessage, + } : providerFailureFromResult( message, bufferedResultArrivals.get(message) ?? completedAt, failureHint, usageLimited, ); + const resultFailure = builtFailure?.failure ?? null; const terminalFailure = resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } @@ -11200,6 +11227,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( completedAt, result: message, ...(terminalFailure === null ? {} : { failure: terminalFailure }), + ...(builtFailure?.source === undefined ? {} : { failureSource: builtFailure.source }), }); } }); diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index d5e9277633bc..31ec1e1514a0 100644 --- a/packages/provider-core/src/server/failure.ts +++ b/packages/provider-core/src/server/failure.ts @@ -256,6 +256,11 @@ function redactProviderFailureText(value: string): string { .trim(); } +/** The message `makeProviderFailure` shows for `message`, before the length cap. */ +export function uncappedProviderFailureMessage(message: string): string { + return redactProviderFailureText(message) || DEFAULT_PROVIDER_FAILURE_MESSAGE; +} + function boundedText(value: string, maxLength: number): string { const redacted = redactProviderFailureText(value); if (redacted.length <= maxLength) return redacted; From b817e2bea0037f4ff749ff861957145d6ae0aba2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 07:37:26 +0000 Subject: [PATCH 5/5] test(claude): keep one case per API error frame path Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 149 +----------------- 1 file changed, 3 insertions(+), 146 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 10d94e695eba..ced8672c8a76 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -98,7 +98,6 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; -import { claudeSignedOutMessage } from "../../provider/Drivers/ClaudeHome.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, @@ -7666,7 +7665,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - const longConfigDir = `/claude/${"config/".repeat(600)}`; // regression: #24171 it.effect.each([ { @@ -7681,12 +7679,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: false, }, - { - name: "drops the CLI's API error frame when a long 403 failure repeats it", - text: `API Error: 403 ${"access detail ".repeat(40)}`.trim(), - apiErrorStatus: 403, - shown: false, - }, { name: "keeps the CLI's API error frame when only the truncated failure matches it", text: `API Error: ${"safeguard detail ".repeat(40)}first ending`, @@ -7694,20 +7686,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: true, }, - { - name: "keeps a 500-character API error frame when the longer result goes on", - text: `API Error: ${"boundary detail ".repeat(31)}`.slice(0, 500), - result: `${`API Error: ${"boundary detail ".repeat(31)}`.slice(0, 500)} and more`, - apiErrorStatus: 400, - shown: true, - }, - { - name: "drops the CLI's API error frame when the result adds a trailing newline", - text: "API Error: rejected", - result: "API Error: rejected\n", - apiErrorStatus: 400, - shown: false, - }, { name: "drops the CLI's API error frame when the result has a control character", text: "API Error: rejected detail", @@ -7715,29 +7693,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: false, }, - { - name: "drops the CLI's API error frame when a listed error has a control character", - text: "API Error: rejected detail", - errors: ["API Error: rejected\u0007detail"], - apiErrorStatus: 400, - shown: false, - }, - { - name: "drops the CLI's API error frame when a full-length listed error has a control character", - text: `API Error: ${"x".repeat(4096 - "API Error: ".length)}`, - errors: [`API Error:\u0000${"x".repeat(4096 - "API Error: ".length)}`], - failure: `API Error: ${"x".repeat(4096 - "API Error: ".length)}`, - apiErrorStatus: 400, - shown: false, - }, - { - name: "keeps the CLI's API error frame when it equals only a capped listed error", - text: `API Error: ${"x".repeat(4094 - "API Error: ".length)}…`, - failure: `API Error: ${"x".repeat(4094 - "API Error: ".length)}…`, - errors: [`API Error: ${"x".repeat(4094 - "API Error: ".length)}\u{1F600}tail`], - apiErrorStatus: 400, - shown: true, - }, { name: "drops the CLI's API error frame when a listed error repeats it under a longer result", text: `API Error: ${"x".repeat(489)}`, @@ -7755,13 +7710,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: true, }, - { - name: "keeps the CLI's API error frame when the result has a different secret", - text: "API Error: Bearer frame-value rejected", - result: "API Error: Bearer result-value rejected", - apiErrorStatus: 400, - shown: true, - }, { name: "drops the CLI's API error frame when its snapshot repeats and the failure repeats it", text: "API Error: Claude Opus 5.5's safeguards flagged this message (https://www.anthropic.com/legal/aup).", @@ -7784,65 +7732,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { apiErrorStatus: 400, shown: true, }, - { - name: "keeps the CLI's API error frame when a 403 result differs beyond the failure cap", - text: `API Error: 403 ${"access detail ".repeat(300)}first ending`, - result: `API Error: 403 ${"access detail ".repeat(300)}second ending`, - apiErrorStatus: 403, - shown: true, - }, - { - name: "keeps the CLI's API error frame when an error result differs beyond the failure cap", - text: `API Error: ${"detail ".repeat(700)}first ending`, - subtype: "error_during_execution", - errors: [`API Error: ${"detail ".repeat(700)}second ending`], - apiErrorStatus: 400, - shown: true, - }, - { - name: "keeps the CLI's API error frame when a listed error differs beyond the failure cap", - text: `API Error: ${"detail ".repeat(700)}first ending`, - errors: [`API Error: ${"detail ".repeat(700)}second ending`], - apiErrorStatus: 400, - shown: true, - }, - { - name: "keeps the CLI's API error frame when joined diagnostics differ beyond the failure cap", - text: `[ede_diagnostic] ${"detail ".repeat(700)}first ending`, - subtype: "error_during_execution", - errors: [`[ede_diagnostic] ${"detail ".repeat(700)}second ending`], - apiErrorStatus: 400, - terminalReason: null, - shown: true, - }, - { - name: "keeps the CLI's API error frame when a long login hint differs beyond the failure cap", - text: claudeSignedOutMessage({ - configDir: `${longConfigDir}frame`, - cwd: NodePath.resolve("/workspace"), - }), - environment: { CLAUDE_CONFIG_DIR: `${longConfigDir}login` }, - frameError: "authentication_failed" as const, - apiErrorStatus: 401, - shown: true, - }, - { - name: "keeps the CLI's API error frame when a custom endpoint's version error is reworded", - text: "API Error: 400 Claude Code 2.1.280 does not support this model; version 2.1.290 or newer is required. Run 'claude update', or update the Claude desktop app, then try again.", - environment: { - CLAUDE_CONFIG_DIR: "/synthetic/no-claude-config", - ANTHROPIC_BASE_URL: "https://gateway.example.test/v1", - }, - installed: "2.1.322", - apiErrorStatus: 400, - shown: true, - }, - { - name: "keeps the CLI's API error frame when a compaction error differs beyond the failure cap", - text: `API Error: ${"detail ".repeat(700)}first ending`, - compactError: `API Error: ${"detail ".repeat(700)}second ending`, - shown: true, - }, { name: "emits the held API error frame before later assistant text", text: "API Error: 400 Request rejected.", @@ -7865,9 +7754,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, ])("$name", (input) => Effect.gen(function* () { - const harness = yield* makeWakeHarnessWithOptions( - "environment" in input ? { environment: input.environment } : undefined, - ); + const harness = yield* makeWakeHarness; yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -7878,17 +7765,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }), ); - if ("installed" in input) { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "init", - uuid: "00000000-0000-4000-8000-000000000615", - session_id: WAKE_NATIVE_SESSION, - claude_code_version: input.installed, - }), - ); - } const result = makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000611", result: "result" in input ? input.result : input.text, @@ -7899,29 +7775,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReason: "terminalReason" in input ? input.terminalReason : "api_error", }); yield* Queue.offerAll(harness.sdkMessages, [ - ...("compactError" in input - ? [ - claudeSdkFrame({ - type: "rate_limit_event", - rate_limit_info: { status: "rejected", rateLimitType: "five_hour", resetsAt: 0 }, - uuid: "00000000-0000-4000-8000-000000000613", - session_id: WAKE_NATIVE_SESSION, - }), - claudeSdkFrame({ - type: "system", - subtype: "status", - status: null, - compact_result: "failed", - compact_error: input.compactError, - uuid: "00000000-0000-4000-8000-000000000614", - session_id: WAKE_NATIVE_SESSION, - }), - ] - : []), ...Array.from({ length: "repeatedSnapshot" in input ? 2 : 1 }, () => makeAssistantErrorFrame({ uuid: "00000000-0000-4000-8000-000000000610", - error: "frameError" in input ? input.frameError : "invalid_request", + error: "invalid_request", text: input.text, }), ), @@ -7933,7 +7790,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ] : []), - "compactError" in input ? claudeSdkFrame({ ...result, local_command: "compact" }) : result, + result, ]); const terminal = yield* Queue.take(harness.terminalReceipts);