From 99cc1f324586fad07f1da14bb9764cd16c64e920 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 10 Nov 2026 18:51:11 +0100 Subject: [PATCH] fix: report Claude Code's API error message as a notice in v2 (#1582) ## Problem When an API request fails, Claude Code writes its own assistant message into the conversation, e.g. "API Error: Connection lost mid-response. The response above may be incomplete." The SDK marks it with `error` on a `` message. The v2 agent forwarded that message as an ordinary `agent_message_chunk`, so clients showed it as Claude's own prose. Right after a thinking block it reads as if Claude wrote it, and a client cannot tell it apart without matching the string. ## Change The v2 agent no longer sends that message as Claude's text: - When the turn fails, the idle's `error` already carries the failure, so nothing else is sent. - Otherwise the text goes out as a `notice` with severity `error`, once, before the turn's idle. A result that repeats the same text is not shown again. Claude Code's sign-in message is handled as before. ## Not changed - v1 keeps its current behaviour: clients with the session-failure extension already drop this text, and others keep the transcript line. - `session/load` replay still shows these messages as text. `getSessionMessages` strips the `error` marker, so replay cannot identify them without matching prose. ## Testing - New v2 cases: a notice when the turn ends normally, one notice when the result repeats the text, and no notice when the idle fails. They fail without the change. - `npm run check` and `npm run test:run` pass. --- src/tests/v2/v2.test.ts | 38 +++++++++++++++++++++++++++++++++++++ src/v2/outcome.ts | 42 ++++++++++++++++++++++++++++++++--------- src/v2/reports.ts | 19 +++++++++++++++++++ src/v2/session.ts | 20 +++++++++++++++++++- 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/src/tests/v2/v2.test.ts b/src/tests/v2/v2.test.ts index 2242720c..0791fb20 100644 --- a/src/tests/v2/v2.test.ts +++ b/src/tests/v2/v2.test.ts @@ -2119,6 +2119,44 @@ describe("ACP v2 core", () => { }); }); + it.each([ + ["a notice when the turn goes on to end", { result: "" }, "end_turn", 1], + ["a notice once when the result repeats its text", {}, "end_turn", 1], + ["the idle's error when the turn fails", { is_error: true }, "error", 0], + ])( + "reports Claude Code's message for a failed API request as %s, not as Claude's text", + async (_, fields, stopReason, notices) => { + const text = "API Error: Connection lost mid-response. The response above may be incomplete."; + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send({ + ...assistant("msg_api_error", [{ type: "text", text }]), + message: { + id: "msg_api_error", + type: "message", + role: "assistant", + model: "", + content: [{ type: "text", text }], + usage: {}, + }, + error: "server_error", + }); + claude.send(result({ result: text, ...fields })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect( + client.sessionUpdates.filter((update) => update.sessionUpdate === "agent_message_chunk"), + ).toEqual([]); + expect(client.sessionUpdates.filter((update) => update.sessionUpdate === "notice")).toEqual( + Array(notices).fill({ sessionUpdate: "notice", severity: "error", title: text }), + ); + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason }); + }, + ); + it.each([ [ "an error at the turn limit", diff --git a/src/v2/outcome.ts b/src/v2/outcome.ts index 7b4e3f25..26077e82 100644 --- a/src/v2/outcome.ts +++ b/src/v2/outcome.ts @@ -17,7 +17,7 @@ import type { import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js"; import { isSyntheticLoginMessage } from "../message-filters.js"; import { resultEnd } from "../result-end.js"; -import { providerFailureCategory } from "../session-failure-extension.js"; +import { assistantMessageText, providerFailureCategory } from "../session-failure-extension.js"; import { getMatchingModelUsage, modelUsageIncrement, @@ -109,8 +109,8 @@ function failed(error: RequestError): IdleOutcome { /** * What the assistant messages of the open turn reported about its end: the - * error kind of a failed API request, Claude Code's sign-in message, and a - * refusal's explanation. Read from the main conversation's messages. + * error kind and text of a failed API request, Claude Code's sign-in message, + * and a refusal's explanation. Read from the main conversation's messages. */ export class TurnReport { errorKind: SDKAssistantMessageError | undefined; @@ -118,12 +118,22 @@ export class TurnReport { loginRequired = false; /** The explanation of the turn's refusal. */ refusalExplanation: string | undefined; + /** + * The text of the message that Claude Code wrote for a failed API request, + * e.g. "API Error: Connection lost mid-response. The response above may be + * incomplete.", until {@link takeApiError} reads it. + */ + private apiError: string | undefined; /** - * An assistant message of the main conversation. Returns whether it is - * Claude Code's sign-in message, which the client is not shown: its - * `/login` instruction is for Claude Code's terminal, and the turn's idle - * fails with `authRequired` instead. + * An assistant message of the main conversation. Returns whether the + * client is not shown it as Claude's text: + * + * - Claude Code's sign-in message. Its `/login` instruction is for Claude + * Code's terminal, and the turn's idle fails with `authRequired` instead. + * - The message that Claude Code wrote for a failed API request (`error` on + * a `` message). The turn's idle carries it when the turn + * fails, and a notice otherwise (see {@link takeApiError}). */ assistant(message: SDKAssistantMessage): boolean { if (message.error) this.errorKind = message.error; @@ -131,11 +141,24 @@ export class TurnReport { this.refusalExplanation = message.message.stop_details?.explanation ?? this.refusalExplanation; } - if (!isSyntheticLoginMessage(message.message)) return false; - this.loginRequired = true; + if (isSyntheticLoginMessage(message.message)) { + this.loginRequired = true; + return true; + } + if (!message.error || message.message.model !== "") return false; + const text = assistantMessageText(message.message); + if (!text) return false; + this.apiError = text; return true; } + /** The text of the turn's failed API request that no notice or idle reported yet. */ + takeApiError(): string | undefined { + const text = this.apiError; + this.apiError = undefined; + return text; + } + /** * Claude Code's `model_refusal_no_fallback`: the explanation when the * refused message carried none (older CLIs, gateways that drop @@ -155,6 +178,7 @@ export class TurnReport { this.errorKind = undefined; this.loginRequired = false; this.refusalExplanation = undefined; + this.apiError = undefined; } } diff --git a/src/v2/reports.ts b/src/v2/reports.ts index c700d261..dd2bb4dc 100644 --- a/src/v2/reports.ts +++ b/src/v2/reports.ts @@ -41,6 +41,7 @@ import { mcpServerStartupNotice, normalizeNoticeText, noticeUpdate, + splitNoticeText, } from "../session-notices.js"; import { DEFAULT_CONTEXT_WINDOW, @@ -223,6 +224,24 @@ export class SessionReports { ); } + /** + * A notice of the message that Claude Code wrote for a failed API request + * (see `TurnReport.takeApiError`). It is a turn's notice, so a result that + * repeats its text is not shown again. + */ + apiError(text: string): void { + this.host.emit([ + { + sessionId: this.host.sessionId, + update: noticeUpdate({ + severity: "error", + ...splitNoticeText(text, "Claude reported an API error"), + }), + }, + ]); + this.noticeTexts.push(normalizeNoticeText(text)); + } + /** * Claude Code's session state went `idle`: the idle compaction may arm, and * the title may have landed (see {@link SessionTitles.onTurnEnd}). diff --git a/src/v2/session.ts b/src/v2/session.ts index 61157e8c..07f7d37d 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -917,7 +917,8 @@ export class V2Session implements RequestTarget { if (message.parent_tool_use_id === null) this.usage.assistant(message.message.model); if (this.stopped.drops(message)) return; if (message.parent_tool_use_id === null) { - // Claude Code's sign-in message is not shown (see `TurnReport`). + // Claude Code's sign-in message and its message for a failed API + // request are not shown as Claude's text (see `TurnReport`). if (this.turnReport.assistant(message)) return; this.outputStarts(); this.reports.assistant(message); @@ -1121,6 +1122,8 @@ export class V2Session implements RequestTarget { this.fastModeReported(message); } this.reports.result(message); + // Before the result's text below, which can repeat the failed request's. + this.reportApiError(end); // The turn goes on in a fresh context (see `ClearContext`). if (this.clearContext.holdsResult(message, end)) { this.turnReport.reset(); @@ -1172,6 +1175,19 @@ export class V2Session implements RequestTarget { return reporting.then(() => this.closeTurn()); } + /** + * Sends the text of a failed API request that Claude Code wrote into the + * conversation (see `TurnReport.assistant`) as a notice. A turn whose idle + * fails sends none: its error carries the failure. `end` is the turn's + * result, if it has one. + */ + private reportApiError(end: TurnEnd | undefined): void { + const text = this.turnReport.takeApiError(); + if (!text) return; + if (idleOutcome(end, this.cancelledWork, this.turnReport).stopReason === "error") return; + this.reports.apiError(text); + } + /** * Claude Code's `idle`. The one that follows a result is that result's, and * can come after the next cycle's first output. Any other ends the open @@ -1281,6 +1297,8 @@ export class V2Session implements RequestTarget { if (request.coveredByCancel) request.abort.abort(); } } + // A turn that ends without a result, such as an interrupted one. + this.reportApiError(this.lastEnd); this.reports.turnEnded(); this.elicitations.turnEnded(); this.fileChanges?.turnClosed(cancelled);