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.
This commit is contained in:
Ben Brandt
2026-11-11 14:45:06 +01:00
committed by GitHub
parent ecd066c870
commit 4df69996ca
3 changed files with 68 additions and 6 deletions

View File

@@ -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: "<synthetic>",
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",

View File

@@ -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 `<synthetic>` message). The turn's idle carries it when the turn
* fails, and a notice otherwise (see {@link takeApiError}).
* a `<synthetic>` 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;

View File

@@ -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();