diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 10fee25d..230cb6c6 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -78,6 +78,18 @@ import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./u export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); +/** + * Extract a bare UUID from an ACP session key. + * + * ACP callers pass session keys in the form `agent:claude:acp:`. + * Claude Code only accepts bare UUIDs for --session-id and --resume, so + * we strip the prefix by taking the last colon-separated segment. + * If the key is already a bare UUID (no colons), it is returned unchanged. + */ +export function extractSessionUUID(sessionKey: string): string { + return sessionKey.split(":").pop() ?? sessionKey; +} + const MAX_TITLE_LENGTH = 256; function sanitizeTitle(text: string): string { @@ -196,8 +208,17 @@ export type NewSessionMeta = { * - mcpServers (merged with ACP's mcpServers) * - disallowedTools (merged with ACP's disallowedTools) * - tools (passed through; defaults to claude_code preset if not provided) + * ACP-specific extension (not forwarded to the SDK): + * - proposedSessionId: if set, used as the session ID instead of a random UUID */ - options?: Options; + options?: Options & { + /** + * If provided, use this UUID as the session ID instead of generating a random one. + * Allows ACP clients to request a deterministic, predictable session file name, + * which is required for reliable session resume (e.g. acpx resume flows). + */ + proposedSessionId?: string; + }; /** * When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message) * in addition to normal processing. @@ -1441,7 +1462,7 @@ export class ClaudeAcpAgent implements Agent { _meta: params._meta, }, { - resume: params.sessionId, + resume: extractSessionUUID(params.sessionId), }, ); @@ -1463,9 +1484,11 @@ export class ClaudeAcpAgent implements Agent { if (creationOpts.forkSession) { sessionId = randomUUID(); } else if (creationOpts.resume) { - sessionId = creationOpts.resume; + sessionId = extractSessionUUID(creationOpts.resume); } else { - sessionId = randomUUID(); + sessionId = + (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.proposedSessionId ?? + randomUUID(); } const input = new Pushable(); @@ -1607,8 +1630,87 @@ export class ClaudeAcpAgent implements Agent { ...(sessionMeta?.additionalRoots ?? []), ]; - if (creationOpts?.resume === undefined || creationOpts?.forkSession) { - // Set our own session id if not resuming an existing session. + // Always fix up options.resume to use a bare UUID (the spread of creationOpts + // above may have set options.resume to a full ACP key like agent:claude:acp:). + if (options.resume) { + options.resume = extractSessionUUID(options.resume as string); + } + + if (creationOpts?.resume) { + // RESUME: Claude Code rejects --resume + --session-id together (exits immediately). + // • File at expected path → --resume UUID alone → Claude Code loads history ✓ + // • File in different dir → override cwd to original → --resume UUID alone ✓ + // (handles cwd mismatch, e.g. oneshot sessions created with cwd=/tmp) + // • File not found → --resume UUID + --session-id UUID → conflict + // → resourceNotFound → acpx falls back to session/new + const { existsSync, readdirSync, readFileSync } = await import("node:fs"); + const projectDir = params.cwd.replace(/\//g, "-"); + const expectedPath = path.join(CLAUDE_CONFIG_DIR, "projects", projectDir, `${sessionId}.jsonl`); + + if (!existsSync(expectedPath)) { + // Not at expected path: search all project dirs (handles cwd mismatch, + // e.g. oneshot sessions with cwd=/tmp resumed from workspace cwd). + let overrideCwd: string | null = null; + try { + const projectsDir = path.join(CLAUDE_CONFIG_DIR, "projects"); + for (const entry of readdirSync(projectsDir, { withFileTypes: true }) as { name: string; isDirectory: () => boolean }[]) { + const candidatePath = path.join(projectsDir, entry.name, `${sessionId}.jsonl`); + if ( + entry.isDirectory() && + entry.name !== projectDir && + existsSync(candidatePath) + ) { + // Read the original cwd from the JSONL session file. + // Claude Code writes cwd on every message line; scan until we find it. + // This avoids the lossy dir-name decode (e.g. '-home-user-my-project' + // would decode to '/home/user/my/project', corrupting hyphenated paths). + let recoveredCwd: string | undefined; + try { + const content = readFileSync(candidatePath, "utf8"); + for (const line of content.split("\n")) { + if (!line.trim()) continue; + const parsed = JSON.parse(line) as Record; + if (typeof parsed.cwd === "string") { + recoveredCwd = parsed.cwd; + break; + } + } + } catch { + /* ignore parse errors — fall through to dir-name decode */ + } + + if (recoveredCwd !== undefined) { + overrideCwd = recoveredCwd; + } else { + // Fallback: decode dir name (lossy for paths containing hyphens). + // e.g. /home/user/my-project → -home-user-my-project → /home/user/my/project (wrong). + // For the common case (cwd=/tmp → -tmp), this works correctly. + const decoded = entry.name.replace(/-/g, "/"); + if (existsSync(decoded)) { + overrideCwd = decoded; + } + } + break; + } + } + } catch { + /* projects dir may not exist yet */ + } + + if (overrideCwd !== null) { + // Found in different dir: use original cwd so Claude Code finds the file. + options.cwd = overrideCwd; + } else { + // Not found anywhere: force conflict so acpx falls back to session/new. + options.sessionId = sessionId; + } + } + // File found at expected path: omit --session-id so --resume UUID alone restores history. + } else if (creationOpts?.forkSession) { + // Fork: pin the new session UUID. + options.sessionId = sessionId; + } else { + // New session: pin the proposed or random UUID. options.sessionId = sessionId; } diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 331d4126..d4c2c771 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -1,7 +1,22 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; import type { Options } from "@anthropic-ai/claude-agent-sdk"; -import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType, NewSessionMeta } from "../acp-agent.js"; +import * as nodefs from "node:fs"; + +// Default: no JSONL files exist anywhere. +// We preserve the actual `promises` export so SettingsManager (which uses +// fs.promises.readFile) continues to work in the other test suites. +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn().mockReturnValue(false), + readdirSync: vi.fn().mockReturnValue([]), + // Default: no session file content (no cwd recoverable from JSONL). + readFileSync: vi.fn().mockReturnValue(""), + }; +}); let capturedOptions: Options | undefined; vi.mock("@anthropic-ai/claude-agent-sdk", async () => { @@ -285,3 +300,297 @@ describe("createSession options merging", () => { expect(capturedOptions!.mcpServers).toHaveProperty("acp-server"); }); }); + +describe("UUID extraction from ACP session keys", () => { + const BARE_UUID = "abc12345-0000-0000-0000-000000000000"; + const ACP_KEY = `agent:claude:acp:${BARE_UUID}`; + + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (_notification: SessionNotification) => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + beforeEach(async () => { + capturedOptions = undefined; + vi.mocked(nodefs.existsSync).mockReturnValue(false); + vi.mocked(nodefs.readdirSync).mockReturnValue([]); + + vi.resetModules(); + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + agent = new ClaudeAcpAgent(createMockClient()); + }); + + it("strips ACP key prefix so resume option uses bare UUID", async () => { + // When resume is triggered via _meta with a full ACP key, the options.resume + // passed to Claude SDK must be the bare UUID only. + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + resume: ACP_KEY, + }, + }, + } as NewSessionMeta, + }); + + expect(capturedOptions!.resume).toBe(BARE_UUID); + }); + + it("leaves bare UUID unchanged when passed as resume", async () => { + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { + resume: BARE_UUID, + }, + }, + } as NewSessionMeta, + }); + + expect(capturedOptions!.resume).toBe(BARE_UUID); + }); +}); + +describe("file-existence-based resume flags", () => { + const BARE_UUID = "deadbeef-0000-0000-0000-000000000001"; + const ACP_KEY = `agent:claude:acp:${BARE_UUID}`; + + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + let CLAUDE_CONFIG_DIR: string; + + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (_notification: SessionNotification) => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + beforeEach(async () => { + capturedOptions = undefined; + // Reset all fs mocks to "nothing found" defaults + vi.mocked(nodefs.existsSync).mockReturnValue(false); + vi.mocked(nodefs.readdirSync).mockReturnValue([]); + vi.mocked(nodefs.readFileSync).mockReturnValue(""); + + vi.resetModules(); + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + CLAUDE_CONFIG_DIR = acpAgent.CLAUDE_CONFIG_DIR; + agent = new ClaudeAcpAgent(createMockClient()); + }); + + it("when JSONL at expected path: options.resume=UUID, options.sessionId unset", async () => { + // Simulate: file exists at expected path + const expectedPath = `${CLAUDE_CONFIG_DIR}/projects/-test/${BARE_UUID}.jsonl`; + vi.mocked(nodefs.existsSync).mockImplementation((p) => p === expectedPath); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { resume: ACP_KEY }, + }, + } as NewSessionMeta, + }); + + expect(capturedOptions!.resume).toBe(BARE_UUID); + expect(capturedOptions!.sessionId).toBeUndefined(); + }); + + it("when JSONL not found anywhere: options.sessionId=UUID set to trigger resourceNotFound", async () => { + // All existsSync calls return false (default mock) + vi.mocked(nodefs.existsSync).mockReturnValue(false); + vi.mocked(nodefs.readdirSync).mockReturnValue([]); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { resume: ACP_KEY }, + }, + } as NewSessionMeta, + }); + + expect(capturedOptions!.sessionId).toBe(BARE_UUID); + }); + + it("when JSONL found in different project dir: reads cwd from JSONL, options.sessionId unset", async () => { + const altDir = "-tmp"; + const altPath = `${CLAUDE_CONFIG_DIR}/projects/${altDir}/${BARE_UUID}.jsonl`; + const altCwd = "/tmp"; + + // Simulate JSONL content: queue-operation lines (no cwd), then a message line with cwd. + const jsonlContent = [ + JSON.stringify({ type: "queue-operation", operation: "enqueue", sessionId: BARE_UUID }), + JSON.stringify({ type: "queue-operation", operation: "dequeue", sessionId: BARE_UUID }), + JSON.stringify({ type: "user", cwd: altCwd, sessionId: BARE_UUID }), + ].join("\n"); + + vi.mocked(nodefs.readdirSync).mockReturnValue([ + { name: altDir, isDirectory: () => true } as any, + ]); + vi.mocked(nodefs.existsSync).mockImplementation((p) => p === altPath); + vi.mocked(nodefs.readFileSync).mockImplementation((p) => { + if (p === altPath) return jsonlContent; + return ""; + }); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { resume: ACP_KEY }, + }, + } as NewSessionMeta, + }); + + // cwd should be the value read from the JSONL, not a lossy dir-name decode. + expect(capturedOptions!.cwd).toBe(altCwd); + // sessionId should NOT be set (--resume only) + expect(capturedOptions!.sessionId).toBeUndefined(); + }); + + it("recovers hyphenated cwd correctly via JSONL (would be wrong with dir-name decode)", async () => { + // /home/user/my-project encodes to -home-user-my-project. + // Dir-name decode: '-home-user-my-project'.replace(/-/g, '/') = '/home/user/my/project' — WRONG. + // JSONL-based recovery returns the actual cwd unchanged. + const altDir = "-home-user-my-project"; + const altPath = `${CLAUDE_CONFIG_DIR}/projects/${altDir}/${BARE_UUID}.jsonl`; + const actualCwd = "/home/user/my-project"; + + const jsonlContent = [ + JSON.stringify({ type: "queue-operation", operation: "enqueue", sessionId: BARE_UUID }), + JSON.stringify({ type: "user", cwd: actualCwd, sessionId: BARE_UUID }), + ].join("\n"); + + vi.mocked(nodefs.readdirSync).mockReturnValue([ + { name: altDir, isDirectory: () => true } as any, + ]); + vi.mocked(nodefs.existsSync).mockImplementation((p) => p === altPath); + vi.mocked(nodefs.readFileSync).mockImplementation((p) => { + if (p === altPath) return jsonlContent; + return ""; + }); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { resume: ACP_KEY }, + }, + } as NewSessionMeta, + }); + + // Must be the original hyphenated path, not the lossy-decoded '/home/user/my/project'. + expect(capturedOptions!.cwd).toBe(actualCwd); + expect(capturedOptions!.sessionId).toBeUndefined(); + }); + + it("falls back to dir-name decode when JSONL has no cwd lines, using existsSync to validate", async () => { + // Simulates old-format JSONL or empty file: no cwd field found. + // Falls back to lossy decode and checks existsSync on the decoded path. + const altDir = "-tmp"; + const altPath = `${CLAUDE_CONFIG_DIR}/projects/${altDir}/${BARE_UUID}.jsonl`; + const decodedCwd = "/tmp"; // '-tmp'.replace(/-/g, '/') = '/tmp' — correct for this simple case + + vi.mocked(nodefs.readdirSync).mockReturnValue([ + { name: altDir, isDirectory: () => true } as any, + ]); + // existsSync: the alt JSONL exists, and the decoded path /tmp also exists + vi.mocked(nodefs.existsSync).mockImplementation((p) => p === altPath || p === decodedCwd); + // readFileSync returns content with no cwd field + vi.mocked(nodefs.readFileSync).mockReturnValue( + JSON.stringify({ type: "queue-operation", operation: "enqueue", sessionId: BARE_UUID }), + ); + + await agent.newSession({ + cwd: "/test", + mcpServers: [], + _meta: { + claudeCode: { + options: { resume: ACP_KEY }, + }, + } as NewSessionMeta, + }); + + expect(capturedOptions!.cwd).toBe(decodedCwd); + expect(capturedOptions!.sessionId).toBeUndefined(); + }); +}); + +describe("proposedSessionId in _meta", () => { + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (_notification: SessionNotification) => {}, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + beforeEach(async () => { + capturedOptions = undefined; + + vi.resetModules(); + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + + agent = new ClaudeAcpAgent(createMockClient()); + }); + + it("uses proposedSessionId from _meta when provided", async () => { + const proposedId = "11111111-1111-1111-1111-111111111111"; + + const result = await agent.newSession({ + cwd: "/test", + mcpServers: [], + // Type cast needed because NewSessionRequest._meta is typed as + // { [key: string]: unknown }, so we cast to NewSessionMeta to satisfy + // the value type while retaining full type safety for the meta shape. + _meta: { + claudeCode: { + options: { + proposedSessionId: proposedId, + }, + }, + } as NewSessionMeta, + }); + + expect(result.sessionId).toBe(proposedId); + expect(capturedOptions!.sessionId).toBe(proposedId); + }); + + it("generates a random session ID when proposedSessionId is absent", async () => { + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + const result = await agent.newSession({ + cwd: "/test", + mcpServers: [], + }); + + expect(result.sessionId).toMatch(uuidRegex); + expect(capturedOptions!.sessionId).toBe(result.sessionId); + }); +});