From 4df69996ca74adff6c42fc60e4b9b742f2d3fdf5 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Wed, 11 Nov 2026 14:45:06 +0100 Subject: [PATCH] fix: report a mid-turn API error notice where the response broke off (#1599) ## Problem When a response stream drops after some blocks arrived, Claude Code keeps the partial response and writes its own message into the conversation, e.g. "API Error: Connection lost mid-response. The response above may be incomplete." If the partial response contained tool calls, Claude Code runs them and the turn goes on. Since #1582 the v2 agent sends this text as a `notice`, but only at the turn's result. In a turn that goes on, the notice arrived after all the later work, so "the response above" pointed at the wrong output. ## Change The v2 agent sends the pending notice as soon as the main conversation goes on past the failed request: at the next tool result, stream event or assistant message. A turn that ends at the failed request behaves as before: the idle's `error` carries it when the turn fails, and a notice goes out at the turn's end otherwise. ## Not changed - v1 keeps its current behaviour, as in #1582. - `session/load` replay still shows these messages as text. ## Testing - New v2 case: a kept tool call, the API error message, the tool result, then more output. The notice comes before the later text. It fails without the change. - The test double follows the message shape and order read from the bundled CLI's code. Not run against a real dropped connection. - `npm run check`, `npm run build` and `npm run test:run` pass. --- src/tests/v2/v2.test.ts | 47 +++++++++++++++++++++++++++++++++++++++++ src/v2/outcome.ts | 5 +++-- src/v2/session.ts | 22 +++++++++++++++---- 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/tests/v2/v2.test.ts b/src/tests/v2/v2.test.ts index 398aae4f..c9f3aaad 100644 --- a/src/tests/v2/v2.test.ts +++ b/src/tests/v2/v2.test.ts @@ -2505,6 +2505,53 @@ describe("ACP v2 core", () => { }, ); + it("reports a failed API request that the turn goes on past where it failed", async () => { + 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 Code keeps the tool call of the response that broke off, and runs it. + claude.send( + assistant("msg_partial", [ + { type: "tool_use", id: "toolu_kept", name: "Bash", input: { command: "ls" } }, + ]), + ); + 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({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "src" }], + }, + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send(assistant("msg_after", [{ type: "text", text: "Done." }])); + claude.send(result({ result: "Done." })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + const notice = { sessionUpdate: "notice", severity: "error", title: text }; + const shown = client.sessionUpdates.filter( + (update) => + update.sessionUpdate === "notice" || update.sessionUpdate === "agent_message_chunk", + ); + expect(shown).toMatchObject([notice, { sessionUpdate: "agent_message_chunk" }]); + expect(shown[0]).toEqual(notice); + }); + it.each([ [ "an error at the turn limit", diff --git a/src/v2/outcome.ts b/src/v2/outcome.ts index 23cdb663..bda779a5 100644 --- a/src/v2/outcome.ts +++ b/src/v2/outcome.ts @@ -136,8 +136,9 @@ export class TurnReport { * - 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}). + * a `` message). A notice carries it where the turn goes on + * past it. At the turn's end, the 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; diff --git a/src/v2/session.ts b/src/v2/session.ts index e78dcd6e..5066b6ad 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -903,6 +903,7 @@ export class V2Session implements RequestTarget { this.tasks.toolResults(this.output, message); return; } + this.wentOnPastApiError(); // The rewrite applies to the updates of this message only. this.output.rewrite = this.clearContext.toolResults(message); try { @@ -935,6 +936,7 @@ export class V2Session implements RequestTarget { if (this.stopped.drops(message)) return; if (message.parent_tool_use_id === null) { this.outputStarts(); + this.wentOnPastApiError(); this.reports.streamEvent(message); } this.output.streamEvent(message); @@ -947,6 +949,7 @@ export class V2Session implements RequestTarget { // request are not shown as Claude's text (see `TurnReport`). if (this.turnReport.assistant(message)) return; this.outputStarts(); + this.wentOnPastApiError(); this.reports.assistant(message); } this.output.assistant(message); @@ -1208,10 +1211,21 @@ export class V2Session implements RequestTarget { } /** - * 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. + * The turn went on past a failed API request: Claude Code kept the tool + * calls of a response that broke off ("Connection lost mid-response. The + * response above may be incomplete.") and ran them. Its notice goes out + * here, after the output it is about, and not at the turn's end. + */ + private wentOnPastApiError(): void { + const text = this.turnReport.takeApiError(); + if (text) this.reports.apiError(text); + } + + /** + * At the turn's end, 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();