Compare commits

..

2 Commits

Author SHA1 Message Date
mark.tkachenko
3dba368bc4 refactor: move the v1 steering and async task stop requests out of acp-agent.ts 2026-11-10 18:39:40 +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
6 changed files with 254 additions and 223 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

@@ -32,8 +32,8 @@ import {
ClaudeAcpAgent,
computeSessionFingerprint,
runPromptWithCancellation,
type SteerRequest,
} from "../../v1/acp-agent.js";
import type { SteerRequest } from "../../v1/steering.js";
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
import { createFastModeConfigOption } from "../../session-fast-mode.js";
import {

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,
@@ -151,6 +147,20 @@ import {
clientSupportsAsyncTasks,
} from "../async-tasks.js";
import { SessionBackgroundWork } from "./background-work.js";
import {
ASYNC_TASK_STOP_METHOD,
parseAsyncTaskStopRequest,
type AsyncTaskStopRequest,
type AsyncTaskStopResponse,
} from "./async-task-stop.js";
import {
STEER_METHOD,
STEER_PRIORITY_LATER,
STEER_PRIORITY_NOW,
parseSteerRequest,
type SteerRequest,
type SteerResponse,
} from "./steering.js";
import {
AUTH_STATUS_UPDATE_METHOD,
type AuthStatus,
@@ -305,7 +315,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 {
@@ -566,112 +576,6 @@ const TURN_NO_RESULT_MESSAGE =
"The turn ended without a result: the agent went idle while this prompt was still in flight " +
"(e.g. the model stream dropped mid-turn). Any partial output may be incomplete; please retry.";
/** Custom (extension) request method a client uses to steer the turn that is
* currently running: the message is injected into the in-flight turn rather
* than queued as a separate `session/prompt`. Named `_session/steering` per the
* agreed ACP steering wire protocol; advertised to clients via the top-level
* `InitializeResponse._meta.steering.supported`. */
const STEER_METHOD = "_session/steering";
/** Stops one Claude background task without cancelling the parent prompt turn. */
const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
type AsyncTaskStopRequest = {
sessionId: string;
asyncTaskId: string;
};
type AsyncTaskStopResponse = {
stopped: boolean;
};
function parseAsyncTaskStopRequest(value: unknown): AsyncTaskStopRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw RequestError.invalidParams(undefined, "async task stop params must be an object");
}
const params = value as Record<string, unknown>;
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
if (!sessionId) {
throw RequestError.invalidParams(
undefined,
"async task stop params require a non-empty sessionId",
);
}
if (!asyncTaskId) {
throw RequestError.invalidParams(
undefined,
"async task stop params require a non-empty asyncTaskId",
);
}
return { sessionId, asyncTaskId };
}
/** How urgently the SDK delivers a steered message relative to the running
* turn — an internal Claude implementation detail, not part of the wire
* contract. `now` pre-empts the current generation, while `later` waits for a
* pending permission/elicitation callback to settle instead of cancelling its
* ACP request and hiding the client's user-input card (IJAI-1191). */
const STEER_PRIORITY_NOW = "now" as const;
const STEER_PRIORITY_LATER = "later" as const;
/** Request-level steering options. `promptRequired` is opt-in so existing Hosts
* keep the established idle fallback behavior. */
type SteerMeta = {
[key: string]: unknown;
steering?: {
idleBehavior?: "promptRequired";
};
};
/** Params of a {@link STEER_METHOD} request. Shaped like the relevant subset of
* a `PromptRequest` so the same `promptToClaude` conversion applies. Delivery
* priority is deliberately NOT exposed here — it's an internal detail the agent
* chooses (see {@link STEER_PRIORITY}). */
export type SteerRequest = {
sessionId: string;
prompt: PromptRequest["prompt"];
_meta?: SteerMeta | null;
};
/** Result of a {@link STEER_METHOD} request. The legacy `startedNewTurn` result
* remains the default idle behavior; `promptRequired` is returned only when the
* Host explicitly opts into the host-owned fallback in request `_meta`. */
export type SteerResponse =
| { outcome: "injected" }
| { outcome: "startedNewTurn" }
| { outcome: "promptRequired"; reason: "noRunningTurn" };
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
* content blocks are handed to `promptToClaude`, which tolerates unknown block
* types — but `sessionId` and a non-empty `prompt` array are required. */
function parseSteerRequest(params: unknown): SteerRequest {
if (!params || typeof params !== "object") {
throw RequestError.invalidParams(undefined, "steer params must be an object");
}
const { sessionId, prompt, _meta } = params as Record<string, unknown>;
if (typeof sessionId !== "string" || sessionId.length === 0) {
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
}
if (!Array.isArray(prompt) || prompt.length === 0) {
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
}
const steering =
_meta && typeof _meta === "object" ? (_meta as Record<string, unknown>).steering : undefined;
const idleBehavior =
steering && typeof steering === "object"
? (steering as Record<string, unknown>).idleBehavior
: undefined;
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
}
return {
sessionId,
prompt: prompt as PromptRequest["prompt"],
_meta: _meta as SteerMeta | null | undefined,
};
}
/** Who started a turn. `idleCompaction` is the adapter's own `/compact` (see
* `IdleCompaction`): it is not a user prompt, so it neither disarms idle
* compaction, asks for a title from its prompt, nor clears the published prompt suggestion. */
@@ -1751,116 +1655,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);

