import { spawn } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { ENV_AGENT_DIR } from "../src/config.ts"; const cliPath = resolve(__dirname, "../src/cli.ts"); const tempDirs: string[] = []; afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }); } }); function createTempDir(): string { const dir = mkdtempSync(join(tmpdir(), "pi-session-id-readonly-")); tempDirs.push(dir); return dir; } function hasSessionWithId(root: string, sessionId: string): boolean { if (!existsSync(root)) return false; for (const entry of readdirSync(root, { withFileTypes: true })) { const path = join(root, entry.name); if (entry.isDirectory() && hasSessionWithId(path, sessionId)) return true; if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; try { const firstLine = readFileSync(path, "utf8").split("\n", 1)[0]; const header = JSON.parse(firstLine) as { type?: string; id?: string }; if (header.type === "session" && header.id === sessionId) return true; } catch { // Ignore malformed session files. } } return false; } interface CliDirs { agentDir: string; projectDir: string; sessionDir: string; } async function runCli( args: string[] | ((dirs: CliDirs) => string[]), setup?: (dirs: CliDirs) => void, ): Promise<{ code: number | null; agentDir: string; stderr: string }> { const tempRoot = createTempDir(); const dirs: CliDirs = { agentDir: join(tempRoot, "agent"), projectDir: join(tempRoot, "project"), sessionDir: join(tempRoot, "sessions"), }; mkdirSync(dirs.agentDir, { recursive: true }); mkdirSync(dirs.projectDir, { recursive: true }); setup?.(dirs); const resolvedArgs = typeof args === "function" ? args(dirs) : args; let stderr = ""; const code = await new Promise((resolvePromise, reject) => { const child = spawn(process.execPath, [cliPath, ...resolvedArgs], { cwd: dirs.projectDir, env: { ...process.env, [ENV_AGENT_DIR]: dirs.agentDir, PI_OFFLINE: "1", TSX_TSCONFIG_PATH: resolve(__dirname, "../../../tsconfig.json"), }, stdio: ["ignore", "ignore", "pipe"], }); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); child.on("error", reject); child.on("close", resolvePromise); }); return { code, agentDir: dirs.agentDir, stderr }; } function writeSession(sessionDir: string, cwd: string, id: string): void { writeFileSync( join(sessionDir, `${id}.jsonl`), `${JSON.stringify({ type: "session", version: 3, id, timestamp: new Date().toISOString(), cwd })}\n`, ); } describe("--session-id read-only commands", () => { it("does not reserve a session for --help", async () => { const result = await runCli(["--session-id", "read-only-help", "--help"]); expect(result.code).toBe(0); expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-help")).toBe(false); }); it("does not reserve a session for --list-models", async () => { const result = await runCli(["--session-id", "read-only-models", "--list-models"]); expect(result.code).toBe(0); expect(hasSessionWithId(join(result.agentDir, "sessions"), "read-only-models")).toBe(false); }); it("rejects an existing fork target session id", async () => { const result = await runCli( (dirs) => ["--session-dir", dirs.sessionDir, "--fork", "source-id", "--session-id", "existing-id", "-p", "hi"], (dirs) => { mkdirSync(dirs.sessionDir, { recursive: true }); writeSession(dirs.sessionDir, dirs.projectDir, "source-id"); writeSession(dirs.sessionDir, dirs.projectDir, "existing-id"); }, ); expect(result.code).toBe(1); expect(result.stderr).toContain("Session already exists with id 'existing-id'"); }); }); describe("--session-id validation", () => { it("rejects ids invalid under SessionManager rules without stack traces", async () => { for (const id of ["-bad", "bad id"]) { const result = await runCli(["--session-id", id, "-p", "hi"]); expect(result.code).toBe(1); expect(result.stderr).toContain("Session id must be non-empty"); expect(result.stderr).not.toContain("SessionManager.create"); } }); });