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
|
||||
|
||||
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`,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
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/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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user