mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
## Problem The v1 schema says a forked session is active once the agent responds, and can be prompted without `session/load` or `session/resume` (agentclientprotocol/agent-client-protocol#2359). `session/fork` only copied the transcript, so prompting the new id failed with "Session not found" until the client loaded or resumed it. ## Change - **The fork starts the new session the way a resume does.** It starts from the copied transcript, with the model and the permission mode that transcript records, and with the request's `cwd`, MCP servers, additional directories and `_meta`. The response carries the session's `modes` and `configOptions`, and the available commands follow it, as for `session/new`. - **A client that loads or resumes after a fork keeps working.** A later `session/load` replays the history into the running session, and a later `session/resume` returns it. Neither starts a second Claude Code process. A load or resume with other settings restarts the session, as for any loaded session. The fingerprint that decides this ignores the AIR fork point in `_meta`. - **Cost:** each fork now starts a Claude Code process. That added about 0.65 s to the fork response in the live run. - **v2:** the v2 fork used to resume the forked session itself. It now uses the session that the v1 fork starts, so the v2 bridge drops its own resume. - **AIR docs:** `docs/air-extensions.md` now says, under the session fork point, what a load or resume after a fork does. ## Evidence **Live, CLI 2.1.293 on Haiku, over v1:** 1. A new session answered "Remember the codeword PELICAN" with `OK`. 2. `session/fork` returned the fork with mode `auto` and the `mode`, `model` and `effort` config options. 3. Prompting the fork right away returned `PELICAN`, with no load or resume in between. 4. A `session/load` of the fork then replayed its history and was ready in 4 ms. The timing log shows one session start for the fork and none for the load, and a further prompt still answered. **Tests:** - New: the fork can be prompted at once, and its response carries modes and config options; a later resume and load reuse the session; and the fork fails when its session can't start. - The existing fork tests now also check that the session starts from the fork, with the request's additional directories. - The v2 fork tests pass unchanged. - **Mutation checks:** removing the start, the response's `modes`, or the additional directories each fails a test. - `npm run check`, build and all 2813 tests pass, with no golden changes.
318 lines
12 KiB
TypeScript
318 lines
12 KiB
TypeScript
/**
|
|
* 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.
|
|
* Where the versions differ, it maps the agent's own types instead: a prompt
|
|
* is served through the agent's turn events (see `prompt.ts`).
|
|
*/
|
|
import type {
|
|
CompleteElicitationNotification,
|
|
CreateElicitationRequest,
|
|
CreateElicitationResponse,
|
|
RequestPermissionResponse,
|
|
} from "@agentclientprotocol/sdk";
|
|
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
|
import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js";
|
|
import type { AcpSessionNotification } from "../acp-subagents.js";
|
|
import type { AcpPermissionRequest } from "../permissions/presentation.js";
|
|
import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
|
|
import { v1PermissionResponse, v2PermissionRequest } from "./permission.js";
|
|
import { v2Foreground, v2Prompt } from "./prompt.js";
|
|
import {
|
|
v1ForkSessionRequest,
|
|
v1NewSessionRequest,
|
|
v1RestoreSessionRequest,
|
|
v1SetSessionConfigOptionRequest,
|
|
v2AvailableCommands,
|
|
v2ConfigOptions,
|
|
v2NewSessionResponse,
|
|
v2ResumeSessionResponse,
|
|
} from "./session.js";
|
|
import { v2SessionUpdate } from "./session-update.js";
|
|
import { V2Terminals } from "./terminal.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).
|
|
*
|
|
* The provider methods pass through: their types are the same in v1 and v2.
|
|
*/
|
|
export function v2AgentApp(
|
|
logger: Logger | undefined,
|
|
onAgent: (agent: ClaudeAcpAgent) => void,
|
|
): v2.AgentApp {
|
|
let agent!: ClaudeAcpAgent;
|
|
let client!: V2ClientConnection;
|
|
return v2
|
|
.agent({ name: "claude-code-acp" })
|
|
.onConnect((connection) => {
|
|
client = new V2ClientConnection(connection.client, logger ?? console);
|
|
agent = new ClaudeAcpAgent(client, logger, {
|
|
v2: true,
|
|
foreground: (sessionId) =>
|
|
v2Foreground((update) => {
|
|
void client.send({ sessionId, update });
|
|
}),
|
|
});
|
|
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))
|
|
.onRequest(v2.methods.agent.providers.list, ({ params }) =>
|
|
agent.unstable_listProviders(params),
|
|
)
|
|
.onRequest(v2.methods.agent.providers.set, ({ params }) => agent.unstable_setProvider(params))
|
|
.onRequest(v2.methods.agent.providers.disable, ({ params }) =>
|
|
agent.unstable_disableProvider(params),
|
|
)
|
|
.onRequest(v2.methods.agent.session.new, async ({ params }) => {
|
|
const response = await agent.newSession(v1NewSessionRequest(params));
|
|
return {
|
|
...v2NewSessionResponse(response),
|
|
...(await setupCommands(agent, response.sessionId, logger)),
|
|
};
|
|
})
|
|
.onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params))
|
|
.onRequest(v2.methods.agent.session.resume, async ({ params }) => {
|
|
const restore = v1RestoreSessionRequest(params);
|
|
const response =
|
|
restore.method === "resume"
|
|
? await agent.resumeSession(restore.request)
|
|
: await client.replaying(params.sessionId, () => agent.loadSession(restore.request));
|
|
return {
|
|
...v2ResumeSessionResponse(response),
|
|
...(await setupCommands(agent, params.sessionId, logger)),
|
|
};
|
|
})
|
|
.onRequest(v2.methods.agent.session.fork, async ({ params }) => {
|
|
// The agent starts the forked session, so the client can prompt it.
|
|
const response = await agent.unstable_forkSession(v1ForkSessionRequest(params));
|
|
return {
|
|
sessionId: response.sessionId,
|
|
...v2ResumeSessionResponse(response),
|
|
...(await setupCommands(agent, response.sessionId, logger)),
|
|
};
|
|
})
|
|
.onRequest(v2.methods.agent.session.close, async ({ params }) => {
|
|
const response = await agent.closeSession(params);
|
|
client.terminals.forget(params.sessionId);
|
|
return response;
|
|
})
|
|
.onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params))
|
|
.onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => {
|
|
const { configOptions, ...response } = await agent.setSessionConfigOption(
|
|
v1SetSessionConfigOptionRequest(params),
|
|
);
|
|
return { ...response, configOptions: v2ConfigOptions(configOptions) };
|
|
})
|
|
.onRequest(v2.methods.agent.session.prompt, ({ params }) =>
|
|
v2Prompt(agent, params, (update) => {
|
|
void client.send({ sessionId: params.sessionId, update });
|
|
}),
|
|
)
|
|
.onNotification(v2.methods.agent.session.cancel, ({ params }) => agent.cancel(params));
|
|
}
|
|
|
|
/**
|
|
* The `availableCommands` of a setup response, so a client can show the
|
|
* session's commands without waiting for an update. The agent sends a v1
|
|
* client the same list as an `available_commands_update` after the response,
|
|
* and a v2 client none (`ClaudeAcpAgent.afterSetupResponse`). Later changes
|
|
* still arrive as updates. An empty list is left out, as the draft asks. The
|
|
* commands are optional, so a failure to read them does not fail the setup.
|
|
*/
|
|
async function setupCommands(
|
|
agent: ClaudeAcpAgent,
|
|
sessionId: string,
|
|
logger: Logger | undefined,
|
|
): Promise<{ availableCommands?: v2.AvailableCommand[] }> {
|
|
try {
|
|
const commands = await agent.availableCommands(sessionId);
|
|
return commands.length > 0 ? { availableCommands: v2AvailableCommands(commands) } : {};
|
|
} catch (error) {
|
|
(logger ?? console).error(`Session ${sessionId}: failed to read the session's commands`, error);
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of
|
|
* the agent as v2 messages.
|
|
*/
|
|
class V2ClientConnection implements AcpClient {
|
|
/**
|
|
* The sessions whose history replays, each with the messages that the
|
|
* replay has cleared so far.
|
|
*/
|
|
private readonly replays = new Map<string, Set<string>>();
|
|
|
|
/** The display terminals of the commands that the agent runs. */
|
|
readonly terminals = new V2Terminals();
|
|
|
|
constructor(
|
|
private readonly ctx: v2.AgentContext,
|
|
private readonly logger: Logger,
|
|
) {}
|
|
|
|
/**
|
|
* Runs `restore`, which replays the history of `sessionId`.
|
|
*
|
|
* The agent replays messages as chunks. A v2 client may still hold a
|
|
* replayed message, and chunks append, so before the first replayed chunk of
|
|
* each message the client gets the message with no content, which clears it,
|
|
* as v2 requires of a message replayed as chunks.
|
|
*/
|
|
async replaying<T>(sessionId: string, restore: () => Promise<T>): Promise<T> {
|
|
this.replays.set(sessionId, new Set());
|
|
try {
|
|
return await restore();
|
|
} finally {
|
|
this.replays.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sends a v2 session update, and logs rather than rejects when that fails.
|
|
* It and {@link sessionUpdate} hand updates to the connection synchronously,
|
|
* so they go out in the order of the calls. The agent's consumer awaits each
|
|
* of its updates before it reports the next turn event, so a turn's state
|
|
* follows the output that came before it. (An update the consumer is still
|
|
* routing can be overtaken by an event from elsewhere, such as a permission
|
|
* request opening.)
|
|
*/
|
|
send(notification: v2.UpdateSessionNotification): Promise<void> {
|
|
return this.ctx.notify(v2.methods.client.session.update, notification).catch((error) => {
|
|
this.logger.error(`Failed to send a ${notification.update.sessionUpdate} update:`, error);
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise<void> {
|
|
const updates = this.v2Updates(notification.sessionId, update).flatMap((v2Update) => {
|
|
const clear = this.replayClear(notification.sessionId, v2Update);
|
|
return clear ? [clear, v2Update] : [v2Update];
|
|
});
|
|
// Every update goes to the connection before any is awaited, so an update
|
|
// that another call sends meanwhile cannot come between them.
|
|
await Promise.all(
|
|
updates.map((v2Update) =>
|
|
this.ctx.notify(v2.methods.client.session.update, { ...notification, update: v2Update }),
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The v2 updates of a v1 update: for a tool call report, the updates of its
|
|
* terminal first, then the report itself (see {@link V2Terminals}).
|
|
*/
|
|
private v2Updates(
|
|
sessionId: string,
|
|
update: AcpSessionNotification["update"],
|
|
): v2.SessionUpdate[] {
|
|
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
|
const v2Update = v2SessionUpdate(update);
|
|
return v2Update ? [v2Update] : [];
|
|
}
|
|
const { terminal, report } = this.terminals.split(sessionId, update);
|
|
const reportUpdate = report && v2SessionUpdate(report);
|
|
return [...terminal, ...(reportUpdate ? [reportUpdate] : [])];
|
|
}
|
|
|
|
/**
|
|
* The update that clears the message of a replayed chunk, when it is the
|
|
* first replayed chunk of that message (see {@link replaying}).
|
|
*/
|
|
private replayClear(sessionId: string, update: v2.SessionUpdate): v2.SessionUpdate | undefined {
|
|
const cleared = this.replays.get(sessionId);
|
|
if (!cleared) return undefined;
|
|
const kind = MESSAGE_OF_CHUNK.get(update.sessionUpdate);
|
|
if (!kind) return undefined;
|
|
const { messageId } = update as { messageId: string };
|
|
const key = `${kind} ${messageId}`;
|
|
if (cleared.has(key)) return undefined;
|
|
cleared.add(key);
|
|
return emptyMessage(kind, messageId);
|
|
}
|
|
|
|
async requestPermission(
|
|
params: AcpPermissionRequest,
|
|
signal?: AbortSignal,
|
|
): Promise<RequestPermissionResponse> {
|
|
const response = await this.ctx.request(
|
|
v2.methods.client.session.requestPermission,
|
|
v2PermissionRequest(params),
|
|
{ cancellationSignal: signal },
|
|
);
|
|
return v1PermissionResponse(response);
|
|
}
|
|
|
|
// Elicitation is the same in v1 and v2.
|
|
createElicitation(
|
|
params: CreateElicitationRequest,
|
|
signal?: AbortSignal,
|
|
): Promise<CreateElicitationResponse> {
|
|
return this.ctx.request(
|
|
v2.methods.client.elicitation.create,
|
|
// The v1 types accept a property schema with any tag; the v2 types accept
|
|
// one only in a received value. As on v1, an MCP server's schema is
|
|
// relayed here unchanged, so this cast does not check it.
|
|
params as v2.CreateElicitationRequest,
|
|
{ cancellationSignal: signal },
|
|
);
|
|
}
|
|
|
|
completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
|
return this.ctx.notify(v2.methods.client.elicitation.complete, params);
|
|
}
|
|
|
|
// 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"));
|
|
}
|
|
}
|
|
|
|
/** The message update of each message chunk. */
|
|
const MESSAGE_OF_CHUNK = new Map<string, "user_message" | "agent_message" | "agent_thought">([
|
|
["user_message_chunk", "user_message"],
|
|
["agent_message_chunk", "agent_message"],
|
|
["agent_thought_chunk", "agent_thought"],
|
|
]);
|
|
|
|
function emptyMessage(
|
|
kind: "user_message" | "agent_message" | "agent_thought",
|
|
messageId: string,
|
|
): v2.SessionUpdate {
|
|
const message = { messageId, content: [] };
|
|
switch (kind) {
|
|
case "user_message":
|
|
return { ...message, sessionUpdate: kind };
|
|
case "agent_message":
|
|
return { ...message, sessionUpdate: kind };
|
|
default:
|
|
return { ...message, sessionUpdate: kind };
|
|
}
|
|
}
|
|
|
|
function isExtensionMethod(method: string): method is v2.ExtensionMethod {
|
|
return method.startsWith("_");
|
|
}
|