mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 20:01:11 +00:00
Compare commits
2 Commits
v0.104.1
...
v0.104.2-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99cc1f3245 | ||
|
|
bce309f482 |
@@ -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`).
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
129
src/v1/client-connection.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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