diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 7fdd1e61..5da496fc 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -9,6 +9,8 @@ import { ForkSessionResponse, InitializeRequest, InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, ListSessionsRequest, ListSessionsResponse, ndJsonStream, @@ -46,8 +48,15 @@ import { } from "@anthropic-ai/claude-agent-sdk"; import * as fs from "node:fs"; import * as path from "node:path"; +import * as readline from "node:readline"; import * as os from "node:os"; -import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; +import { + encodeProjectPath, + nodeToWebReadable, + nodeToWebWritable, + Pushable, + unreachable, +} from "./utils.js"; import { createMcpServer } from "./mcp-server.js"; import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js"; import { @@ -88,6 +97,10 @@ function decodeProjectPath(encodedPath: string): string { return encodedPath.replace(/-/g, "/"); } +function sessionFilePath(cwd: string, sessionId: string): string { + return path.join(CLAUDE_CONFIG_DIR, "projects", encodeProjectPath(cwd), `${sessionId}.jsonl`); +} + const MAX_TITLE_LENGTH = 128; function sanitizeTitle(text: string): string { @@ -118,6 +131,17 @@ type Session = { settingsManager: SettingsManager; }; +type SessionHistoryEntry = { + type?: string; + isSidechain?: boolean; + sessionId?: string; + message?: { + role?: string; + content?: unknown; + model?: string; + }; +}; + type BackgroundTerminal = | { handle: TerminalHandle; @@ -228,6 +252,7 @@ export class ClaudeAcpAgent implements Agent { http: true, sse: true, }, + loadSession: true, sessionCapabilities: { fork: {}, list: {}, @@ -286,6 +311,32 @@ export class ClaudeAcpAgent implements Agent { return response; } + async loadSession(params: LoadSessionRequest): Promise { + try { + await fs.promises.access(sessionFilePath(params.cwd, params.sessionId)); + } catch { + throw new Error("Session not found"); + } + + const response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + _meta: params._meta, + }, + { + resume: params.sessionId, + }, + ); + + await this.replaySessionHistory(params.sessionId, params.cwd); + + return { + modes: response.modes, + models: response.models, + }; + } + /** * List Claude Code sessions by parsing JSONL files * Sessions are stored in ~/.claude/projects// @@ -656,6 +707,71 @@ export class ClaudeAcpAgent implements Agent { } } + private async replaySessionHistory(sessionId: string, cwd: string): Promise { + const filePath = sessionFilePath(cwd, sessionId); + const toolUseCache: ToolUseCache = {}; + const stream = fs.createReadStream(filePath, { encoding: "utf-8" }); + const reader = readline.createInterface({ input: stream, crlfDelay: Infinity }); + + try { + for await (const line of reader) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + + let entry: SessionHistoryEntry; + try { + entry = JSON.parse(trimmed) as SessionHistoryEntry; + } catch { + continue; + } + + if (entry.type !== "user" && entry.type !== "assistant") { + continue; + } + + if (entry.isSidechain) { + continue; + } + + if (entry.sessionId && entry.sessionId !== sessionId) { + continue; + } + + const message = entry.message; + if (!message) { + continue; + } + + const role = + message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null; + if (!role) { + continue; + } + + const content = message.content; + if (typeof content !== "string" && !Array.isArray(content)) { + continue; + } + + for (const notification of toAcpNotifications( + content, + role, + sessionId, + toolUseCache, + this.client, + this.logger, + { registerHooks: false }, + )) { + await this.client.sessionUpdate(notification); + } + } + } finally { + reader.close(); + } + } + async readTextFile(params: ReadTextFileRequest): Promise { const response = await this.client.readTextFile(params); return response; @@ -1242,7 +1358,9 @@ export function toAcpNotifications( toolUseCache: ToolUseCache, client: AgentSideConnection, logger: Logger, + options?: { registerHooks?: boolean }, ): SessionNotification[] { + const registerHooks = options?.registerHooks !== false; if (typeof content === "string") { return [ { @@ -1307,32 +1425,33 @@ export function toAcpNotifications( }; } } else { - // Register hook callback to receive the structured output from the hook - registerHookCallback(chunk.id, { - onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => { - const toolUse = toolUseCache[toolUseId]; - if (toolUse) { - const update: SessionNotification["update"] = { - _meta: { - claudeCode: { - toolResponse, - toolName: toolUse.name, - }, - } satisfies ToolUpdateMeta, - toolCallId: toolUseId, - sessionUpdate: "tool_call_update", - }; - await client.sessionUpdate({ - sessionId, - update, - }); - } else { - logger.error( - `[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`, - ); - } - }, - }); + if (registerHooks) { + registerHookCallback(chunk.id, { + onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => { + const toolUse = toolUseCache[toolUseId]; + if (toolUse) { + const update: SessionNotification["update"] = { + _meta: { + claudeCode: { + toolResponse, + toolName: toolUse.name, + }, + } satisfies ToolUpdateMeta, + toolCallId: toolUseId, + sessionUpdate: "tool_call_update", + }; + await client.sessionUpdate({ + sessionId, + update, + }); + } else { + logger.error( + `[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`, + ); + } + }, + }); + } let rawInput; try { diff --git a/src/tests/list-sessions.test.ts b/src/tests/list-sessions.test.ts index 47a5fff2..906f2a08 100644 --- a/src/tests/list-sessions.test.ts +++ b/src/tests/list-sessions.test.ts @@ -4,6 +4,7 @@ import * as path from "node:path"; import * as os from "node:os"; import { AgentSideConnection } from "@agentclientprotocol/sdk"; import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; +import { encodeProjectPath } from "../utils.js"; describe("unstable_listSessions", () => { let tempDir: string; @@ -21,18 +22,6 @@ describe("unstable_listSessions", () => { } as unknown as AgentSideConnection; } - // Helper to encode a path like Claude does: - // - Unix: "/Users/test" -> "-Users-test" - // - Windows: "C:\Users\test" -> "C-Users-test" - function encodePath(cwd: string): string { - // Handle Windows paths - if (/^[A-Za-z]:\\/.test(cwd)) { - return cwd.replace(/\\/g, "-").replace(":", ""); - } - // Unix paths - return cwd.replace(/\//g, "-"); - } - // Helper to write a session file function writeSessionFile( cwd: string, @@ -45,7 +34,7 @@ describe("unstable_listSessions", () => { isAgentFile?: boolean; } = {}, ): void { - const encodedPath = encodePath(cwd); + const encodedPath = encodeProjectPath(cwd); const projectDir = path.join(tempDir, "projects", encodedPath); fs.mkdirSync(projectDir, { recursive: true }); @@ -248,7 +237,7 @@ describe("unstable_listSessions", () => { it("uses filename as sessionId when not in file content", async () => { const cwd = "/Users/test/project"; - const encodedPath = encodePath(cwd); + const encodedPath = encodeProjectPath(cwd); const projectDir = path.join(tempDir, "projects", encodedPath); fs.mkdirSync(projectDir, { recursive: true }); @@ -263,7 +252,7 @@ describe("unstable_listSessions", () => { it("returns null title when no user message exists", async () => { const cwd = "/Users/test/project"; - const encodedPath = encodePath(cwd); + const encodedPath = encodeProjectPath(cwd); const projectDir = path.join(tempDir, "projects", encodedPath); fs.mkdirSync(projectDir, { recursive: true }); diff --git a/src/tests/load-session.test.ts b/src/tests/load-session.test.ts new file mode 100644 index 00000000..978aa32f --- /dev/null +++ b/src/tests/load-session.test.ts @@ -0,0 +1,250 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk"; +import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; +import { encodeProjectPath } from "../utils.js"; + +const { registerHookCallbackSpy } = vi.hoisted(() => ({ + registerHookCallbackSpy: vi.fn(), +})); + +vi.mock("../tools.js", async () => { + const actual = await vi.importActual("../tools.js"); + return { + ...actual, + registerHookCallback: registerHookCallbackSpy, + }; +}); + +describe("loadSession", () => { + let tempDir: string; + let originalClaudeConfigDir: string | undefined; + let agent: ClaudeAcpAgentType; + let ClaudeAcpAgent: typeof ClaudeAcpAgentType; + let sessionUpdates: SessionNotification[]; + let createSessionSpy: ReturnType; + + // Helper to create a mock AgentSideConnection + function createMockClient(): AgentSideConnection { + return { + sessionUpdate: async (notification: SessionNotification) => { + sessionUpdates.push(notification); + }, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AgentSideConnection; + } + + function writeSessionFile( + cwd: string, + sessionId: string, + entries: Array | string>, + ): void { + const encodedPath = encodeProjectPath(cwd); + const projectDir = path.join(tempDir, "projects", encodedPath); + fs.mkdirSync(projectDir, { recursive: true }); + + const filePath = path.join(projectDir, `${sessionId}.jsonl`); + const lines = entries.map((entry) => + typeof entry === "string" ? entry : JSON.stringify(entry), + ); + fs.writeFileSync(filePath, lines.join("\n")); + } + + beforeEach(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-load-session-")); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tempDir; + sessionUpdates = []; + registerHookCallbackSpy.mockClear(); + + vi.resetModules(); + + const acpAgent = await import("../acp-agent.js"); + ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; + + agent = new ClaudeAcpAgent(createMockClient()); + createSessionSpy = vi.fn(async () => ({ modes: null, models: null })); + (agent as unknown as { createSession: typeof createSessionSpy }).createSession = + createSessionSpy; + }); + + afterEach(() => { + if (originalClaudeConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } else { + delete process.env.CLAUDE_CONFIG_DIR; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("throws when the session file does not exist", async () => { + await expect( + agent.loadSession({ + cwd: "/Users/test/project", + sessionId: "missing-session", + mcpServers: [], + }), + ).rejects.toThrow("Session not found"); + + expect(createSessionSpy).not.toHaveBeenCalled(); + }); + + it("replays history and returns modes/models from createSession", async () => { + const cwd = "/Users/test/project"; + const sessionId = "session-123"; + + writeSessionFile(cwd, sessionId, [ + { type: "summary", summary: "ignored" }, + { + type: "user", + sessionId, + message: { role: "user", content: "Hello" }, + }, + { + type: "assistant", + sessionId, + message: { + role: "assistant", + content: [{ type: "thinking", thinking: "Thinking" }], + }, + }, + { + type: "assistant", + sessionId, + message: { + role: "assistant", + content: [{ type: "text", text: "Hi" }], + }, + }, + { + type: "assistant", + sessionId, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-1", + name: "Read", + input: { file_path: "/tmp/example.txt" }, + }, + ], + }, + }, + { + type: "user", + sessionId, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool-1", content: "ok" }], + }, + }, + ]); + + const result = await agent.loadSession({ cwd, sessionId, mcpServers: [] }); + + expect(result).toEqual({ modes: null, models: null }); + expect(createSessionSpy).toHaveBeenCalledWith( + expect.objectContaining({ cwd, mcpServers: [] }), + { resume: sessionId }, + ); + expect(registerHookCallbackSpy).not.toHaveBeenCalled(); + + expect(sessionUpdates.map((notification) => notification.update.sessionUpdate)).toEqual([ + "user_message_chunk", + "agent_thought_chunk", + "agent_message_chunk", + "tool_call", + "tool_call_update", + ]); + + expect(sessionUpdates[0]?.update).toMatchObject({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Hello" }, + }); + expect(sessionUpdates[1]?.update).toMatchObject({ + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Thinking" }, + }); + expect(sessionUpdates[2]?.update).toMatchObject({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Hi" }, + }); + expect(sessionUpdates[3]?.update).toMatchObject({ + sessionUpdate: "tool_call", + toolCallId: "tool-1", + status: "pending", + }); + expect(sessionUpdates[4]?.update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-1", + status: "completed", + }); + }); + + it("skips sidechain, mismatched sessionId, invalid, and unsupported entries", async () => { + const cwd = "/Users/test/project"; + const sessionId = "session-456"; + + writeSessionFile(cwd, sessionId, [ + "{not valid json}", + { type: "summary", summary: "ignore" }, + { + type: "user", + sessionId, + isSidechain: true, + message: { role: "user", content: "Sidechain" }, + }, + { + type: "assistant", + sessionId: "other-session", + message: { role: "assistant", content: "Wrong session" }, + }, + { + type: "assistant", + sessionId, + message: { role: "system", content: "Unsupported role" }, + }, + { + type: "user", + sessionId, + message: { role: "user", content: 123 }, + }, + { + type: "user", + sessionId, + message: { role: "user", content: "Keep me" }, + }, + ]); + + await agent.loadSession({ cwd, sessionId, mcpServers: [] }); + + expect(sessionUpdates).toHaveLength(1); + expect(sessionUpdates[0]?.update).toMatchObject({ + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "Keep me" }, + }); + }); + + it("handles Windows-style paths", async () => { + const cwd = "C:\\Users\\test\\project"; + const sessionId = "win-session"; + + writeSessionFile(cwd, sessionId, [ + { + type: "user", + sessionId, + message: { role: "user", content: "Hello" }, + }, + ]); + + await agent.loadSession({ cwd, sessionId, mcpServers: [] }); + + expect(sessionUpdates).toHaveLength(1); + expect(sessionUpdates[0]?.sessionId).toBe(sessionId); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index d81e6215..d2f77448 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -169,3 +169,18 @@ export function extractLinesWithByteLimit( linesRead: linesSeen, }; } + +// Helper to encode a path like Claude does: +// - Unix: "/Users/test" -> "-Users-test" +// - Windows: "C:\Users\test" -> "C-Users-test" +export function encodeProjectPath(cwd: string): string { + const windowsPathMatch = cwd.match(/^([A-Za-z]):[\\/]/); + if (windowsPathMatch) { + const driveLetter = windowsPathMatch[1]; + const rest = cwd.slice(2); + return `${driveLetter}${rest.replace(/[\\/]/g, "-")}`; + } + + // Unix paths + return cwd.replace(/\//g, "-"); +}