import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url); const { sessionPathKey } = await jiti.import("./session-path.ts"); const { buildSessionContext, cacheSessionPath, invalidateSessionPathCache, readSessionHeader, resolveSessionIdByPath, } = await jiti.import("./session-reader.ts"); function userEntry(id, parentId, content, timestamp = "2026-01-01T00:00:00.000Z") { return { type: "message", id, parentId, timestamp, message: { role: "user", content, }, }; } function assistantEntry(id, parentId, text, timestamp = "2026-01-01T00:00:00.000Z") { return { type: "message", id, parentId, timestamp, message: { role: "assistant", provider: "test", model: "test-model", content: [{ type: "text", text }], }, }; } test("renders the SDK compaction-aware context with aligned entry IDs", () => { const entries = [ userEntry("u1", null, "old user request"), assistantEntry("a1", "u1", "old assistant answer"), userEntry("u2", "a1", "kept user request"), { type: "compaction", id: "cmp", parentId: "u2", timestamp: "2026-01-01T00:00:03.000Z", summary: "old exchange summary", firstKeptEntryId: "u2", tokensBefore: 123, }, userEntry("u3", "cmp", "after compaction"), ]; const context = buildSessionContext(entries); assert.deepEqual(context.entryIds, ["cmp", "u2", "u3"]); assert.deepEqual( context.messages.map((message) => [message.role, message.customType, message.content]), [ ["custom", "compaction", "old exchange summary"], ["user", undefined, "kept user request"], ["user", undefined, "after compaction"], ], ); }); test("uses only the latest compaction on the active path", () => { const entries = [ userEntry("u1", null, "old request"), assistantEntry("a1", "u1", "old answer"), userEntry("u2", "a1", "first kept request"), { type: "compaction", id: "cmp1", parentId: "u2", timestamp: "2026-01-01T00:00:03.000Z", summary: "first summary", firstKeptEntryId: "u2", tokensBefore: 100, }, assistantEntry("a2", "cmp1", "second kept answer"), userEntry("u3", "a2", "second kept request"), { type: "compaction", id: "cmp2", parentId: "u3", timestamp: "2026-01-01T00:00:06.000Z", summary: "latest summary", firstKeptEntryId: "a2", tokensBefore: 200, }, assistantEntry("a3", "cmp2", "latest answer"), ]; const context = buildSessionContext(entries); assert.deepEqual(context.entryIds, ["cmp2", "a2", "u3", "a3"]); assert.equal(context.messages[0].role, "custom"); assert.equal(context.messages[0].content, "latest summary"); assert.equal(context.messages.length, context.entryIds.length); }); test("uses the selected leaf's path before a later compaction", () => { const entries = [ userEntry("u1", null, "root request"), assistantEntry("a1", "u1", "root answer"), userEntry("u2", "a1", "main branch"), { type: "compaction", id: "cmp", parentId: "u2", timestamp: "2026-01-01T00:00:03.000Z", summary: "main branch summary", firstKeptEntryId: "u2", tokensBefore: 100, }, userEntry("alt", "a1", "alternate branch"), ]; const context = buildSessionContext(entries, "alt"); assert.deepEqual(context.entryIds, ["u1", "a1", "alt"]); assert.equal(context.messages.some((message) => message.role === "custom"), false); }); test("returns an empty context for a null leaf", () => { const context = buildSessionContext([ userEntry("u1", null, "not active"), ], null); assert.deepEqual(context.messages, []); assert.deepEqual(context.entryIds, []); }); test("defers historical thinking without changing live-session content", () => { const entries = [ userEntry("u1", null, "start"), { ...assistantEntry("a1", "u1", "answer"), message: { role: "assistant", provider: "test", model: "test-model", content: [ { type: "thinking", thinking: "large reasoning" }, { type: "text", text: "answer" }, ], }, }, ]; const deferred = buildSessionContext(entries, undefined, { deferThinking: true }); assert.deepEqual(deferred.messages[1].content[0], { type: "thinking", thinking: "", deferred: true, }); const full = buildSessionContext(entries); assert.equal(full.messages[1].content[0].thinking, "large reasoning"); }); test("does not defer empty historical thinking blocks", () => { const entries = [ userEntry("u1", null, "start"), { ...assistantEntry("a1", "u1", "answer"), message: { role: "assistant", provider: "test", model: "test-model", content: [ { type: "thinking", thinking: "" }, { type: "text", text: "answer" }, ], }, }, ]; const context = buildSessionContext(entries, undefined, { deferThinking: true }); assert.deepEqual(context.messages[1].content[0], { type: "thinking", thinking: "" }); }); test("defers only base64 images from historical tool results", () => { const userImage = { type: "image", source: { type: "base64", media_type: "image/png", data: "QUJDRA==" }, }; const toolImage = { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "QUJDRA==" }, }; const toolUrlImage = { type: "image", source: { type: "url", url: "https://example.com/result.png" }, }; const flatToolImage = { type: "image", data: "QUJDRA==", mimeType: "image/png", }; const entries = [ userEntry("u1", null, [{ type: "text", text: "inspect this" }, userImage]), assistantEntry("a1", "u1", "reading"), { type: "message", id: "tr1", parentId: "a1", timestamp: "2026-01-01T00:00:01.000Z", message: { role: "toolResult", toolCallId: "call1", content: [ { type: "text", text: "Read image file" }, toolImage, flatToolImage, toolUrlImage, ], }, }, ]; const deferred = buildSessionContext(entries, undefined, { deferToolResultImages: true }); assert.deepEqual(deferred.messages[0].content[1], userImage); assert.deepEqual(deferred.messages[2].content[0], { type: "text", text: "Read image file" }); assert.deepEqual(deferred.messages[2].content[1], toolUrlImage); assert.match(deferred.messages[2].content[2].text, /2 tool result images omitted.*image\/jpeg, image\/png.*~8 bytes/); const full = buildSessionContext(entries); assert.deepEqual(full.messages[2].content[1], toolImage); assert.deepEqual(full.messages[2].content[2], flatToolImage); assert.deepEqual(full.messages[2].content[3], toolUrlImage); }); test("preserves hidden custom messages so the UI can render them collapsed", () => { const entries = [ userEntry("u1", null, "start"), { type: "custom_message", id: "c1", parentId: "u1", timestamp: "2026-01-01T00:00:01.000Z", customType: "extension_debug", content: "hidden extension payload", display: false, details: { source: "test" }, }, assistantEntry("a1", "c1", "done"), ]; const context = buildSessionContext(entries); assert.deepEqual(context.entryIds, ["u1", "c1", "a1"]); assert.equal(context.messages[1].role, "custom"); assert.equal(context.messages[1].customType, "extension_debug"); assert.equal(context.messages[1].display, false); assert.equal(context.messages[1].content, "hidden extension payload"); }); test("preserves valid epoch timestamps on synthetic UI messages", () => { const entries = [ userEntry("u1", null, "start"), { type: "compaction", id: "cmp", parentId: "u1", timestamp: "1970-01-01T00:00:00.000Z", summary: "epoch summary", firstKeptEntryId: "u1", tokensBefore: 10, }, ]; const context = buildSessionContext(entries); assert.equal(context.messages[0].role, "custom"); assert.equal(context.messages[0].customType, "compaction"); assert.equal(context.messages[0].timestamp, 0); }); test("reads only a bounded session header, including headers larger than 4 KiB", () => { const dir = mkdtempSync(join(tmpdir(), "pi-web-header-")); const filePath = join(dir, "session.jsonl"); const parentSession = `/tmp/${"p".repeat(5_000)}.jsonl`; writeFileSync(filePath, `${JSON.stringify({ type: "session", version: 3, id: "session", timestamp: "2026-01-01T00:00:00.000Z", cwd: dir, parentSession, })}\n${JSON.stringify(userEntry("u1", null, "message"))}\n`); try { assert.equal(readSessionHeader(filePath)?.parentSession, parentSession); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("returns null for malformed or unbounded session headers", () => { const dir = mkdtempSync(join(tmpdir(), "pi-web-header-invalid-")); const malformedPath = join(dir, "malformed.jsonl"); const oversizedPath = join(dir, "oversized.jsonl"); writeFileSync(malformedPath, "{not-json}\n"); writeFileSync(oversizedPath, "x".repeat(64 * 1024)); try { assert.equal(readSessionHeader(malformedPath), null); assert.equal(readSessionHeader(oversizedPath), null); } finally { rmSync(dir, { recursive: true, force: true }); } }); test("keeps forward and reverse session path caches in sync", async () => { const sessionId = "cache-test-session"; const filePath = join(tmpdir(), "pi-web-cache-test", "..", "cache-test", "session.jsonl"); cacheSessionPath(sessionId, filePath); try { assert.equal( await resolveSessionIdByPath(filePath), sessionId, ); } finally { invalidateSessionPathCache(sessionId); } assert.equal(globalThis.__piSessionPathCache?.has(sessionId), false); assert.equal(globalThis.__piPathToSessionIdCache?.has(sessionPathKey(filePath)), false); });