diff --git a/src/acp-agent.ts b/src/acp-agent.ts index b7f899c9..77ce9476 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -24,7 +24,6 @@ import { ResumeSessionRequest, ResumeSessionResponse, SessionConfigOption, - SessionInfo, SessionModelState, SessionNotification, SetSessionConfigOptionRequest, @@ -42,6 +41,8 @@ import { import { SettingsManager } from "./settings.js"; import { CanUseTool, + getSessionMessages, + listSessions, McpServerConfig, ModelInfo, Options, @@ -70,15 +71,8 @@ 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 { - encodeProjectPath, - nodeToWebReadable, - nodeToWebWritable, - Pushable, - unreachable, -} from "./utils.js"; +import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; import { toolInfoFromToolUse, planEntries, @@ -118,11 +112,7 @@ type SDKMessageTemp = export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); -function sessionFilePath(cwd: string, sessionId: string): string { - return path.join(CLAUDE_CONFIG_DIR, "projects", encodeProjectPath(cwd), `${sessionId}.jsonl`); -} - -const MAX_TITLE_LENGTH = 128; +const MAX_TITLE_LENGTH = 256; function sanitizeTitle(text: string): string { // Replace newlines and collapse whitespace @@ -153,17 +143,6 @@ type Session = { configOptions: SessionConfigOption[]; }; -type SessionHistoryEntry = { - type?: string; - isSidechain?: boolean; - sessionId?: string; - message?: { - role?: string; - content?: unknown; - model?: string; - }; -}; - type BackgroundTerminal = | { handle: TerminalHandle; @@ -374,52 +353,7 @@ export class ClaudeAcpAgent implements Agent { return response; } - /** - * Find a session file by ID, first checking the given cwd's project directory, - * then falling back to scanning all project directories. - * Returns the absolute file path if found, or null if not found. - */ - private async findSessionFile(sessionId: string, cwd: string): Promise { - const fileName = `${sessionId}.jsonl`; - - // Fast path: check the expected location based on cwd - const expectedPath = sessionFilePath(cwd, sessionId); - try { - await fs.promises.access(expectedPath); - return expectedPath; - } catch { - // Not found at expected path, scan all project directories - } - - const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects"); - try { - const projectDirs = await fs.promises.readdir(claudeDir); - for (const encodedPath of projectDirs) { - const projectDir = path.join(claudeDir, encodedPath); - const stat = await fs.promises.stat(projectDir); - if (!stat.isDirectory()) continue; - - const candidatePath = path.join(projectDir, fileName); - try { - await fs.promises.access(candidatePath); - return candidatePath; - } catch { - continue; - } - } - } catch { - // projects directory doesn't exist or isn't readable - } - - return null; - } - async loadSession(params: LoadSessionRequest): Promise { - const filePath = await this.findSessionFile(params.sessionId, params.cwd); - if (!filePath) { - throw new Error("Session not found"); - } - const response = await this.createSession( { cwd: params.cwd, @@ -431,7 +365,7 @@ export class ClaudeAcpAgent implements Agent { }, ); - await this.replaySessionHistory(params.sessionId, filePath); + await this.replaySessionHistory(params.sessionId); // Send available commands after replay so it doesn't interleave with history setTimeout(() => { @@ -445,167 +379,22 @@ export class ClaudeAcpAgent implements Agent { }; } - /** - * List Claude Code sessions by parsing JSONL files - * Sessions are stored in ~/.claude/projects// - * Implements the draft session/list RFD spec - */ async unstable_listSessions(params: ListSessionsRequest): Promise { - // Note: We load all sessions into memory for sorting, so pagination here is for - // API response size limits rather than memory efficiency. This matches the RFD spec. - const PAGE_SIZE = 50; - const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects"); + const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined }); + const sessions = []; - try { - await fs.promises.access(claudeDir); - } catch { - return { sessions: [] }; + for (const session of sdk_sessions) { + if (!session.cwd) continue; + sessions.push({ + sessionId: session.sessionId, + cwd: session.cwd, + title: sanitizeTitle(session.summary), + updatedAt: new Date(session.lastModified).toISOString(), + }); } - - // Collect all sessions across all project directories - const allSessions: SessionInfo[] = []; - const encodedCwdFilter = params.cwd ? encodeProjectPath(params.cwd) : null; - - try { - const projectDirs = await fs.promises.readdir(claudeDir); - - for (const encodedPath of projectDirs) { - const projectDir = path.join(claudeDir, encodedPath); - const stat = await fs.promises.stat(projectDir); - if (!stat.isDirectory()) continue; - - // Path encoding is not always reversible (hyphens can be separators or literals), - // so only use encoded value as a coarse pre-filter. - if (encodedCwdFilter && encodedPath !== encodedCwdFilter) continue; - - const files = await fs.promises.readdir(projectDir); - // Filter to user session files only. Skip agent-*.jsonl files which contain - // internal agent metadata and system logs, not user-visible conversation sessions. - const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-")); - - for (const file of jsonlFiles) { - const filePath = path.join(projectDir, file); - try { - const content = await fs.promises.readFile(filePath, "utf-8"); - const lines = content.trim().split("\n").filter(Boolean); - - const sessionId = file.replace(".jsonl", ""); - let parsedAnyEntry = false; - let sessionCwd: string | undefined; - - // Find first user message for title - let title: string | undefined; - for (const line of lines) { - try { - const entry = JSON.parse(line); - parsedAnyEntry = true; - if (entry.isSidechain === true) { - continue; - } - const entrySessionId = - typeof entry.sessionId === "string" ? entry.sessionId : undefined; - if (typeof entry.sessionId === "string" && entry.sessionId !== entrySessionId) { - continue; - } - if (typeof entry.cwd === "string") { - sessionCwd = entry.cwd; - } - if (!title && entry.type === "user" && entry.message?.content) { - const msgContent = entry.message.content; - if (typeof msgContent === "string") { - title = sanitizeTitle(msgContent); - } - if (Array.isArray(msgContent) && msgContent.length > 0) { - const first = msgContent[0]; - const text = - typeof first === "string" - ? first - : first && typeof first === "object" && typeof first.text === "string" - ? first.text - : undefined; - if (text) { - title = sanitizeTitle(text); - } - } - } - - // Continue scanning until we have both fields, since cwd can appear - // in later entries even after the first user title-bearing message. - if (title && sessionCwd) { - break; - } - } catch { - // Skip malformed lines - } - } - if (!parsedAnyEntry) continue; - - // SessionInfo.cwd is currently required. For entries that do not - // include an explicit cwd in the session JSONL (typically metadata-only files), - // we skip them instead of decoding folder names because path encoding is lossy. - if (!sessionCwd) continue; - - // Even after encoded-path pre-filtering, verify per-entry cwd to disambiguate - // collisions such as "/a-b" and "/a/b" that map to the same encoded folder name. - if (params.cwd && sessionCwd !== params.cwd) continue; - - // Get file modification time as updatedAt - const fileStat = await fs.promises.stat(filePath); - const updatedAt = fileStat.mtime.toISOString(); - - allSessions.push({ - sessionId, - cwd: sessionCwd, - title: title ?? null, - updatedAt, - }); - } catch (err) { - this.logger.error( - `[unstable_listSessions] Failed to parse session file: ${filePath}`, - err, - ); - } - } - } - } catch (err) { - this.logger.error("[unstable_listSessions] Failed to list sessions", err); - return { sessions: [] }; - } - - // Sort by updatedAt descending (most recent first) - allSessions.sort((a, b) => { - const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0; - const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0; - return timeB - timeA; - }); - - // Handle pagination with cursor - let startIndex = 0; - if (params.cursor) { - try { - const decoded = Buffer.from(params.cursor, "base64").toString("utf-8"); - const cursorData = JSON.parse(decoded); - startIndex = cursorData.offset ?? 0; - } catch { - // Invalid cursor, start from beginning - } - } - - const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE); - const hasMore = startIndex + PAGE_SIZE < allSessions.length; - - const response: ListSessionsResponse = { - sessions: pageOfSessions, + return { + sessions, }; - - if (hasMore) { - const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString( - "base64", - ); - response.nextCursor = nextCursor; - } - - return response; } async authenticate(_params: AuthenticateRequest): Promise { @@ -895,67 +684,23 @@ export class ClaudeAcpAgent implements Agent { } } - private async replaySessionHistory(sessionId: string, filePath: string): Promise { + private async replaySessionHistory(sessionId: string): Promise { const toolUseCache: ToolUseCache = {}; - const stream = fs.createReadStream(filePath, { encoding: "utf-8" }); - const reader = readline.createInterface({ input: stream, crlfDelay: Infinity }); + const messages = await getSessionMessages(sessionId); - 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, clientCapabilities: this.clientCapabilities }, - )) { - await this.client.sessionUpdate(notification); - } + for (const message of messages) { + for (const notification of toAcpNotifications( + // @ts-expect-error - untyped in SDK but we handle all of these + message.message, + message.type, + sessionId, + toolUseCache, + this.client, + this.logger, + { registerHooks: false, clientCapabilities: this.clientCapabilities }, + )) { + await this.client.sessionUpdate(notification); } - } finally { - reader.close(); } } diff --git a/src/tests/list-sessions.test.ts b/src/tests/list-sessions.test.ts deleted file mode 100644 index 6b4e4afb..00000000 --- a/src/tests/list-sessions.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -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 } from "@agentclientprotocol/sdk"; -import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js"; -import { encodeProjectPath } from "../utils.js"; - -describe("unstable_listSessions", () => { - let tempDir: string; - let originalClaudeConfigDir: string | undefined; - let agent: ClaudeAcpAgentType; - let ClaudeAcpAgent: typeof ClaudeAcpAgentType; - - // Helper to create a mock AgentSideConnection - function createMockClient(): AgentSideConnection { - return { - sessionUpdate: async () => {}, - requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), - readTextFile: async () => ({ content: "" }), - writeTextFile: async () => ({}), - } as unknown as AgentSideConnection; - } - - // Helper to write a session file - function writeSessionFile( - cwd: string, - sessionId: string, - options: { - userMessage?: string; - userMessageArray?: boolean; - mtime?: Date; - malformed?: boolean; - isAgentFile?: boolean; - omitCwdFromEntries?: boolean; - } = {}, - ): void { - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - const filename = options.isAgentFile ? `agent-${sessionId}.jsonl` : `${sessionId}.jsonl`; - const filePath = path.join(projectDir, filename); - - if (options.malformed) { - fs.writeFileSync(filePath, "not valid json\n{also bad"); - return; - } - - const lines: string[] = []; - - // First line with sessionId - lines.push(JSON.stringify({ sessionId, type: "init" })); - - // User message if provided - if (options.userMessage) { - const content = options.userMessageArray - ? [{ type: "text", text: options.userMessage }] - : options.userMessage; - lines.push( - JSON.stringify({ - type: "user", - sessionId, - ...(options.omitCwdFromEntries ? {} : { cwd }), - message: { content }, - }), - ); - } - - // Assistant response - lines.push( - JSON.stringify({ - type: "assistant", - sessionId, - ...(options.omitCwdFromEntries ? {} : { cwd }), - message: { content: "Hello!" }, - }), - ); - - fs.writeFileSync(filePath, lines.join("\n")); - - // Set modification time if specified - if (options.mtime) { - fs.utimesSync(filePath, options.mtime, options.mtime); - } - } - - beforeEach(async () => { - // Create temp directory to isolate tests from real filesystem. - // We set process.env.CLAUDE_CONFIG_DIR before importing the module so that - // CLAUDE_CONFIG_DIR is evaluated with our temp directory. - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-test-")); - originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - process.env.CLAUDE_CONFIG_DIR = tempDir; - - // Reset modules to pick up the new CLAUDE_CONFIG_DIR env var - vi.resetModules(); - - // Dynamic import after setting env var - const acpAgent = await import("../acp-agent.js"); - ClaudeAcpAgent = acpAgent.ClaudeAcpAgent; - - // Create agent instance - agent = new ClaudeAcpAgent(createMockClient()); - }); - - afterEach(() => { - // Restore env and cleanup - if (originalClaudeConfigDir !== undefined) { - process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; - } else { - delete process.env.CLAUDE_CONFIG_DIR; - } - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it("returns empty array when projects directory does not exist", async () => { - const result = await agent.unstable_listSessions({}); - expect(result.sessions).toEqual([]); - expect(result.nextCursor).toBeUndefined(); - }); - - it("returns empty array when projects directory is empty", async () => { - fs.mkdirSync(path.join(tempDir, "projects"), { recursive: true }); - - const result = await agent.unstable_listSessions({}); - expect(result.sessions).toEqual([]); - }); - - it("parses session files and returns correct metadata", async () => { - const cwd = "/Users/test/myproject"; - const sessionId = "sess-123"; - const mtime = new Date("2025-01-15T10:30:00Z"); - - writeSessionFile(cwd, sessionId, { - userMessage: "Hello, can you help me?", - mtime, - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]).toMatchObject({ - sessionId: "sess-123", - cwd: "/Users/test/myproject", - title: "Hello, can you help me?", - }); - expect(result.sessions[0]!.updatedAt).toBeDefined(); - }); - - it("extracts title from array-style user message content", async () => { - writeSessionFile("/Users/test/project", "sess-456", { - userMessage: "Fix the bug in auth", - userMessageArray: true, - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.title).toBe("Fix the bug in auth"); - }); - - it("truncates long titles to 128 characters with ellipsis", async () => { - const longMessage = "A".repeat(150); - writeSessionFile("/Users/test/project", "sess-789", { - userMessage: longMessage, - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.title).toBe("A".repeat(127) + "…"); - }); - - it("replaces newlines with spaces in titles", async () => { - const messageWithNewlines = "First line\nSecond line\r\nThird line"; - writeSessionFile("/Users/test/project", "sess-newlines", { - userMessage: messageWithNewlines, - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.title).toBe("First line Second line Third line"); - }); - - it("collapses multiple whitespace characters in titles", async () => { - const messageWithWhitespace = "Hello world \n\n test"; - writeSessionFile("/Users/test/project", "sess-whitespace", { - userMessage: messageWithWhitespace, - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.title).toBe("Hello world test"); - }); - - it("filters sessions by cwd parameter", async () => { - writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "Project A" }); - writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "Project B" }); - - const result = await agent.unstable_listSessions({ cwd: "/Users/test/projectone" }); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("sess-a"); - }); - - it("uses per-entry cwd when filtering even if encoded folder collides", async () => { - const encodedPath = encodeProjectPath("/Users/test/a-b"); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - fs.writeFileSync( - path.join(projectDir, "sess-a-b.jsonl"), - [ - JSON.stringify({ sessionId: "sess-a-b", type: "init" }), - JSON.stringify({ - type: "user", - sessionId: "sess-a-b", - cwd: "/Users/test/a-b", - message: { content: "A-B" }, - }), - ].join("\n"), - ); - - fs.writeFileSync( - path.join(projectDir, "sess-a-slash-b.jsonl"), - [ - JSON.stringify({ sessionId: "sess-a-slash-b", type: "init" }), - JSON.stringify({ - type: "user", - sessionId: "sess-a-slash-b", - cwd: "/Users/test/a/b", - message: { content: "A/B" }, - }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({ cwd: "/Users/test/a-b" }); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("sess-a-b"); - expect(result.sessions[0]!.cwd).toBe("/Users/test/a-b"); - }); - - it("returns the true cwd for paths containing hyphens", async () => { - const cwd = "/Users/test/claude-agent-acp"; - writeSessionFile(cwd, "hyphen-session", { userMessage: "Hyphen test" }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.cwd).toBe(cwd); - expect(result.sessions[0]!.sessionId).toBe("hyphen-session"); - }); - - it("filters hyphenated paths by cwd correctly", async () => { - writeSessionFile("/Users/test/claude-agent-acp", "hyphen-a", { userMessage: "A" }); - writeSessionFile("/Users/test/other-project", "hyphen-b", { userMessage: "B" }); - - const result = await agent.unstable_listSessions({ cwd: "/Users/test/claude-agent-acp" }); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("hyphen-a"); - expect(result.sessions[0]!.cwd).toBe("/Users/test/claude-agent-acp"); - }); - - it("returns all sessions when cwd is not specified", async () => { - writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "A" }); - writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "B" }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(2); - }); - - it("sorts sessions by updatedAt descending (most recent first)", async () => { - writeSessionFile("/Users/test/project", "old-session", { - userMessage: "Old", - mtime: new Date("2025-01-01T00:00:00Z"), - }); - writeSessionFile("/Users/test/project", "new-session", { - userMessage: "New", - mtime: new Date("2025-01-15T00:00:00Z"), - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.sessionId).toBe("new-session"); - expect(result.sessions[1]!.sessionId).toBe("old-session"); - }); - - it("skips files starting with agent-", async () => { - writeSessionFile("/Users/test/project", "regular-session", { userMessage: "Regular" }); - writeSessionFile("/Users/test/project", "hidden-session", { - userMessage: "Hidden", - isAgentFile: true, - }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("regular-session"); - }); - - it("skips malformed session files gracefully", async () => { - writeSessionFile("/Users/test/project", "good-session", { userMessage: "Good" }); - writeSessionFile("/Users/test/project", "bad-session", { malformed: true }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("good-session"); - }); - - it("uses filename as sessionId when not in file content", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - // Write file without sessionId in content (uses filename fallback), but with cwd. - const filePath = path.join(projectDir, "fallback-id.jsonl"); - fs.writeFileSync( - filePath, - [ - JSON.stringify({ type: "init" }), - JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.sessionId).toBe("fallback-id"); - }); - - it("keeps sessionId stable from filename when later entries have different sessionId", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - const filePath = path.join(projectDir, "stable-id.jsonl"); - fs.writeFileSync( - filePath, - [ - JSON.stringify({ type: "init", sessionId: "stable-id" }), - JSON.stringify({ - type: "user", - sessionId: "stable-id", - cwd, - message: { content: "Hello" }, - }), - JSON.stringify({ - type: "assistant", - sessionId: "other-id", - cwd, - message: { content: "Wrong session id from sidechain" }, - }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("stable-id"); - }); - - it("derives cwd from entries for the filename sessionId only", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - const filePath = path.join(projectDir, "target-session.jsonl"); - fs.writeFileSync( - filePath, - [ - JSON.stringify({ - type: "user", - sessionId: "target-session", - cwd, - message: { content: "Correct cwd entry" }, - }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("target-session"); - expect(result.sessions[0]!.cwd).toBe(cwd); - }); - - it("keeps scanning after title to find later cwd in same session", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - const filePath = path.join(projectDir, "late-cwd.jsonl"); - fs.writeFileSync( - filePath, - [ - JSON.stringify({ type: "init", sessionId: "late-cwd" }), - JSON.stringify({ - type: "user", - sessionId: "late-cwd", - message: { content: "Title from first user message" }, - }), - JSON.stringify({ - type: "assistant", - sessionId: "late-cwd", - cwd, - message: { content: "Assistant entry carries cwd" }, - }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("late-cwd"); - expect(result.sessions[0]!.cwd).toBe(cwd); - expect(result.sessions[0]!.title).toBe("Title from first user message"); - }); - - it("ignores sidechain entries when deriving cwd and title", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - const filePath = path.join(projectDir, "sidechain-metadata.jsonl"); - fs.writeFileSync( - filePath, - [ - JSON.stringify({ type: "init", sessionId: "sidechain-metadata" }), - JSON.stringify({ - type: "user", - sessionId: "sidechain-metadata", - isSidechain: true, - cwd: "/Users/test/wrong-project", - message: { content: "Wrong sidechain title" }, - }), - JSON.stringify({ - type: "user", - sessionId: "sidechain-metadata", - cwd, - message: { content: "Correct primary title" }, - }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({ cwd }); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("sidechain-metadata"); - expect(result.sessions[0]!.cwd).toBe(cwd); - expect(result.sessions[0]!.title).toBe("Correct primary title"); - }); - - it("skips entries without cwd when cwd filter is not provided", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - const filePath = path.join(projectDir, "no-cwd.jsonl"); - fs.writeFileSync(filePath, JSON.stringify({ sessionId: "no-cwd", type: "summary" }) + "\n"); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(0); - }); - - it("returns null title when no user message exists", async () => { - const cwd = "/Users/test/project"; - const encodedPath = encodeProjectPath(cwd); - const projectDir = path.join(tempDir, "projects", encodedPath); - fs.mkdirSync(projectDir, { recursive: true }); - - // Write file with only init and assistant message - const filePath = path.join(projectDir, "no-user.jsonl"); - fs.writeFileSync( - filePath, - [ - JSON.stringify({ sessionId: "no-user", type: "init" }), - JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }), - ].join("\n"), - ); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions[0]!.title).toBeNull(); - }); - - describe("pagination", () => { - it("returns nextCursor when there are more results", async () => { - // Create 60 sessions (more than PAGE_SIZE of 50) - for (let i = 0; i < 60; i++) { - writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, { - userMessage: `Session ${i}`, - mtime: new Date(Date.now() - i * 1000), // Each older by 1 second - }); - } - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(50); - expect(result.nextCursor).toBeDefined(); - }); - - it("does not return nextCursor when all results fit in one page", async () => { - for (let i = 0; i < 10; i++) { - writeSessionFile("/Users/test/project", `sess-${i}`, { userMessage: `Session ${i}` }); - } - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(10); - expect(result.nextCursor).toBeUndefined(); - }); - - it("returns next page when cursor is provided", async () => { - // Create 60 sessions - for (let i = 0; i < 60; i++) { - writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, { - userMessage: `Session ${i}`, - mtime: new Date(Date.now() - i * 1000), - }); - } - - // Get first page - const firstPage = await agent.unstable_listSessions({}); - expect(firstPage.sessions).toHaveLength(50); - expect(firstPage.nextCursor).toBeDefined(); - - // Get second page - const secondPage = await agent.unstable_listSessions({ cursor: firstPage.nextCursor }); - expect(secondPage.sessions).toHaveLength(10); - expect(secondPage.nextCursor).toBeUndefined(); - }); - - it("handles invalid cursor gracefully (starts from beginning)", async () => { - writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" }); - - const result = await agent.unstable_listSessions({ cursor: "invalid-base64!" }); - - expect(result.sessions).toHaveLength(1); - }); - - it("handles malformed cursor JSON gracefully", async () => { - writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" }); - - // Valid base64 but not valid JSON - const badCursor = Buffer.from("not json").toString("base64"); - const result = await agent.unstable_listSessions({ cursor: badCursor }); - - expect(result.sessions).toHaveLength(1); - }); - }); - - describe("Windows path support", () => { - it("decodes Windows-style paths correctly", async () => { - // Simulate a Windows path encoded as "C-Users-test-project" - const windowsCwd = "C:\\Users\\test\\project"; - writeSessionFile(windowsCwd, "win-session", { userMessage: "Windows test" }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.cwd).toBe("C:\\Users\\test\\project"); - expect(result.sessions[0]!.sessionId).toBe("win-session"); - }); - - it("filters Windows paths by cwd correctly", async () => { - writeSessionFile("C:\\Users\\test\\projectone", "win-a", { userMessage: "A" }); - writeSessionFile("C:\\Users\\test\\projecttwo", "win-b", { userMessage: "B" }); - - const result = await agent.unstable_listSessions({ cwd: "C:\\Users\\test\\projectone" }); - - expect(result.sessions).toHaveLength(1); - expect(result.sessions[0]!.sessionId).toBe("win-a"); - }); - - it("handles mixed Unix and Windows paths", async () => { - writeSessionFile("/Users/test/unixproject", "unix-session", { userMessage: "Unix" }); - writeSessionFile("C:\\Users\\test\\winproject", "win-session", { userMessage: "Windows" }); - - const result = await agent.unstable_listSessions({}); - - expect(result.sessions).toHaveLength(2); - const cwds = result.sessions.map((s) => s.cwd); - expect(cwds).toContain("/Users/test/unixproject"); - expect(cwds).toContain("C:\\Users\\test\\winproject"); - }); - }); -}); diff --git a/src/tests/load-session.test.ts b/src/tests/load-session.test.ts deleted file mode 100644 index 7523319a..00000000 --- a/src/tests/load-session.test.ts +++ /dev/null @@ -1,320 +0,0 @@ -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("finds session file when cwd differs from the original session cwd", async () => { - const originalCwd = "/Users/test/original-project"; - const differentCwd = "/Users/test/different-project"; - const sessionId = "cross-cwd-session"; - - // Session was created in originalCwd - writeSessionFile(originalCwd, sessionId, [ - { - type: "user", - sessionId, - cwd: originalCwd, - message: { role: "user", content: "Hello from original" }, - }, - ]); - - // Load with a different cwd - await agent.loadSession({ cwd: differentCwd, sessionId, mcpServers: [] }); - - expect(sessionUpdates).toHaveLength(1); - expect(sessionUpdates[0]?.update).toMatchObject({ - sessionUpdate: "user_message_chunk", - content: { type: "text", text: "Hello from original" }, - }); - expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ cwd: differentCwd }), { - resume: sessionId, - }); - }); - - it("sends available_commands_update after history replay, not during", async () => { - const cwd = "/Users/test/project"; - const sessionId = "session-commands"; - - // Mock createSession to populate the sessions map with a mock query - createSessionSpy.mockImplementationOnce(async () => { - (agent as unknown as { sessions: Record }).sessions[sessionId] = { - query: { - supportedCommands: async () => [{ name: "help", description: "Get help" }], - }, - }; - return { modes: null, models: null }; - }); - - writeSessionFile(cwd, sessionId, [ - { - type: "user", - sessionId, - message: { role: "user", content: "Hello" }, - }, - { - type: "assistant", - sessionId, - message: { role: "assistant", content: [{ type: "text", text: "Hi" }] }, - }, - ]); - - await agent.loadSession({ cwd, sessionId, mcpServers: [] }); - - // Flush the setTimeout + async work inside sendAvailableCommandsUpdate - await new Promise((resolve) => setTimeout(resolve, 10)); - - const updateTypes = sessionUpdates.map((n) => n.update.sessionUpdate); - - // available_commands_update should come AFTER all replay notifications - expect(updateTypes).toEqual([ - "user_message_chunk", - "agent_message_chunk", - "available_commands_update", - ]); - }); - - 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 fc9fbfd9..814021b2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -105,10 +105,3 @@ export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void { } } } - -// 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 { - return cwd.replace(/[^a-zA-Z0-9]/g, "-"); -}