mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: serve experimental ACP v2 (#1400)
## Summary The draft ACP v2 is no longer opt-in. The adapter always connects through the SDK's protocol router (as codex-acp does), so a client that asks for protocol version 2 or later in `initialize` gets v2, and every other client gets v1 as before. v2 is still experimental and follows the draft as it changes. - Removes `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2`, which only turned on what is now the default. - Adds `CLAUDE_AGENT_ACP_PROTOCOL` to limit the adapter to one version: - unset, empty, or `auto`: both, picked by the client's `initialize`. - `v1`: a client that asks for v2 is negotiated down to v1. This is the escape hatch if a client's v2 handling breaks. - `v2`: a v1 client's `initialize` is rejected with `-32022 Unsupported protocol version`. - Any other value fails at startup, so a typo like `CLAUDE_AGENT_ACP_PROTOCOL=1` doesn't silently serve both. - Fixes the comment in `src/acp-subagents.ts` that said v2 clients don't get subagents. Note: a client that already sends `protocolVersion >= 2` switches to the v2 surface as soon as this lands on `main`, which publishes a preview. ## Testing - New routing tests: down-negotiation to v1, rejecting v1 when limited to v2, env parsing. - `npm run check`, `npm run build`, `npm run test:run` pass. - Ran the built binary over stdio with each setting: default → v2 for a v2 client, `v1` → protocol version 1, `v2` → `-32022` for a v1 client, invalid value → exits with an error at startup. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
11
README.md
11
README.md
@@ -95,10 +95,13 @@ root session. Clients that use the historical `_meta["subagent-transcript"]` cap
|
|||||||
|
|
||||||
### Experimental ACP v2
|
### Experimental ACP v2
|
||||||
|
|
||||||
Started with `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the adapter also serves the
|
The adapter also serves the [draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview):
|
||||||
[draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview): a client that asks for
|
a client that asks for protocol version 2 or later in `initialize` gets v2, and every other client
|
||||||
protocol version 2 or later in `initialize` gets v2, and every other client gets v1, as before.
|
gets v1. The draft can still change, and this surface changes with it.
|
||||||
The draft can still change, and this surface changes with it.
|
|
||||||
|
`CLAUDE_AGENT_ACP_PROTOCOL` limits the adapter to one version. With `v1`, a client that asks
|
||||||
|
for v2 is negotiated down to v1. With `v2`, the adapter rejects the `initialize` of a v1 client.
|
||||||
|
`auto`, the default, serves both.
|
||||||
|
|
||||||
On v2 the adapter serves the session baseline (`session/new`, `session/list`, `session/resume`
|
On v2 the adapter serves the session baseline (`session/new`, `session/list`, `session/resume`
|
||||||
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
|
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
|
|||||||
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
|
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
|
||||||
* as revised): `subagent_update` and `session_message`, which the SDK types.
|
* as revised): `subagent_update` and `session_message`, which the SDK types.
|
||||||
* A client that declares `clientCapabilities.subagents` and is not AIR gets
|
* A client that declares `clientCapabilities.subagents` and is not AIR gets
|
||||||
* it. (ACP v2 clients do not get subagents yet.)
|
* it. (An ACP v2 client gets it without declaring anything, since subagents
|
||||||
|
* are part of v2's baseline.)
|
||||||
* - `air`: the earlier draft of that RFD, which AIR implements:
|
* - `air`: the earlier draft of that RFD, which AIR implements:
|
||||||
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
|
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
|
||||||
* whichever capability it declares (`docs/air-extensions.md`).
|
* whichever capability it declares (`docs/air-extensions.md`).
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { claudeCliPath } from "./acp-agent.js";
|
import { claudeCliPath } from "./acp-agent.js";
|
||||||
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
import { applyManagedPolicyEnv } from "./managed-policy.js";
|
||||||
import { serveAcp } from "./serve.js";
|
import { acpProtocolsFromEnv, serveAcp } from "./serve.js";
|
||||||
import packageJson from "../package.json" with { type: "json" };
|
import packageJson from "../package.json" with { type: "json" };
|
||||||
import { appendFileSync, mkdirSync } from "node:fs";
|
import { appendFileSync, mkdirSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -83,9 +83,8 @@ if (process.argv.includes("--cli")) {
|
|||||||
: undefined;
|
: undefined;
|
||||||
logger?.log("Claude ACP started");
|
logger?.log("Claude ACP started");
|
||||||
|
|
||||||
// The draft ACP v2 is opt-in while it is a draft.
|
|
||||||
const acp = serveAcp(logger, {
|
const acp = serveAcp(logger, {
|
||||||
experimentalV2: process.env.CLAUDE_AGENT_ACP_EXPERIMENTAL_V2 === "1",
|
protocols: acpProtocolsFromEnv(process.env.CLAUDE_AGENT_ACP_PROTOCOL),
|
||||||
});
|
});
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
|
|||||||
52
src/serve.ts
52
src/serve.ts
@@ -1,19 +1,32 @@
|
|||||||
import { ndJsonStream } from "@agentclientprotocol/sdk/unstable";
|
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
|
||||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||||
import { v2AgentApp } from "./v2/agent.js";
|
import { v2AgentApp } from "./v2/agent.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves ACP on stdio.
|
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
|
||||||
*
|
* `auto`, where the client's `initialize` picks the version.
|
||||||
* 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 type AcpProtocols = "auto" | "v1" | "v2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads `CLAUDE_AGENT_ACP_PROTOCOL`: unset, empty, or `auto` serves both
|
||||||
|
* versions, and `v1` or `v2` serves that version alone. Throws on any other
|
||||||
|
* value, so that a typo such as `CLAUDE_AGENT_ACP_PROTOCOL=1` fails at startup
|
||||||
|
* instead of silently serving both.
|
||||||
|
*/
|
||||||
|
export function acpProtocolsFromEnv(value: string | undefined): AcpProtocols {
|
||||||
|
if (value === undefined || value === "") return "auto";
|
||||||
|
if (value === "auto" || value === "v1" || value === "v2") return value;
|
||||||
|
throw new Error(
|
||||||
|
`CLAUDE_AGENT_ACP_PROTOCOL must be "auto", "v1", or "v2", not ${JSON.stringify(value)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serves ACP on stdio through {@link acpProtocolRouter}. */
|
||||||
export function serveAcp(
|
export function serveAcp(
|
||||||
logger: Logger | undefined,
|
logger: Logger | undefined,
|
||||||
options: { experimentalV2: boolean },
|
options: { protocols: AcpProtocols },
|
||||||
): { closed: Promise<void>; dispose(): Promise<void> } {
|
): { closed: Promise<void>; dispose(): Promise<void> } {
|
||||||
const output = nodeToWebWritable(process.stdout);
|
const output = nodeToWebWritable(process.stdout);
|
||||||
const input = nodeToWebReadable(process.stdin);
|
const input = nodeToWebReadable(process.stdin);
|
||||||
@@ -23,9 +36,9 @@ export function serveAcp(
|
|||||||
const onAgent = (created: ClaudeAcpAgent) => {
|
const onAgent = (created: ClaudeAcpAgent) => {
|
||||||
agent = created;
|
agent = created;
|
||||||
};
|
};
|
||||||
const { closed } = options.experimentalV2
|
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols).connect(
|
||||||
? acpProtocolRouter(logger, onAgent).connect(v2.ndJsonStream(output, input))
|
v2.ndJsonStream(output, input),
|
||||||
: v1AgentApp(logger, onAgent).connect(ndJsonStream(output, input));
|
);
|
||||||
return {
|
return {
|
||||||
closed,
|
closed,
|
||||||
dispose: async () => {
|
dispose: async () => {
|
||||||
@@ -36,16 +49,19 @@ export function serveAcp(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes a connection to the protocol version that the client negotiates in
|
* Routes a connection to the protocol version that the client negotiates in
|
||||||
* `initialize`: the experimental draft ACP v2 for a client that requests
|
* `initialize`. With `auto`, a client that requests version 2 or later gets
|
||||||
* version 2 or later, and ACP v1 otherwise. `onAgent` receives the agent of
|
* the draft ACP v2, and every other client gets ACP v1. With `v1`, a v2
|
||||||
* the connection once the router has opened it.
|
* client is negotiated down to v1. With `v2`, the router rejects the
|
||||||
|
* `initialize` of a v1 client as an unsupported protocol version. `onAgent`
|
||||||
|
* receives the agent of the connection once the router has opened it.
|
||||||
*/
|
*/
|
||||||
export function acpProtocolRouter(
|
export function acpProtocolRouter(
|
||||||
logger: Logger | undefined,
|
logger: Logger | undefined,
|
||||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||||
|
protocols: AcpProtocols = "auto",
|
||||||
): v2.AgentProtocolRouter {
|
): v2.AgentProtocolRouter {
|
||||||
return v2
|
const router = v2.agentProtocolRouter();
|
||||||
.agentProtocolRouter()
|
if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent));
|
||||||
.withV1(v1AgentApp(logger, onAgent))
|
if (protocols !== "v1") router.withV2(v2AgentApp(logger, onAgent));
|
||||||
.withV2(v2AgentApp(logger, onAgent));
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import type { ClaudeAcpAgent } from "../acp-agent.js";
|
|||||||
import type { AuthStatusUpdateNotification } from "../auth-status.js";
|
import type { AuthStatusUpdateNotification } from "../auth-status.js";
|
||||||
import { clientSupportsCompactionUpdates } from "../context-compaction.js";
|
import { clientSupportsCompactionUpdates } from "../context-compaction.js";
|
||||||
import { v2DiffContent } from "../diff.js";
|
import { v2DiffContent } from "../diff.js";
|
||||||
import { acpProtocolRouter } from "../serve.js";
|
import { type AcpProtocols, acpProtocolRouter, acpProtocolsFromEnv } from "../serve.js";
|
||||||
import { clientSupportsNotices } from "../session-notices.js";
|
import { clientSupportsNotices } from "../session-notices.js";
|
||||||
import { v1PermissionResponse, v2PermissionRequest } from "../v2/permission.js";
|
import { v1PermissionResponse, v2PermissionRequest } from "../v2/permission.js";
|
||||||
import { v1PromptRequest } from "../v2/prompt.js";
|
import { v1PromptRequest } from "../v2/prompt.js";
|
||||||
@@ -157,10 +157,10 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
/** Connects a fresh router to a client, and returns the client end of the stream. */
|
||||||
function connectRouter(): v2.Stream {
|
function connectRouter(protocols?: AcpProtocols): v2.Stream {
|
||||||
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||||
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||||
acpProtocolRouter(undefined, (agent) => agents.push(agent)).connect({
|
acpProtocolRouter(undefined, (agent) => agents.push(agent), protocols).connect({
|
||||||
readable: toAgent.readable,
|
readable: toAgent.readable,
|
||||||
writable: toClient.writable,
|
writable: toClient.writable,
|
||||||
});
|
});
|
||||||
@@ -316,6 +316,52 @@ describe("ACP protocol routing", () => {
|
|||||||
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
|
expect((method as v2.AuthMethodTerminal).args?.[0]).toBe("--cli");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("negotiates a v2 client down to v1 when limited to v1", async () => {
|
||||||
|
// On the raw stream, since the v2 client app cannot read the v1 response
|
||||||
|
// that a real client would switch to v1 on.
|
||||||
|
const stream = connectRouter("v1");
|
||||||
|
const writer = stream.writable.getWriter();
|
||||||
|
await writer.write({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "initialize",
|
||||||
|
params: { protocolVersion: v2.PROTOCOL_VERSION, info: V2_CLIENT_INFO, capabilities: {} },
|
||||||
|
});
|
||||||
|
const reader = stream.readable.getReader();
|
||||||
|
let response: v2.AnyWireMessage | undefined;
|
||||||
|
while (!response) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
if (!Array.isArray(value) && "id" in value && value.id === 1) response = value;
|
||||||
|
}
|
||||||
|
await writer.close();
|
||||||
|
|
||||||
|
expect(response).toMatchObject({ id: 1, result: { protocolVersion: 1 } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a v1 client when limited to v2", async () => {
|
||||||
|
const initialize = v1
|
||||||
|
.client({ name: "v1-test-client" })
|
||||||
|
.connectWith(connectRouter("v2") as unknown as v1.Stream, (agent) =>
|
||||||
|
agent.request(v1.methods.agent.initialize, {
|
||||||
|
protocolVersion: v1.PROTOCOL_VERSION,
|
||||||
|
clientCapabilities: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(initialize).rejects.toThrow(/Unsupported protocol version/);
|
||||||
|
expect(agents).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the protocols to serve from CLAUDE_AGENT_ACP_PROTOCOL", () => {
|
||||||
|
expect(acpProtocolsFromEnv(undefined)).toBe("auto");
|
||||||
|
expect(acpProtocolsFromEnv("")).toBe("auto");
|
||||||
|
expect(acpProtocolsFromEnv("auto")).toBe("auto");
|
||||||
|
expect(acpProtocolsFromEnv("v1")).toBe("v1");
|
||||||
|
expect(acpProtocolsFromEnv("v2")).toBe("v2");
|
||||||
|
expect(() => acpProtocolsFromEnv("1")).toThrow(/CLAUDE_AGENT_ACP_PROTOCOL/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The commands of every session of the mocked SDK, in v2 form. */
|
/** The commands of every session of the mocked SDK, in v2 form. */
|
||||||
|
|||||||
Reference in New Issue
Block a user