diff --git a/packages/server/src/server/agent/providers/claude/project-dir.test.ts b/packages/server/src/server/agent/providers/claude/project-dir.test.ts new file mode 100644 index 000000000..e90e13aad --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/project-dir.test.ts @@ -0,0 +1,109 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, rm, symlink, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; + +import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +import { claudeProjectDir } from "./project-dir.js"; + +// Parity oracle: the Claude SDK's getSessionInfo({ dir }) canonicalizes the +// given dir with the SDK's own encoder and looks for `.jsonl` under +// that path. If we write a session file at the path our function computes and +// the SDK finds it, our encoding matches theirs for that input. + +const workspaceRoot = join(homedir(), ".paseo-claude-parity-tests"); +const tmpWorkspaceRoot = join(tmpdir(), "paseo-claude-parity"); +const createdSessionFiles: string[] = []; + +interface ParityCase { + label: string; + build(): Promise; +} + +const cases: ParityCase[] = [ + { + label: "plain path under home", + build: () => ensureDir(join(workspaceRoot, "plain")), + }, + { + label: "path with spaces", + build: () => ensureDir(join(workspaceRoot, "with spaces and stuff")), + }, + { + label: "path with parens and other shell-meta", + build: () => ensureDir(join(workspaceRoot, "proj (work) [v2]")), + }, + { + label: "path with combining-mark unicode (NFD input)", + build: () => ensureDir(join(workspaceRoot, "cafe\u0301-nfd")), + }, + { + label: "path under macOS tmpdir (realpath delta)", + build: () => ensureDir(join(tmpWorkspaceRoot, "var-folders-case")), + }, + { + label: "user-defined symlink", + build: async () => { + const real = await ensureDir(join(workspaceRoot, "real-target")); + const link = join(workspaceRoot, "via-symlink"); + if (!existsSync(link)) { + await symlink(real, link); + } + return link; + }, + }, + { + label: "deep path that triggers truncation + hash", + build: async () => { + // ~60 segments × 6 chars = 360+ char path, well past the 200-char cap. + let p = workspaceRoot; + for (let i = 0; i < 60; i++) p = join(p, `seg${i.toString().padStart(3, "0")}`); + return ensureDir(p); + }, + }, + { + label: "non-existent path (canonicalize fallback)", + // realpath will throw; canonicalize falls back to NFC of input. SDK does + // the same, so paths should still match. + build: async () => join(workspaceRoot, "this-path-was-never-created-" + randomUUID()), + }, +]; + +describe("claudeProjectDir parity with Claude Agent SDK", () => { + beforeAll(async () => { + await mkdir(workspaceRoot, { recursive: true }); + await mkdir(tmpWorkspaceRoot, { recursive: true }); + }); + + afterAll(async () => { + for (const file of createdSessionFiles) { + await rm(file, { force: true }); + } + await rm(workspaceRoot, { recursive: true, force: true }); + await rm(tmpWorkspaceRoot, { recursive: true, force: true }); + }); + + for (const c of cases) { + test(c.label, async () => { + const cwd = await c.build(); + const sessionId = randomUUID(); + + const ourDir = await claudeProjectDir(cwd); + await mkdir(ourDir, { recursive: true }); + const sessionFile = join(ourDir, `${sessionId}.jsonl`); + await writeFile(sessionFile, '{"type":"summary","summary":"parity"}\n'); + createdSessionFiles.push(sessionFile); + + const info = await getSessionInfo(sessionId, { dir: cwd }); + expect(info?.sessionId).toBe(sessionId); + }); + } +}); + +async function ensureDir(path: string): Promise { + await mkdir(path, { recursive: true }); + return path; +} diff --git a/packages/server/src/server/agent/providers/claude/project-dir.ts b/packages/server/src/server/agent/providers/claude/project-dir.ts new file mode 100644 index 000000000..d8cc95e64 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/project-dir.ts @@ -0,0 +1,52 @@ +import { realpath } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +// Verbatim port of the Claude Agent SDK's project-directory encoding so +// paseo computes the same `~/.claude/projects/` path the SDK does. +// The SDK ships only as a precompiled bundle; grep the JS source at +// node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs for `function f1`, +// `function i4`, `function tB`, `S0=200`. + +const PROJECT_DIR_LENGTH_CAP = 200; + +export interface ClaudeProjectDirOptions { + configDir?: string; +} + +export async function claudeProjectDir( + cwd: string, + options?: ClaudeProjectDirOptions, +): Promise { + const canonical = await canonicalize(cwd); + const projectsRoot = join(resolveConfigDir(options), "projects"); + return join(projectsRoot, encode(canonical)); +} + +async function canonicalize(input: string): Promise { + try { + return (await realpath(input)).normalize("NFC"); + } catch { + return input.normalize("NFC"); + } +} + +function encode(input: string): string { + const replaced = input.replace(/[^a-zA-Z0-9]/g, "-"); + if (replaced.length <= PROJECT_DIR_LENGTH_CAP) { + return replaced; + } + return `${replaced.slice(0, PROJECT_DIR_LENGTH_CAP)}-${hashSuffix(input)}`; +} + +function hashSuffix(input: string): string { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) - hash + input.charCodeAt(i)) | 0; + } + return Math.abs(hash).toString(36); +} + +function resolveConfigDir(options?: ClaudeProjectDirOptions): string { + return options?.configDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); +}