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.
161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
/**
|
|
* The ACP v2 session lifecycle, expressed through the v1 session methods of
|
|
* `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves,
|
|
* and its v1 responses become v2 responses.
|
|
*/
|
|
import {
|
|
RequestError,
|
|
type AvailableCommand,
|
|
type ForkSessionRequest,
|
|
type LoadSessionRequest,
|
|
type McpServer,
|
|
type NewSessionRequest,
|
|
type NewSessionResponse,
|
|
type ResumeSessionRequest,
|
|
type ResumeSessionResponse,
|
|
type SessionConfigOption,
|
|
type SessionConfigSelectGroup,
|
|
type SessionConfigSelectOptions,
|
|
type SetSessionConfigOptionRequest,
|
|
} from "@agentclientprotocol/sdk";
|
|
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
|
|
|
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
|
|
const { mcpServers, ...request } = params;
|
|
// v1 requires the list; v2 treats an omitted list as an empty one.
|
|
return { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) };
|
|
}
|
|
|
|
/**
|
|
* The v1 request that a v2 `session/resume` means.
|
|
*
|
|
* v2 restores a session with `session/resume` and replays its history only
|
|
* when `replayFrom` asks for it. v1 has two methods: `session/resume`, which
|
|
* never replays, and `session/load`, which replays the whole history before it
|
|
* responds. `replayFrom: { type: "start" }` is the second; a cursor that
|
|
* starts elsewhere is rejected.
|
|
*/
|
|
export function v1RestoreSessionRequest(
|
|
params: v2.ResumeSessionRequest,
|
|
):
|
|
| { method: "resume"; request: ResumeSessionRequest }
|
|
| { method: "load"; request: LoadSessionRequest } {
|
|
const { replayFrom, mcpServers, ...request } = params;
|
|
if (replayFrom == null) {
|
|
return {
|
|
method: "resume",
|
|
request: { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) },
|
|
};
|
|
}
|
|
if (replayFrom.type !== "start") {
|
|
throw RequestError.invalidParams(
|
|
{ replayFrom },
|
|
`Replaying from a ${replayFrom.type} cursor is not supported`,
|
|
);
|
|
}
|
|
return {
|
|
method: "load",
|
|
request: { ...request, mcpServers: (mcpServers ?? []).map(v1McpServer) },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The v1 request of a v2 `session/fork`. In both versions the agent's fork
|
|
* returns a session that the client can prompt, with its config options.
|
|
*/
|
|
export function v1ForkSessionRequest(params: v2.ForkSessionRequest): ForkSessionRequest {
|
|
const { mcpServers, ...request } = params;
|
|
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
|
|
}
|
|
|
|
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
|
|
export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse {
|
|
return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) };
|
|
}
|
|
|
|
export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse {
|
|
return {
|
|
...v2ConfigOptionsField(response.configOptions),
|
|
...(response._meta != null ? { _meta: response._meta } : {}),
|
|
};
|
|
}
|
|
|
|
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
|
|
export function v1SetSessionConfigOptionRequest(
|
|
params: v2.SetSessionConfigOptionRequest,
|
|
): SetSessionConfigOptionRequest {
|
|
const request = {
|
|
sessionId: params.sessionId,
|
|
configId: params.configId,
|
|
...(params._meta != null ? { _meta: params._meta } : {}),
|
|
};
|
|
if (v2.SetSessionConfigOptionRequest.isId(params)) {
|
|
return { ...request, value: params.value };
|
|
}
|
|
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
|
|
return { ...request, type: "boolean", value: params.value };
|
|
}
|
|
throw RequestError.invalidParams(
|
|
{ type: params.type },
|
|
`Config option values of type ${params.type} are not supported`,
|
|
);
|
|
}
|
|
|
|
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
|
|
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
|
|
return options.map((option) => {
|
|
const { id, ...rest } = option;
|
|
if (rest.type === "select") {
|
|
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
|
|
}
|
|
return { ...rest, configId: id };
|
|
});
|
|
}
|
|
|
|
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
|
|
if (!isGrouped(options)) return options;
|
|
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
|
|
}
|
|
|
|
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
|
|
return options.some((entry) => "group" in entry);
|
|
}
|
|
|
|
/** v2 tags the free-text input of a command with `type: "text"`. */
|
|
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
|
|
return commands.map(({ input, ...command }) => ({
|
|
...command,
|
|
...(input != null ? { input: { type: "text", ...input } } : {}),
|
|
}));
|
|
}
|
|
|
|
/** v1 responses may send `null`; v2 omits the field instead. */
|
|
function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): {
|
|
configOptions?: v2.SessionConfigOption[];
|
|
} {
|
|
return options != null ? { configOptions: v2ConfigOptions(options) } : {};
|
|
}
|
|
|
|
/**
|
|
* v2 requires a `type` on every transport and makes the empty lists optional.
|
|
* The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag
|
|
* is dropped. A transport that v1 cannot express is rejected rather than
|
|
* silently left out of the session.
|
|
*/
|
|
function v1McpServer(server: v2.McpServer): McpServer {
|
|
if (v2.McpServer.isStdio(server)) {
|
|
const { name, command, args, env, _meta } = server;
|
|
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
|
|
}
|
|
if (v2.McpServer.isHttp(server)) {
|
|
return { ...server, headers: server.headers ?? [] };
|
|
}
|
|
if (v2.McpServer.isAcp(server)) {
|
|
return server;
|
|
}
|
|
throw RequestError.invalidParams(
|
|
{ type: server.type },
|
|
`MCP servers of type ${server.type} are not supported`,
|
|
);
|
|
}
|