diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b232252ee7fb..266e4906d599 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8099,6 +8099,172 @@ 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: "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 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: "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 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: "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: "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.", + 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; + 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: [], + }), + ); + 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, [ + ...Array.from({ length: "repeatedSnapshot" in input ? 2 : 1 }, () => + makeAssistantErrorFrame({ + uuid: "00000000-0000-4000-8000-000000000610", + error: "invalid_request", + text: input.text, + }), + ), + ...("followingText" in input + ? [ + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000612", + text: input.followingText, + }), + ] + : []), + result, + ]); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal( + terminal.status, + "terminalReason" in input && input.terminalReason === "aborted_streaming" + ? "interrupted" + : "failed", + ); + if (terminal.status === "failed") { + 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); + } + const assistantTexts = harness.events.flatMap((event) => + event.type === "message.updated" && event.message.role === "assistant" + ? [event.message.text] + : [], + ); + assert.deepEqual( + assistantTexts, + !input.shown + ? [] + : "followingText" in input + ? [input.text, input.followingText] + : [input.text], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.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 6c30816d1ff0..88586937df35 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -228,6 +228,7 @@ import { makeProviderFailure, makeProviderRetryTurnItem, PROVIDER_RETRY_BUDGET_MS, + uncappedProviderFailureMessage, } from "@t3tools/provider-core/server/failure"; import { turnScopedSelectionTransition } from "@t3tools/provider-core/server/selectionTransition"; import { providerMessageTextWithAttachmentPaths } from "@t3tools/provider-core/server/attachmentPrompt"; @@ -4341,6 +4342,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 : []; @@ -4584,7 +4592,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" && @@ -4599,13 +4607,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 @@ -4624,11 +4634,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; @@ -4648,8 +4660,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") @@ -4658,6 +4671,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 { @@ -4850,6 +4871,10 @@ interface ActiveClaudeTurnContext { } | null; // 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; @@ -8934,6 +8959,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; }) { @@ -9027,6 +9053,16 @@ 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.status !== "failed" || + !failureRepeatsFrame(input.failureSource, apiErrorFrame.text)) + ) { + yield* emitAssistantTextArtifacts({ context: input.context, ...apiErrorFrame }); + } + if ( input.context.assistant.emittedNativeItemIds.size === 0 && input.context.assistant.fallbackText.length > 0 @@ -9335,6 +9371,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return now; }); + const flushHeldApiError = Effect.fnUntraced(function* (context: ActiveClaudeTurnContext) { + const held = context.assistant.apiErrorFrame; + context.assistant.apiErrorFrame = null; + if (held !== null) yield* emitAssistantTextArtifacts({ context, ...held }); + }); + const endSubagentsOfClosedQuery = Effect.fnUntraced(function* (input: { readonly closedQuery: ClaudeLiveQueryContext; readonly context: ActiveClaudeTurnContext; @@ -10399,6 +10441,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( reasoning.messageId !== null ) { const assistant = context.assistant; + // A held API error keeps its place before this reply but shows only once text arrives. + if (assistant.apiErrorFrame !== null) { + yield* resolveItemOrdinal(context, assistant.apiErrorFrame.nativeItemId); + if (event.content_block.text.length > 0) yield* flushHeldApiError(context); + } const index = assistant.nextBlockIndex.get(reasoning.messageId) ?? 0; assistant.nextBlockIndex.set(reasoning.messageId, index + 1); const itemId = `${reasoning.messageId}:text:${index}`; @@ -10418,6 +10465,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } else if (event.type === "content_block_delta" && event.delta.type === "text_delta") { const itemId = context.assistant.streamBlocks.get(event.index); if (itemId !== undefined) { + if (event.delta.text.length > 0) yield* flushHeldApiError(context); yield* assistantDeltas.append({ turnId: context.nativeTurnId, itemId, @@ -10472,6 +10520,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (streamed === undefined) { unstreamedSnapshotText.push(block.text); } else { + if (block.text) yield* flushHeldApiError(context); const text = streamed.text !== null && (!block.text || block.text === streamed.text) ? streamed.text @@ -11369,6 +11418,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } if (assistantText !== null && assistantText.text.length > 0) { + const heldApiError = context.assistant.apiErrorFrame; + context.assistant.apiErrorFrame = 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) { + context.assistant.apiErrorFrame = assistantText; + return; + } const text = streamedSnapshotReply === null ? assistantText.text : unstreamedSnapshotText.join(""); if (text.length === 0) return; @@ -11536,20 +11595,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( usageLimited && message.subtype === "success" && message.local_command === "compact" ? context.compactionFailure : 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 resetTimes = Array.from(context.rateLimitResetTimes.values()); const assistantLimit = context.latestAssistantRateLimit; if ( @@ -11579,6 +11643,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( completedAt, result: message, ...(terminalFailure === null ? {} : { failure: terminalFailure }), + ...(builtFailure?.source === undefined ? {} : { failureSource: builtFailure.source }), }); } }); @@ -12836,6 +12901,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( streamItems: new Map(), lastReply: null, noticeTexts: new Set(), + apiErrorFrame: null, }, reasoning: { messageId: null, diff --git a/packages/provider-core/src/server/failure.ts b/packages/provider-core/src/server/failure.ts index 86919622f03d..ed7d7a67ad82 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;