import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import test from "node:test"; import { inspect } from "./inspect.mjs"; const script = fileURLToPath(new URL("./inspect.mjs", import.meta.url)); const stamp = "2026-09-01T12:00:00.000Z"; const entry = (id, parentId, type, rest = {}) => ({ id, parentId, type, timestamp: stamp, ...rest }); const message = (id, parentId, role, rest = {}) => entry(id, parentId, "message", { message: { role, content: [{ type: "text", text: `${role} text` }], ...rest }, }); function fixture(t) { const root = mkdtempSync(join(tmpdir(), "pi-sessions-test-")); t.after(() => rmSync(root, { recursive: true, force: true })); const cwd = join(root, "project"); const home = join(root, "home"); const agent = join(home, ".pi/agent"); const store = join(home, ".local/state/pi/sessions"); for (const dir of [cwd, agent, store]) mkdirSync(dir, { recursive: true }); const ctx = { cwd, home, env: {} }; function json(file, value) { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(value)); } function add(id, name = "task", entries = [message("u", null, "user")], options = {}) { const file = join(options.dir ?? store, `${id}.jsonl`); const header = { type: "session", version: 3, id, cwd: options.cwd ?? cwd, timestamp: options.timestamp ?? stamp, }; const records = [header, ...entries]; if (name !== null) records.push(entry("name", entries.at(-1)?.id ?? null, "session_info", { name })); mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${records.map((e) => JSON.stringify(e)).join("\n")}\n`); return file; } return { root, cwd, home, agent, store, ctx, json, add }; } test("storage precedence: CLI, environment, project, global, then ordered fallbacks", async (t) => { const f = fixture(t); for (const id of ["cli", "env", "project", "global"]) f.add(id, id, [], { dir: join(f.cwd, id) }); f.json(join(f.agent, "settings.json"), { sessionDir: "global" }); f.json(join(f.cwd, ".pi/settings.json"), { sessionDir: "project" }); assert.equal((await inspect(["list"], f.ctx)).sessions[0].id, "project"); f.ctx.env.PI_CODING_AGENT_SESSION_DIR = "env"; assert.equal((await inspect(["list"], f.ctx)).sessions[0].id, "env"); assert.equal((await inspect(["list", "--session-dir", "cli"], f.ctx)).sessions[0].id, "cli"); delete f.ctx.env.PI_CODING_AGENT_SESSION_DIR; rmSync(join(f.cwd, ".pi/settings.json")); assert.equal((await inspect(["list"], f.ctx)).sessions[0].id, "global"); f.json(join(f.agent, "settings.json"), { sessionDir: "missing" }); f.add("fallback"); assert.equal((await inspect(["list"], f.ctx)).total, 0, "configured empty roots never fall through"); rmSync(join(f.agent, "settings.json")); assert.equal((await inspect(["list"], f.ctx)).sessions[0].id, "fallback"); rmSync(f.store, { recursive: true }); f.add("agent", "agent", [], { dir: join(f.agent, "sessions/nested") }); f.add("local", "local", [], { dir: join(f.cwd, ".pi/sessions") }); assert.equal((await inspect(["list"], f.ctx)).sessions[0].id, "agent"); rmSync(join(f.agent, "sessions"), { recursive: true }); assert.equal((await inspect(["list"], f.ctx)).sessions[0].id, "local"); }); test("agent directory and tilde settings resolve without loading other settings fields", async (t) => { const f = fixture(t); const agent = join(f.root, "agent override"); f.json(join(agent, "settings.json"), { sessionDir: "~/sessions", irrelevant: "NEVER_EMIT_SETTINGS_CONTENT" }); f.ctx.env.PI_CODING_AGENT_DIR = agent; f.add("custom", "custom", [], { dir: join(f.home, "sessions") }); const result = await inspect(["list"], f.ctx); assert.equal(result.sessions[0].id, "custom"); assert.doesNotMatch(JSON.stringify(result), /NEVER_EMIT_SETTINGS_CONTENT/); }); test("list filters literal names, exact cwd, UTC dates and pages with stable ordering", async (t) => { const f = fixture(t); f.add("bbb", "same [literal]", [], { timestamp: "2026-09-02T12:00:00Z" }); f.add("aaa", "same [literal]", [], { timestamp: "2026-09-02T12:00:00Z" }); f.add("old", "same [literal]", [], { timestamp: "2026-08-31T12:00:00Z" }); f.add("other", "another", [], { cwd: join(f.cwd, "other") }); const r = await inspect( [ "list", "[literal]", "--cwd", f.cwd, "--since", "2026-09-01", "--until", "2026-09-02", "--offset", "1", "--limit", "1", ], f.ctx, ); assert.equal(r.total, 2); assert.equal(r.sessions.length, 1); assert.equal(r.sessions[0].id, "bbb"); f.add("utc", "offset date", [], { timestamp: "2026-09-01T01:00:00+02:00" }); const utc = await inspect(["list", "offset date", "--until", "2026-08-31"], f.ctx); assert.equal(utc.total, 1); assert.equal(utc.sessions[0].started, "2026-08-31T23:00:00.000Z"); await assert.rejects(inspect(["list", "--since", "2026-02-30"], f.ctx), /date/i); await assert.rejects(inspect(["list", "--limit", "101"], f.ctx), /limit/i); await assert.rejects(inspect(["list", "--offset", "-1"], f.ctx), /offset/i); }); test("scoped lists skip only valid headers proven outside date and cwd bounds", async (t) => { const f = fixture(t); f.add("inside", "inside", [], { timestamp: "2026-09-01T00:00:00Z" }); const before = f.add("before", "before", [], { timestamp: "2026-08-31T23:59:59Z" }); const after = f.add("after", "after", [], { timestamp: "2026-09-03T00:00:00Z" }); const other = f.add("other", "other", [], { cwd: join(f.cwd, "other") }); for (const file of [before, after, other]) writeFileSync(file, `${readFileSync(file, "utf8")}{"MALFORMED_BODY":`); const scoped = await inspect( ["list", "--cwd", f.cwd, "--since", "2026-09-01", "--until", "2026-09-02"], f.ctx, ); assert.equal(scoped.complete, true); assert.equal(scoped.scanned, 4); assert.equal(scoped.parsed, 1); assert.equal(scoped.skipped, 3); assert.equal(scoped.errorCount, 0); assert.equal(scoped.sessions[0].id, "inside"); assert.equal(scoped.scanned, scoped.parsed + scoped.skipped + scoped.errorCount); const unscoped = await inspect(["list"], f.ctx); assert.equal(unscoped.complete, false); assert.equal(unscoped.scanned, 4); assert.equal(unscoped.parsed, 1); assert.equal(unscoped.skipped, 0); assert.equal(unscoped.errorCount, 3); assert.equal(unscoped.scanned, unscoped.parsed + unscoped.skipped + unscoped.errorCount); await assert.rejects(inspect(["show", before, "--since", "2026-09-01"], f.ctx), /line 3/); }); test("scoped lists retain unknown and in-scope errors before query filtering", async (t) => { const f = fixture(t); f.add("name-hit", "wanted by name", [], { timestamp: "2026-09-01T12:00:00Z" }); const broken = f.add("broken", "unrelated", [], { timestamp: "2026-09-01T12:00:00Z" }); writeFileSync(broken, `${readFileSync(broken, "utf8")}{"MALFORMED_BODY":`); const unknown = f.add("unknown", "unknown", [], { timestamp: "2026-08-01T12:00:00Z" }); f.json(unknown, { type: "session", version: 99, id: "unknown", cwd: f.cwd, timestamp: "2026-08-01T12:00:00Z", }); const result = await inspect(["list", "wanted by name", "--since", "2026-09-01"], f.ctx); assert.equal(result.complete, false); assert.equal(result.scanned, 3); assert.equal(result.parsed, 1); assert.equal(result.skipped, 0); assert.equal(result.errorCount, 2); assert.equal(result.sessions[0].id, "name-hit"); assert.match(result.errors.find((error) => error.file === broken).error, /line 3/); assert.match(result.errors.find((error) => error.file === unknown).error, /version/i); assert.equal(result.scanned, result.parsed + result.skipped + result.errorCount); }); test("duplicate names and ID prefixes require disambiguation; discovered paths work", async (t) => { const f = fixture(t); const file = f.add("abc-1", "duplicate"); f.add("abc-2", "duplicate"); await assert.rejects(inspect(["show", "duplicate"], f.ctx), /ambiguous/i); await assert.rejects(inspect(["show", "abc"], f.ctx), /ambiguous/i); assert.equal((await inspect(["show", "abc-1"], f.ctx)).id, "abc-1"); assert.equal((await inspect(["show", file], f.ctx)).file, file); await assert.rejects(inspect(["show", "missing"], f.ctx), /not found/i); }); test("persisted branch excludes abandoned errors; explicit leaf recovers them", async (t) => { const f = fixture(t); f.add("branches", null, [ message("u", null, "user"), message("old", "u", "assistant", { stopReason: "error" }), message("new", "u", "assistant", { stopReason: "stop", content: [{ type: "toolCall", id: "call1", name: "read", arguments: { path: "ARGUMENT_MUST_NOT_APPEAR" } }], }), message("result", "new", "toolResult", { toolName: "read", isError: true }), message("abort", "result", "assistant", { stopReason: "aborted" }), entry("exec", "abort", "message", { message: { role: "bashExecution", exitCode: 1, output: "OUTPUT_MUST_NOT_APPEAR" }, }), ]); const r = await inspect(["show", "branches"], f.ctx); assert.equal(r.leafId, "exec"); assert.equal(r.heads.total, 2); assert.equal(r.branch.counts.assistantErrors, 0); assert.equal(r.branch.counts.toolErrors, 1); assert.equal(r.branch.counts.toolCalls, 1); assert.equal(r.branch.counts.aborts, 1); assert.equal(r.branch.counts.bashErrors, 1); assert.deepEqual( r.timeline.map((e) => e.id), ["u", "new", "result", "abort", "exec"], ); assert.equal(r.timeline[1].line, 4); const old = await inspect(["show", "branches", "--leaf", "old"], f.ctx); assert.equal(old.branch.counts.assistantErrors, 1); assert.equal(old.counts.assistantErrors, 1); assert.doesNotMatch(JSON.stringify(r), /ARGUMENT_MUST_NOT_APPEAR|OUTPUT_MUST_NOT_APPEAR|user text/); await assert.rejects(inspect(["show", "branches", "--leaf", "unknown"], f.ctx), /leaf/i); }); test("retained-tail compaction supplies context once, without inflating branch turns", async (t) => { const f = fixture(t); f.add("tail", null, [ message("u1", null, "user"), message("u2", "u1", "user"), entry("c", "u2", "compaction", { summary: "SUMMARY_TEXT", tokensBefore: 9000, retainedTail: [ { role: "user", content: "retained user" }, { role: "assistant", content: [{ type: "text", text: "retained answer" }] }, ], }), message("a", "c", "assistant"), ]); const r = await inspect(["show", "tail", "--view", "context", "--text"], f.ctx); assert.equal(r.branch.counts.users, 2); assert.equal(r.context.counts.users, 1); assert.equal(r.context.counts.assistants, 2); assert.deepEqual( r.timeline.map((e) => e.id), ["c", "c:tail:0", "c:tail:1", "a"], ); assert.equal(r.timeline[0].text, "SUMMARY_TEXT"); assert.equal(r.timeline[1].line, 4); assert.equal(r.timeline[1].text, "retained user"); }); test("firstKeptEntryId and branch summaries reconstruct the documented context order", async (t) => { const f = fixture(t); f.add("kept", null, [ message("u1", null, "user"), message("u2", "u1", "user"), entry("c", "u2", "compaction", { summary: "summary", tokensBefore: 5000, firstKeptEntryId: "u2" }), entry("b", "c", "branch_summary", { summary: "branch text", fromId: "u1" }), message("a", "b", "assistant"), ]); const r = await inspect(["show", "kept", "--view", "context"], f.ctx); assert.deepEqual( r.timeline.map((e) => e.id), ["c", "u2", "b", "a"], ); assert.equal(r.context.counts.users, 1); assert.equal(r.context.counts.compactions, 1); }); test("text is bounded while content exposes exact persisted payloads on request", async (t) => { const f = fixture(t); f.add("bounded", null, [ message("u", null, "user", { content: "x".repeat(10000) }), message("a", "u", "assistant", { errorMessage: "EXACT_ERROR", content: [ { type: "thinking", thinking: "EXACT_THINKING" }, { type: "image", data: "EXACT_IMAGE", mimeType: "image/png" }, { type: "text", text: "visible answer" }, { type: "toolCall", name: "bash", id: "t", arguments: { command: "EXACT_ARGUMENTS" } }, ], }), message("r", "a", "toolResult", { content: [{ type: "text", text: "EXACT_RESULT" }], details: { reason: "EXACT_DETAILS" }, }), entry("custom", "r", "custom", { customType: "state", data: "EXACT_STATE" }), ]); const r = await inspect(["show", "bounded", "--text", "--limit", "1"], f.ctx); assert.equal(r.total, 4); assert.equal(r.timeline.length, 1); assert.ok(r.timeline[0].text.length <= 500); assert.equal(r.timeline[0].textTruncated, true); const text = await inspect(["show", "bounded", "--text"], f.ctx); assert.doesNotMatch(JSON.stringify(text), /EXACT_/); assert.equal(text.timeline[1].text, "visible answer"); const content = await inspect(["show", "bounded", "--content"], f.ctx); assert.equal(content.timeline[1].payload.message.errorMessage, "EXACT_ERROR"); assert.equal(content.timeline[1].payload.message.content[0].thinking, "EXACT_THINKING"); assert.equal(content.timeline[1].payload.message.content[1].data, "EXACT_IMAGE"); assert.equal(content.timeline[1].payload.message.content[3].arguments.command, "EXACT_ARGUMENTS"); assert.equal(content.timeline[2].payload.message.content[0].text, "EXACT_RESULT"); assert.equal(content.timeline[2].payload.message.details.reason, "EXACT_DETAILS"); assert.equal(content.timeline[3].payload.data, "EXACT_STATE"); const page = await inspect(["show", "bounded", "--offset", "1", "--limit", "1"], f.ctx); assert.equal(page.timeline[0].id, "a"); assert.equal(page.timeline[0].text, undefined); assert.equal(page.timeline[0].payload, undefined); }); test("malformed lines and partial tails report locations, never parser content; listing is explicitly partial", async (t) => { const f = fixture(t); f.add("valid"); const broken = f.add("broken", null, []); writeFileSync(broken, `${readFileSync(broken, "utf8")}{"NEVER_ECHO_BAD_JSON":`); const r = await inspect(["list"], f.ctx); assert.equal(r.complete, false); assert.equal(r.errorCount, 1); assert.equal(r.sessions[0].id, "valid"); assert.match(r.errors[0].error, /line 2/); assert.doesNotMatch(JSON.stringify(r), /NEVER_ECHO_BAD_JSON/); await assert.rejects(inspect(["show", broken], f.ctx), /line 2/); const settings = join(f.agent, "settings.json"); writeFileSync(settings, '{"NEVER_ECHO_SETTINGS":'); await assert.rejects( inspect(["list"], f.ctx), (error) => /settings/.test(error.message) && !error.message.includes("NEVER_ECHO_SETTINGS"), ); }); test("unsupported versions, duplicate IDs and missing parents fail, rather than guessing", async (t) => { const f = fixture(t); const file = f.add("invalid", null, [message("u", "absent", "user")]); await assert.rejects(inspect(["show", file], f.ctx), /parent/i); f.add("invalid", null, [message("u", null, "user"), message("u", null, "user")]); await assert.rejects(inspect(["show", file], f.ctx), /duplicate/i); f.json(file, { type: "session", version: 99, id: "invalid", cwd: f.cwd, timestamp: stamp }); await assert.rejects(inspect(["show", file], f.ctx), /version/i); }); test("latest compaction wins, including an empty retained tail", async (t) => { const f = fixture(t); f.add("latest", null, [ message("u", null, "user"), entry("c1", "u", "compaction", { summary: "old", retainedTail: [{ role: "user", content: "old user" }] }), entry("c2", "c1", "compaction", { summary: "new", retainedTail: [], firstKeptEntryId: "u" }), ]); const r = await inspect(["show", "latest", "--view", "context"], f.ctx); assert.deepEqual( r.timeline.map((e) => e.id), ["c2"], ); assert.equal(r.context.counts.users, 0); assert.equal(r.branch.counts.compactions, 2); }); test("invalid compaction metadata is rejected without serializing its payload", async (t) => { const f = fixture(t); const file = f.add("bad-compaction", null, [ entry("c", null, "compaction", { retainedTail: [], firstKeptEntryId: { value: "HIDDEN_METADATA" } }), ]); await assert.rejects( inspect(["show", file], f.ctx), (e) => /line 2/.test(e.message) && !e.message.includes("HIDDEN_METADATA"), ); f.add("bad-compaction", null, [entry("c", null, "compaction", { firstKeptEntryId: "absent" })]); await assert.rejects(inspect(["show", file], f.ctx), (e) => e.message.includes(file) && /line 2/.test(e.message)); }); test("empty stores, header-only sessions and valid unterminated final lines are read-only", async (t) => { const f = fixture(t); assert.equal((await inspect(["list"], f.ctx)).total, 0); const file = f.add("header", null, []); const text = readFileSync(file, "utf8").trimEnd(); writeFileSync(file, text); const result = await inspect(["show", file], f.ctx); assert.equal(result.total, 0); assert.equal(result.leafId, null); assert.equal(result.scanned, 1); assert.equal(readFileSync(file, "utf8"), text); writeFileSync(file, ""); await assert.rejects(inspect(["show", file], f.ctx), /empty session/); }); test("file symlinks are not followed and the input file is unchanged", async (t) => { const f = fixture(t); const file = f.add("real"); const before = readFileSync(file); const time = statSync(file).mtimeMs; if (process.platform !== "win32") symlinkSync(file, join(f.store, "alias.jsonl")); const r = await inspect(["list"], f.ctx); assert.equal(r.total, 1); await inspect(["show", file, "--view", "context", "--text"], f.ctx); assert.deepEqual(readFileSync(file), before); assert.equal(statSync(file).mtimeMs, time); }); test("CLI prints JSON, reports partial failures through exit status, and rejects unsupported options", (t) => { const f = fixture(t); f.add("cli"); const args = [script, "list", "--session-dir", f.store]; const env = { ...process.env, PI_CODING_AGENT_DIR: f.agent, PI_CODING_AGENT_SESSION_DIR: "" }; const good = spawnSync(process.execPath, args, { cwd: f.cwd, env, encoding: "utf8" }); assert.equal(good.status, 0, good.stderr); assert.equal(JSON.parse(good.stdout).sessions[0].id, "cli"); writeFileSync(join(f.store, "broken.jsonl"), "bad"); const partial = spawnSync(process.execPath, args, { cwd: f.cwd, env, encoding: "utf8" }); assert.equal(partial.status, 1); assert.equal(JSON.parse(partial.stdout).complete, false); const invalid = spawnSync(process.execPath, [script, "--unsupported"], { encoding: "utf8" }); assert.equal(invalid.status, 1); assert.ok(JSON.parse(invalid.stderr).error); const help = spawnSync(process.execPath, [script, "--help"], { encoding: "utf8" }); assert.equal(help.status, 0); assert.match(help.stdout, /show/); if (process.platform !== "win32") { const linkedScript = join(f.cwd, "inspect-link.mjs"); symlinkSync(script, linkedScript); const linked = spawnSync(process.execPath, [linkedScript, "--help"], { encoding: "utf8" }); assert.equal(linked.status, 0); assert.match(linked.stdout, /show/); } });