mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
fix: start a forked session so the client can prompt it at once (#1317)
## 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.
This commit is contained in:
@@ -1541,6 +1541,8 @@ It adds this object to the `session/fork` request:
|
|||||||
When only one stored message has the fingerprint, the adapter uses that message.
|
When only one stored message has the fingerprint, the adapter uses that message.
|
||||||
- When no message matches, the request fails with `invalidParams`.
|
- When no message matches, the request fails with `invalidParams`.
|
||||||
- The fork keeps the history up to that message.
|
- The fork keeps the history up to that message.
|
||||||
|
- With or without this object, the forked session runs once the response arrives, so the client can prompt it without `session/load` or `session/resume`.
|
||||||
|
A later load replays its history into it, and a later resume returns it. A load or resume with other settings (`cwd`, MCP servers, additional directories, or the session options in `_meta`) restarts it, as for any loaded session.
|
||||||
|
|
||||||
## Presentation hints
|
## Presentation hints
|
||||||
|
|
||||||
|
|||||||
@@ -2947,13 +2947,29 @@ export class ClaudeAcpAgent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the session's transcript into a new session, then starts that
|
||||||
|
* session, so the client can prompt it as soon as the response arrives, as
|
||||||
|
* the schema requires of a fork. It starts like a resumed session: from the
|
||||||
|
* copied transcript, with the model and the permission mode it records. A
|
||||||
|
* later load or resume with the same settings reuses it.
|
||||||
|
*/
|
||||||
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
||||||
if (this.providerUpdate) await this.providerUpdate;
|
if (this.providerUpdate) await this.providerUpdate;
|
||||||
return forkSession(params, {
|
const { sessionId } = await forkSession(params, {
|
||||||
liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
|
liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
messageIdForGrouping,
|
messageIdForGrouping,
|
||||||
});
|
});
|
||||||
|
const { modes, configOptions } = await this.getOrCreateSession({
|
||||||
|
sessionId,
|
||||||
|
cwd: params.cwd,
|
||||||
|
mcpServers: params.mcpServers,
|
||||||
|
additionalDirectories: params.additionalDirectories,
|
||||||
|
_meta: params._meta,
|
||||||
|
});
|
||||||
|
this.afterSetupResponse(sessionId, params.mcpServers ?? []);
|
||||||
|
return { sessionId, modes, configOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||||
|
|||||||
@@ -10540,9 +10540,114 @@ describe("session/fork", () => {
|
|||||||
vi.mocked(importSessionToStore).mockClear();
|
vi.mocked(importSessionToStore).mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const modes = { currentModeId: "plan", availableModes: [] };
|
||||||
|
|
||||||
|
/** An agent whose sessions start without Claude Code: each one gets a
|
||||||
|
* scripted query that runs `turn` for every prompt. */
|
||||||
|
function forkingAgent(turn: (prompt: any) => any[] = () => []) {
|
||||||
|
const updates: SessionNotification[] = [];
|
||||||
|
const agent = new ClaudeAcpAgent(
|
||||||
|
{ sessionUpdate: async (n: SessionNotification) => updates.push(n) } as unknown as AcpClient,
|
||||||
|
{ log: () => {}, error: () => {} },
|
||||||
|
);
|
||||||
|
const createSession = vi
|
||||||
|
.spyOn(agent as any, "createSession")
|
||||||
|
.mockImplementation(async (params: any, options: any) => {
|
||||||
|
const sessionId = options.resume as string;
|
||||||
|
const input = new Pushable<any>();
|
||||||
|
async function* answer() {
|
||||||
|
for await (const prompt of input) yield* turn(prompt);
|
||||||
|
}
|
||||||
|
agent.sessions[sessionId] = mockSessionState(
|
||||||
|
{
|
||||||
|
// The commands go to the client after the response.
|
||||||
|
query: Object.assign(wrapQuery(answer()), { supportedCommands: async () => [] }),
|
||||||
|
input,
|
||||||
|
modes,
|
||||||
|
sessionFingerprint: computeSessionFingerprint(params),
|
||||||
|
},
|
||||||
|
agent,
|
||||||
|
sessionId,
|
||||||
|
);
|
||||||
|
return { sessionId, modes, configOptions: [] };
|
||||||
|
});
|
||||||
|
return { agent, updates, createSession };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("starts the forked session, so the client can prompt it at once", async () => {
|
||||||
|
const { agent, updates, createSession } = forkingAgent((prompt) => [
|
||||||
|
userEcho(prompt),
|
||||||
|
{
|
||||||
|
type: "assistant",
|
||||||
|
parent_tool_use_id: null,
|
||||||
|
uuid: randomUUID(),
|
||||||
|
session_id: "fork-id",
|
||||||
|
message: {
|
||||||
|
id: "msg_fork",
|
||||||
|
role: "assistant",
|
||||||
|
model: "claude-sonnet-4-20250514",
|
||||||
|
content: [{ type: "text", text: "Forked answer" }],
|
||||||
|
usage: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
successfulResultMessage({ session_id: "fork-id" }),
|
||||||
|
{ type: "system", subtype: "session_state_changed", state: "idle", session_id: "fork-id" },
|
||||||
|
]);
|
||||||
|
vi.mocked(forkSession).mockResolvedValueOnce({ sessionId: "fork-id" });
|
||||||
|
|
||||||
|
const response = await agent.unstable_forkSession({
|
||||||
|
sessionId: "source-id",
|
||||||
|
cwd: "/workspace",
|
||||||
|
mcpServers: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// It starts like a resumed session, from the copied transcript.
|
||||||
|
expect(createSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/workspace", mcpServers: [] }),
|
||||||
|
expect.objectContaining({ resume: "fork-id" }),
|
||||||
|
);
|
||||||
|
expect(response).toEqual({ sessionId: "fork-id", modes, configOptions: [] });
|
||||||
|
await expect(
|
||||||
|
agent.prompt({ sessionId: "fork-id", prompt: [{ type: "text", text: "go on" }] }),
|
||||||
|
).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||||
|
expect(updates).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: "fork-id",
|
||||||
|
update: expect.objectContaining({
|
||||||
|
sessionUpdate: "agent_message_chunk",
|
||||||
|
content: { type: "text", text: "Forked answer" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses the forked session for a later resume or load with the same settings", async () => {
|
||||||
|
const { agent, createSession } = forkingAgent();
|
||||||
|
vi.mocked(forkSession).mockResolvedValueOnce({ sessionId: "fork-id" });
|
||||||
|
const settings = { cwd: "/workspace", mcpServers: [] };
|
||||||
|
await agent.unstable_forkSession({ sessionId: "source-id", ...settings });
|
||||||
|
const forked = agent.sessions["fork-id"];
|
||||||
|
|
||||||
|
await agent.resumeSession({ sessionId: "fork-id", ...settings });
|
||||||
|
vi.mocked(getSessionMessages).mockResolvedValueOnce([]);
|
||||||
|
await agent.loadSession({ sessionId: "fork-id", ...settings });
|
||||||
|
|
||||||
|
expect(createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(agent.sessions["fork-id"]).toBe(forked);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails when the forked session cannot start", async () => {
|
||||||
|
const { agent, createSession } = forkingAgent();
|
||||||
|
createSession.mockRejectedValueOnce(new Error("Claude Code did not start"));
|
||||||
|
vi.mocked(forkSession).mockResolvedValueOnce({ sessionId: "fork-id" });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.unstable_forkSession({ sessionId: "source-id", cwd: "/workspace", mcpServers: [] }),
|
||||||
|
).rejects.toThrow("Claude Code did not start");
|
||||||
|
});
|
||||||
|
|
||||||
it("forks the latest turn in the requested workspace", async () => {
|
it("forks the latest turn in the requested workspace", async () => {
|
||||||
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
const { agent, createSession } = forkingAgent();
|
||||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
|
||||||
vi.mocked(forkSession).mockResolvedValueOnce({ sessionId: "fork-id" });
|
vi.mocked(forkSession).mockResolvedValueOnce({ sessionId: "fork-id" });
|
||||||
|
|
||||||
const response = await agent.unstable_forkSession({
|
const response = await agent.unstable_forkSession({
|
||||||
@@ -10554,11 +10659,14 @@ describe("session/fork", () => {
|
|||||||
|
|
||||||
expect(response.sessionId).toBe("fork-id");
|
expect(response.sessionId).toBe("fork-id");
|
||||||
expect(forkSession).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
|
expect(forkSession).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
|
||||||
|
expect(createSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ cwd: "/workspace", additionalDirectories: ["/workspace/extra"] }),
|
||||||
|
expect.objectContaining({ resume: "fork-id" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forks at an AIR message id through the current SDK", async () => {
|
it("forks at an AIR message id through the current SDK", async () => {
|
||||||
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
const { agent } = forkingAgent();
|
||||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
|
||||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||||
{
|
{
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
@@ -10614,8 +10722,7 @@ describe("session/fork", () => {
|
|||||||
messageOccurrence: 2,
|
messageOccurrence: 2,
|
||||||
},
|
},
|
||||||
])("forks at an inactive persisted branch by $resolution", async (forkPoint) => {
|
])("forks at an inactive persisted branch by $resolution", async (forkPoint) => {
|
||||||
const client = { sessionUpdate: async () => {} } as unknown as AcpClient;
|
const { agent } = forkingAgent();
|
||||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
|
||||||
const targetText = "Answer from the branch that is no longer active.";
|
const targetText = "Answer from the branch that is no longer active.";
|
||||||
const targetFingerprint = `sha256:${createHash("sha256").update(targetText).digest("hex")}`;
|
const targetFingerprint = `sha256:${createHash("sha256").update(targetText).digest("hex")}`;
|
||||||
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
vi.mocked(getSessionMessages).mockResolvedValueOnce([
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js";
|
|||||||
import { v1PermissionResponse, v2PermissionRequest } from "./permission.js";
|
import { v1PermissionResponse, v2PermissionRequest } from "./permission.js";
|
||||||
import { v2Foreground, v2Prompt } from "./prompt.js";
|
import { v2Foreground, v2Prompt } from "./prompt.js";
|
||||||
import {
|
import {
|
||||||
v1ForkSessionRequests,
|
v1ForkSessionRequest,
|
||||||
v1NewSessionRequest,
|
v1NewSessionRequest,
|
||||||
v1RestoreSessionRequest,
|
v1RestoreSessionRequest,
|
||||||
v1SetSessionConfigOptionRequest,
|
v1SetSessionConfigOptionRequest,
|
||||||
@@ -93,12 +93,12 @@ export function v2AgentApp(
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.onRequest(v2.methods.agent.session.fork, async ({ params }) => {
|
.onRequest(v2.methods.agent.session.fork, async ({ params }) => {
|
||||||
const requests = v1ForkSessionRequests(params);
|
// The agent starts the forked session, so the client can prompt it.
|
||||||
const { sessionId } = await agent.unstable_forkSession(requests.fork);
|
const response = await agent.unstable_forkSession(v1ForkSessionRequest(params));
|
||||||
return {
|
return {
|
||||||
sessionId,
|
sessionId: response.sessionId,
|
||||||
...v2ResumeSessionResponse(await agent.resumeSession(requests.resume(sessionId))),
|
...v2ResumeSessionResponse(response),
|
||||||
...(await setupCommands(agent, sessionId, logger)),
|
...(await setupCommands(agent, response.sessionId, logger)),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.onRequest(v2.methods.agent.session.close, async ({ params }) => {
|
.onRequest(v2.methods.agent.session.close, async ({ params }) => {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export function v2InitializeResponse(response: InitializeResponse): v2.Initializ
|
|||||||
* - The session baseline: `session/new`, `list`, `resume` (with replay),
|
* - The session baseline: `session/new`, `list`, `resume` (with replay),
|
||||||
* `close`, `prompt`, `cancel`, and `update`.
|
* `close`, `prompt`, `cancel`, and `update`.
|
||||||
* - `session/delete`, `session/fork` (which returns a session the client can
|
* - `session/delete`, `session/fork` (which returns a session the client can
|
||||||
* prompt, see `v1ForkSessionRequests`), and `additionalDirectories`.
|
* prompt at once), and `additionalDirectories`.
|
||||||
* - The prompt content and MCP transports that the agent's v1 `initialize`
|
* - The prompt content and MCP transports that the agent's v1 `initialize`
|
||||||
* lists: images, embedded context, and HTTP servers. Stdio servers are in the
|
* lists: images, embedded context, and HTTP servers. Stdio servers are in the
|
||||||
* v1 baseline; v2 has no SSE transport.
|
* v1 baseline; v2 has no SSE transport.
|
||||||
|
|||||||
@@ -60,28 +60,12 @@ export function v1RestoreSessionRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The v1 requests that a v2 `session/fork` means.
|
* 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.
|
||||||
* The agent's v1 fork copies the transcript into a new session and returns
|
|
||||||
* only its id; a v1 client then resumes or loads it. A v2 fork returns the new
|
|
||||||
* session with its config options, like `session/new`, so the client can
|
|
||||||
* prompt it. The v2 surface therefore forks, then resumes the fork without
|
|
||||||
* replay, with the request's `cwd`, `additionalDirectories`, and
|
|
||||||
* `mcpServers`. The request's `_meta` goes to the fork alone.
|
|
||||||
*/
|
*/
|
||||||
export function v1ForkSessionRequests(params: v2.ForkSessionRequest): {
|
export function v1ForkSessionRequest(params: v2.ForkSessionRequest): ForkSessionRequest {
|
||||||
fork: ForkSessionRequest;
|
const { mcpServers, ...request } = params;
|
||||||
resume: (sessionId: string) => ResumeSessionRequest;
|
return { ...request, ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}) };
|
||||||
} {
|
|
||||||
const { mcpServers, _meta, ...request } = params;
|
|
||||||
const session = {
|
|
||||||
...request,
|
|
||||||
...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
fork: { ...session, ...(_meta != null ? { _meta } : {}) },
|
|
||||||
resume: (sessionId) => ({ ...session, sessionId }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
|
/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */
|
||||||
|
|||||||
Reference in New Issue
Block a user