mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-14 21:31:52 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user