Compare commits

...

3 Commits

Author SHA1 Message Date
acp-release-bot[bot]
686c0c99b3 chore(main): release 0.85.1 (#1213)
🤖 I have created a release *beep* *boop*
---


##
[0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1)
(2026-10-01)


### Bug Fixes

* answer session/close without waiting for the interrupt reply
([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216))
([0c862e2](0c862e2949))
* fail turns with unfinished tools
([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212))
([7f8d547](7f8d5471e2))
* restore background task stops on replay instead of showing them as
prompts
([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205))
([4ffa625](4ffa625977))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-02 10:58:36 +02:00
Ben Brandt
8e893f51ea 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`.
2026-10-01 16:46:57 +02:00
nikita-ashihmin
0c862e2949 fix: answer session/close without waiting for the interrupt reply (#1216)
## Summary

- `teardownSession` sends the interrupt but does not await its reply
- `query.close()` follows at once and ends the CLI process
- `session/cancel` still awaits the interrupt reply

On a fresh session, the background `getContextUsage` request from
`refreshContextWindowInBackground` is still in flight. The SDK
serializes control requests, so the CLI answered the interrupt only
after it. With a real CLI, `session/close` took 2.5 s in an empty
directory and 3.2 s in a large repository. ACP clients with a 3 s close
timeout timed out. With this change, close answers in about 2 ms, and
the CLI process exits.

## Tests

- `npm run test:run -- src/tests/create-session-options.test.ts`
- `npx tsc --noEmit`
- `npm run check`
2026-10-01 18:03:14 +04:00
11 changed files with 539 additions and 35 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.85.0"
".": "0.85.1"
}

View File

@@ -1,5 +1,14 @@
# Changelog
## [0.85.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.85.0...v0.85.1) (2026-10-01)
### Bug Fixes
* answer session/close without waiting for the interrupt reply ([#1216](https://github.com/agentclientprotocol/claude-agent-acp/issues/1216)) ([0c862e2](https://github.com/agentclientprotocol/claude-agent-acp/commit/0c862e29496bb018e7a4400697bd2315dfd5b845))
* fail turns with unfinished tools ([#1212](https://github.com/agentclientprotocol/claude-agent-acp/issues/1212)) ([7f8d547](https://github.com/agentclientprotocol/claude-agent-acp/commit/7f8d5471e24b981b129b3c9c2d6736c4ba3f8011))
* restore background task stops on replay instead of showing them as prompts ([#1205](https://github.com/agentclientprotocol/claude-agent-acp/issues/1205)) ([4ffa625](https://github.com/agentclientprotocol/claude-agent-acp/commit/4ffa625977af7e85526b9a3098f4dee5844467b4))
## [0.85.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.84.0...v0.85.0) (2026-10-01)

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.85.0",
"version": "0.85.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.85.0",
"version": "0.85.1",
"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"

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.85.0",
"version": "0.85.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -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"

View File

@@ -1,5 +1,6 @@
import {
agent as acpAgent,
AgentApp,
AgentContext,
AuthenticateRequest,
AuthMethod,
@@ -6829,6 +6830,18 @@ export class ClaudeAcpAgent {
}
async cancel(params: CancelNotification): Promise<void> {
await this.cancelTurns(params, { awaitInterrupt: true });
}
/** Cancel the session's turns and interrupt the SDK query. With
* `awaitInterrupt: false` the interrupt is sent, but its reply is not
* awaited. `teardownSession` uses that: it closes the query right after, and
* the reply can queue behind a slow control request, such as the background
* `getContextUsage` of a fresh session. */
private async cancelTurns(
params: CancelNotification,
options: { awaitInterrupt: boolean },
): Promise<void> {
this.exitPlan.cancel(params.sessionId);
const session = this.sessions[params.sessionId];
if (!session) {
@@ -7063,7 +7076,14 @@ export class ClaudeAcpAgent {
}, this.forceCancelGraceMs);
}
const receipt = await session.query.interrupt();
const interrupt = session.query.interrupt();
if (!options.awaitInterrupt) {
// The caller closes the query next, which rejects the pending reply.
// The receipt only adjusts orphan accounting of a live session.
Promise.resolve(interrupt).catch(() => {});
return;
}
const receipt = await interrupt;
// On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
// lists exactly which queued messages survive the interrupt and will still
// run. An orphaned turn whose uuid is absent was dropped by the interrupt
@@ -7169,7 +7189,7 @@ export class ClaudeAcpAgent {
return;
}
try {
await this.cancel({ sessionId });
await this.cancelTurns({ sessionId }, { awaitInterrupt: false });
} catch (error) {
this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
}
@@ -10855,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))
@@ -10903,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 };
}

View File

@@ -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
View 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
View 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" });
});
});
});

View File

@@ -18,6 +18,8 @@ let sessionMessages: Record<string, unknown>[];
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
let initModels: Record<string, unknown>[] | undefined;
let setModelImpl: ((model: string) => Promise<void>) | undefined;
let interruptImpl: (() => Promise<undefined>) | undefined;
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
let mcpAuthenticateImpl: (serverName: string) => Promise<{
authUrl?: string;
@@ -34,8 +36,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.options;
return makeMockQuery({
interrupt: async () => undefined,
close: () => {},
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
close: () => closeSpy(),
initializationResult: async () => ({
models: initModels ?? [
{
@@ -86,6 +88,8 @@ describe("createSession options merging", () => {
vi.mocked(getSessionMessages).mockClear();
initModels = undefined;
setModelImpl = undefined;
interruptImpl = undefined;
closeSpy = vi.fn<() => void>();
mcpServerStatusResult = async () => [];
mcpAuthenticateImpl = async () => ({
requiresUserAction: false,
@@ -847,6 +851,36 @@ describe("createSession options merging", () => {
});
});
describe("session/close right after session/new", () => {
it("answers at once and closes the query", async () => {
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.closeSession({ sessionId });
expect(closeSpy).toHaveBeenCalledOnce();
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
undefined,
);
});
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
// SDK control requests are serialized. On a fresh session the background
// getContextUsage is still in flight, so a real CLI answers an interrupt
// only after it, which took ~3s and timed out the client's close.
contextUsageResult = () => new Promise<never>(() => {});
interruptImpl = () => new Promise<never>(() => {});
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const closed = await Promise.race([
agent.closeSession({ sessionId }).then(() => "closed"),
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
]);
expect(closed).toBe("closed");
expect(closeSpy).toHaveBeenCalledOnce();
});
});
describe("context window seeding", () => {
function sessionFor(sessionId: string) {
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];

88
src/v2/agent.ts Normal file
View 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
View 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" };
}