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:
Ben Brandt
2026-10-13 07:28:48 +02:00
committed by GitHub
parent 9db8a28b64
commit 383bc7dcf0
6 changed files with 144 additions and 35 deletions

View File

@@ -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

View File

@@ -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> {

View File

@@ -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([

View File

@@ -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 }) => {

View File

@@ -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.

View File

@@ -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. */