mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-29 21:42:06 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
188
src/tests/fork-session.test.ts
Normal file
188
src/tests/fork-session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user