mirror of
https://github.com/Heretek-AI/ompweb.git
synced 2026-11-07 10:56:00 -10:00
* fix(chat): catch up history with persisted cursors * test(chat): use canonical hook dependency imports * fix(chat): harden catch-up settlement and indexed paging * fix(chat): publish complete catch-up snapshots atomically
1719 lines
74 KiB
JavaScript
1719 lines
74 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { appendFileSync, closeSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, statSync, truncateSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import test from "node:test";
|
|
import { createJiti } from "jiti";
|
|
import fs from "node:fs";
|
|
import { syncBuiltinESMExports } from "node:module";
|
|
|
|
const jiti = createJiti(import.meta.url);
|
|
const { sessionPathKey } = await jiti.import("./paths.ts");
|
|
const { forEachFileLineSync, serializeTitleSlot } = await jiti.import("./omp/session-files.ts");
|
|
const { SessionFileTooLargeError } = await jiti.import("./session-reader.ts");
|
|
const { subscribeSessionFileChanges } = await jiti.import("./session-watcher.ts");
|
|
const {
|
|
buildSessionContext,
|
|
cacheSessionPath,
|
|
getSessionEntries,
|
|
getSessionEntriesForDisplay,
|
|
getTodoPhasesFromEntries,
|
|
getSessionHistoryPage,
|
|
getSessionContextBoundary,
|
|
invalidateSessionCaches,
|
|
getSessionEntriesForDisplayAsync,
|
|
invalidateSessionListCache,
|
|
invalidateSessionListMeta,
|
|
invalidateSessionPathCache,
|
|
listAllSessions,
|
|
readSessionHeader,
|
|
resolveSessionIdByPath,
|
|
resolveSessionPath,
|
|
} = 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("can expose pre-compaction entries for read-only transcript browsing", () => {
|
|
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, undefined, { includePreCompaction: true });
|
|
|
|
assert.deepEqual(context.entryIds, ["u1", "a1", "u2", "cmp", "u3"]);
|
|
assert.deepEqual(
|
|
context.messages.map((message) => message.role === "custom" ? message.customType : message.content),
|
|
["old user request", [{ type: "text", text: "old assistant answer" }], "kept user request", "compaction", "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("reads the latest valid persisted todo snapshot from the selected branch", () => {
|
|
const entries = [
|
|
userEntry("u1", null, "start"),
|
|
{
|
|
type: "message",
|
|
id: "todo-main",
|
|
parentId: "u1",
|
|
timestamp: "2026-01-01T00:00:01.000Z",
|
|
message: {
|
|
role: "toolResult",
|
|
toolName: "todo",
|
|
content: [],
|
|
details: { phases: [{ name: "Main", tasks: [{ content: "Keep", status: "in_progress" }] }] },
|
|
},
|
|
},
|
|
{
|
|
type: "custom",
|
|
id: "todo-alt",
|
|
parentId: "u1",
|
|
timestamp: "2026-01-01T00:00:02.000Z",
|
|
customType: "user_todo_edit",
|
|
data: { phases: [{ name: "Alternate", tasks: [{ content: "Use this", status: "pending" }] }] },
|
|
},
|
|
];
|
|
|
|
assert.deepEqual(getTodoPhasesFromEntries(entries, "todo-main"), [
|
|
{ name: "Main", tasks: [{ content: "Keep", status: "in_progress" }] },
|
|
]);
|
|
assert.deepEqual(buildSessionContext(entries, "todo-alt").todoPhases, [
|
|
{ name: "Alternate", tasks: [{ content: "Use this", status: "pending" }] },
|
|
]);
|
|
});
|
|
|
|
test("reads the latest todo snapshot on one chain, not the oldest", () => {
|
|
const entries = [
|
|
{
|
|
type: "message",
|
|
id: "todo-early",
|
|
parentId: null,
|
|
message: {
|
|
role: "toolResult",
|
|
toolName: "todo",
|
|
details: { phases: [{ name: "Early", tasks: [{ content: "step", status: "pending" }] }] },
|
|
},
|
|
},
|
|
{
|
|
type: "message",
|
|
id: "todo-late",
|
|
parentId: "todo-early",
|
|
message: {
|
|
role: "toolResult",
|
|
toolName: "todo",
|
|
details: { phases: [{ name: "Late", tasks: [{ content: "step", status: "completed" }] }] },
|
|
},
|
|
},
|
|
];
|
|
|
|
assert.deepEqual(getTodoPhasesFromEntries(entries, "todo-late"), [
|
|
{ name: "Late", tasks: [{ content: "step", status: "completed" }] },
|
|
]);
|
|
});
|
|
|
|
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("deferThinking tolerates string-content assistant entries without crashing", () => {
|
|
// normalizeToolCalls passes non-array content through unchanged; the
|
|
// defer-thinking branch must not 500 the context route for legacy entries.
|
|
const entries = [
|
|
userEntry("u1", null, "start"),
|
|
{
|
|
type: "message",
|
|
id: "a1",
|
|
parentId: "u1",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: { role: "assistant", content: "legacy plain-text answer" },
|
|
},
|
|
];
|
|
|
|
const context = buildSessionContext(entries, undefined, { deferThinking: true });
|
|
assert.equal(context.messages[1].role, "assistant");
|
|
assert.equal(context.messages[1].content, "legacy plain-text answer");
|
|
});
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
/** Build a throwaway agent dir with one project directory of session files. */
|
|
function withAgentDir(run) {
|
|
const agentDir = mkdtempSync(join(tmpdir(), "omp-web-agent-"));
|
|
const projectDir = join(agentDir, "sessions", "-project");
|
|
mkdirSync(projectDir, { recursive: true });
|
|
const previous = process.env.PI_CODING_AGENT_DIR;
|
|
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
invalidateSessionListCache();
|
|
return Promise.resolve(run(projectDir)).finally(() => {
|
|
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
else process.env.PI_CODING_AGENT_DIR = previous;
|
|
invalidateSessionListCache();
|
|
rmSync(agentDir, { recursive: true, force: true });
|
|
});
|
|
}
|
|
|
|
function writeSessionFile(dir, name, header, entries = []) {
|
|
const filePath = join(dir, name);
|
|
const lines = [JSON.stringify({ type: "session", version: 3, ...header })];
|
|
for (const entry of entries) lines.push(JSON.stringify(entry));
|
|
writeFileSync(filePath, `${lines.join("\n")}\n`);
|
|
return filePath;
|
|
}
|
|
|
|
// omp writes header.parentSession as a file path (RPC branch) OR as a bare
|
|
// session id (TUI /fork, `omp --fork`, /tan). Both must link to the parent.
|
|
test("links forked children whose parentSession is a bare session id", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const cwd = join(tmpdir(), "omp-web-missing-project");
|
|
const parentPath = writeSessionFile(dir, "2026-01-01_parent.jsonl", {
|
|
id: "parent-id",
|
|
cwd,
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "root")]);
|
|
writeSessionFile(dir, "2026-01-02_by-path.jsonl", {
|
|
id: "child-by-path",
|
|
cwd,
|
|
timestamp: "2026-01-02T00:00:00.000Z",
|
|
parentSession: parentPath,
|
|
}, [userEntry("u1", null, "branched")]);
|
|
writeSessionFile(dir, "2026-01-03_by-id.jsonl", {
|
|
id: "child-by-id",
|
|
cwd,
|
|
timestamp: "2026-01-03T00:00:00.000Z",
|
|
parentSession: "parent-id",
|
|
}, [userEntry("u1", null, "forked")]);
|
|
writeSessionFile(dir, "2026-01-04_orphan.jsonl", {
|
|
id: "orphan",
|
|
cwd,
|
|
timestamp: "2026-01-04T00:00:00.000Z",
|
|
parentSession: "gone-id",
|
|
}, [userEntry("u1", null, "orphan")]);
|
|
|
|
const byId = new Map((await listAllSessions()).map((s) => [s.id, s]));
|
|
assert.equal(byId.get("child-by-path")?.parentSessionId, "parent-id");
|
|
assert.equal(byId.get("child-by-id")?.parentSessionId, "parent-id");
|
|
assert.equal(byId.get("orphan")?.parentSessionId, undefined);
|
|
});
|
|
});
|
|
|
|
test("stops resolving a session path once the file is deleted", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const filePath = writeSessionFile(dir, "2026-01-01_doomed.jsonl", {
|
|
id: "doomed",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "hello")]);
|
|
|
|
assert.equal(await resolveSessionPath("doomed"), filePath);
|
|
unlinkSync(filePath);
|
|
// A stale cache hit here would make the agent routes spawn omp with
|
|
// --resume against a missing file, silently creating a new session.
|
|
assert.equal(await resolveSessionPath("doomed"), null);
|
|
assert.equal(globalThis.__piSessionPathCache?.has("doomed"), false);
|
|
});
|
|
});
|
|
|
|
test("loads sessions larger than the line-reader chunk without corrupting text", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "omp-web-large-"));
|
|
const filePath = join(dir, "large.jsonl");
|
|
// Multi-byte content and >1 MiB of it, so lines and characters both straddle
|
|
// the reader's chunk boundaries.
|
|
const count = 900;
|
|
const lines = [JSON.stringify({
|
|
type: "session",
|
|
version: 3,
|
|
id: "large",
|
|
cwd: dir,
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
})];
|
|
for (let i = 0; i < count; i++) {
|
|
lines.push(JSON.stringify(userEntry(`u${i}`, i === 0 ? null : `u${i - 1}`, `${"あ".repeat(500)}${i}`)));
|
|
}
|
|
writeFileSync(filePath, `${lines.join("\n")}\n`);
|
|
|
|
try {
|
|
const entries = getSessionEntries(filePath);
|
|
assert.equal(entries.length, count);
|
|
assert.equal(entries[450].message.content, `${"あ".repeat(500)}450`);
|
|
assert.equal(entries[count - 1].message.content, `${"あ".repeat(500)}${count - 1}`);
|
|
} 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);
|
|
});
|
|
|
|
// The session-file walk cache keys on the sessions-root mtime, which does not
|
|
// change when a file is added inside an existing project subdirectory on some
|
|
// filesystems (Windows/NTFS). invalidateSessionListCache() must clear it so a
|
|
// brand-new session appears in the sidebar without a full app reload.
|
|
test("invalidateSessionListCache clears the session-file walk cache", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
writeSessionFile(dir, "2026-01-01_first.jsonl", {
|
|
id: "first",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "first")]);
|
|
|
|
const before = await listAllSessions();
|
|
assert.equal(before.some((s) => s.id === "first"), true);
|
|
|
|
// Simulate the walk cache being populated; the root mtime does not change
|
|
// when the new file lands inside the project subdirectory.
|
|
writeSessionFile(dir, "2026-01-02_second.jsonl", {
|
|
id: "second",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-02T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "second")]);
|
|
|
|
// Without invalidation the stale walk cache would hide the new session.
|
|
invalidateSessionListCache();
|
|
const after = await listAllSessions();
|
|
assert.equal(after.some((s) => s.id === "second"), true);
|
|
});
|
|
});
|
|
|
|
test("invalidateSessionCaches(path) drops only that file's parse caches", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const fileA = writeSessionFile(dir, "2026-01-01_a.jsonl", {
|
|
id: "a",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "a")]);
|
|
const fileB = writeSessionFile(dir, "2026-01-01_b.jsonl", {
|
|
id: "b",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "b")]);
|
|
|
|
// Prime both per-file parse caches.
|
|
getSessionEntries(fileA);
|
|
getSessionEntries(fileB);
|
|
|
|
const cacheKey = (p) => sessionPathKey(p);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(cacheKey(fileA)), true);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(cacheKey(fileB)), true);
|
|
|
|
// Only A's entry is dropped; B's parse stays cached.
|
|
invalidateSessionCaches(fileA);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(cacheKey(fileA)), false, "A parse cache must drop");
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(cacheKey(fileB)), true, "B parse cache must survive");
|
|
|
|
// Same-size + same-mtime replacement of A must be re-parsed after the
|
|
// targeted invalidation: the targeted invalidation is the ONLY signal the
|
|
// (size, mtimeMs) key cannot detect.
|
|
const entries = getSessionEntries(fileA);
|
|
assert.equal(entries.length, 1, "A re-parses to fresh contents after targeted invalidation");
|
|
});
|
|
});
|
|
|
|
test("targeted invalidation catches same-size + same-mtime content changes", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const filePath = writeSessionFile(dir, "2026-01-01_t.jsonl", {
|
|
id: "t",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "version-one")]);
|
|
|
|
// Normalize mtime to a whole-millisecond value FIRST so the restore below
|
|
// is exact on filesystems with finer precision (NTFS 100ns): with a
|
|
// fractional original, JS Date truncation would make mtimeMs differ after
|
|
// utimesSync and the stat key would invalidate "for free", masking the
|
|
// very staleness this test must catch.
|
|
const FIXED_MTIME = new Date("2026-01-02T00:00:00.000Z");
|
|
utimesSync(filePath, FIXED_MTIME, FIXED_MTIME);
|
|
const originalStat = statSync(filePath);
|
|
const originalSize = originalStat.size;
|
|
const originalMtime = originalStat.mtimeMs;
|
|
assert.equal(originalMtime, FIXED_MTIME.getTime(), "mtime normalized to whole ms");
|
|
|
|
// Prime the parse cache keyed on (size, originalMtime).
|
|
const cachedFirst = getSessionEntries(filePath);
|
|
assert.equal(cachedFirst[0].message.content, "version-one");
|
|
|
|
// Rewrite with DIFFERENT content of EXACTLY the same byte length, then
|
|
// restore the mtime: (size, mtimeMs) now matches the cached entry again,
|
|
// so only an explicit invalidation can see through.
|
|
const rewrittenBody = JSON.stringify({ type: "session", version: 3, id: "t", cwd: join(tmpdir(), "omp-web-missing-project"), timestamp: "2026-01-01T00:00:00.000Z" })
|
|
+ "\n"
|
|
+ JSON.stringify(userEntry("u1", null, "version-two"))
|
|
+ "\n";
|
|
assert.equal(
|
|
Buffer.byteLength(rewrittenBody, "utf8"),
|
|
originalSize,
|
|
"rewrite must preserve byte length so the stat key cannot detect it",
|
|
);
|
|
writeFileSync(filePath, rewrittenBody);
|
|
utimesSync(filePath, FIXED_MTIME, FIXED_MTIME);
|
|
const restoredStat = statSync(filePath);
|
|
assert.equal(restoredStat.size, originalSize, "size restored");
|
|
assert.equal(restoredStat.mtimeMs, originalMtime, "mtime restored to pre-rewrite value");
|
|
|
|
// WITHOUT invalidation the memoized parse must serve the STALE snapshot —
|
|
// this proves the stat key alone cannot detect the change. Then the
|
|
// targeted invalidation must force a fresh parse.
|
|
const stale = getSessionEntries(filePath);
|
|
assert.equal(stale[0].message.content, "version-one", "cached snapshot served without invalidation (stale)");
|
|
|
|
invalidateSessionCaches(filePath);
|
|
const fresh = getSessionEntries(filePath);
|
|
assert.equal(fresh[0].message.content, "version-two", "same-size+mtime change must be picked up after targeted invalidation");
|
|
});
|
|
});
|
|
|
|
|
|
test("task toolResults keep size-bounded details; other toolResults stay stripped", () => {
|
|
const entries = [
|
|
{
|
|
type: "message",
|
|
id: "t1",
|
|
parentId: null,
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: {
|
|
role: "toolResult",
|
|
toolCallId: "tc1",
|
|
toolName: "task",
|
|
content: [],
|
|
details: {
|
|
projectAgentsDir: "C:\work\agents",
|
|
totalDurationMs: 360000,
|
|
progress: [{ id: "Scout", agent: "scout", status: "running", task: "Map", tokens: 100, cost: 0.001 }],
|
|
results: [{
|
|
id: "Scout", agent: "scout", task: "Map", exitCode: 0, tokens: 999000, cost: 1.25,
|
|
output: "x".repeat(5000),
|
|
stderr: "err",
|
|
aborted: false,
|
|
resolvedModel: "m".repeat(5000),
|
|
outputPath: "p".repeat(5000),
|
|
}],
|
|
async: { state: "completed", jobId: "Scout", type: "task", message: "x".repeat(10000) },
|
|
agent: "a".repeat(5000),
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message",
|
|
id: "t2",
|
|
parentId: "t1",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: {
|
|
role: "toolResult",
|
|
toolCallId: "tc2",
|
|
toolName: "edit",
|
|
content: [],
|
|
details: { huge: "x".repeat(10000), patch: "the patch" },
|
|
},
|
|
},
|
|
];
|
|
const context = buildSessionContext(entries);
|
|
const taskMessage = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "tc1");
|
|
const editMessage = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "tc2");
|
|
assert.ok(taskMessage);
|
|
const taskDetails = taskMessage.details ?? {};
|
|
assert.ok(Array.isArray(taskDetails.results));
|
|
assert.equal(taskDetails.results[0].tokens, 999000);
|
|
// bulky output is not shipped to the client
|
|
assert.equal("output" in taskDetails.results[0], false);
|
|
assert.equal("stderr" in taskDetails.results[0], false);
|
|
assert.equal(taskDetails.progress[0].id, "Scout");
|
|
assert.equal(taskDetails.async.jobId, "Scout");
|
|
// async is projected: extra payload fields never ride the response
|
|
assert.equal("message" in taskDetails.async, false);
|
|
// long scalar strings are truncated to the 240-char bound
|
|
assert.equal(taskDetails.results[0].resolvedModel.length <= 241, true);
|
|
assert.equal(taskDetails.results[0].outputPath.length <= 241, true);
|
|
// agent is an allowlisted (truncated) field — present but bounded
|
|
assert.equal(taskDetails.results[0].agent.length <= 241, true);
|
|
// non-task details keep only the allowlisted patch/diff keys
|
|
assert.deepEqual(editMessage.details, { patch: "the patch" });
|
|
});
|
|
|
|
test("hub send/jobs toolResults keep bounded roster details; other hub ops stay stripped", () => {
|
|
const entries = [
|
|
{
|
|
type: "message",
|
|
id: "h1",
|
|
parentId: null,
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: {
|
|
role: "toolResult",
|
|
toolCallId: "hc1",
|
|
toolName: "hub",
|
|
content: [],
|
|
details: {
|
|
op: "send",
|
|
to: "VisualFix",
|
|
receipts: [{ to: "VisualFix", outcome: "injected", extra: "x".repeat(5000) }],
|
|
noise: "dropped",
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message",
|
|
id: "h2",
|
|
parentId: "h1",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: {
|
|
role: "toolResult",
|
|
toolCallId: "hc2",
|
|
toolName: "hub",
|
|
content: [],
|
|
details: {
|
|
op: "jobs",
|
|
jobs: [{ id: "VisualFix", type: "task", status: "running", label: "VisualFix", durationMs: 1890000, secret: "dropped" }],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: "message",
|
|
id: "h3",
|
|
parentId: "h2",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: {
|
|
role: "toolResult",
|
|
toolCallId: "hc3",
|
|
toolName: "hub",
|
|
content: [],
|
|
details: { op: "list", peers: [], counts: { running: 0 } },
|
|
},
|
|
},
|
|
];
|
|
const context = buildSessionContext(entries);
|
|
const send = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "hc1");
|
|
const jobs = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "hc2");
|
|
const list = context.messages.find((m) => m.role === "toolResult" && m.toolCallId === "hc3");
|
|
assert.ok(send);
|
|
assert.deepEqual(send.details, { op: "send", to: ["VisualFix"], receipts: [{ to: "VisualFix", outcome: "injected" }] });
|
|
assert.ok(jobs);
|
|
assert.deepEqual(jobs.details, { op: "jobs", jobs: [{ id: "VisualFix", type: "task", status: "running", label: "VisualFix", durationMs: 1890000 }] });
|
|
// list carries no chat rendering — nothing allowlisted, details dropped
|
|
assert.ok(list);
|
|
assert.equal("details" in list, false);
|
|
});
|
|
|
|
|
|
test("message entries without a message object are skipped, not fatal", () => {
|
|
// Imports and hand-edited files can contain type:"message" entries whose
|
|
// `message` object is missing entirely. Every reader (context build, todo
|
|
// phases, model inference) must skip them instead of throwing.
|
|
const entries = [
|
|
{
|
|
type: "session",
|
|
version: 3,
|
|
id: "s1",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
cwd: "/tmp",
|
|
},
|
|
{
|
|
type: "message",
|
|
id: "bad1",
|
|
parentId: null,
|
|
timestamp: "2026-01-01T00:00:01.000Z",
|
|
},
|
|
userEntry("u1", "bad1", "still readable"),
|
|
{
|
|
type: "message",
|
|
id: "bad2",
|
|
parentId: "u1",
|
|
timestamp: "2026-01-01T00:00:02.000Z",
|
|
message: null,
|
|
},
|
|
];
|
|
|
|
const context = buildSessionContext(entries);
|
|
assert.equal(context.messages.length, 1);
|
|
assert.equal(context.messages[0].role, "user");
|
|
assert.deepEqual(context.entryIds, ["u1"]);
|
|
|
|
assert.doesNotThrow(() => getTodoPhasesFromEntries(entries, "u1"));
|
|
});
|
|
|
|
test("single files larger than the cache budget are not cached, so they cannot starve peers", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
// Tiny session to confirm the parse cache works normally.
|
|
const smallPath = writeSessionFile(dir, "2026-01-01_small.jsonl", {
|
|
id: "small",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "small")]);
|
|
getSessionEntries(smallPath);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(smallPath)), true, "normal files cache");
|
|
|
|
// A file bigger than the whole budget must NOT be injected into the cache
|
|
// (it would pin MAX_SESSION_ENTRIES_CACHE_BYTES for itself and evict every
|
|
// peer on the next insert). The budget is 256 MiB; sparse-file it: write a
|
|
// real 1-byte file then truncate to budget+1 so stat.size reflects a giant
|
|
// without materializing it.
|
|
const giantPath = join(dir, "2026-01-01_giant.jsonl");
|
|
writeFileSync(giantPath, "{\"type\":\"session\",\"version\":3,\"id\":\"giant\",\"cwd\":\"/tmp\",\"timestamp\":\"2026-01-01T00:00:00.000Z\"}\n");
|
|
// truncate to just past the budget
|
|
const budget = 256 * 1024 * 1024;
|
|
truncateSync(giantPath, budget + 1);
|
|
getSessionEntries(giantPath);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(giantPath)), false, "giant does not cache");
|
|
|
|
// The small file's parse must still be cached (the giant did not evict it).
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(smallPath)), true, "peer still cached after giant");
|
|
});
|
|
});
|
|
|
|
test("getSessionEntriesForDisplay resolves blobs on copies without contaminating the shared cache", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
// A blob-bearing assistant image entry: data is a blob:sha256 ref.
|
|
// The blob store lives under the agent dir (<agent>/blobs/<hash>).
|
|
const blobHash = "a".repeat(64);
|
|
// getBlobsDir() = <agentDir>/blobs — withAgentDir sets agentDir; `dir`
|
|
// is <agentDir>/sessions/-project, so ../.. is the agentDir root.
|
|
const blobDir = join(dir, "..", "..", "blobs");
|
|
mkdirSync(blobDir, { recursive: true });
|
|
writeFileSync(join(blobDir, blobHash), "fake-image-bytes");
|
|
|
|
const filePath = writeSessionFile(dir, "2026-01-01_blob.jsonl", {
|
|
id: "blob",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [
|
|
userEntry("u1", null, "show me"),
|
|
{
|
|
type: "message",
|
|
id: "a1",
|
|
parentId: "u1",
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
message: {
|
|
role: "assistant",
|
|
provider: "t",
|
|
model: "m",
|
|
content: [{ type: "image", data: `blob:sha256:${blobHash}`, mimeType: "image/png" }],
|
|
},
|
|
},
|
|
]);
|
|
|
|
// Prime the shared cache WITHOUT blob resolution.
|
|
const cached = getSessionEntries(filePath);
|
|
assert.equal(cached[1].message.content[0].data, `blob:sha256:${blobHash}`, "cache holds the unresolved ref");
|
|
|
|
// Display read resolves the blob to inline base64...
|
|
const display = getSessionEntriesForDisplay(filePath);
|
|
assert.equal(
|
|
display[1].message.content[0].data,
|
|
Buffer.from("fake-image-bytes").toString("base64"),
|
|
"display path resolves the blob to base64",
|
|
);
|
|
// ...and leaves the shared cache untouched.
|
|
const cachedAfter = getSessionEntries(filePath);
|
|
assert.equal(cachedAfter[1].message.content[0].data, `blob:sha256:${blobHash}`, "cache is NOT mutated by display read");
|
|
assert.notEqual(cachedAfter[1], display[1], "display read must not share the blob entry object");
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// Targeted invalidation + concurrency (watcher hot-path regression coverage)
|
|
// ============================================================================
|
|
|
|
// Invalidation that lands WHILE a list scan is still in flight must never let
|
|
// the stale scan result repopulate the cache (generation gate); callers that
|
|
// arrive after the invalidation must start a fresh scan instead of reusing
|
|
// the stale in-flight promise.
|
|
test("invalidation during an in-flight scan does not repopulate the list cache", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
writeSessionFile(dir, "2026-01-01_gen.jsonl", {
|
|
id: "gen",
|
|
cwd: join(tmpdir(), "omp-web-missing-project"),
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "gen")]);
|
|
|
|
// Concurrent callers within one generation share one in-flight scan:
|
|
// listAllSessions is async, so each call wraps the shared tracked scan
|
|
// promise in its own async wrapper — the shared scan is observable as an
|
|
// identical result array and a single tracked promise on globalThis.
|
|
const p1 = listAllSessions();
|
|
const sharedScan = globalThis.__piSessionListPromise;
|
|
assert.notEqual(sharedScan, undefined, "listAllSessions must track its in-flight scan");
|
|
const p2 = listAllSessions();
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
assert.equal(r1, r2, "concurrent callers must share one in-flight scan (identical result array)");
|
|
|
|
// Invalidate SYNCHRONOUSLY after starting a scan, before it settles: the
|
|
// stale result must be discarded by the generation gate.
|
|
invalidateSessionListCache();
|
|
await sharedScan;
|
|
assert.equal(
|
|
globalThis.__piSessionListCache,
|
|
undefined,
|
|
"a scan invalidated mid-flight must not repopulate the cache",
|
|
);
|
|
|
|
// A caller after the invalidation starts a NEW scan, not the stale one.
|
|
const staleScan = sharedScan;
|
|
await listAllSessions();
|
|
assert.notEqual(
|
|
globalThis.__piSessionListPromise,
|
|
staleScan,
|
|
"post-invalidation caller must not reuse the stale scan promise",
|
|
);
|
|
const fresh = await listAllSessions();
|
|
assert.ok(fresh.some((s) => s.id === "gen"), "fresh scan sees the session");
|
|
assert.ok(globalThis.__piSessionListCache, "a clean scan repopulates the cache");
|
|
});
|
|
});
|
|
|
|
// The per-file scan memo is keyed on (size, mtimeMs), which a title-slot
|
|
// rewrite does NOT change: the 256-byte slot is rewritten in place with equal
|
|
// byte length and the mtime restored. Only targeted invalidation of the scan
|
|
// memo lets the sidebar pick up the new title.
|
|
test("same-size + same-mtime title-slot rewrite is caught by targeted scan invalidation", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const cwd = join(tmpdir(), "omp-web-missing-project");
|
|
const titleSlot = serializeTitleSlot({ title: "title-original", updatedAt: "2026-01-01T00:00:00.000Z" });
|
|
const body =
|
|
JSON.stringify({ type: "session", version: 3, id: "titled", cwd, timestamp: "2026-01-01T00:00:00.000Z" })
|
|
+ "\n"
|
|
+ JSON.stringify(userEntry("u1", null, "hello"))
|
|
+ "\n";
|
|
const filePath = join(dir, "2026-01-01_titled.jsonl");
|
|
writeFileSync(filePath, titleSlot + body);
|
|
|
|
const FIXED_MTIME = new Date("2026-01-02T00:00:00.000Z");
|
|
utimesSync(filePath, FIXED_MTIME, FIXED_MTIME);
|
|
|
|
const first = await listAllSessions();
|
|
assert.equal(first.find((s) => s.id === "titled")?.name, "title-original");
|
|
|
|
// In-place rewrite, same byte length (both titles are 14 ASCII chars),
|
|
// mtime restored: the (size, mtimeMs) scan key cannot detect this.
|
|
const newSlot = serializeTitleSlot({ title: "title-CHANGED", updatedAt: "2026-01-01T00:00:00.000Z" });
|
|
assert.equal(Buffer.byteLength(newSlot), Buffer.byteLength(titleSlot), "rewrite must keep the slot byte length");
|
|
const fd = openSync(filePath, "r+");
|
|
writeSync(fd, newSlot, 0, Buffer.byteLength(newSlot), 0);
|
|
closeSync(fd);
|
|
utimesSync(filePath, FIXED_MTIME, FIXED_MTIME);
|
|
|
|
// Clear only the list TTL cache: the scan memo now serves the stale
|
|
// title — exactly the staleness window the watcher's targeted flush
|
|
// must close.
|
|
invalidateSessionListMeta();
|
|
const stale = await listAllSessions();
|
|
assert.equal(
|
|
stale.find((s) => s.id === "titled")?.name,
|
|
"title-original",
|
|
"scan memo must serve the old title before targeted invalidation",
|
|
);
|
|
|
|
// Watcher-style known-path flush: drop the list metadata AND only this
|
|
// file's parse + scan memos.
|
|
invalidateSessionCaches(filePath);
|
|
const fresh = await listAllSessions();
|
|
assert.equal(fresh.find((s) => s.id === "titled")?.name, "title-CHANGED");
|
|
});
|
|
});
|
|
|
|
// Real fs.watch integration: continuous appends to one session must collapse
|
|
// into a debounced flush that resolves the session id, targets ONLY that
|
|
// file's parse caches, and leaves unrelated sessions cached.
|
|
test("watcher coalesces continuous writes into targeted invalidation, keeping unrelated caches", { timeout: 15000 }, async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const cwd = join(tmpdir(), "omp-web-missing-project");
|
|
const fileA = writeSessionFile(dir, "2026-01-01_wa.jsonl", {
|
|
id: "watch-a",
|
|
cwd,
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "a")]);
|
|
const fileB = writeSessionFile(dir, "2026-01-01_wb.jsonl", {
|
|
id: "watch-b",
|
|
cwd,
|
|
timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [userEntry("u1", null, "b")]);
|
|
|
|
// Prime both per-file parse caches and resolve watch-a's id so the flush
|
|
// path exercises the cached id resolution.
|
|
getSessionEntries(fileA);
|
|
getSessionEntries(fileB);
|
|
assert.equal(await resolveSessionIdByPath(fileA), "watch-a");
|
|
|
|
const notified = [];
|
|
const unsubscribe = subscribeSessionFileChanges((ids) => notified.push(...ids));
|
|
try {
|
|
// Continuous writes: two appends inside the 250ms debounce window must
|
|
// collapse into ONE flush that targets watch-a only.
|
|
appendFileSync(fileA, JSON.stringify(userEntry("u2", "u1", "a2")) + "\n");
|
|
appendFileSync(fileA, JSON.stringify(userEntry("u3", "u2", "a3")) + "\n");
|
|
|
|
const deadline = Date.now() + 10_000;
|
|
while (!notified.includes("watch-a")) {
|
|
if (Date.now() > deadline) {
|
|
assert.fail(`watcher never notified for watch-a; got: ${JSON.stringify(notified)}`);
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
assert.equal(
|
|
globalThis.__ompSessionEntriesCache?.has(sessionPathKey(fileA)),
|
|
false,
|
|
"written session's parse cache must be dropped",
|
|
);
|
|
assert.equal(
|
|
globalThis.__ompSessionEntriesCache?.has(sessionPathKey(fileB)),
|
|
true,
|
|
"unrelated session's parse cache must survive the targeted flush",
|
|
);
|
|
assert.equal(getSessionEntries(fileA).length, 3, "appended entries are visible after invalidation");
|
|
} finally {
|
|
unsubscribe();
|
|
}
|
|
});
|
|
});
|
|
|
|
// ============================================================================
|
|
// High-cost read dedup + oversize cache hygiene (TODO §2)
|
|
// ============================================================================
|
|
|
|
const DISPLAY_LOAD_CEILING = 1024 * 1024 * 1024; // MAX_SESSION_LOAD_BYTES
|
|
|
|
// A cached small file that grows past the load ceiling can never match its
|
|
// (size, mtime) cache key again — the stale entry must be dropped when the
|
|
// 413 gate fires, not left to linger until LRU eviction.
|
|
test("oversize cache entry is dropped when a cached file outgrows the load ceiling", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "omp-web-oversize-"));
|
|
try {
|
|
const filePath = join(dir, "grown.jsonl");
|
|
writeFileSync(filePath, [
|
|
JSON.stringify({ type: "session", version: 3, id: "grown", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z" }),
|
|
JSON.stringify(userEntry("u1", null, "small")),
|
|
].join("\n") + "\n");
|
|
|
|
// Prime the parse cache.
|
|
assert.equal(getSessionEntries(filePath).length, 1);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)), true);
|
|
|
|
// Grow to past the load ceiling (sparse truncate — no real bytes written).
|
|
truncateSync(filePath, DISPLAY_LOAD_CEILING + 1);
|
|
|
|
// Sync display read: 413 semantics preserved AND the stale entry is gone.
|
|
assert.throws(() => getSessionEntriesForDisplay(filePath), SessionFileTooLargeError);
|
|
assert.equal(
|
|
globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)),
|
|
false,
|
|
"stale cache entry must be dropped at the 413 gate",
|
|
);
|
|
|
|
// Async display read: same 413 semantics, same cleanup.
|
|
await assert.rejects(
|
|
() => getSessionEntriesForDisplayAsync(filePath),
|
|
SessionFileTooLargeError,
|
|
"async display read must preserve the structured 413",
|
|
);
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)), false);
|
|
assert.equal(
|
|
globalThis.__ompEntriesInFlight?.size ?? 0,
|
|
0,
|
|
"a rejected read must not linger in the in-flight map",
|
|
);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Concurrent same-tick display reads for one file must share ONE parse: both
|
|
// callers resolve to the identical entry array (separate parses would produce
|
|
// distinct arrays), and the in-flight bookkeeping clears after settling.
|
|
test("concurrent display reads share one in-flight parse", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "omp-web-dedup-"));
|
|
try {
|
|
const filePath = join(dir, "shared.jsonl");
|
|
writeFileSync(filePath, [
|
|
JSON.stringify({ type: "session", version: 3, id: "shared", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z" }),
|
|
JSON.stringify(userEntry("u1", null, "hello")),
|
|
JSON.stringify(assistantEntry("a1", "u1", "world")),
|
|
].join("\n") + "\n");
|
|
|
|
// Two calls in the same tick, before any microtask runs.
|
|
const p1 = getSessionEntriesForDisplayAsync(filePath);
|
|
const p2 = getSessionEntriesForDisplayAsync(filePath);
|
|
|
|
// The second call must have joined the first one's parse, not started
|
|
// its own (and the map entry is visible while the parse is pending).
|
|
assert.equal(globalThis.__ompEntriesInFlight?.size, 1, "exactly one in-flight parse");
|
|
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
assert.equal(r1.length, 2);
|
|
// The transform returns a fresh array per caller, but the entries inside
|
|
// come from the ONE shared parse: separate parses would produce distinct
|
|
// entry objects.
|
|
assert.equal(r1[0], r2[0], "entries must come from the same shared parse");
|
|
assert.equal(r1[1], r2[1], "entries must come from the same shared parse");
|
|
assert.equal(
|
|
globalThis.__ompEntriesInFlight?.size,
|
|
0,
|
|
"in-flight entry must be removed once the parse settles",
|
|
);
|
|
|
|
// A settled file is a stat cache hit afterwards: no in-flight entry.
|
|
const again = await getSessionEntriesForDisplayAsync(filePath);
|
|
assert.equal(again.length, 2);
|
|
assert.equal(globalThis.__ompEntriesInFlight?.size, 0);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Distinct files must not collide in the dedup map, and a rejection (here:
|
|
// the 413 gate) must not poison a later read after the file shrank back.
|
|
test("dedup is per-file and rejections are not memoized", async () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "omp-web-dedup2-"));
|
|
try {
|
|
const fileA = join(dir, "a.jsonl");
|
|
const fileB = join(dir, "b.jsonl");
|
|
for (const [filePath, id] of [[fileA, "a"], [fileB, "b"]]) {
|
|
writeFileSync(filePath, [
|
|
JSON.stringify({ type: "session", version: 3, id, cwd: dir, timestamp: "2026-01-01T00:00:00.000Z" }),
|
|
JSON.stringify(userEntry("u1", null, id)),
|
|
].join("\n") + "\n");
|
|
}
|
|
|
|
const [ra, rb] = await Promise.all([
|
|
getSessionEntriesForDisplayAsync(fileA),
|
|
getSessionEntriesForDisplayAsync(fileB),
|
|
]);
|
|
assert.equal(ra.length, 1);
|
|
assert.equal(rb.length, 1);
|
|
assert.equal(ra[0].message.content, "a");
|
|
assert.equal(rb[0].message.content, "b");
|
|
assert.equal(globalThis.__ompEntriesInFlight?.size, 0);
|
|
|
|
// Oversize B past the ceiling, then shrink it back: the first (oversize)
|
|
// read rejects, the second (shrunk) read succeeds — no memoized failure.
|
|
truncateSync(fileB, DISPLAY_LOAD_CEILING + 1);
|
|
await assert.rejects(() => getSessionEntriesForDisplayAsync(fileB), SessionFileTooLargeError);
|
|
truncateSync(fileB, 0);
|
|
writeFileSync(fileB, [
|
|
JSON.stringify({ type: "session", version: 3, id: "b", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z" }),
|
|
JSON.stringify(userEntry("u1", null, "shrunk-back")),
|
|
].join("\n") + "\n");
|
|
const recovered = await getSessionEntriesForDisplayAsync(fileB);
|
|
assert.equal(recovered.length, 1);
|
|
assert.equal(recovered[0].message.content, "shrunk-back");
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test("history catch-up pages persisted IDs contiguously without resolving old blobs", async (t) => {
|
|
await withAgentDir(async (dir) => {
|
|
const hash = "b".repeat(64);
|
|
const blobDir = join(dir, "..", "..", "blobs");
|
|
mkdirSync(blobDir, { recursive: true });
|
|
const blobPath = join(blobDir, hash);
|
|
writeFileSync(blobPath, "old image");
|
|
const filePath = writeSessionFile(dir, "2026-01-01_catch-up.jsonl", {
|
|
id: "catch-up", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, [
|
|
{ ...userEntry("image", null, ""), message: { role: "user", content: [{ type: "image", data: `blob:sha256:${hash}`, mimeType: "image/png" }] } },
|
|
userEntry("repeat-1", "image", "same"),
|
|
userEntry("repeat-2", "repeat-1", "same"),
|
|
assistantEntry("answer", "repeat-2", "answer"),
|
|
]);
|
|
const blobReads = [];
|
|
const originalRead = fs.readFileSync;
|
|
const readMock = t.mock.method(fs, "readFileSync", (path, ...args) => {
|
|
if (String(path) === blobPath) blobReads.push(String(path));
|
|
return originalRead(path, ...args);
|
|
});
|
|
syncBuiltinESMExports();
|
|
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
|
|
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
|
|
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
|
|
const first = readPage(filePath, null, 1);
|
|
assert.deepEqual(blobReads, [blobPath], "positive control: the observer sees a real image-page blob read");
|
|
blobReads.length = 0;
|
|
assert.equal(first.history.mode, "replace");
|
|
assert.equal(first.history.hasMore, true);
|
|
assert.deepEqual(first.history.context.entryIds, ["image"]);
|
|
assert.equal(first.history.context.messages[0].content[0].data, Buffer.from("old image").toString("base64"));
|
|
assert.equal(getSessionEntries(filePath)[0].message.content[0].data, `blob:sha256:${hash}`);
|
|
const second = readPage(filePath, first.history.cursor, 2);
|
|
assert.equal(second.history.mode, "append");
|
|
assert.equal(second.history.baseEntryId, "image");
|
|
assert.deepEqual(second.history.context.entryIds, ["repeat-1", "repeat-2"]);
|
|
assert.deepEqual(second.history.context.messages.map((message) => message.content), ["same", "same"]);
|
|
assert.equal(second.history.hasMore, true);
|
|
const third = readPage(filePath, second.history.cursor, 2);
|
|
assert.deepEqual(third.history.context.entryIds, ["answer"]);
|
|
assert.equal(third.history.hasMore, false);
|
|
assert.equal(third.history.context.model.modelId, "test-model", "page metadata describes the whole selected context");
|
|
const quiet = readPage(filePath, third.history.cursor, 2);
|
|
assert.equal(quiet.history.mode, "append");
|
|
assert.deepEqual(quiet.history.context.entryIds, []);
|
|
assert.deepEqual(quiet.history.cursor, third.history.cursor);
|
|
assert.deepEqual(blobReads, [], "delivered image prefixes must not perform blob I/O on later pages");
|
|
});
|
|
});
|
|
|
|
test("persisted history catch-up resets after unknown cursors, branch changes and compaction", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const entries = [
|
|
userEntry("root", null, "root"),
|
|
assistantEntry("left", "root", "left"),
|
|
assistantEntry("right", "root", "right"),
|
|
];
|
|
const filePath = writeSessionFile(dir, "2026-01-01_reset.jsonl", {
|
|
id: "catch-up-reset", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, entries);
|
|
const left = getSessionHistoryPage(filePath, null, 200, "left");
|
|
const right = getSessionHistoryPage(filePath, left.history.cursor, 200, "right");
|
|
assert.equal(right.history.mode, "replace");
|
|
assert.deepEqual(right.history.context.entryIds, ["root", "right"]);
|
|
assert.equal(right.leafId, "right");
|
|
const unknown = getSessionHistoryPage(filePath, { firstEntryId: "root", lastEntryId: "missing" }, 200);
|
|
assert.equal(unknown.history.mode, "replace");
|
|
assert.equal(unknown.history.baseEntryId, null);
|
|
appendFileSync(filePath, JSON.stringify({
|
|
type: "compaction", id: "summary", parentId: "right", timestamp: "2026-01-01T00:00:04.000Z",
|
|
summary: "condensed", firstKeptEntryId: "right", tokensBefore: 100,
|
|
}) + "\n");
|
|
const compacted = getSessionHistoryPage(filePath, right.history.cursor, 200);
|
|
assert.equal(compacted.history.mode, "replace");
|
|
assert.deepEqual(compacted.history.context.entryIds, ["summary", "right"]);
|
|
assert.equal(compacted.history.context.messages[0].content, "condensed");
|
|
const transcript = getSessionHistoryPage(filePath, compacted.history.cursor, 200, undefined, { includePreCompaction: true });
|
|
assert.equal(transcript.history.mode, "replace");
|
|
assert.deepEqual(transcript.history.context.entryIds, ["root", "right", "summary"]);
|
|
});
|
|
});
|
|
|
|
test("history pages seek only new bodies even above the raw-cache budget", async (t) => {
|
|
await withAgentDir(async (dir) => {
|
|
const entries = [
|
|
assistantEntry("page-one", null, "前文 ".repeat(1000)),
|
|
assistantEntry("page-two", "page-one", "second"),
|
|
assistantEntry("page-three", "page-two", "third"),
|
|
];
|
|
const filePath = writeSessionFile(dir, "indexed.jsonl", {
|
|
id: "indexed", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
|
|
}, entries);
|
|
const fds = new Set();
|
|
const reads = [];
|
|
const touchedBodies = new Set();
|
|
const entryIds = new Set(entries.map((entry) => entry.id));
|
|
const original = { open: fs.openSync, close: fs.closeSync, read: fs.readSync, stat: fs.statSync, fstat: fs.fstatSync, parse: JSON.parse };
|
|
// Report a >256 MiB file, but perform every observed read on the small
|
|
// physical fixture. Both stat paths describe the same artificial version.
|
|
const reported = (stat) => Object.assign(stat, { size: 256 * 1024 * 1024 + 1 });
|
|
const mocks = [
|
|
t.mock.method(fs, "openSync", (path, ...args) => {
|
|
const fd = original.open(path, ...args);
|
|
if (String(path) === filePath) fds.add(fd);
|
|
return fd;
|
|
}),
|
|
t.mock.method(fs, "closeSync", (fd) => { fds.delete(fd); return original.close(fd); }),
|
|
t.mock.method(fs, "statSync", (path, ...args) => {
|
|
const stat = original.stat(path, ...args);
|
|
return String(path) === filePath ? reported(stat) : stat;
|
|
}),
|
|
t.mock.method(fs, "fstatSync", (fd, ...args) => {
|
|
const stat = original.fstat(fd, ...args);
|
|
return fds.has(fd) ? reported(stat) : stat;
|
|
}),
|
|
t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
|
|
const count = original.read(fd, buffer, offset, length, position);
|
|
if (fds.has(fd) && count > 0) reads.push({ position, count, text: buffer.subarray(offset, offset + count).toString("utf8") });
|
|
return count;
|
|
}),
|
|
t.mock.method(JSON, "parse", (text, ...args) => {
|
|
const value = original.parse(text, ...args);
|
|
if (value?.type === "message" && entryIds.has(value.id)) {
|
|
const content = value.message.content;
|
|
Object.defineProperty(value.message, "content", {
|
|
enumerable: true, configurable: true,
|
|
get() { touchedBodies.add(value.id); return content; },
|
|
});
|
|
}
|
|
return value;
|
|
}),
|
|
];
|
|
syncBuiltinESMExports();
|
|
t.after(() => { for (const mock of mocks) mock.mock.restore(); syncBuiltinESMExports(); });
|
|
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
|
|
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
|
|
const first = readPage(filePath, null, 1);
|
|
assert.ok(reads.some((read) => read.position === null && read.text.includes('"page-three"')), "positive control: index construction scanned the real fixture");
|
|
assert.deepEqual([...touchedBodies], ["page-one"], "indexing does not transform off-page bodies");
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)) ?? false, false, "oversized raw bodies are never cached");
|
|
let cursor = first.history.cursor;
|
|
const messages = [...first.history.context.messages];
|
|
for (const id of ["page-two", "page-three"]) {
|
|
reads.length = 0;
|
|
touchedBodies.clear();
|
|
const page = readPage(filePath, cursor, 1);
|
|
assert.equal(page.history.mode, "append");
|
|
assert.deepEqual(page.history.context.entryIds, [id]);
|
|
assert.deepEqual([...touchedBodies], [id], "delivered bodies must not be converted again");
|
|
assert.ok(reads.length > 0, "positive control: each uncached page body was read from disk");
|
|
assert.ok(reads.every((read) => Number.isInteger(read.position) && read.position > 0), "later pages seek, never scan from the start");
|
|
const readEntries = reads.map((read) => original.parse(read.text));
|
|
assert.deepEqual(readEntries.map((entry) => entry.id), [id], "only requested JSONL bodies are read");
|
|
messages.push(...page.history.context.messages);
|
|
cursor = page.history.cursor;
|
|
}
|
|
assert.deepEqual(messages, buildSessionContext(entries).messages);
|
|
reads.length = 0;
|
|
touchedBodies.clear();
|
|
const quiet = readPage(filePath, cursor, 1);
|
|
assert.deepEqual(quiet.history.context.messages, []);
|
|
assert.deepEqual(reads, [], "an unchanged completed cursor needs no body I/O");
|
|
assert.deepEqual([...touchedBodies], []);
|
|
});
|
|
});
|
|
|
|
test("active-context boundaries reuse the history index without reading or hydrating bodies", async (t) => {
|
|
await withAgentDir(async (dir) => {
|
|
const hash = "c".repeat(64);
|
|
const blobDir = join(dir, "..", "..", "blobs");
|
|
mkdirSync(blobDir, { recursive: true });
|
|
const blobPath = join(blobDir, hash);
|
|
writeFileSync(blobPath, "boundary image");
|
|
const entries = [
|
|
userEntry("omitted", null, "old context"),
|
|
{ ...userEntry("image", "omitted", ""), message: { role: "user", content: [{ type: "image", data: `blob:sha256:${hash}`, mimeType: "image/png" }] } },
|
|
{ type: "compaction", id: "summary", parentId: "image", summary: "condensed", firstKeptEntryId: "image", tokensBefore: 50 },
|
|
assistantEntry("inactive", "summary", "other branch"),
|
|
assistantEntry("active", "summary", "current branch"),
|
|
];
|
|
const filePath = writeSessionFile(dir, "boundary.jsonl", { id: "boundary", cwd: dir }, entries);
|
|
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath)).entryIds;
|
|
assert.deepEqual(expected, ["summary", "image", "active"]);
|
|
invalidateSessionCaches(filePath);
|
|
const fds = new Set();
|
|
const reads = [];
|
|
const blobReads = [];
|
|
const touchedBodies = new Set();
|
|
const original = { open: fs.openSync, close: fs.closeSync, read: fs.readSync, readFile: fs.readFileSync, parse: JSON.parse };
|
|
const mocks = [
|
|
t.mock.method(fs, "openSync", (path, ...args) => {
|
|
const fd = original.open(path, ...args);
|
|
if (String(path) === filePath) fds.add(fd);
|
|
return fd;
|
|
}),
|
|
t.mock.method(fs, "closeSync", (fd) => { fds.delete(fd); return original.close(fd); }),
|
|
t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
|
|
const count = original.read(fd, buffer, offset, length, position);
|
|
if (fds.has(fd) && count > 0) reads.push({ position, count });
|
|
return count;
|
|
}),
|
|
t.mock.method(fs, "readFileSync", (path, ...args) => {
|
|
if (String(path) === blobPath) blobReads.push(String(path));
|
|
return original.readFile(path, ...args);
|
|
}),
|
|
t.mock.method(JSON, "parse", (text, ...args) => {
|
|
const value = original.parse(text, ...args);
|
|
if (value?.type === "message" && entries.some((entry) => entry.id === value.id)) {
|
|
const content = value.message.content;
|
|
Object.defineProperty(value.message, "content", {
|
|
enumerable: true, configurable: true,
|
|
get() { touchedBodies.add(value.id); return content; },
|
|
});
|
|
}
|
|
return value;
|
|
}),
|
|
];
|
|
syncBuiltinESMExports();
|
|
t.after(() => { for (const mock of mocks) mock.mock.restore(); syncBuiltinESMExports(); });
|
|
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
|
|
const { getSessionContextBoundary: readBoundary, getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
|
|
assert.deepEqual(readBoundary(filePath), { entryIds: expected });
|
|
assert.ok(reads.some((read) => read.position === null), "positive control: a cold boundary scans the real file");
|
|
assert.deepEqual([...touchedBodies], [], "index projection does not hydrate message content");
|
|
assert.deepEqual(blobReads, [], "even a cold boundary does not resolve blobs");
|
|
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)) ?? false, false);
|
|
reads.length = 0;
|
|
const page = readPage(filePath, null, 200);
|
|
assert.deepEqual(page.history.context.entryIds, expected);
|
|
assert.equal(page.history.context.messages[1].content[0].data, Buffer.from("boundary image").toString("base64"));
|
|
assert.deepEqual(blobReads, [blobPath], "positive control: the page really reads its image blob");
|
|
assert.deepEqual([...touchedBodies], ["image", "active"], "positive control: the page really converts its bodies");
|
|
assert.ok(reads.length > 0 && reads.every((read) => read.position > 0), "the page reuses the boundary index and seeks bodies");
|
|
reads.length = 0;
|
|
blobReads.length = 0;
|
|
touchedBodies.clear();
|
|
assert.deepEqual(readBoundary(filePath), { entryIds: expected });
|
|
assert.deepEqual(reads, [], "an unchanged boundary performs no transcript-body I/O");
|
|
assert.deepEqual(blobReads, []);
|
|
assert.deepEqual([...touchedBodies], []);
|
|
});
|
|
});
|
|
|
|
test("active-context boundaries refresh after append and prefix rewrites, including file growth", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const header = { id: "boundary-changes", cwd: dir };
|
|
const entries = [userEntry("root", null, "first"), userEntry("child", "root", "second")];
|
|
const filePath = writeSessionFile(dir, "boundary-changes.jsonl", header, entries);
|
|
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["root", "child"]);
|
|
entries.push(userEntry("appended", "child", "third"));
|
|
appendFileSync(filePath, JSON.stringify(entries.at(-1)) + "\n");
|
|
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["root", "child", "appended"]);
|
|
const before = statSync(filePath);
|
|
entries[1] = userEntry("other", null, "rewritten branch with a longer body");
|
|
entries[2].parentId = "other";
|
|
writeSessionFile(dir, "boundary-changes.jsonl", header, entries);
|
|
assert.ok(statSync(filePath).size > before.size, "the prefix rewrite grows the file; growth is not append-only evidence");
|
|
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["other", "appended"]);
|
|
const rewritten = statSync(filePath);
|
|
entries[1].id = "fresh";
|
|
entries[2].parentId = "fresh";
|
|
writeSessionFile(dir, "boundary-changes.jsonl", header, entries);
|
|
utimesSync(filePath, rewritten.atime, rewritten.mtime);
|
|
assert.equal(statSync(filePath).size, rewritten.size);
|
|
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["fresh", "appended"]);
|
|
assert.deepEqual(getSessionHistoryPage(filePath, null, 200).history.context, buildSessionContext(entries));
|
|
});
|
|
});
|
|
|
|
test("indexed pages trim the same Unicode line whitespace as the lenient full reader", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const entries = [userEntry("one", null, "first"), assistantEntry("two", "one", "second")];
|
|
const filePath = join(dir, "trimmed.jsonl");
|
|
writeFileSync(filePath, [
|
|
JSON.stringify({ type: "session", version: 3, id: "trimmed", cwd: dir }),
|
|
"\ufeff" + JSON.stringify(entries[0]) + "\u00a0",
|
|
"{torn write",
|
|
"\u2003" + JSON.stringify(entries[1]) + "\ufeff",
|
|
].join("\r\n"));
|
|
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath));
|
|
assert.deepEqual(expected.entryIds, ["one", "two"]);
|
|
assert.deepEqual(getSessionContextBoundary(filePath), { entryIds: expected.entryIds });
|
|
assert.deepEqual(getSessionHistoryPage(filePath, null, 200).history.context, expected);
|
|
});
|
|
});
|
|
|
|
test("indexed pages concatenate to full Unicode/CRLF compaction and branch context", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const phases = [{ name: "最新", tasks: [{ content: "finish", status: "completed" }] }];
|
|
const entries = [
|
|
userEntry("root", null, "漢字 ".repeat(300_000)),
|
|
{ type: "compaction", id: "old-summary", parentId: "root", timestamp: "2026-01-01T00:00:00.000Z", summary: "旧 summary", firstKeptEntryId: "root", tokensBefore: 500 },
|
|
{ type: "thinking_level_change", id: "thinking", parentId: "old-summary", thinkingLevel: "high" },
|
|
{ type: "model_change", id: "model", parentId: "thinking", model: "provider/model/with/slashes", role: "default" },
|
|
{ type: "custom", customType: "user_todo_edit", id: "todo", parentId: "model", data: { phases } },
|
|
{ type: "branch_summary", id: "branch-summary", parentId: "todo", timestamp: "2026-01-01T00:00:00.000Z", summary: "旁支" },
|
|
{ type: "message", id: "invalid-message", parentId: "branch-summary" },
|
|
{ type: "compaction", id: "new-summary", parentId: "invalid-message", timestamp: "2026-01-01T00:00:01.000Z", summary: "最新 summary", firstKeptEntryId: "old-summary", tokensBefore: 1000, tokensAfter: 50, method: "manual" },
|
|
assistantEntry("left", "new-summary", "左"),
|
|
assistantEntry("right", "new-summary", "右"),
|
|
];
|
|
const filePath = join(dir, "unicode.jsonl");
|
|
const records = [
|
|
{ type: "session", version: 3, id: "unicode", cwd: dir },
|
|
...entries,
|
|
];
|
|
// The first body spans scanner chunks; CRLF and an unterminated last line
|
|
// make byte offsets differ from both JS character and LF-only offsets.
|
|
writeFileSync(filePath, serializeTitleSlot({ title: "題名", updatedAt: "2026-01-01T00:00:00.000Z" })
|
|
+ records.map((entry) => JSON.stringify(entry)).join("\r\n"));
|
|
for (const leafId of ["left", "right", "missing"]) {
|
|
for (const includePreCompaction of [false, true]) {
|
|
const options = { includePreCompaction, deferThinking: true, deferToolResultImages: true };
|
|
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath), leafId, options);
|
|
const actual = { ...expected, messages: [], entryIds: [] };
|
|
let cursor = null;
|
|
for (;;) {
|
|
const { history, tipId, leafId: selectedLeaf } = getSessionHistoryPage(filePath, cursor, 2, leafId, options);
|
|
assert.equal(tipId, "right");
|
|
assert.equal(selectedLeaf, leafId === "missing" ? "right" : leafId);
|
|
assert.deepEqual(history.context.todoPhases, leafId === "missing" ? [] : phases);
|
|
assert.deepEqual(history.context.model, expected.model);
|
|
assert.equal(history.context.thinkingLevel, "high");
|
|
actual.messages.push(...history.context.messages);
|
|
actual.entryIds.push(...history.context.entryIds);
|
|
if (!history.hasMore) break;
|
|
cursor = history.cursor;
|
|
}
|
|
assert.deepEqual(actual, expected);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
test("indexed legacy pages preserve migrations and compaction links without reassigning IDs", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
for (const version of [1, 2]) {
|
|
const entries = [
|
|
userEntry("root", null, "旧 request"),
|
|
{ ...userEntry("hook", "root", "legacy hook"), message: { role: "hookMessage", customType: "hook", content: "legacy hook", display: false } },
|
|
{ type: "compaction", id: "summary", parentId: "hook", timestamp: "2026-01-01T00:00:00.000Z", summary: "short", firstKeptEntryId: "hook", firstKeptEntryIndex: 2, tokensBefore: 50 },
|
|
assistantEntry("answer", "summary", "response"),
|
|
];
|
|
if (version === 1) for (const entry of entries) { delete entry.id; delete entry.parentId; }
|
|
const filePath = join(dir, `v${version}.jsonl`);
|
|
writeFileSync(filePath, [
|
|
{ type: "session", version, id: `legacy-${version}`, cwd: dir },
|
|
...entries,
|
|
].map((entry) => JSON.stringify(entry)).join("\n"));
|
|
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath));
|
|
const pages = [];
|
|
let cursor = null;
|
|
for (;;) {
|
|
const { history } = getSessionHistoryPage(filePath, cursor, 1);
|
|
assert.equal(history.mode, cursor ? "append" : "replace");
|
|
pages.push(history);
|
|
if (!history.hasMore) break;
|
|
cursor = history.cursor;
|
|
}
|
|
const messages = pages.flatMap((page) => page.context.messages);
|
|
const ids = pages.flatMap((page) => page.context.entryIds);
|
|
// v1 has no persisted IDs: native-compatible migration assigns fresh
|
|
// IDs on a cold load, but those IDs and firstKept links survive paging.
|
|
assert.equal(messages[0].details.firstKeptEntryId, ids[1]);
|
|
if (version === 1) expected.messages[0].details.firstKeptEntryId = ids[1];
|
|
else assert.deepEqual(ids, expected.entryIds);
|
|
assert.deepEqual(messages, expected.messages);
|
|
assert.equal(new Set(ids).size, ids.length);
|
|
assert.deepEqual(getSessionHistoryPage(filePath, pages.at(-1).cursor, 1).history.context.messages, []);
|
|
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ids, "boundary and pages share migrated IDs");
|
|
}
|
|
});
|
|
});
|
|
|
|
test("history offsets invalidate for append, same-size rewrite, replacement, truncation and explicit invalidation", async () => {
|
|
await withAgentDir(async (dir) => {
|
|
const filePath = writeSessionFile(dir, "changing.jsonl", { id: "changing", cwd: dir }, [
|
|
userEntry("one", null, "original"),
|
|
userEntry("two", "one", "version-one"),
|
|
]);
|
|
const initial = getSessionHistoryPage(filePath, null, 1);
|
|
const before = statSync(filePath);
|
|
const header = JSON.stringify({ type: "session", version: 3, id: "changing", cwd: dir });
|
|
const body = (text) => [header, JSON.stringify(userEntry("one", null, "original")), JSON.stringify(userEntry("two", "one", text))].join("\n") + "\n";
|
|
// A write with the original mtime still changes ctime.
|
|
writeFileSync(filePath, body("version-two"));
|
|
utimesSync(filePath, before.atime, before.mtime);
|
|
assert.equal(getSessionHistoryPage(filePath, initial.history.cursor, 1).history.context.messages[0].content, "version-two");
|
|
const replacement = join(dir, "replacement.jsonl");
|
|
writeFileSync(replacement, body("version-new"));
|
|
utimesSync(replacement, before.atime, before.mtime);
|
|
renameSync(replacement, filePath);
|
|
assert.equal(getSessionHistoryPage(filePath, initial.history.cursor, 1).history.context.messages[0].content, "version-new");
|
|
appendFileSync(filePath, JSON.stringify(userEntry("three", "two", "appended")) + "\n");
|
|
const appended = getSessionHistoryPage(filePath, { firstEntryId: "one", lastEntryId: "two" }, 1);
|
|
assert.deepEqual(appended.history.context.entryIds, ["three"]);
|
|
writeFileSync(filePath, header + "\n" + JSON.stringify(userEntry("one", null, "original")) + "\n");
|
|
const truncated = getSessionHistoryPage(filePath, appended.history.cursor, 1);
|
|
assert.equal(truncated.history.mode, "replace");
|
|
assert.deepEqual(truncated.history.context.entryIds, ["one"]);
|
|
invalidateSessionCaches(filePath);
|
|
assert.deepEqual(getSessionHistoryPage(filePath, null, 1).history.context.messages, truncated.history.context.messages);
|
|
truncateSync(filePath, DISPLAY_LOAD_CEILING + 1);
|
|
assert.throws(() => getSessionHistoryPage(filePath, null, 1), SessionFileTooLargeError);
|
|
writeFileSync(filePath, body("after-shrink"));
|
|
assert.equal(getSessionHistoryPage(filePath, initial.history.cursor, 1).history.context.messages[0].content, "after-shrink");
|
|
});
|
|
});
|
|
|
|
test("an over-budget metadata index is not retained and does not evict cached peers", async (t) => {
|
|
await withAgentDir(async (dir) => {
|
|
const peer = writeSessionFile(dir, "peer.jsonl", { id: "peer", cwd: dir }, [
|
|
userEntry("peer-one", null, "first"), userEntry("peer-two", "peer-one", "second"),
|
|
]);
|
|
const filePath = writeSessionFile(dir, "large-metadata.jsonl", { id: "large-metadata", cwd: dir }, [
|
|
{ type: "custom", customType: "user_todo_edit", id: "todo", parentId: null, data: {
|
|
phases: [{ name: "large", tasks: [{ content: "x".repeat(17 * 1024 * 1024), status: "pending" }] }],
|
|
} },
|
|
userEntry("one", "todo", "first"), userEntry("two", "one", "second"),
|
|
]);
|
|
let scannedBytes = 0;
|
|
const originalRead = fs.readSync;
|
|
const readMock = t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
|
|
const count = originalRead(fd, buffer, offset, length, position);
|
|
if (position === null) scannedBytes += count;
|
|
return count;
|
|
});
|
|
syncBuiltinESMExports();
|
|
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
|
|
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
|
|
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
|
|
const peerFirst = readPage(peer, null, 1);
|
|
scannedBytes = 0;
|
|
const first = readPage(filePath, null, 1);
|
|
assert.ok(scannedBytes >= statSync(filePath).size, "positive control: the real metadata-heavy fixture was scanned");
|
|
scannedBytes = 0;
|
|
const second = readPage(filePath, first.history.cursor, 1);
|
|
assert.deepEqual(second.history.context.entryIds, ["two"], "the memory bound never truncates history");
|
|
assert.equal(second.history.context.todoPhases[0].tasks[0].content.length, 17 * 1024 * 1024, "metadata is returned in full, not clipped to fit the cache");
|
|
assert.ok(scannedBytes >= statSync(filePath).size, "UTF-16 metadata larger than the entire 32 MiB index budget is not retained");
|
|
scannedBytes = 0;
|
|
assert.deepEqual(readPage(peer, peerFirst.history.cursor, 1).history.context.entryIds, ["peer-two"]);
|
|
assert.equal(scannedBytes, 0, "an uncacheable file must not evict an unrelated reusable index");
|
|
});
|
|
});
|
|
|
|
test("history never advances a cursor through offsets invalidated during a real page read", async (t) => {
|
|
await withAgentDir(async (dir) => {
|
|
const filePath = writeSessionFile(dir, "race.jsonl", { id: "race", cwd: dir }, [
|
|
userEntry("one", null, "first"), userEntry("two", "one", "second"),
|
|
]);
|
|
const first = getSessionHistoryPage(filePath, null, 1);
|
|
const originalRead = fs.readSync;
|
|
let changed = false;
|
|
const readMock = t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
|
|
const count = originalRead(fd, buffer, offset, length, position);
|
|
if (!changed && position !== null && buffer.subarray(offset, offset + count).toString("utf8").includes('"id":"two"')) {
|
|
changed = true;
|
|
appendFileSync(filePath, JSON.stringify(userEntry("three", "two", "newer")) + "\n");
|
|
}
|
|
return count;
|
|
});
|
|
syncBuiltinESMExports();
|
|
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
|
|
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
|
|
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
|
|
assert.throws(() => readPage(filePath, first.history.cursor, 1), /Session changed/);
|
|
assert.equal(changed, true, "positive control: mutation happened inside the actual page read");
|
|
const recovered = readPage(filePath, first.history.cursor, 2);
|
|
assert.equal(recovered.history.mode, "append");
|
|
assert.deepEqual(recovered.history.context.entryIds, ["two", "three"]);
|
|
});
|
|
});
|
|
|
|
test("streamed line ranges count physical UTF-8 bytes across chunks, CRLF and malformed bytes", () => {
|
|
const dir = mkdtempSync(join(tmpdir(), "omp-web-line-ranges-"));
|
|
try {
|
|
const first = Buffer.from("a".repeat(1024 * 1024 - 1) + "界\r\n");
|
|
const invalid = Buffer.from([0xff, 0x0d, 0x0a]);
|
|
const last = Buffer.from("末尾");
|
|
const body = Buffer.concat([first, invalid, last]);
|
|
const filePath = join(dir, "lines.jsonl");
|
|
writeFileSync(filePath, body);
|
|
const lines = [];
|
|
forEachFileLineSync(filePath, (line, offset, length) => lines.push({ line, offset, length }));
|
|
assert.deepEqual(lines.map(({ offset, length }) => [offset, length]), [
|
|
[0, first.length - 1],
|
|
[first.length, invalid.length - 1],
|
|
[first.length + invalid.length, last.length],
|
|
]);
|
|
for (const { line, offset, length } of lines) {
|
|
assert.equal(line, body.subarray(offset, offset + length).toString("utf8"), "a sought range decodes to the exact streamed line");
|
|
}
|
|
assert.ok(lines[0].line.endsWith("界\r"), "multi-byte character split across chunks is preserved");
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|