mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 20:01:11 +00:00
Compare commits
1 Commits
v0.85.1
...
v0.85.2-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c60fa48fd8 |
8
package-lock.json
generated
8
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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})`));
|
||||
}
|
||||
|
||||
@@ -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
38
src/v2/session-update.ts
Normal 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
131
src/v2/session.ts
Normal 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`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user