refactor: serve the ACP v2 session lifecycle without prompts (#1219)

Step 2 of opt-in ACP v2 support (step 1: #1217). Behind
`CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the v2 surface now serves
`session/new`, `session/list`, `session/resume` (without replay),
`session/close`, `session/delete`, and `session/set_config_option`. ACP
v1 is unchanged.

`capabilities.session` is still not advertised. Under v2, advertising it
commits the agent to the whole baseline, including `session/prompt` and
`replayFrom: start` replay, and those come in later steps. Until then
these methods are served but not advertised.

## Translation

As in step 1, each v2 request becomes the v1 request that
`ClaudeAcpAgent` already serves, and each v1 response or update becomes
its v2 form (`src/v2/session.ts`, `src/v2/session-update.ts`):

- **MCP servers:** v2 requires a `type` on every transport, but the
agent recognizes a v1 stdio server by its *missing* `type`. A v2 `type:
"stdio"` config passed through as is would be silently skipped, so the
tag is dropped. The lists v1 requires (`args`, `env`, `headers`) are
defaulted. A transport v1 can't express is rejected with invalid params
instead of being left out of the session.
- **Config options:** `id` → `configId`, a select group's `group` →
`groupId`. A select value in `session/set_config_option` drops its v2
`type: "id"`, and value types v1 can't express are rejected.
- **Modes:** responses drop `modes`, and `current_mode_update` is
dropped. The agent always lists the mode as the `mode` config option,
and every mode change also reaches the client as a
`config_option_update` or in the `set_config_option` response.
- **Commands:** `available_commands_update` inputs gain `type: "text"`.
- **Notices, session info, usage:** passed through unchanged. v2 needs
no capability for notices, so a v2 client is reported to the agent as
taking them, and advisories arrive as `notice` instead of transcript
messages.
- **Elicitation:** the same in v1 and v2, so it's forwarded. MCP OAuth
at `session/new` works.
- Update kinds that aren't translated yet (messages, tool calls, plans,
extensions) throw, so a gap shows up as an error, not a missing update.
- `replayFrom` is rejected until replayed history can be translated.

Also bumps `@agentclientprotocol/sdk`:
- to 1.6.1, whose router types `closed` as always present, so `serveAcp`
drops its `closed!`;
- to 1.7.0, which type-checks the v2 extensible-union values the adapter
builds, at any depth
([typescript-sdk#268](https://github.com/agentclientprotocol/typescript-sdk/pull/268)).
The v2 translations compile unchanged. The exception is
`createElicitation`, which relays the v1 request: v1 types a property
schema's tag as any string, and an MCP server's schema passes through
unchecked, as on v1. It gets a cast with a comment saying so.

## Testing

- `src/tests/acp-v2.test.ts` drives the router with the SDK's v2 client
app, which validates every response, session update, and elicitation
against the v2 schema. New cases cover session creation with v2 MCP
servers, config options, and commands; rejected MCP transports;
list/close/resume/delete and rejected `replayFrom`; setting the mode
with no `current_mode_update` reaching the client; notice support; MCP
OAuth through URL elicitation; and unit tests for grouped options,
rejected value types, and untranslated updates. Breaking the stdio or
command-input translation makes the tests fail.
- Ran the built binary over stdio against a real Claude Code session: v2
`initialize`, `session/new`, `list`, setting the mode, and `close` all
passed the v2 client's validation, and the 52 real commands arrived with
`type: "text"` inputs. `session/new` took ~0.7 s, the same as over v1.
- `npm run build`, `npm run check`, `npm run test:run`.
This commit is contained in:
Ben Brandt
2026-10-02 06:00:11 -07:00
committed by GitHub
parent 686c0c99b3
commit c60fa48fd8
8 changed files with 562 additions and 43 deletions

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "0.85.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.6.0",
"@agentclientprotocol/sdk": "1.7.0",
"@anthropic-ai/claude-agent-sdk": "0.3.286",
"diff": "9.0.0",
"zod": "4.6.5"
@@ -39,9 +39,9 @@
}
},
"node_modules/@agentclientprotocol/sdk": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.6.0.tgz",
"integrity": "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ==",
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.7.0.tgz",
"integrity": "sha512-ZORuxsuEnLjRly7t6Ad7zAsWitjWsyprswyPUHz/yycPb+tTm3DGa8XheaHhRXqX8LS9snuxaYavXSRA50TlHw==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"

View File

@@ -65,7 +65,7 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.6.0",
"@agentclientprotocol/sdk": "1.7.0",
"@anthropic-ai/claude-agent-sdk": "0.3.286",
"diff": "9.0.0",
"zod": "4.6.5"

View File

@@ -27,8 +27,7 @@ export function serveAcp(
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
return {
// The router types `closed` as optional, but always sets it.
closed: closed!,
closed,
dispose: async () => {
await agent?.dispose();
},

View File

@@ -1,14 +1,22 @@
/**
* The experimental draft ACP v2 surface, driven through the protocol router
* with the SDK's own v1 and v2 client apps. The v2 client app validates every
* response against the v2 schema, so a malformed v2 message fails the test.
* response, session update, and elicitation it receives against the v2
* schema, so a malformed v2 message fails the test.
*/
import { mkdtemp, rm } from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as v1 from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { ClaudeAcpAgent } from "../acp-agent.js";
import type { AuthStatusUpdateNotification } from "../auth-status.js";
import { acpProtocolRouter } from "../serve.js";
import { clientSupportsNotices } from "../session-notices.js";
import { v2SessionUpdate } from "../v2/session-update.js";
import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "../v2/session.js";
import packageJson from "../../package.json" with { type: "json" };
const execFileSpy = vi.hoisted(() => vi.fn());
@@ -18,6 +26,64 @@ vi.mock("node:child_process", async () => {
return { ...actual, execFile: execFileSpy };
});
/** What the mocked Claude Agent SDK saw and answers. Reset before each test. */
const sdk = vi.hoisted(() => ({
queryOptions: [] as Options[],
mcpServerStatus: async (): Promise<Array<{ name: string; status: string }>> => [],
mcpAuthenticate: async (
_serverName: string,
): Promise<{ authUrl?: string; requiresUserAction: boolean; callbackExpected: boolean }> => ({
requiresUserAction: false,
callbackExpected: false,
}),
}));
const sdkDefaults = { ...sdk };
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
return {
...actual,
query: ({ options }: { options: Options }) => {
sdk.queryOptions.push(options);
return makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet",
description: "Fast",
supportsAutoMode: true,
},
],
}),
supportedCommands: async () => [
{ name: "review", description: "Review a change", argumentHint: "<pull request>" },
],
mcpServerStatus: () => sdk.mcpServerStatus(),
mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName),
});
},
listSessions: vi.fn(async () => [
{
sessionId: "11111111-2222-4333-8444-555555555555",
cwd: "/workspace/project",
summary: "Fix the build",
lastModified: Date.UTC(2026, 9, 1),
},
]),
getSessionMessages: vi.fn(async () => []),
deleteSession: vi.fn(async () => {}),
};
});
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),
}));
const CLI_SUBSCRIPTION = JSON.stringify({
loggedIn: true,
authMethod: "claude.ai",
@@ -29,19 +95,28 @@ const CLI_SUBSCRIPTION = JSON.stringify({
const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" };
const agents: ClaudeAcpAgent[] = [];
/** An empty Claude config directory and a session cwd, so no test reads the real ones. */
let tempDir: string;
let cwd: string;
beforeEach(() => {
beforeEach(async () => {
// `claude auth status --json` reports a subscription, `claude auth logout` succeeds.
execFileSpy.mockImplementation((...invocation: unknown[]) => {
const args = invocation[1] as string[];
const callback = invocation[invocation.length - 1] as (...a: unknown[]) => void;
callback(null, { stdout: args[1] === "status" ? CLI_SUBSCRIPTION : "", stderr: "" });
});
tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-"));
cwd = await mkdtemp(path.join(tempDir, "project-"));
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude"));
Object.assign(sdk, sdkDefaults, { queryOptions: [] });
});
afterEach(async () => {
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
execFileSpy.mockReset();
vi.unstubAllEnvs();
await rm(tempDir, { recursive: true, force: true });
});
/** Connects a fresh router to a client, and returns the client end of the stream. */
@@ -55,18 +130,36 @@ function connectRouter(): v2.Stream {
return { readable: toClient.readable, writable: toAgent.writable };
}
/** A v2 client app that collects every `_auth/status_update` it receives. */
/**
* A v2 client app that collects every `_auth/status_update`, session update,
* and elicitation it receives, and accepts every elicitation.
*/
function v2Client() {
const authUpdates: AuthStatusUpdateNotification[] = [];
const sessionUpdates: v2.UpdateSessionNotification[] = [];
const elicitations: v2.CreateElicitationRequest[] = [];
const completedElicitations: v2.CompleteElicitationNotification[] = [];
let notifyAuthUpdate = () => {};
const app = v2.client({ name: V2_CLIENT_INFO.name }).onNotification(
"_auth/status_update",
(params) => params as AuthStatusUpdateNotification,
({ params }) => {
authUpdates.push(params);
notifyAuthUpdate();
},
);
const app = v2
.client({ name: V2_CLIENT_INFO.name })
.onNotification(
"_auth/status_update",
(params) => params as AuthStatusUpdateNotification,
({ params }) => {
authUpdates.push(params);
notifyAuthUpdate();
},
)
.onNotification(v2.methods.client.session.update, ({ params }) => {
sessionUpdates.push(params);
})
.onRequest(v2.methods.client.elicitation.create, ({ params }) => {
elicitations.push(params);
return { action: "accept" };
})
.onNotification(v2.methods.client.elicitation.complete, ({ params }) => {
completedElicitations.push(params);
});
/** Resolves once the client has received `count` auth status updates. */
const authUpdate = (count: number) =>
new Promise<AuthStatusUpdateNotification>((resolve) => {
@@ -77,7 +170,23 @@ function v2Client() {
};
notifyAuthUpdate();
});
return { app, authUpdate };
/** The session updates of one kind that the client has received. */
const updates = <Kind extends string>(kind: Kind) =>
sessionUpdates
.map((notification) => notification.update)
.filter(
(update): update is Extract<v2.SessionUpdate, { sessionUpdate: Kind }> =>
update.sessionUpdate === kind,
);
return { app, authUpdate, sessionUpdates, updates, elicitations, completedElicitations };
}
async function initializeV2(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) {
return agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
capabilities,
});
}
describe("ACP protocol routing", () => {
@@ -127,7 +236,7 @@ describe("ACP protocol routing", () => {
title: "Claude Agent",
version: packageJson.version,
});
// The v2 session baseline and the providers methods are not served yet.
// The whole v2 session baseline and the providers methods are not served yet.
expect(response.capabilities?.session).toBeUndefined();
expect(response.capabilities?.providers).toBeUndefined();
expect(response.authMethods?.length).toBeGreaterThan(0);
@@ -138,21 +247,217 @@ describe("ACP protocol routing", () => {
}
});
it("rejects v2 session methods, which the v2 surface does not serve yet", async () => {
it("rejects v2 prompts, which the v2 surface does not serve yet", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await agent.request(v2.methods.agent.initialize, {
protocolVersion: v2.PROTOCOL_VERSION,
info: V2_CLIENT_INFO,
});
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await expect(
agent.request(v2.methods.agent.session.new, { cwd: process.cwd() }),
agent.request(v2.methods.agent.session.prompt, {
sessionId,
prompt: [{ type: "text", text: "hello" }],
}),
).rejects.toMatchObject({ code: -32601 });
await authUpdate(1);
});
});
});
describe("ACP v2 sessions", () => {
it("creates a session with v2 MCP servers, config options, and commands", async () => {
const client = v2Client();
const response = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const response = await agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [
{ type: "stdio", name: "files", command: "/usr/local/bin/mcp-files" },
{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" },
],
});
await vi.waitFor(() => expect(client.updates("available_commands_update")).toHaveLength(1));
await client.authUpdate(1);
return response;
});
// A v2 stdio server has a `type`, which v1 omits, and may omit its lists.
expect(sdk.queryOptions[0].mcpServers).toMatchObject({
files: { type: "stdio", command: "/usr/local/bin/mcp-files", args: [] },
linear: { type: "http", url: "https://mcp.linear.app/mcp" },
});
expect(response).not.toHaveProperty("modes");
expect(response.configOptions?.map((option) => option.configId)).toEqual(["mode", "model"]);
for (const option of response.configOptions ?? []) {
expect(option).not.toHaveProperty("id");
}
expect(response.configOptions?.[0]).toMatchObject({ category: "mode", type: "select" });
expect(client.updates("available_commands_update")[0].availableCommands).toEqual([
{
name: "review",
description: "Review a change",
input: { type: "text", hint: "<pull request>" },
},
]);
});
it("rejects an MCP transport that v1 cannot express", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await expect(
agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "_custom", name: "custom" }],
}),
).rejects.toMatchObject({ code: -32602 });
await authUpdate(1);
});
expect(sdk.queryOptions).toHaveLength(0);
});
it("lists, resumes, closes, and deletes sessions", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
expect(await agent.request(v2.methods.agent.session.list, {})).toEqual({
sessions: [
{
sessionId: "11111111-2222-4333-8444-555555555555",
cwd: "/workspace/project",
title: "Fix the build",
updatedAt: "2026-10-01T00:00:00.000Z",
},
],
});
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.close, { sessionId });
// A closed session is resumed from its transcript.
const resumed = await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
expect(sdk.queryOptions.at(-1)?.resume).toBe(sessionId);
expect(resumed).not.toHaveProperty("modes");
expect(resumed.configOptions?.[0].configId).toBe("mode");
await expect(
agent.request(v2.methods.agent.session.resume, {
sessionId,
cwd,
replayFrom: { type: "start" },
}),
).rejects.toMatchObject({ code: -32602 });
await agent.request(v2.methods.agent.session.delete, { sessionId });
await authUpdate(1);
});
});
it("sets a config option with a v2 value and reports modes only as config options", async () => {
const client = v2Client();
const response = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const response = await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "mode",
type: "id",
value: "plan",
});
await client.authUpdate(1);
return response;
});
expect(response.configOptions[0]).toMatchObject({ configId: "mode", currentValue: "plan" });
expect(client.updates("current_mode_update")).toEqual([]);
});
it("tells the agent that a v2 client takes notices", async () => {
const { app, authUpdate } = v2Client();
await app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
await authUpdate(1);
});
expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true);
});
it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => {
let statusCall = 0;
sdk.mcpServerStatus = async () =>
statusCall++ === 0
? [{ name: "linear", status: "needs-auth" }]
: [{ name: "linear", status: "connected" }];
sdk.mcpAuthenticate = async () => ({
authUrl: "https://example.com/oauth/authorize",
requiresUserAction: true,
callbackExpected: true,
});
const client = v2Client();
const sessionId = await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent, { elicitation: { url: {} } });
const { sessionId } = await agent.request(v2.methods.agent.session.new, {
cwd,
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
});
await vi.waitFor(() => expect(client.completedElicitations).toHaveLength(1));
await client.authUpdate(1);
return sessionId;
});
expect(client.elicitations).toEqual([
{
mode: "url",
sessionId,
message: "Authenticate with MCP server linear",
url: "https://example.com/oauth/authorize",
elicitationId: expect.stringMatching(/^mcp-oauth-/),
},
]);
expect(client.completedElicitations).toEqual([
{ elicitationId: (client.elicitations[0] as { elicitationId: string }).elicitationId },
]);
});
});
describe("ACP v2 session translation", () => {
it("renames the group id of grouped select options", () => {
expect(
v2ConfigOptions([
{
id: "model",
name: "Model",
type: "select",
currentValue: "sonnet",
options: [{ group: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
},
]),
).toEqual([
{
configId: "model",
name: "Model",
type: "select",
currentValue: "sonnet",
options: [{ groupId: "claude", name: "Claude", options: [{ value: "sonnet", name: "S" }] }],
},
]);
});
it("rejects a config option value type that v1 cannot express", () => {
expect(() =>
v1SetSessionConfigOptionRequest({
sessionId: "s",
configId: "c",
type: "_range",
value: 3,
}),
).toThrow("Config option values of type _range are not supported");
});
it("fails on session updates that it does not translate yet", () => {
expect(() =>
v2SessionUpdate({ sessionUpdate: "tool_call", toolCallId: "t", title: "Read" }),
).toThrow("does not translate tool_call session updates yet");
});
});
describe("ACP v2 auth", () => {
it("pushes the auth status of the connection after initialize", async () => {
const { app, authUpdate } = v2Client();

View File

@@ -5,9 +5,24 @@
* request into the v1 request that the agent serves, and each v1 message that
* the agent sends into its v2 form, so v1 and v2 share one implementation.
*/
import type {
CompleteElicitationNotification,
CreateElicitationRequest,
CreateElicitationResponse,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
import {
v1NewSessionRequest,
v1ResumeSessionRequest,
v1SetSessionConfigOptionRequest,
v2ConfigOptions,
v2NewSessionResponse,
v2ResumeSessionResponse,
} from "./session.js";
import { v2SessionUpdate } from "./session-update.js";
/**
* The ACP v2 surface, for one connection.
@@ -31,16 +46,30 @@ export function v2AgentApp(
v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))),
)
.onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params))
.onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params));
.onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params))
.onRequest(v2.methods.agent.session.new, async ({ params }) =>
v2NewSessionResponse(await agent.newSession(v1NewSessionRequest(params))),
)
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
.onRequest(v2.methods.agent.session.resume, async ({ params }) =>
v2ResumeSessionResponse(await agent.resumeSession(v1ResumeSessionRequest(params))),
)
.onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params))
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
.onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => {
const { configOptions, ...response } = await agent.setSessionConfigOption(
v1SetSessionConfigOptionRequest(params),
);
return { ...response, configOptions: v2ConfigOptions(configOptions) };
});
}
/**
* The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of
* the agent as v2 messages.
*
* The v2 surface serves no sessions yet, so only connection-level messages
* reach it, such as the `_auth/status_update` extension notification. Every
* session-scoped method rejects until the v2 surface serves sessions.
* The v2 surface does not serve prompts yet, so permission requests, which
* only a prompt turn makes, still reject.
*/
class V2ClientConnection implements AcpClient {
constructor(private readonly ctx: v2.AgentContext) {}
@@ -52,20 +81,39 @@ class V2ClientConnection implements AcpClient {
return this.ctx.notify(method, params);
}
sessionUpdate(): Promise<void> {
return noV2Sessions("session/update");
async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise<void> {
const v2Update = v2SessionUpdate(update);
if (v2Update) {
await this.ctx.notify(v2.methods.client.session.update, {
...notification,
update: v2Update,
});
}
}
requestPermission(): Promise<never> {
return noV2Sessions("session/request_permission");
return Promise.reject(
new Error("The ACP v2 surface does not serve session/request_permission yet"),
);
}
createElicitation(): Promise<never> {
return noV2Sessions("elicitation/create");
// Elicitation is the same in v1 and v2.
createElicitation(
params: CreateElicitationRequest,
signal?: AbortSignal,
): Promise<CreateElicitationResponse> {
return this.ctx.request(
v2.methods.client.elicitation.create,
// The v1 types accept a property schema with any tag; the v2 types accept
// one only in a received value. As on v1, an MCP server's schema is
// relayed here unchanged, so this cast does not check it.
params as v2.CreateElicitationRequest,
{ cancellationSignal: signal },
);
}
completeElicitation(): Promise<void> {
return noV2Sessions("elicitation/complete");
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
}
// v2 has no client file system. The agent never calls these on v2, because
@@ -82,7 +130,3 @@ class V2ClientConnection implements AcpClient {
function isExtensionMethod(method: string): method is v2.ExtensionMethod {
return method.startsWith("_");
}
function noV2Sessions(method: string): Promise<never> {
return Promise.reject(new Error(`The ACP v2 surface does not serve sessions yet (${method})`));
}

View File

@@ -26,8 +26,10 @@ export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRe
// reads, writes, and runs commands itself.
fs: { readTextFile: false, writeTextFile: false },
terminal: false,
// Every v2 client handles boolean config options; v1 asks for a marker.
session: { configOptions: { boolean: {} } },
// Every v2 client handles boolean config options and notices; v1 asks
// for a marker. With notices, the agent reports advisories as notices
// rather than as transcript messages.
session: { configOptions: { boolean: {} }, notices: {} },
// v1 marks terminal auth support with a boolean, v2 with an object.
auth: {
terminal: capabilities?.auth?.terminal != null,

38
src/v2/session-update.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* The v2 form of the session updates that `ClaudeAcpAgent` sends as v1
* updates.
*/
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
/**
* The v2 form of a v1 session update, or `undefined` when v2 has no
* counterpart and the update tells a v2 client nothing new.
*
* Update kinds that the v2 surface does not translate yet throw, so a gap is
* an error rather than a silently missing update.
*/
export function v2SessionUpdate(
update: AcpSessionNotification["update"],
): v2.SessionUpdate | undefined {
switch (update.sessionUpdate) {
case "available_commands_update":
return { ...update, availableCommands: v2AvailableCommands(update.availableCommands) };
case "config_option_update":
return { ...update, configOptions: v2ConfigOptions(update.configOptions) };
case "session_info_update":
case "usage_update":
case "notice":
return update;
case "current_mode_update":
// v2 has no modes: the mode is the `mode` config option. Every mode
// change also reaches the client as a `config_option_update` or in the
// `configOptions` of a `session/set_config_option` response.
return undefined;
default:
throw new Error(
`The ACP v2 surface does not translate ${update.sessionUpdate} session updates yet`,
);
}
}

131
src/v2/session.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* The ACP v2 session lifecycle, expressed through the v1 session methods of
* `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves,
* and its v1 responses become v2 responses.
*/
import {
RequestError,
type AvailableCommand,
type McpServer,
type NewSessionRequest,
type NewSessionResponse,
type ResumeSessionRequest,
type ResumeSessionResponse,
type SessionConfigOption,
type SessionConfigSelectGroup,
type SessionConfigSelectOptions,
type SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
const { mcpServers, ...request } = params;
// v1 requires the list; v2 treats an omitted list as an empty one.
return { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) };
}
/**
* `replayFrom` is rejected until the v2 surface can translate the replayed
* history, which needs the message and tool call updates.
*/
export function v1ResumeSessionRequest(params: v2.ResumeSessionRequest): ResumeSessionRequest {
const { replayFrom, mcpServers, ...request } = params;
if (replayFrom != null) {
throw RequestError.invalidParams(
{ replayFrom },
"The ACP v2 surface does not replay session history yet",
);
}
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
}
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse {
return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) };
}
export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse {
return {
...v2ConfigOptionsField(response.configOptions),
...(response._meta != null ? { _meta: response._meta } : {}),
};
}
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
export function v1SetSessionConfigOptionRequest(
params: v2.SetSessionConfigOptionRequest,
): SetSessionConfigOptionRequest {
const request = {
sessionId: params.sessionId,
configId: params.configId,
...(params._meta != null ? { _meta: params._meta } : {}),
};
if (v2.SetSessionConfigOptionRequest.isId(params)) {
return { ...request, value: params.value };
}
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
return { ...request, type: "boolean", value: params.value };
}
throw RequestError.invalidParams(
{ type: params.type },
`Config option values of type ${params.type} are not supported`,
);
}
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
return options.map((option) => {
const { id, ...rest } = option;
if (rest.type === "select") {
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
}
return { ...rest, configId: id };
});
}
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
if (!isGrouped(options)) return options;
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
}
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
return options.some((entry) => "group" in entry);
}
/** v2 tags the free-text input of a command with `type: "text"`. */
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
return commands.map(({ input, ...command }) => ({
...command,
...(input != null ? { input: { type: "text", ...input } } : {}),
}));
}
/** v1 responses may send `null`; v2 omits the field instead. */
function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): {
configOptions?: v2.SessionConfigOption[];
} {
return options != null ? { configOptions: v2ConfigOptions(options) } : {};
}
/**
* v2 requires a `type` on every transport and makes the empty lists optional.
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
* is dropped. A transport that v1 cannot express is rejected rather than
* silently left out of the session.
*/
function v1McpServer(server: v2.McpServer): McpServer {
if (v2.McpServer.isStdio(server)) {
const { name, command, args, env, _meta } = server;
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
}
if (v2.McpServer.isHttp(server)) {
return { ...server, headers: server.headers ?? [] };
}
if (v2.McpServer.isAcp(server)) {
return server;
}
throw RequestError.invalidParams(
{ type: server.type },
`MCP servers of type ${server.type} are not supported`,
);
}