fix: allow forking sessions into a different directory (#1483)

Fix `session/fork` when the requested working directory differs from the
directory containing the source session's transcript, such as a
subthread in another worktree.

The Claude Agent SDK's `dir` option restricts **source transcript
lookup**; it does not select the fork's runtime directory. Passing ACP's
destination `cwd` therefore prevents the SDK from finding a source
session stored elsewhere.

- Omit `dir` from both SDK fork calls, active-history lookup, and
full-history import so every fork-resolution path searches all projects.
- Continue starting the forked query with the requested `cwd`, MCP
servers, and other session settings.
- Add 12 real-SDK tests using isolated temporary transcripts, covering
same- and cross-directory forks through latest history, active messages,
inactive branches, fingerprints, and live message maps, plus
missing-message errors. Check copied history, fresh UUIDs, and an
unchanged source transcript.
- Extend ACP v2 coverage to verify same- and cross-directory forks
remain immediately promptable and use the requested runtime directory.

## Validation

- Before the fix, all six cross-directory regression cases failed while
the six same-directory cases passed; after the fix, all 12 pass.
- `npm run check` — passed.
- `npm run build` — passed.
- `npm run test:run -- --reporter=dot --silent` — passed: 60 files,
3,437 tests; 4 files and 30 tests skipped.
- `git diff --check` — passed.

Final validation ran on macOS with Node 24. Earlier Windows validation
passed all fork-related tests but encountered unrelated path-separator
assertions and checkout line-ending formatting failures; those are
outside this change.

No authenticated Claude prompt or end-to-end Delta subthread test was
run. The new transcript tests use the real SDK without starting Claude
Code; ACP query execution is mocked in the protocol test.
This commit is contained in:
Ben Brandt
2026-10-29 20:20:46 +01:00
committed by GitHub
parent 52c241e715
commit 8bd0bc9b03
4 changed files with 202 additions and 19 deletions

View File

@@ -69,10 +69,7 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
}
async function loadFullSessionHistory(
sessionId: string,
cwd: string,
): Promise<SessionStoreEntry[]> {
async function loadFullSessionHistory(sessionId: string): Promise<SessionStoreEntry[]> {
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
const entries: SessionStoreEntry[] = [];
const store: SessionStore = {
@@ -81,7 +78,7 @@ async function loadFullSessionHistory(
},
load: async () => null,
};
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
await importSessionToStore(sessionId, store, { includeSubagents: false });
return entries;
}
@@ -185,8 +182,10 @@ export async function forkSession(
): Promise<ForkSessionResponse> {
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
const point = forkPoint(params._meta);
// SDK `dir` scopes source lookup, not the fork's working directory. Search
// all projects: params.cwd can name a different workspace for the new session.
if (!point) {
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
const forked = await forkClaudeSession(params.sessionId);
timing.phase("sdk-fork", " resolution=latest");
return { sessionId: forked.sessionId };
}
@@ -195,9 +194,7 @@ export async function forkSession(
const liveUuid = candidateIds
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
.find(Boolean);
const history = liveUuid
? undefined
: await getSessionMessages(params.sessionId, { dir: params.cwd });
const history = liveUuid ? undefined : await getSessionMessages(params.sessionId);
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
const messageUuid =
liveUuid ??
@@ -213,7 +210,7 @@ export async function forkSession(
const fullHistoryUuid = messageUuid
? undefined
: resolveFromFullHistory(
await loadFullSessionHistory(params.sessionId, params.cwd),
await loadFullSessionHistory(params.sessionId),
candidateIds,
point,
dependencies.messageIdForGrouping,
@@ -231,7 +228,6 @@ export async function forkSession(
}
const forked = await forkClaudeSession(params.sessionId, {
dir: params.cwd,
upToMessageId: messageUuid ?? fullHistoryUuid,
});
timing.phase("sdk-fork");

View File

@@ -14496,7 +14496,7 @@ describe("session/fork", () => {
});
expect(response.sessionId).toBe("fork-id");
expect(forkSession).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
expect(forkSession).toHaveBeenCalledWith("source-id");
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/workspace", additionalDirectories: ["/workspace/extra"] }),
expect.objectContaining({ resume: "fork-id" }),
@@ -14540,10 +14540,9 @@ describe("session/fork", () => {
});
expect(response.sessionId).toBe("fork-id");
expect(getSessionMessages).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
expect(getSessionMessages).toHaveBeenCalledWith("source-id");
expect(importSessionToStore).not.toHaveBeenCalled();
expect(forkSession).toHaveBeenCalledWith("source-id", {
dir: "/workspace",
upToMessageId: "assistant-uuid",
});
});
@@ -14668,11 +14667,9 @@ describe("session/fork", () => {
expect(response.sessionId).toBe("fork-id");
expect(importSessionToStore).toHaveBeenLastCalledWith("source-id", expect.any(Object), {
dir: "/workspace",
includeSubagents: false,
});
expect(forkSession).toHaveBeenLastCalledWith("source-id", {
dir: "/workspace",
upToMessageId: "inactive-assistant-uuid",
});
});

View File

@@ -1079,8 +1079,9 @@ describe("ACP v2 prompts", () => {
]);
});
it("forks a session into one that the client can prompt at once", async () => {
it.each(["same", "different"])("forks a promptable session in the %s cwd", async (dir) => {
const forkId = "99999999-8888-4777-8666-555555555555";
const forkCwd = dir === "same" ? cwd : await mkdtemp(path.join(tempDir, "fork-"));
const echoed = scriptTurns(async function* (options) {
yield assistantText(options, "Forked.");
yield result(options);
@@ -1091,17 +1092,18 @@ describe("ACP v2 prompts", () => {
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const fork = await agent.request(v2.methods.agent.session.fork, {
sessionId,
cwd,
cwd: forkCwd,
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
});
expect(fork.sessionId).toBe(forkId);
expect(fork.configOptions?.map((option) => option.configId)).toContain("mode");
expect(forkSession).toHaveBeenCalledWith(sessionId, { dir: cwd });
expect(forkSession).toHaveBeenCalledWith(sessionId);
// The fork runs as its own Claude Code session, resumed from the copied
// transcript, with the MCP servers of the fork request.
const forkQuery = sdk.queryOptions.at(-1)!;
expect(forkQuery.resume).toBe(forkId);
expect(forkQuery.cwd).toBe(forkCwd);
expect(Object.keys(forkQuery.mcpServers ?? {})).toContain("linear");
// No session/resume needed before the first prompt.

View File

@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
import { createHash, randomUUID } from "node:crypto";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
import { messageIdForGrouping } from "../acp-agent.js";
import { forkSession } from "../fork-session.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
// Use the real SDK against temporary transcripts, without starting Claude Code.
let root: string;
let sourceCwd: string;
let destinationCwd: string;
let sessionId: string;
let sourceFile: string;
let transcript: string;
let activeUuid: string;
beforeEach(async () => {
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "fork-session-")));
sourceCwd = path.join(root, "source");
destinationCwd = path.join(root, "destination");
const configDir = path.join(root, "config");
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
await fs.mkdir(sourceCwd);
await fs.mkdir(destinationCwd);
sessionId = randomUUID();
const userUuid = randomUUID();
const firstUuid = randomUUID();
const followupUuid = randomUUID();
activeUuid = randomUUID();
const common = { sessionId, cwd: sourceCwd, isSidechain: false };
const entries = [
{
...common,
type: "user",
uuid: userUuid,
parentUuid: null,
message: { role: "user", content: "First prompt" },
},
{
...common,
type: "assistant",
uuid: firstUuid,
parentUuid: userUuid,
message: {
id: "msg_first",
role: "assistant",
content: [{ type: "text", text: "First answer" }],
},
},
{
...common,
type: "user",
uuid: followupUuid,
parentUuid: firstUuid,
message: { role: "user", content: "Follow-up prompt" },
},
{
...common,
type: "assistant",
uuid: randomUUID(),
parentUuid: followupUuid,
message: {
id: "msg_inactive",
role: "assistant",
content: [{ type: "text", text: "Inactive answer" }],
},
},
{
...common,
type: "assistant",
uuid: activeUuid,
parentUuid: followupUuid,
message: {
id: "msg_active",
role: "assistant",
content: [{ type: "text", text: "Active answer" }],
},
},
];
transcript =
entries
.map((entry, index) =>
JSON.stringify({ ...entry, timestamp: new Date(1_700_000_000_000 + index).toISOString() }),
)
.join("\n") + "\n";
const projectDir = path.join(configDir, "projects", encodeProjectPath(sourceCwd));
await fs.mkdir(projectDir, { recursive: true });
sourceFile = path.join(projectDir, `${sessionId}.jsonl`);
await fs.writeFile(sourceFile, transcript);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
});
describe.each(["same directory", "different directory"])("fork into %s", (directory) => {
it.each([
{ resolution: "latest", messageId: undefined, expectedAnswer: "Active answer", count: 4 },
{
resolution: "active history",
messageId: "msg_first",
expectedAnswer: "First answer",
count: 2,
},
{
resolution: "inactive branch",
messageId: "msg_inactive",
expectedAnswer: "Inactive answer",
count: 4,
},
{
resolution: "fingerprint",
messageId: "stale-message-id",
expectedAnswer: "Inactive answer",
count: 4,
},
{ resolution: "live map", messageId: "msg_active", expectedAnswer: "Active answer", count: 4 },
])("resolves the fork point through $resolution", async (scenario) => {
const originalMessages = await getSessionMessages(sessionId);
expect(originalMessages.map(messageIdForGrouping)).not.toContain("msg_inactive");
const cwd = directory === "same directory" ? sourceCwd : destinationCwd;
const result = await forkSession(
{
sessionId,
cwd,
mcpServers: [],
...(scenario.messageId && {
_meta: {
jetbrains: {
air: {
fork: {
version: 1,
messageId: scenario.messageId,
...(scenario.resolution === "fingerprint" && {
messageFingerprint: `sha256:${createHash("sha256").update("Inactive answer").digest("hex")}`,
messageOccurrence: 1,
}),
},
},
},
},
}),
},
{
messageIdForGrouping,
...(scenario.resolution === "live map" && {
liveMessageIdToUuid: new Map([["msg_active", activeUuid]]),
}),
},
);
expect(result.sessionId).not.toBe(sessionId);
const messages = await getSessionMessages(result.sessionId);
expect(messages).toHaveLength(scenario.count);
expect(messages.at(-1)?.message).toMatchObject({
content: [{ type: "text", text: scenario.expectedAnswer }],
});
const originalUuids = originalMessages.map((message) => message.uuid);
for (const message of messages) {
expect(originalUuids).not.toContain(message.uuid);
}
expect(await fs.readFile(sourceFile, "utf8")).toBe(transcript);
});
it("rejects a fork point that is not in the source session", async () => {
await expect(
forkSession(
{
sessionId,
cwd: directory === "same directory" ? sourceCwd : destinationCwd,
mcpServers: [],
_meta: { jetbrains: { air: { fork: { version: 1, messageId: "missing" } } } },
},
{ messageIdForGrouping },
),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining(
`Fork point message missing was not found in session ${sessionId}`,
),
});
expect(await fs.readFile(sourceFile, "utf8")).toBe(transcript);
});
});