Compare commits

...

1 Commits

Author SHA1 Message Date
Ben Brandt
99cc1f3245 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.
2026-11-10 18:51:11 +01:00
4 changed files with 109 additions and 10 deletions

View File

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

View File

@@ -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;
}
}

View File

@@ -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}).

View File

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