35
src/v1/async-task-stop.ts Normal file
View File

@@ -0,0 +1,35 @@
import { RequestError } from "@agentclientprotocol/sdk/unstable";
/** Stops one Claude background task without cancelling the parent prompt turn. */
export const ASYNC_TASK_STOP_METHOD = "_session/async_task/stop";
export type AsyncTaskStopRequest = {
sessionId: string;
asyncTaskId: string;
};
export type AsyncTaskStopResponse = {
stopped: boolean;
};
export function parseAsyncTaskStopRequest(value: unknown): AsyncTaskStopRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw RequestError.invalidParams(undefined, "async task stop params must be an object");
}
const params = value as Record<string, unknown>;
const sessionId = typeof params.sessionId === "string" ? params.sessionId.trim() : "";
const asyncTaskId = typeof params.asyncTaskId === "string" ? params.asyncTaskId.trim() : "";
if (!sessionId) {
throw RequestError.invalidParams(
undefined,
"async task stop params require a non-empty sessionId",
);
}
if (!asyncTaskId) {
throw RequestError.invalidParams(
undefined,
"async task stop params require a non-empty asyncTaskId",
);
}
return { sessionId, asyncTaskId };
}

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

73
src/v1/steering.ts Normal file
View File

@@ -0,0 +1,73 @@
import { PromptRequest, RequestError } from "@agentclientprotocol/sdk/unstable";
/** Custom (extension) request method a client uses to steer the turn that is
* currently running: the message is injected into the in-flight turn rather
* than queued as a separate `session/prompt`. Named `_session/steering` per the
* agreed ACP steering wire protocol; advertised to clients via the top-level
* `InitializeResponse._meta.steering.supported`. */
export const STEER_METHOD = "_session/steering";
/** How urgently the SDK delivers a steered message relative to the running
* turn — an internal Claude implementation detail, not part of the wire
* contract. `now` pre-empts the current generation, while `later` waits for a
* pending permission/elicitation callback to settle instead of cancelling its
* ACP request and hiding the client's user-input card (IJAI-1191). */
export const STEER_PRIORITY_NOW = "now" as const;
export const STEER_PRIORITY_LATER = "later" as const;
/** Request-level steering options. `promptRequired` is opt-in so existing Hosts
* keep the established idle fallback behavior. */
type SteerMeta = {
[key: string]: unknown;
steering?: {
idleBehavior?: "promptRequired";
};
};
/** Params of a {@link STEER_METHOD} request. Shaped like the relevant subset of
* a `PromptRequest` so the same `promptToClaude` conversion applies. Delivery
* priority is deliberately NOT exposed here — it's an internal detail the agent
* chooses (see {@link STEER_PRIORITY}). */
export type SteerRequest = {
sessionId: string;
prompt: PromptRequest["prompt"];
_meta?: SteerMeta | null;
};
/** Result of a {@link STEER_METHOD} request. The legacy `startedNewTurn` result
* remains the default idle behavior; `promptRequired` is returned only when the
* Host explicitly opts into the host-owned fallback in request `_meta`. */
export type SteerResponse =
| { outcome: "injected" }
| { outcome: "startedNewTurn" }
| { outcome: "promptRequired"; reason: "noRunningTurn" };
/** Validate raw JSON-RPC params into a {@link SteerRequest}. Kept minimal — the
* content blocks are handed to `promptToClaude`, which tolerates unknown block
* types — but `sessionId` and a non-empty `prompt` array are required. */
export function parseSteerRequest(params: unknown): SteerRequest {
if (!params || typeof params !== "object") {
throw RequestError.invalidParams(undefined, "steer params must be an object");
}
const { sessionId, prompt, _meta } = params as Record<string, unknown>;
if (typeof sessionId !== "string" || sessionId.length === 0) {
throw RequestError.invalidParams(undefined, "steer params require a non-empty sessionId");
}
if (!Array.isArray(prompt) || prompt.length === 0) {
throw RequestError.invalidParams(undefined, "steer params require a non-empty prompt array");
}
const steering =
_meta && typeof _meta === "object" ? (_meta as Record<string, unknown>).steering : undefined;
const idleBehavior =
steering && typeof steering === "object"
? (steering as Record<string, unknown>).idleBehavior
: undefined;
if (idleBehavior !== undefined && idleBehavior !== "promptRequired") {
throw RequestError.invalidParams(undefined, "unsupported steering idleBehavior");
}
return {
sessionId,
prompt: prompt as PromptRequest["prompt"],
_meta: _meta as SteerMeta | null | undefined,
};
}