Compare commits

...

2 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
Mark Tkachenko
bce309f482 refactor: move the v1 client connection out of acp-agent.ts (#1576)
Moves `ClientConnection` and `ChangedMetaClient` verbatim from
`src/v1/acp-agent.ts` into `src/v1/client-connection.ts`. No behaviour
change.

- Both classes gain `export`; nothing else in them changes.
- `client-connection.ts` imports nothing from `acp-agent.ts`.
- The `acp-client.ts` comment that names where `ClientConnection` lives
now points to the new file.
- Emitted JS differs from `main` only in `dist/v1/acp-agent.js` (the
import and the two classes) plus the new `dist/v1/client-connection.js`.

Review with `git diff --color-moved` to see the move.
2026-11-10 17:54:17 +01:00
7 changed files with 240 additions and 126 deletions

View File

@@ -16,7 +16,7 @@ import type { AcpPermissionRequest } from "./permissions/presentation.js";
* Client-facing surface the agent calls back into. This is the subset of ACP
* client methods the agent actually uses, expressed as a narrow interface so
* tests can supply lightweight mocks. In the v1 agent it is backed by
* `ClientConnection` (`v1/acp-agent.ts`) over the SDK's typed `AgentContext`.
* `ClientConnection` (`v1/client-connection.ts`) over the SDK's typed `AgentContext`.
* The v2 session output passes one that only takes `sessionUpdate`
* (`v2/stream.ts`).
*/

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

@@ -1,15 +1,12 @@
import {
agent as acpAgent,
AgentApp,
AgentContext,
AuthenticateRequest,
AuthMethod,
AuthStatusResponse,
AuthStatusUpdateNotification,
AvailableCommand,
CancelNotification,
ClientCapabilities,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
DisableProviderRequest,
@@ -36,7 +33,6 @@ import {
RequestError,
SetProviderRequest,
SetProviderResponse,
RequestPermissionRequest,
RequestPermissionResponse,
ResumeSessionRequest,
ResumeSessionResponse,
@@ -305,7 +301,7 @@ import {
} from "../session-usage.js";
import { previewPatchContent } from "../diff.js";
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
import { ChangedMetaClient, ClientConnection } from "./client-connection.js";
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "../tool-calls/client-capabilities.js";
import {
@@ -1751,116 +1747,6 @@ const LIMIT_FAILURE_CATEGORY = {
error_max_structured_output_retries: "provider_error",
} as const satisfies Record<string, ClaudeFailureKind>;
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: AcpSessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
}
requestPermission(
params: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
return this.ctx.notify(methods.client.auth.statusUpdate, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
/**
* The client that the agent talks to. For an AIR client, every session update
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
* merges only the top-level tool call fields, not the `_meta` keys. So another
* client gets the full `_meta` on each update.
*/
class ChangedMetaClient implements AcpClient {
private readonly filter = new ChangedMetaFilter();
constructor(
private readonly inner: AcpClient,
private readonly airClient: () => boolean,
) {}
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
if (!this.airClient()) return this.inner.sessionUpdate(params);
const update = this.filter.apply(params.update as SessionNotification["update"]);
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
}
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.inner.requestPermission(params, signal);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.inner.readTextFile(params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.inner.writeTextFile(params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.inner.createElicitation(params, signal);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.inner.completeElicitation(params);
}
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
return this.inner.authStatusUpdate(params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}
function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const cleanup = () => signal.removeEventListener("abort", onAbort);

129
src/v1/client-connection.ts Normal file
View File

@@ -0,0 +1,129 @@
import {
AgentContext,
AuthStatusUpdateNotification,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
methods,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import type { AcpClient } from "../acp-client.js";
import { AcpSessionNotification, asSdkSessionNotification } from "../acp-subagents.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
import type { AcpPermissionRequest } from "../permissions/presentation.js";
/**
* Bridges {@link AcpClient} to the connection-scoped {@link AgentContext}
* exposed by `AgentApp.connect(...)` as `connection.client`. The peer handle is
* valid for the entire connection lifetime, so it is captured once at
* construction.
*/
export class ClientConnection implements AcpClient {
constructor(private readonly ctx: AgentContext) {}
sessionUpdate(params: AcpSessionNotification): Promise<void> {
return this.ctx.notify(methods.client.session.update, asSdkSessionNotification(params));
}
requestPermission(
params: RequestPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.ctx.request(methods.client.session.requestPermission, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.ctx.request(methods.client.fs.readTextFile, params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.ctx.request(methods.client.fs.writeTextFile, params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(methods.client.elicitation.create, params, {
cancellationSignal: signal,
stopWaitingOnCancel: true,
});
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(methods.client.elicitation.complete, params);
}
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
return this.ctx.notify(methods.client.auth.statusUpdate, params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.ctx.notify(method, params);
}
}
/**
* The client that the agent talks to. For an AIR client, every session update
* passes the {@link ChangedMetaFilter} last, after the native subagent routing,
* so a `tool_call_update` carries only the `_meta` keys that changed. ACP
* merges only the top-level tool call fields, not the `_meta` keys. So another
* client gets the full `_meta` on each update.
*/
export class ChangedMetaClient implements AcpClient {
private readonly filter = new ChangedMetaFilter();
constructor(
private readonly inner: AcpClient,
private readonly airClient: () => boolean,
) {}
async sessionUpdate(params: AcpSessionNotification): Promise<void> {
if (!this.airClient()) return this.inner.sessionUpdate(params);
const update = this.filter.apply(params.update as SessionNotification["update"]);
if (update) await this.inner.sessionUpdate({ ...params, update } as AcpSessionNotification);
}
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse> {
return this.inner.requestPermission(params, signal);
}
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
return this.inner.readTextFile(params);
}
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
return this.inner.writeTextFile(params);
}
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.inner.createElicitation(params, signal);
}
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.inner.completeElicitation(params);
}
authStatusUpdate(params: AuthStatusUpdateNotification): Promise<void> {
return this.inner.authStatusUpdate(params);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}

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