mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
3 Commits
v0.85.1-pr
...
v0.85.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
686c0c99b3 | ||
|
|
8e893f51ea | ||
|
|
0c862e2949 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.85.0"
|
||||
".": "0.85.1"
|
||||
}
|
||||
|
||||
@@ -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
12
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -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
52
src/serve.ts
Normal 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
208
src/tests/acp-v2.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
88
src/v2/agent.ts
Normal 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
80
src/v2/initialize.ts
Normal 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" };
|
||||
}
|
||||
Reference in New Issue
Block a user