mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
refactor: add an opt-in experimental ACP v2 handshake (#1217)
First step toward optional support for the [draft ACP v2 protocol](https://agentclientprotocol.com/protocol/v2/draft/overview). ACP v1 stays the default and is unchanged. ## What it does Set `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1` and the adapter routes each connection by the protocol version the client requests in `initialize` (via the SDK's `AgentProtocolRouter`): - A client that requests version 2 or later gets the draft ACP v2 `initialize`, `auth/login`, and `auth/logout`, plus the `_auth/status_update` push. - Every other client gets ACP v1, served by the same handlers as today. Without the variable, the adapter never builds the router and serves ACP v1 exactly as before. The v2 response does not advertise `capabilities.session` yet. A v2 agent that advertises it must serve the whole session baseline (`session/new`, `list`, `resume`, `close`, `prompt`, `cancel`, `update`), which follow-up PRs add. So for now a v2 client can initialize and authenticate, but cannot open a session. ## How `ClaudeAcpAgent` keeps speaking ACP v1 types. The v2 surface in `src/v2/` is a thin translation layer around it, so v1 and v2 share one implementation: - `v2AgentApp` registers the v2 methods and translates each request to the v1 request the agent already serves, and each response back. For `initialize` that means `info`/`capabilities` instead of `agentInfo`/`agentCapabilities`, and auth methods with `methodId` and a required `type`, with terminal `env` as `{ name, value }` pairs. A v2 client gets no client `fs` or terminals, and boolean config options are baseline. - `V2ClientConnection` is the `AcpClient` of a v2 connection. It sends the agent's v1-shaped messages in their v2 form. Only connection-level messages can reach it so far; session-scoped methods reject until v2 sessions land. - `v1AgentApp` is the existing v1 handler registration, pulled out of `runAcp` so the router can reuse it. Each surface now creates the agent of its connection in `onConnect`, because under the router the connection opens only after `initialize` has been read. `runAcp` (a public export) behaves as before. Also bumps `@agentclientprotocol/sdk` to 1.6.0 (schema v2.0.0-alpha.7). The bump changes nothing for v1: the full suite passes unchanged on it. ## Testing - `src/tests/acp-v2.test.ts` drives the real router over in-memory streams with the SDK's own v1 and v2 client apps (the v2 client validates every response against the v2 schema). It covers the v1 handshake through the router, the v2 handshake, method-not-found for v2 session methods, the `_auth/status_update` push, and gateway `auth/login` followed by `auth/logout`. - Ran the built binary over stdio: v2 client with the flag, v1 client with the flag, v2 client without the flag (gets the unchanged v1 response), and stdin EOF before `initialize` (exits 0). - `npm run build`, `npm run check`, `npm run test:run`.
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -9,7 +9,7 @@
|
||||
"version": "0.85.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.1",
|
||||
"@agentclientprotocol/sdk": "1.6.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.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.5.1.tgz",
|
||||
"integrity": "sha512-nSUIrC1fOR9+ppKfx9KdwjBBND2sAPzFHo4yVQrGbXZHnayUYpwL8CDVR5LSxbapY/oKMOzNP0wvwfyx3PS2KA==",
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.6.0.tgz",
|
||||
"integrity": "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.5.1",
|
||||
"@agentclientprotocol/sdk": "1.6.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.286",
|
||||
"diff": "9.0.0",
|
||||
"zod": "4.6.5"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
agent as acpAgent,
|
||||
AgentApp,
|
||||
AgentContext,
|
||||
AuthenticateRequest,
|
||||
AuthMethod,
|
||||
@@ -10874,21 +10875,25 @@ export async function runPromptWithCancellation(
|
||||
}
|
||||
}
|
||||
|
||||
export function runAcp(logger?: Logger) {
|
||||
const input = nodeToWebWritable(process.stdout);
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
const stream = ndJsonStream(input, output);
|
||||
|
||||
// `connect(...)` returns a connection-scoped peer handle (`connection.client`)
|
||||
// that stays valid for the whole connection, so the agent captures it once.
|
||||
// Handlers close over `agent`, which is assigned synchronously right after
|
||||
// `connect()` returns — before the connection processes any inbound message.
|
||||
// It cannot be `const`: its value depends on `connection.client`, which does
|
||||
// not exist until `connect()` has been called.
|
||||
// eslint-disable-next-line prefer-const
|
||||
let agent: ClaudeAcpAgent;
|
||||
const connection = acpAgent({ name: "claude-code-acp" })
|
||||
/**
|
||||
* The ACP v1 surface of the adapter, for one connection.
|
||||
*
|
||||
* The agent of the connection is created when the connection opens, from the
|
||||
* connection-scoped peer handle (`connection.client`), which stays valid for
|
||||
* the whole connection. Connect handlers run before the connection processes
|
||||
* any inbound message, so every handler below sees the agent. `onAgent`
|
||||
* receives it for the owner of the process (shutdown).
|
||||
*/
|
||||
export function v1AgentApp(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): AgentApp {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
return acpAgent({ name: "claude-code-acp" })
|
||||
.onConnect((connection) => {
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
||||
onAgent(agent);
|
||||
})
|
||||
.onRequest(methods.agent.initialize, (ctx) => agent.initialize(ctx.params))
|
||||
.onRequest(methods.agent.session.new, (ctx) => agent.newSession(ctx.params))
|
||||
.onRequest(methods.agent.session.load, (ctx) => agent.loadSession(ctx.params))
|
||||
@@ -10922,10 +10927,15 @@ export function runAcp(logger?: Logger) {
|
||||
GOAL_CONTROL_METHOD,
|
||||
{ parse: parseGoalRequest },
|
||||
(ctx) => agent.goal(ctx.params),
|
||||
)
|
||||
.connect(stream);
|
||||
);
|
||||
}
|
||||
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
||||
/** Serves ACP v1 on stdio. */
|
||||
export function runAcp(logger?: Logger) {
|
||||
const stream = ndJsonStream(nodeToWebWritable(process.stdout), nodeToWebReadable(process.stdin));
|
||||
let agent!: ClaudeAcpAgent;
|
||||
// `connect` runs the connect handlers before it returns, so `agent` is set.
|
||||
const connection = v1AgentApp(logger, (created) => (agent = created)).connect(stream);
|
||||
return { connection, agent };
|
||||
}
|
||||
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import { claudeCliPath } from "./acp-agent.js";
|
||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||
import { serveAcp } from "./serve.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -82,10 +83,13 @@ if (process.argv.includes("--cli")) {
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
// The draft ACP v2 is opt-in while it is a draft.
|
||||
const acp = serveAcp(logger, {
|
||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
await acp.dispose().catch((err) => {
|
||||
console.error("Error during cleanup:", err);
|
||||
});
|
||||
process.exit(0);
|
||||
@@ -94,7 +98,7 @@ if (process.argv.includes("--cli")) {
|
||||
// Exit cleanly when the ACP connection closes (e.g. stdin EOF, transport
|
||||
// error). Without this, `process.stdin.resume()` keeps the event loop
|
||||
// alive indefinitely, causing orphan process accumulation in oneshot mode.
|
||||
connection.closed.then(shutdown);
|
||||
acp.closed.then(shutdown);
|
||||
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
52
src/serve.ts
Normal file
52
src/serve.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* Serves ACP on stdio.
|
||||
*
|
||||
* With `experimentalV2`, the connection goes through {@link acpProtocolRouter}.
|
||||
* Without it, the adapter serves ACP v1 alone and never routes by protocol
|
||||
* version, exactly as before.
|
||||
*/
|
||||
export function serveAcp(
|
||||
logger: Logger | undefined,
|
||||
options: { experimentalV2: boolean },
|
||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||
const output = nodeToWebWritable(process.stdout);
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = options.experimentalV2
|
||||
? 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!,
|
||||
dispose: async () => {
|
||||
await agent?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes a connection to the protocol version that the client negotiates in
|
||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
||||
* the connection once the router has opened it.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentProtocolRouter {
|
||||
return v2
|
||||
.agentProtocolRouter()
|
||||
.withV1(v1AgentApp(logger, onAgent))
|
||||
.withV2(v2AgentApp(logger, onAgent));
|
||||
}
|
||||
208
src/tests/acp-v2.test.ts
Normal file
208
src/tests/acp-v2.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AuthStatusUpdateNotification } from "../auth-status.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import packageJson from "../../package.json" with { type: "json" };
|
||||
|
||||
const execFileSpy = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
return { ...actual, execFile: execFileSpy };
|
||||
});
|
||||
|
||||
const CLI_SUBSCRIPTION = JSON.stringify({
|
||||
loggedIn: true,
|
||||
authMethod: "claude.ai",
|
||||
apiProvider: "firstParty",
|
||||
email: "user@example.com",
|
||||
subscriptionType: "max",
|
||||
});
|
||||
|
||||
const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" };
|
||||
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// `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: "" });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(agents.splice(0).map((agent) => agent.dispose()));
|
||||
execFileSpy.mockReset();
|
||||
});
|
||||
|
||||
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
||||
function connectRouter(): v2.Stream {
|
||||
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
return { readable: toClient.readable, writable: toAgent.writable };
|
||||
}
|
||||
|
||||
/** A v2 client app that collects every `_auth/status_update` it receives. */
|
||||
function v2Client() {
|
||||
const authUpdates: AuthStatusUpdateNotification[] = [];
|
||||
let notifyAuthUpdate = () => {};
|
||||
const app = v2.client({ name: V2_CLIENT_INFO.name }).onNotification(
|
||||
"_auth/status_update",
|
||||
(params) => params as AuthStatusUpdateNotification,
|
||||
({ params }) => {
|
||||
authUpdates.push(params);
|
||||
notifyAuthUpdate();
|
||||
},
|
||||
);
|
||||
/** Resolves once the client has received `count` auth status updates. */
|
||||
const authUpdate = (count: number) =>
|
||||
new Promise<AuthStatusUpdateNotification>((resolve) => {
|
||||
notifyAuthUpdate = () => {
|
||||
if (authUpdates.length >= count) {
|
||||
resolve(authUpdates[count - 1]);
|
||||
}
|
||||
};
|
||||
notifyAuthUpdate();
|
||||
});
|
||||
return { app, authUpdate };
|
||||
}
|
||||
|
||||
describe("ACP protocol routing", () => {
|
||||
it("serves the unchanged v1 handshake to a v1 client", async () => {
|
||||
let authUpdated!: () => void;
|
||||
const authUpdate = new Promise<void>((resolve) => (authUpdated = resolve));
|
||||
const response = await v1
|
||||
.client({ name: "v1-test-client" })
|
||||
.onNotification(
|
||||
"_auth/status_update",
|
||||
(params) => params,
|
||||
() => authUpdated(),
|
||||
)
|
||||
.connectWith(connectRouter() as unknown as v1.Stream, async (agent) => {
|
||||
const response = await agent.request(v1.methods.agent.initialize, {
|
||||
protocolVersion: v1.PROTOCOL_VERSION,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
await authUpdate;
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(1);
|
||||
expect(response.agentCapabilities?.loadSession).toBe(true);
|
||||
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
|
||||
expect(response.authMethods?.length).toBeGreaterThan(0);
|
||||
for (const method of response.authMethods ?? []) {
|
||||
expect(method).toHaveProperty("id");
|
||||
}
|
||||
});
|
||||
|
||||
it("serves the v2 handshake to a v2 client", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
const response = await app.connectWith(connectRouter(), async (agent) => {
|
||||
const response = await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities: { auth: { terminal: {} } },
|
||||
});
|
||||
await authUpdate(1);
|
||||
return response;
|
||||
});
|
||||
|
||||
expect(response.protocolVersion).toBe(2);
|
||||
expect(response.info).toEqual({
|
||||
name: packageJson.name,
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
});
|
||||
// The 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);
|
||||
for (const method of response.authMethods ?? []) {
|
||||
expect(method).not.toHaveProperty("id");
|
||||
expect(method).toMatchObject({ methodId: expect.any(String), type: "terminal" });
|
||||
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects v2 session methods, 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 expect(
|
||||
agent.request(v2.methods.agent.session.new, { cwd: process.cwd() }),
|
||||
).rejects.toMatchObject({ code: -32601 });
|
||||
await authUpdate(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACP v2 auth", () => {
|
||||
it("pushes the auth status of the connection after initialize", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
const update = await app.connectWith(connectRouter(), async (agent) => {
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
});
|
||||
return authUpdate(1);
|
||||
});
|
||||
|
||||
expect(update.authStatus).toMatchObject({
|
||||
kind: "account",
|
||||
account: { email: "user@example.com" },
|
||||
});
|
||||
});
|
||||
|
||||
it("logs in to a gateway with auth/login and out with auth/logout", async () => {
|
||||
const { app, authUpdate } = v2Client();
|
||||
await app.connectWith(connectRouter(), async (agent) => {
|
||||
const response = await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: V2_CLIENT_INFO,
|
||||
capabilities: { auth: { _meta: { gateway: true } } },
|
||||
});
|
||||
expect(response.authMethods).toContainEqual({
|
||||
methodId: "gateway",
|
||||
type: "agent",
|
||||
name: "Custom model gateway",
|
||||
description: "Use a custom gateway to authenticate and access models",
|
||||
_meta: { gateway: { protocol: "anthropic" } },
|
||||
});
|
||||
await authUpdate(1);
|
||||
|
||||
await agent.request(v2.methods.agent.auth.login, {
|
||||
methodId: "gateway",
|
||||
_meta: { gateway: { baseUrl: "https://gateway.example.com", headers: {} } },
|
||||
});
|
||||
expect((await authUpdate(2)).authStatus).toMatchObject({
|
||||
kind: "gateway",
|
||||
detail: "gateway.example.com",
|
||||
});
|
||||
|
||||
await agent.request(v2.methods.agent.auth.logout, {});
|
||||
expect(execFileSpy).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
["auth", "logout"],
|
||||
expect.any(Function),
|
||||
);
|
||||
expect((await authUpdate(3)).authStatus).toMatchObject({ kind: "account" });
|
||||
});
|
||||
});
|
||||
});
|
||||
88
src/v2/agent.ts
Normal file
88
src/v2/agent.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The experimental draft ACP v2 surface of the adapter.
|
||||
*
|
||||
* `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2
|
||||
* 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 * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
|
||||
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
|
||||
|
||||
/**
|
||||
* The ACP v2 surface, for one connection.
|
||||
*
|
||||
* As in `v1AgentApp`, the agent of the connection is created when the
|
||||
* connection opens, before the connection processes any inbound message.
|
||||
* `onAgent` receives it for the owner of the process (shutdown).
|
||||
*/
|
||||
export function v2AgentApp(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
): v2.AgentApp {
|
||||
let agent!: ClaudeAcpAgent;
|
||||
return v2
|
||||
.agent({ name: "claude-code-acp" })
|
||||
.onConnect((connection) => {
|
||||
agent = new ClaudeAcpAgent(new V2ClientConnection(connection.client), logger);
|
||||
onAgent(agent);
|
||||
})
|
||||
.onRequest(v2.methods.agent.initialize, async ({ params }) =>
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
class V2ClientConnection implements AcpClient {
|
||||
constructor(private readonly ctx: v2.AgentContext) {}
|
||||
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void> {
|
||||
if (!isExtensionMethod(method)) {
|
||||
return Promise.reject(new Error(`${method} is not an ACP extension method`));
|
||||
}
|
||||
return this.ctx.notify(method, params);
|
||||
}
|
||||
|
||||
sessionUpdate(): Promise<void> {
|
||||
return noV2Sessions("session/update");
|
||||
}
|
||||
|
||||
requestPermission(): Promise<never> {
|
||||
return noV2Sessions("session/request_permission");
|
||||
}
|
||||
|
||||
createElicitation(): Promise<never> {
|
||||
return noV2Sessions("elicitation/create");
|
||||
}
|
||||
|
||||
completeElicitation(): Promise<void> {
|
||||
return noV2Sessions("elicitation/complete");
|
||||
}
|
||||
|
||||
// v2 has no client file system. The agent never calls these on v2, because
|
||||
// `v1InitializeRequest` reports no `fs` capability.
|
||||
readTextFile(): Promise<never> {
|
||||
return Promise.reject(new Error("ACP v2 has no client file system"));
|
||||
}
|
||||
|
||||
writeTextFile(): Promise<never> {
|
||||
return Promise.reject(new Error("ACP v2 has no client file system"));
|
||||
}
|
||||
}
|
||||
|
||||
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})`));
|
||||
}
|
||||
80
src/v2/initialize.ts
Normal file
80
src/v2/initialize.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The ACP v2 `initialize` handshake, expressed through the v1 handshake of
|
||||
* `ClaudeAcpAgent`.
|
||||
*
|
||||
* The agent decides its capabilities and auth methods in v1 terms. The v2
|
||||
* request becomes the v1 request that the agent reads, and the v1 response of
|
||||
* the agent becomes the v2 response, so both protocol versions advertise from
|
||||
* one source.
|
||||
*/
|
||||
import {
|
||||
PROTOCOL_VERSION as V1_PROTOCOL_VERSION,
|
||||
type AuthMethod,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
|
||||
/** The v1 `initialize` request that a v2 client means. */
|
||||
export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest {
|
||||
const capabilities = request.capabilities;
|
||||
return {
|
||||
protocolVersion: V1_PROTOCOL_VERSION,
|
||||
clientInfo: request.info,
|
||||
clientCapabilities: {
|
||||
// v2 has no client file system and no client terminals: the agent
|
||||
// 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: {} } },
|
||||
// v1 marks terminal auth support with a boolean, v2 with an object.
|
||||
auth: {
|
||||
terminal: capabilities?.auth?.terminal != null,
|
||||
...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}),
|
||||
},
|
||||
...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}),
|
||||
...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}),
|
||||
},
|
||||
...(request._meta != null ? { _meta: request._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v2 `initialize` response for the v1 response of the agent.
|
||||
*
|
||||
* It advertises no `session` capability yet. A v2 agent that advertises
|
||||
* `session` must serve the whole session baseline (`session/new`, `list`,
|
||||
* `resume`, `close`, `prompt`, `cancel`, and `update`), and the v2 surface of
|
||||
* the adapter does not serve it yet. Nor does it advertise the unstable
|
||||
* `providers` methods, which the v2 surface does not serve yet either.
|
||||
*/
|
||||
export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse {
|
||||
if (!response.agentInfo) {
|
||||
throw new Error("ACP v2 requires agentInfo, and the agent reported none");
|
||||
}
|
||||
const meta = response.agentCapabilities?._meta;
|
||||
return {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: response.agentInfo,
|
||||
// v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth
|
||||
// methods must serve `auth/logout`.
|
||||
capabilities: meta != null ? { _meta: meta } : {},
|
||||
authMethods: (response.authMethods ?? []).map(v2AuthMethod),
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */
|
||||
function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
|
||||
if ("type" in method && method.type === "terminal") {
|
||||
const { id, env, ...terminal } = method;
|
||||
return {
|
||||
...terminal,
|
||||
methodId: id,
|
||||
...(env ? { env: Object.entries(env).map(([name, value]) => ({ name, value })) } : {}),
|
||||
};
|
||||
}
|
||||
const { id, ...agent } = method;
|
||||
return { ...agent, methodId: id, type: "agent" };
|
||||
}
|
||||
Reference in New Issue
Block a user