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
Started with `CLAUDE_AGENT_ACP_EXPERIMENTAL_V2=1`, the adapter also serves the
[draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview): a client that asks for
protocol version 2 or later in `initialize` gets v2, and every other client gets v1, as before.
The draft can still change, and this surface changes with it.
The adapter also serves the [draft ACP v2](https://agentclientprotocol.com/protocol/v2/overview):
a client that asks for protocol version 2 or later in `initialize` gets v2, and every other client
gets v1. 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`
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,
* as revised): `subagent_update` and `session_message`, which the SDK types.
* 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:
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
* whichever capability it declares (`docs/air-extensions.md`).

View File

@@ -2,7 +2,7 @@
import { claudeCliPath } from "./acp-agent.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 { appendFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
@@ -83,9 +83,8 @@ if (process.argv.includes("--cli")) {
: undefined;
logger?.log("Claude ACP started");
// 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",
protocols: acpProtocolsFromEnv(process.env.CLAUDE_AGENT_ACP_PROTOCOL),
});
async function shutdown() {

View File

@@ -1,19 +1,32 @@
import { ndJsonStream } from "@agentclientprotocol/sdk/unstable";
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
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.
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
* `auto`, where the client's `initialize` picks the version.
*/
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(
logger: Logger | undefined,
options: { experimentalV2: boolean },
options: { protocols: AcpProtocols },
): { closed: Promise<void>; dispose(): Promise<void> } {
const output = nodeToWebWritable(process.stdout);
const input = nodeToWebReadable(process.stdin);
@@ -23,9 +36,9 @@ export function serveAcp(
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));
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols).connect(
v2.ndJsonStream(output, input),
);
return {
closed,
dispose: async () => {
@@ -36,16 +49,19 @@ export function serveAcp(
/**
* 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.
* `initialize`. With `auto`, a client that requests version 2 or later gets
* the draft ACP v2, and every other client gets ACP v1. With `v1`, a v2
* 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(
logger: Logger | undefined,
onAgent: (agent: ClaudeAcpAgent) => void,
protocols: AcpProtocols = "auto",
): v2.AgentProtocolRouter {
return v2
.agentProtocolRouter()
.withV1(v1AgentApp(logger, onAgent))
.withV2(v2AgentApp(logger, onAgent));
const router = v2.agentProtocolRouter();
if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent));
if (protocols !== "v1") router.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 { clientSupportsCompactionUpdates } from "../context-compaction.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 { v1PermissionResponse, v2PermissionRequest } from "../v2/permission.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. */
function connectRouter(): v2.Stream {
function connectRouter(protocols?: AcpProtocols): 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({
acpProtocolRouter(undefined, (agent) => agents.push(agent), protocols).connect({
readable: toAgent.readable,
writable: toClient.writable,
});
@@ -316,6 +316,52 @@ describe("ACP protocol routing", () => {
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. */