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:
Ben Brandt
2026-10-21 13:37:06 +02:00
committed by GitHub
parent a1d0eeeaad
commit 4d378f93f3
5 changed files with 94 additions and 29 deletions

View File

@@ -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`,

View File

@@ -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`).

View File

@@ -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() {

View File

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

View File

@@ -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. */