import { describe, expect, it } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getClaudeDesktopDefaultRoot, getClaudeDesktopSessionsStreaming, } from "./claude-desktop"; async function writeSession( root: string, userId: string, accountId: string, sessionId: string, body: Record, ): Promise { const dir = join(root, userId, accountId); await mkdir(dir, { recursive: true }); const path = join(dir, `${sessionId}.json`); await writeFile(path, JSON.stringify(body)); return path; } describe("claude-desktop ingestion", () => { it("yields one ChatSession per local_*.json with synthesized initial user message", async () => { const root = await mkdtemp(join(tmpdir(), "scribe-claude-desktop-")); try { await writeSession(root, "user-a", "account-1", "local_abc", { sessionId: "local_abc", title: "Crime map for zip code 12180", initialMessage: "Give me crime map for 12180.", model: "claude-opus-4-6", createdAt: 1_700_000_000_000, lastActivityAt: 1_700_000_120_000, slashCommands: ["neighborhood:crime-report"], }); const sessions: Array< Awaited< ReturnType< typeof getClaudeDesktopSessionsStreaming > extends AsyncGenerator ? Promise : never > > = []; for await (const session of getClaudeDesktopSessionsStreaming( [root], undefined, { includeDefaultRoot: false, }, )) { sessions.push(session); } expect(sessions).toHaveLength(1); const [only] = sessions; expect(only.providerId).toBe("claude_desktop"); expect(only.externalThreadId).toBe("local_abc"); expect(only.title).toBe("Crime map for zip code 12180"); expect(only.messages).toHaveLength(1); expect(only.messages[0].role).toBe("user"); expect(only.messages[0].text).toBe("Give me crime map for 12180."); expect(only.startedAt?.getTime()).toBe(1_700_000_000_000); expect(only.endedAt?.getTime()).toBe(1_700_000_120_000); expect(only.metadata?.model).toBe("claude-opus-4-6"); } finally { await rm(root, { recursive: true, force: true }); } }); it("skips malformed JSON, non-local_* filenames, and missing timestamps", async () => { const root = await mkdtemp(join(tmpdir(), "scribe-claude-desktop-")); const accountDir = join(root, "user-a", "account-1"); try { await mkdir(accountDir, { recursive: true }); await writeFile(join(accountDir, "local_bad.json"), "{not json"); await writeFile(join(accountDir, "otherfile.json"), JSON.stringify({})); await writeFile( join(accountDir, "local_notime.json"), JSON.stringify({ sessionId: "local_notime", initialMessage: "hi" }), ); const sessions = []; for await (const session of getClaudeDesktopSessionsStreaming( [root], undefined, { includeDefaultRoot: false, }, )) { sessions.push(session); } expect(sessions).toHaveLength(0); } finally { await rm(root, { recursive: true, force: true }); } }); it("respects the since filter on lastActivityAt", async () => { const root = await mkdtemp(join(tmpdir(), "scribe-claude-desktop-")); try { await writeSession(root, "user-a", "account-1", "local_old", { sessionId: "local_old", title: "old", initialMessage: "older work", createdAt: 1_000_000_000_000, lastActivityAt: 1_000_000_030_000, }); await writeSession(root, "user-a", "account-1", "local_new", { sessionId: "local_new", title: "new", initialMessage: "recent work", createdAt: 1_700_000_000_000, lastActivityAt: 1_700_000_060_000, }); const sessions = []; const since = new Date(1_500_000_000_000); for await (const session of getClaudeDesktopSessionsStreaming( [root], since, { includeDefaultRoot: false }, )) { sessions.push(session); } expect(sessions).toHaveLength(1); expect(sessions[0].externalThreadId).toBe("local_new"); } finally { await rm(root, { recursive: true, force: true }); } }); it("falls back to initialMessage when title is missing", async () => { const root = await mkdtemp(join(tmpdir(), "scribe-claude-desktop-")); try { await writeSession(root, "user-a", "account-1", "local_xyz", { sessionId: "local_xyz", initialMessage: "This is a longer initial message that should be truncated when used as the session title", createdAt: 1_700_000_000_000, lastActivityAt: 1_700_000_010_000, }); const sessions = []; for await (const session of getClaudeDesktopSessionsStreaming( [root], undefined, { includeDefaultRoot: false, }, )) { sessions.push(session); } expect(sessions).toHaveLength(1); expect(sessions[0].title).toMatch(/\.\.\.$/); expect(sessions[0].title?.length).toBeLessThanOrEqual(84); } finally { await rm(root, { recursive: true, force: true }); } }); it("returns a platform-specific default root path", () => { const root = getClaudeDesktopDefaultRoot(); if (process.platform === "darwin") { expect(root).toContain("Library/Application Support/Claude"); expect(root).toContain("local-agent-mode-sessions"); } else if (process.platform === "linux") { expect(root).toContain(".config/Claude/local-agent-mode-sessions"); } else if (process.platform === "win32") { // APPDATA may or may not be set in test envs; just check shape when set if (process.env.APPDATA) { expect(root).toContain("Claude"); expect(root).toContain("local-agent-mode-sessions"); } } }); it("returns nothing when the scan root does not exist", async () => { const fakeRoot = join( tmpdir(), `scribe-claude-desktop-missing-${Date.now()}`, ); const sessions = []; for await (const session of getClaudeDesktopSessionsStreaming( [fakeRoot], undefined, { includeDefaultRoot: false, }, )) { sessions.push(session); } expect(sessions).toHaveLength(0); }); });