diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 5a4b575d..73c0c754 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -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 no message matches, the request fails with `invalidParams`. - 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 diff --git a/src/acp-agent.ts b/src/acp-agent.ts index e0fc12bc..417a8e4d 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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 { if (this.providerUpdate) await this.providerUpdate; - return forkSession(params, { + const { sessionId } = await forkSession(params, { liveMessageIdToUuid: this.sessions[params.sessionId]?.messageIdToUuid, logger: this.logger, 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 { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index dccf1ceb..2ae6306c 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -10540,9 +10540,114 @@ describe("session/fork", () => { 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(); + 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 () => { - const client = { sessionUpdate: async () => {} } as unknown as AcpClient; - const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + const { agent, createSession } = forkingAgent(); vi.mocked(forkSession).mockResolvedValueOnce({ sessionId: "fork-id" }); const response = await agent.unstable_forkSession({ @@ -10554,11 +10659,14 @@ describe("session/fork", () => { expect(response.sessionId).toBe("fork-id"); 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 () => { - const client = { sessionUpdate: async () => {} } as unknown as AcpClient; - const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + const { agent } = forkingAgent(); vi.mocked(getSessionMessages).mockResolvedValueOnce([ { type: "assistant", @@ -10614,8 +10722,7 @@ describe("session/fork", () => { messageOccurrence: 2, }, ])("forks at an inactive persisted branch by $resolution", async (forkPoint) => { - const client = { sessionUpdate: async () => {} } as unknown as AcpClient; - const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + const { agent } = forkingAgent(); const targetText = "Answer from the branch that is no longer active."; const targetFingerprint = `sha256:${createHash("sha256").update(targetText).digest("hex")}`; vi.mocked(getSessionMessages).mockResolvedValueOnce([ diff --git a/src/v2/agent.ts b/src/v2/agent.ts index eabc22bb..9b739802 100644 --- a/src/v2/agent.ts +++ b/src/v2/agent.ts @@ -21,7 +21,7 @@ import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js"; import { v1PermissionResponse, v2PermissionRequest } from "./permission.js"; import { v2Foreground, v2Prompt } from "./prompt.js"; import { - v1ForkSessionRequests, + v1ForkSessionRequest, v1NewSessionRequest, v1RestoreSessionRequest, v1SetSessionConfigOptionRequest, @@ -93,12 +93,12 @@ export function v2AgentApp( }; }) .onRequest(v2.methods.agent.session.fork, async ({ params }) => { - const requests = v1ForkSessionRequests(params); - const { sessionId } = await agent.unstable_forkSession(requests.fork); + // The agent starts the forked session, so the client can prompt it. + const response = await agent.unstable_forkSession(v1ForkSessionRequest(params)); return { - sessionId, - ...v2ResumeSessionResponse(await agent.resumeSession(requests.resume(sessionId))), - ...(await setupCommands(agent, sessionId, logger)), + sessionId: response.sessionId, + ...v2ResumeSessionResponse(response), + ...(await setupCommands(agent, response.sessionId, logger)), }; }) .onRequest(v2.methods.agent.session.close, async ({ params }) => { diff --git a/src/v2/initialize.ts b/src/v2/initialize.ts index ecde4323..c6f973e5 100644 --- a/src/v2/initialize.ts +++ b/src/v2/initialize.ts @@ -93,7 +93,7 @@ export function v2InitializeResponse(response: InitializeResponse): v2.Initializ * - The session baseline: `session/new`, `list`, `resume` (with replay), * `close`, `prompt`, `cancel`, and `update`. * - `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` * lists: images, embedded context, and HTTP servers. Stdio servers are in the * v1 baseline; v2 has no SSE transport. diff --git a/src/v2/session.ts b/src/v2/session.ts index 60148a70..24aba672 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -60,28 +60,12 @@ export function v1RestoreSessionRequest( } /** - * The v1 requests that a v2 `session/fork` means. - * - * 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. + * 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 v1ForkSessionRequests(params: v2.ForkSessionRequest): { - fork: ForkSessionRequest; - resume: (sessionId: string) => ResumeSessionRequest; -} { - const { mcpServers, _meta, ...request } = params; - const session = { - ...request, - ...(mcpServers ? { mcpServers: mcpServers.map(v1McpServer) } : {}), - }; - return { - fork: { ...session, ...(_meta != null ? { _meta } : {}) }, - resume: (sessionId) => ({ ...session, sessionId }), - }; +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. */