Files

342 lines
10 KiB
JavaScript

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);
});