Compare commits

...

1 Commits

Author SHA1 Message Date
mark.tkachenko
a39e511c97 refactor: move AcpClient and Logger out of acp-agent.ts 2026-11-09 13:02:48 +01:00
2 changed files with 162 additions and 145 deletions

View File

@@ -1,13 +1,11 @@
import {
agent as acpAgent,
AgentApp,
AgentContext,
AuthenticateRequest,
AuthMethod,
AvailableCommand,
CancelNotification,
ClientCapabilities,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
DisableProviderRequest,
@@ -37,7 +35,6 @@ import {
RequestError,
SetProviderRequest,
SetProviderResponse,
RequestPermissionRequest,
RequestPermissionResponse,
ResumeSessionRequest,
ResumeSessionResponse,
@@ -106,6 +103,10 @@ import {
toGoalSnapshot,
} from "./goal-extension.js";
import { blobResourceToClaude } from "./blob-resources.js";
import { ChangedMetaClient, ClientConnection } from "./acp-client.js";
import type { AcpClient, Logger } from "./acp-client.js";
export type { AcpClient, Logger } from "./acp-client.js";
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
import {
@@ -318,7 +319,6 @@ import {
withRunningTerminal,
withRunningToolCall,
} from "./tool-calls/background.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
import {
@@ -427,16 +427,6 @@ function isEmptyUserInterruptionDiagnostic(
);
}
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}
type AccumulatedUsage = {
inputTokens: number;
outputTokens: number;
@@ -2409,137 +2399,6 @@ export function isReplayHiddenMetaMessage(message: unknown): boolean {
);
}
/**
* 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 production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* 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);
}
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);
}
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);

158
src/acp-client.ts Normal file
View File

@@ -0,0 +1,158 @@
import {
AgentContext,
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
methods,
ReadTextFileRequest,
ReadTextFileResponse,
RequestPermissionRequest,
RequestPermissionResponse,
SessionNotification,
WriteTextFileRequest,
WriteTextFileResponse,
} from "@agentclientprotocol/sdk/unstable";
import { AcpSessionNotification, asSdkSessionNotification } from "./acp-subagents.js";
import { type AcpPermissionRequest } from "./permissions/presentation.js";
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
/**
* Logger interface for customizing logging output
*/
export interface Logger {
log: (...args: any[]) => void;
error: (...args: any[]) => void;
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
warn?: (...args: any[]) => void;
}
/**
* 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 production it is backed by
* {@link ClientConnection} over the SDK's typed `AgentContext`.
*/
export interface AcpClient {
sessionUpdate(params: AcpSessionNotification): Promise<void>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* permission request so the client can dismiss its prompt, and rejects the
* call at once: a client that ignores the cancel never answers. */
requestPermission(
params: AcpPermissionRequest,
signal?: AbortSignal,
): Promise<RequestPermissionResponse>;
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
* elicitation so the client can dismiss its prompt, and rejects the call
* at once, as {@link requestPermission} does. */
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse>;
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
}
/**
* 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);
}
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);
}
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
return this.inner.extNotification(method, params);
}
}