mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 03:41:17 +00:00
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 `<synthetic>` 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.
This commit is contained in:
@@ -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: "<synthetic>",
|
||||
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",
|
||||
|
||||
@@ -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 `<synthetic>` 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 !== "<synthetic>") 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}).
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user