packages/conversation is a library with no server: safe-fs, the Pi session parser, CHAT-01 pages, pinned snapshots, cursors and follow. The control board adds GET /api/conversations and /api/conversation behind the Host and Origin guard. Both are read-only, their queries are validated, and each refusal code maps to a status. Dewey authored it (packet 0cf177b1, revision 2). Filbert reviewed the code: R1 revise (branch ids moving on append, the assumed-link bridge merging branches, one unreadable seat directory turning the catalogue into a 500), then R2 approve (3b14d66c). Darkwing reviewed the routes: R1 approve (07b10ad1), R2 approve (b9d92003). The package lands with the routes, because serve.mjs imports the reader at load. On an index export: the eight suites 24/90/43/17/14/15/63/18, conversation and control-board 153/153, webui 9/9. Co-Authored-By: Claude Opus 5.5 <[email protected]>
774 lines
42 KiB
JavaScript
774 lines
42 KiB
JavaScript
// packages/conversation (#1507, CHAT-02): fixtures F1–F15 and F17 from the
|
||
// brief (agents/dewey/work/chat-02/BRIEF.md §2.1). F16 is in the control-board
|
||
// suite, which serves these routes. Every reader call runs inside noWrites(),
|
||
// which fingerprints the fixture tree before and after (F17). Every page and
|
||
// cursor is checked against the CHAT-01 schema at the end.
|
||
|
||
import { test, after } from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, readdirSync, rmSync, chmodSync, symlinkSync, lstatSync, renameSync, openSync, writeSync, closeSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { join, resolve, relative } from "node:path";
|
||
import { createHash } from "node:crypto";
|
||
import { spawnSync } from "node:child_process";
|
||
import { rootsFromSpecs, createReader, conversationId, cwdInProject, UNSUPPORTED_HARNESS } from "../src/reader.mjs";
|
||
import { fragments, safeId, LIMITS } from "../src/parts.mjs";
|
||
|
||
const repoRoot = resolve(import.meta.dirname, "..", "..", "..");
|
||
const schemaPath = join(repoRoot, "docs", "plans", "chat-01", "contracts.schema.json");
|
||
const tmp = mkdtempSync(join(tmpdir(), "conversation-"));
|
||
const records = [];
|
||
after(() => {
|
||
// chmod 000 fixtures would block removal
|
||
spawnSync("chmod", ["-R", "u+rwx", tmp]);
|
||
rmSync(tmp, { recursive: true, force: true });
|
||
});
|
||
|
||
let fixtureCount = 0;
|
||
const time = (i) => new Date(Date.UTC(2026, 8, 26, 12, 0, 0) + i * 1000).toISOString();
|
||
|
||
// A project with one seat's sessions directory.
|
||
function project(seat = "pi-seat") {
|
||
const base = join(tmp, `f${++fixtureCount}`);
|
||
const proj = join(base, "proj");
|
||
const dir = join(proj, ".pi", "state", seat, "sessions");
|
||
mkdirSync(dir, { recursive: true });
|
||
return { base, proj, dir, seat, spec: { agent: seat, project: "proj", sessionsDir: dir } };
|
||
}
|
||
|
||
const header = (proj, extra = {}) => ({ type: "session", version: 3, id: "0f5e1c2a-1111-4222-8333-944455556666", timestamp: time(0), cwd: proj, ...extra });
|
||
const msg = (id, parentId, role, content, extra = {}, i = 1) => ({ type: "message", id, parentId, timestamp: time(i), message: { role, content, ...extra } });
|
||
const user = (id, parentId, text, i) => msg(id, parentId, "user", [{ type: "text", text }], {}, i);
|
||
const assistant = (id, parentId, text, i) => msg(id, parentId, "assistant", [{ type: "text", text }], { stopReason: "stop" }, i);
|
||
const lines = (...values) => values.map((v) => (typeof v === "string" ? v : JSON.stringify(v)) + "\n").join("");
|
||
|
||
function writeSession(dir, name, head, entries, trailing = "") {
|
||
const path = join(dir, name);
|
||
writeFileSync(path, lines(head, ...entries) + trailing);
|
||
return path;
|
||
}
|
||
|
||
// Fingerprint of every file and directory under `root`, symlinks not followed.
|
||
function fingerprint(root) {
|
||
const out = {};
|
||
const walk = (path) => {
|
||
const st = lstatSync(path, { bigint: true });
|
||
const rec = { dev: String(st.dev), ino: String(st.ino), mtime: String(st.mtimeNs), size: String(st.size), mode: String(st.mode) };
|
||
if (st.isDirectory()) {
|
||
rec.list = st.mode & 0o400n ? readdirSync(path).sort() : null;
|
||
for (const n of rec.list ?? []) walk(join(path, n));
|
||
} else if (st.isFile() && st.mode & 0o400n) {
|
||
rec.sha256 = createHash("sha256").update(readFileSync(path)).digest("hex");
|
||
}
|
||
out[path] = rec;
|
||
};
|
||
walk(root);
|
||
return out;
|
||
}
|
||
|
||
// F17: a reader operation leaves every file and listing in `root` unchanged.
|
||
function noWrites(root, fn) {
|
||
const before = fingerprint(root);
|
||
const result = fn();
|
||
assert.deepEqual(fingerprint(root), before, "a reader operation changed the fixture tree");
|
||
for (const key of ["page", "cursor", "follow"]) if (result?.[key]) records.push(result[key]);
|
||
return result;
|
||
}
|
||
|
||
function readerFor(fx, opts = {}) {
|
||
return createReader({ roots: rootsFromSpecs([fx.spec], opts.registrations ?? []), ...opts });
|
||
}
|
||
|
||
function openOne(fx, reader, name = "s.jsonl", args = {}) {
|
||
return noWrites(fx.base, () => reader.open({ conversation: conversationId(rootsFromSpecs([fx.spec])[0], name), ...args }));
|
||
}
|
||
|
||
// Every page from an open, following next cursors.
|
||
function readAll(fx, reader, first) {
|
||
const pages = [first];
|
||
let r = first;
|
||
while (r.ok && r.page.hasMore) {
|
||
const page = r.page;
|
||
r = noWrites(fx.base, () => reader.next({ cursor: page.nextCursor, conversation: page.conversation, branch: page.branch }));
|
||
assert.equal(r.ok, true, JSON.stringify(r.refusal));
|
||
pages.push(r);
|
||
}
|
||
return pages;
|
||
}
|
||
|
||
const texts = (page) => page.entries.map((e) => e.content.map((b) => b.text ?? b.summary ?? b.argumentsText ?? "").join(""));
|
||
const roles = (page) => page.entries.map((e) => e.role);
|
||
|
||
test("a plain conversation: catalogue row, one page, CHAT-01 records", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("a1", null, "hello", 1),
|
||
{ type: "session_info", id: "a2", parentId: "a1", timestamp: time(2), name: "Greeting" },
|
||
assistant("a3", "a2", "hi there", 3),
|
||
{ type: "model_change", id: "a4", parentId: "a3", timestamp: time(4), provider: "x", modelId: "y" },
|
||
]);
|
||
const reader = readerFor(fx);
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
assert.equal(cat.conversations.length, 1);
|
||
const row = cat.conversations[0];
|
||
assert.equal(row.title, "Greeting");
|
||
assert.equal(row.availability, "available");
|
||
assert.equal(row.readOnly, true);
|
||
assert.equal(row.conversationCreatedAt, time(0));
|
||
assert.equal(row.lastActivityAt, time(4));
|
||
assert.equal(row.engineStartedAt, null);
|
||
const r = openOne(fx, reader);
|
||
assert.equal(r.ok, true);
|
||
assert.deepEqual(roles(r.page), ["user", "assistant"]);
|
||
assert.deepEqual(texts(r.page), ["hello", "hi there"]);
|
||
assert.equal(r.page.hasMore, false);
|
||
assert.equal(r.page.nextCursor, null);
|
||
assert.equal(r.cursor, null);
|
||
assert.ok(r.follow, "the last page carries a follow cursor");
|
||
assert.equal(r.page.branch, "main");
|
||
assert.equal(r.view.defaultBranch, "main");
|
||
assert.equal(r.page.entries[1].parent, "a2");
|
||
assert.equal(r.page.readOnly, true);
|
||
});
|
||
|
||
test("native entries map to blocks: tools, thinking, bash, notices, ids that do not fit", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("b1", null, "run it", 1),
|
||
msg("b2", "b1", "assistant", [
|
||
{ type: "thinking", thinking: "plan" },
|
||
{ type: "thinking", thinking: "" },
|
||
{ type: "toolCall", id: "call_x|fc_y", name: "bash", arguments: { command: "ls" } },
|
||
{ type: "thinking", thinking: "[Reasoning redacted]", redacted: true, thinkingSignature: "opaque" },
|
||
], { stopReason: "toolUse" }, 2),
|
||
msg("b3", "b2", "toolResult", [{ type: "text", text: "a" }, { type: "image", data: "AAAA", mimeType: "image/png" }, { type: "text", text: "b" }], { toolCallId: "call_x|fc_y", toolName: "bash", isError: false }, 3),
|
||
{ type: "message", id: "b4", parentId: "b3", timestamp: time(4), message: { role: "bashExecution", command: "pwd", output: "/p", exitCode: 1, cancelled: false } },
|
||
{ type: "custom_message", id: "b5", parentId: "b4", timestamp: time(5), customType: "x", content: "shown", display: true },
|
||
{ type: "custom_message", id: "b6", parentId: "b5", timestamp: time(6), customType: "x", content: "hidden", display: false },
|
||
msg("b7", "b6", "assistant", [{ type: "text", text: "partial" }], { stopReason: "error", errorMessage: "overloaded" }, 7),
|
||
{ type: "future_kind", id: "b8", parentId: "b7", timestamp: time(8) },
|
||
]);
|
||
const r = openOne(fx, readerFor(fx));
|
||
assert.equal(r.ok, true);
|
||
assert.deepEqual(roles(r.page), ["user", "assistant", "tool", "tool", "notice", "assistant", "notice", "notice"]);
|
||
const [, a, tool, bash, shown, , end, unknown] = r.page.entries;
|
||
assert.deepEqual(a.content.map((b) => [b.type, b.visibility ?? null]), [["thinking", "permitted-visible"], ["thinking", "unavailable"], ["tool-call", null], ["thinking", "unavailable"]]);
|
||
assert.equal(a.content[3].text, "", "redacted reasoning shows no placeholder");
|
||
assert.equal(a.content[2].call, safeId("call_x|fc_y"));
|
||
assert.match(a.content[2].call, /^h-[0-9a-f]{40}$/);
|
||
assert.equal(a.content[2].argumentsText, '{"command":"ls"}');
|
||
assert.deepEqual(tool.content.map((b) => b.type), ["tool-result", "attachment"]);
|
||
assert.equal(tool.content[0].text, "a\nb");
|
||
assert.equal(tool.content[0].call, a.content[2].call);
|
||
assert.deepEqual(bash.content.map((b) => b.type), ["tool-call", "tool-result"]);
|
||
assert.equal(bash.content[1].isError, true);
|
||
assert.equal(shown.content[0].text, "shown");
|
||
assert.match(end.content[0].text, /overloaded/);
|
||
assert.match(unknown.content[0].text, /future_kind/);
|
||
assert.equal(new Set(r.page.entries.map((e) => e.id)).size, r.page.entries.length, "entry ids are unique");
|
||
});
|
||
|
||
test("F1: a malformed line is an unavailable part at its position, and reading continues", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("c1", null, "one", 1),
|
||
assistant("c2", "c1", "two", 2),
|
||
'{"type":"message","id":"c3", broken',
|
||
user("c4", "c2", "four", 4),
|
||
"[1,2,3]",
|
||
assistant("x.line-6", "c4", "five", 5), // a native id shaped like a notice id
|
||
]);
|
||
const r = openOne(fx, readerFor(fx));
|
||
assert.equal(r.ok, true);
|
||
assert.deepEqual(roles(r.page), ["user", "assistant", "notice", "user", "notice", "assistant"]);
|
||
assert.equal(new Set(r.page.entries.map((e) => e.id)).size, 6, "native ids never collide with notice ids");
|
||
assert.match(texts(r.page)[2], /^Line 4 could not be read/);
|
||
assert.match(texts(r.page)[4], /^Line 6 could not be read/);
|
||
assert.equal(r.view.unreadableLines, 2);
|
||
const cat = noWrites(fx.base, () => readerFor(fx).catalogue());
|
||
assert.equal(cat.conversations[0].availability, "available");
|
||
});
|
||
|
||
test("F1: a missing parent stops the history with a notice that names the unreadable lines", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("d1", null, "one", 1),
|
||
"garbage",
|
||
assistant("d3", "d2", "three", 3),
|
||
]);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
assert.deepEqual([roles(r.page), r.page.branch], [["notice", "assistant"], "b.d3"]);
|
||
assert.match(texts(r.page)[0], /missing from the file\. Line 3 could not be read and may have held it\.$/);
|
||
// The history before the gap is its own branch, and the line shows there too.
|
||
const main = openOne(fx, reader, "s.jsonl", { branch: "main" });
|
||
assert.deepEqual([roles(main.page), texts(main.page)[0]], [["user", "notice"], "one"]);
|
||
assert.match(texts(main.page)[1], /^Line 3 could not be read\. It may belong to this branch or another one\.$/);
|
||
const fx2 = project();
|
||
writeSession(fx2.dir, "s.jsonl", header(fx2.proj), [user("e1", null, "one", 1), assistant("e3", "e2", "three", 3)]);
|
||
const r2 = openOne(fx2, readerFor(fx2));
|
||
assert.deepEqual(roles(r2.page), ["notice", "assistant"], "no unreadable line: history stops with a notice");
|
||
assert.equal(texts(r2.page)[0], "History before this point is unavailable: an earlier entry is missing from the file.");
|
||
});
|
||
|
||
test("F1: an unreadable fork is never merged into another branch's history", () => {
|
||
// Line 4 held X, a fork from a; y is X's child.
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("a", null, "root", 1),
|
||
assistant("b", "a", "ON THE OTHER BRANCH", 2),
|
||
'{"type":"message","id":"X","parentId":"a",',
|
||
user("y", "X", "leaf", 4),
|
||
]);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
assert.deepEqual(r.view.branches.map((b) => [b.branch, b.isDefault]), [["main", false], ["b.y", true]]);
|
||
assert.deepEqual(roles(r.page), ["notice", "user"]);
|
||
assert.match(texts(r.page)[0], /Line 4 could not be read and may have held it/);
|
||
assert.ok(!texts(r.page).some((t) => /ON THE OTHER BRANCH|^root$/.test(t)), "no text from the other branch");
|
||
});
|
||
|
||
test("F1: a follow stays on its branch when the next entry's parent is unreadable", () => {
|
||
const fx = project();
|
||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("t1", null, "one", 1), "garbage"]);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
assert.deepEqual([roles(r.page), r.page.branch], [["user", "notice"], "main"]);
|
||
// t3's parent was on the unreadable line, so t3 starts its own branch. The
|
||
// view keeps its branch and parts, and reports the new default.
|
||
appendFileSync(path, lines(assistant("t3", "t2", "three", 3)));
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||
assert.deepEqual([f.ok, f.page.entries.length, f.page.branch, f.view.defaultBranch], [true, 0, "main", "b.t3"]);
|
||
const again = openOne(fx, reader);
|
||
assert.deepEqual([roles(again.page), again.page.branch], [["notice", "assistant"], "b.t3"]);
|
||
assert.match(texts(again.page)[0], /Line 3 could not be read and may have held it/);
|
||
});
|
||
|
||
test("F1: a file whose entries are all unreadable shows a notice per line", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), ["garbage", "[1]", '{"type":"message"}']);
|
||
const r = openOne(fx, readerFor(fx));
|
||
assert.deepEqual([r.ok, r.page.branch, r.view.unreadableLines], [true, "main", 3]);
|
||
assert.deepEqual(texts(r.page).map((t) => t.match(/^Line (\d+)/)[1]), ["2", "3", "4"]);
|
||
});
|
||
|
||
test("F2: a truncated trailing line marks the view incomplete, not an error", () => {
|
||
const fx = project();
|
||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("f1", null, "one", 1)], '{"type":"message","id":"f2","parentId":"f1"');
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
assert.equal(r.ok, true);
|
||
assert.equal(r.view.incomplete, true);
|
||
assert.deepEqual(texts(r.page), ["one"]);
|
||
assert.equal(r.view.unreadableLines, 0);
|
||
appendFileSync(path, ',"timestamp":"' + time(2) + '","message":{"role":"assistant","content":[{"type":"text","text":"two"}]}}\n');
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||
assert.equal(f.ok, true);
|
||
assert.deepEqual(texts(f.page), ["two"]);
|
||
assert.equal(f.view.incomplete, false);
|
||
assert.equal(f.page.sourceEpoch, r.page.sourceEpoch);
|
||
});
|
||
|
||
// 150 one-part entries: two pages.
|
||
function longSession(fx) {
|
||
const entries = [];
|
||
for (let i = 0; i < 150; i++) entries.push(i % 2 ? assistant(`g${i}`, i ? `g${i - 1}` : null, `m${i}`, i + 1) : user(`g${i}`, i ? `g${i - 1}` : null, `m${i}`, i + 1));
|
||
return writeSession(fx.dir, "s.jsonl", header(fx.proj), entries);
|
||
}
|
||
|
||
test("pagination: 100 parts, then the rest; parts concatenate to the whole branch", () => {
|
||
const fx = project();
|
||
longSession(fx);
|
||
const reader = readerFor(fx);
|
||
const pages = readAll(fx, reader, openOne(fx, reader));
|
||
assert.deepEqual(pages.map((p) => p.page.entries.length), [100, 50]);
|
||
assert.deepEqual(pages.flatMap((p) => texts(p.page)), Array.from({ length: 150 }, (_, i) => `m${i}`));
|
||
assert.equal(pages[0].cursor.lastEntry, pages[0].page.entries[99].id);
|
||
assert.equal(pages[0].follow, null);
|
||
assert.ok(pages[1].follow);
|
||
});
|
||
|
||
test("F3: a replaced file (new inode) refuses old cursors with reconcile", () => {
|
||
const fx = project();
|
||
const path = longSession(fx);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
const content = readFileSync(path);
|
||
writeFileSync(path + ".new", content);
|
||
renameSync(path + ".new", path);
|
||
for (const cursor of [r.cursor.id]) {
|
||
const x = noWrites(fx.base, () => reader.next({ cursor, conversation: r.page.conversation, branch: r.page.branch }));
|
||
assert.deepEqual([x.ok, x.refusal.code, x.refusal.reconcile], [false, "source-replaced", true]);
|
||
}
|
||
const short = project();
|
||
writeSession(short.dir, "s.jsonl", header(short.proj), [user("h1", null, "one", 1)]);
|
||
const reader2 = readerFor(short);
|
||
const r2 = openOne(short, reader2);
|
||
const p2 = join(short.dir, "s.jsonl");
|
||
writeFileSync(p2 + ".new", readFileSync(p2));
|
||
renameSync(p2 + ".new", p2);
|
||
const f = noWrites(short.base, () => reader2.next({ cursor: r2.follow.id, conversation: r2.page.conversation, branch: r2.page.branch }));
|
||
assert.deepEqual([f.ok, f.refusal.code, f.refusal.reconcile], [false, "source-replaced", true], "follow cursor too");
|
||
const reopened = openOne(short, reader2);
|
||
assert.notEqual(reopened.page.sourceEpoch, r2.page.sourceEpoch, "a reopen is a new epoch");
|
||
});
|
||
|
||
test("F4: a same-inode rewrite of the prefix refuses old cursors with reconcile", () => {
|
||
const fx = project();
|
||
const path = longSession(fx);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
const ino = lstatSync(path).ino;
|
||
const text = readFileSync(path, "utf8").replace('"m3"', '"M3"');
|
||
const fd = openSync(path, "r+");
|
||
writeSync(fd, text, 0);
|
||
closeSync(fd);
|
||
assert.equal(lstatSync(path).ino, ino, "same inode");
|
||
const x = noWrites(fx.base, () => reader.next({ cursor: r.cursor.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||
assert.deepEqual([x.ok, x.refusal.code, x.refusal.reconcile], [false, "source-replaced", true]);
|
||
// Shorter than the snapshot.
|
||
const r2 = openOne(fx, reader);
|
||
writeFileSync(path, lines(header(fx.proj)));
|
||
const y = noWrites(fx.base, () => reader.next({ cursor: r2.cursor.id, conversation: r2.page.conversation, branch: r2.page.branch }));
|
||
assert.deepEqual([y.ok, y.refusal.code, y.refusal.reconcile], [false, "source-replaced", true]);
|
||
});
|
||
|
||
test("F5: growth between pages keeps the epoch and the page stops at the pinned length", () => {
|
||
const fx = project();
|
||
const path = longSession(fx);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
appendFileSync(path, lines(user("g150", "g149", "later", 200)) + '{"type":"mess');
|
||
const r2 = noWrites(fx.base, () => reader.next({ cursor: r.cursor.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||
assert.equal(r2.ok, true);
|
||
assert.equal(r2.page.entries.length, 50);
|
||
assert.equal(r2.page.snapshotDigest, r.page.snapshotDigest);
|
||
assert.equal(r2.page.sourceEpoch, r.page.sourceEpoch);
|
||
assert.equal(r2.view.incomplete, false, "the pinned snapshot ended on a complete line");
|
||
assert.ok(!texts(r2.page).includes("later"));
|
||
// Following picks up the growth, up to the last complete line.
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: r2.follow.id, conversation: r2.page.conversation, branch: r2.page.branch }));
|
||
assert.equal(f.ok, true);
|
||
assert.deepEqual(texts(f.page), ["later"]);
|
||
assert.equal(f.page.sourceEpoch, r.page.sourceEpoch);
|
||
assert.notEqual(f.page.snapshotDigest, r.page.snapshotDigest);
|
||
assert.equal(f.page.branch, "main");
|
||
assert.equal(f.view.incomplete, true);
|
||
// A read of a file that grows by a partial line cuts at the last newline.
|
||
const again = openOne(fx, reader);
|
||
assert.equal(again.view.incomplete, true);
|
||
const all = readAll(fx, reader, again).flatMap((p) => texts(p.page));
|
||
assert.equal(all.length, 151);
|
||
});
|
||
|
||
test("F6: unknown, foreign and expired cursors refuse and leave the cursor usable", () => {
|
||
const fx = project();
|
||
longSession(fx);
|
||
let clock = Date.parse("2026-09-26T12:00:00Z");
|
||
const reader = readerFor(fx, { now: () => clock, ttlMs: 60_000 });
|
||
const r = openOne(fx, reader);
|
||
const good = { cursor: r.cursor.id, conversation: r.page.conversation, branch: r.page.branch };
|
||
const cases = [
|
||
[{ ...good, cursor: "c-unknown" }, "cursor-unknown"],
|
||
[{ ...good, actor: "someone-else" }, "cursor-foreign"],
|
||
[{ ...good, purpose: "drafts" }, "cursor-foreign"],
|
||
[{ ...good, conversation: "pi-" + "0".repeat(32) }, "cursor-foreign"],
|
||
[{ ...good, branch: "g1" }, "cursor-foreign"],
|
||
];
|
||
for (const [args, code] of cases) {
|
||
const x = noWrites(fx.base, () => reader.next(args));
|
||
assert.deepEqual([x.ok, x.refusal.code, x.refusal.reconcile], [false, code, true], code);
|
||
}
|
||
const ok = noWrites(fx.base, () => reader.next(good));
|
||
assert.equal(ok.ok, true, "the old view's cursor still works after the refusals");
|
||
clock += 60_000;
|
||
const expired = noWrites(fx.base, () => reader.next(good));
|
||
assert.deepEqual([expired.ok, expired.refusal.code, expired.refusal.reconcile], [false, "cursor-expired", true]);
|
||
const gone = noWrites(fx.base, () => reader.next(good));
|
||
assert.equal(gone.refusal.code, "cursor-unknown");
|
||
// Evicted cursors are unknown.
|
||
const small = readerFor(fx, { maxCursors: 2 });
|
||
const first = openOne(fx, small);
|
||
openOne(fx, small);
|
||
openOne(fx, small);
|
||
const evicted = noWrites(fx.base, () => small.next({ cursor: first.cursor.id, conversation: first.page.conversation, branch: first.page.branch }));
|
||
assert.equal(evicted.refusal.code, "cursor-unknown");
|
||
assert.ok(small.cursorCount() <= 2);
|
||
// open refuses other actors and purposes outright.
|
||
assert.equal(openOne(fx, reader, "s.jsonl", { actor: "x" }).refusal.code, "unknown-actor");
|
||
assert.equal(openOne(fx, reader, "s.jsonl", { purpose: "drafts" }).refusal.code, "unsupported-purpose");
|
||
});
|
||
|
||
// A file outside every root that no read may touch: unreadable, so an
|
||
// attempted open would throw EACCES rather than refuse.
|
||
function forbidden(base, name = "outside.jsonl") {
|
||
const dir = join(base, "outside");
|
||
mkdirSync(dir, { recursive: true });
|
||
const path = join(dir, name);
|
||
writeFileSync(path, lines(header("/elsewhere"), user("z1", null, "secret", 1)));
|
||
chmodSync(path, 0o000);
|
||
return path;
|
||
}
|
||
|
||
test("F7: a symlinked file and a symlinked directory component are refused, never opened", () => {
|
||
const fx = project();
|
||
const outside = forbidden(fx.base);
|
||
symlinkSync(outside, join(fx.dir, "link.jsonl"));
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("i1", null, "ok", 1)]);
|
||
const reader = readerFor(fx);
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
const link = cat.conversations.find((c) => c.availability === "denied");
|
||
assert.deepEqual([link.refusal, link.title], ["unsafe-path", null]);
|
||
const r = openOne(fx, reader, "link.jsonl");
|
||
assert.deepEqual([r.ok, r.refusal.code], [false, "unsafe-path"]);
|
||
assert.equal(cat.conversations.filter((c) => c.availability === "available").length, 1);
|
||
|
||
// .pi/state/<seat> is a symlink to a directory holding a sessions dir.
|
||
const fy = project("other");
|
||
const realSeat = join(fy.base, "real-seat");
|
||
mkdirSync(join(realSeat, "sessions"), { recursive: true });
|
||
writeFileSync(join(realSeat, "sessions", "s.jsonl"), lines(header(fy.proj), user("j1", null, "hidden", 1)));
|
||
chmodSync(join(realSeat, "sessions", "s.jsonl"), 0o000);
|
||
mkdirSync(join(fy.proj, ".pi", "state"), { recursive: true });
|
||
symlinkSync(realSeat, join(fy.proj, ".pi", "state", "linked"));
|
||
const spec = { agent: "linked", project: "proj", sessionsDir: join(fy.proj, ".pi", "state", "linked", "sessions") };
|
||
const reader2 = createReader({ roots: rootsFromSpecs([spec]) });
|
||
const cat2 = noWrites(fy.base, () => reader2.catalogue());
|
||
assert.deepEqual(cat2.conversations, []);
|
||
assert.deepEqual(cat2.refusedRoots, [{ seat: "linked", project: "proj", refusal: "unsafe-path" }]);
|
||
const root = rootsFromSpecs([spec])[0];
|
||
const r2 = noWrites(fy.base, () => reader2.open({ conversation: conversationId(root, "s.jsonl") }));
|
||
assert.deepEqual([r2.ok, r2.refusal.code], [false, "unknown-conversation"]);
|
||
});
|
||
|
||
test("F8: a file swapped for a symlink after the catalogue is refused", () => {
|
||
const fx = project();
|
||
const outside = forbidden(fx.base);
|
||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("k1", null, "ok", 1)]);
|
||
const reader = readerFor(fx);
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
const r = openOne(fx, reader);
|
||
assert.equal(r.ok, true);
|
||
rmSync(path);
|
||
symlinkSync(outside, path);
|
||
const x = noWrites(fx.base, () => reader.open({ conversation: cat.conversations[0].conversation }));
|
||
assert.deepEqual([x.ok, x.refusal.code], [false, "unsafe-path"]);
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: r.page.branch }));
|
||
assert.deepEqual([f.ok, f.refusal.code], [false, "unsafe-path"]);
|
||
});
|
||
|
||
test("F9: registrations never add or redirect a root", () => {
|
||
const fx = project("seat-a");
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("l1", null, "ok", 1)]);
|
||
const outside = forbidden(fx.base);
|
||
const other = project("seat-a");
|
||
writeSession(other.dir, "o.jsonl", header(other.proj), [user("l2", null, "other project", 1)]);
|
||
const linkDir = join(fx.base, "link-to-other");
|
||
symlinkSync(other.dir, linkDir);
|
||
const reg = (extra) => ({ version: 1, seat: "seat-a", project: "proj", layout: "repo", harness: "claude-code", startedAt: time(0), ...extra });
|
||
const registrations = [
|
||
reg({ sessionsDir: join(fx.base, "outside") }), // outside every root
|
||
reg({ sessionsDir: linkDir }), // a symlink to another project's root
|
||
reg({ sessionsDir: other.dir }), // another project's directory
|
||
reg({ sessionsDir: fx.dir, project: "someone-else" }), // this directory, another project
|
||
reg({ seat: "seat-b", sessionsDir: join(fx.base, "outside") }), // a non-Pi placeholder outside the project
|
||
];
|
||
const roots = rootsFromSpecs([fx.spec], registrations);
|
||
assert.deepEqual(roots.map((r) => [r.seat, r.dir, r.harness, r.unsupportedReason]), [["seat-a", fx.dir, "pi", null]]);
|
||
const reader = createReader({ roots });
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
assert.deepEqual(cat.conversations.map((c) => [c.title, c.availability]), [["ok", "available"]]);
|
||
assert.ok(outside);
|
||
// A registration that matches the root narrows it and supplies launch time.
|
||
const matched = rootsFromSpecs([fx.spec], [reg({ harness: "pi", sessionsDir: fx.dir, startedAt: time(-5) })]);
|
||
assert.equal(matched[0].engineStartedAt, time(-5));
|
||
const cat2 = noWrites(fx.base, () => createReader({ roots: matched }).catalogue());
|
||
assert.equal(cat2.conversations[0].engineStartedAt, time(-5));
|
||
// Only repository specs are roots.
|
||
assert.deepEqual(rootsFromSpecs([
|
||
{ agent: "x", project: "fleet", sessionsDir: join(fx.base, "fleet", "x", ".pi", "agent", "sessions") },
|
||
{ agent: "seat-a", project: "wrong", sessionsDir: fx.dir },
|
||
{ agent: "seat-b", project: "proj", sessionsDir: fx.dir },
|
||
{ ...fx.spec, connector: "discord" },
|
||
]), []);
|
||
});
|
||
|
||
test("F10: a header cwd naming another project is refused", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(join(fx.base, "another-project")), [user("m1", null, "x", 1)]);
|
||
writeSession(fx.dir, "sub.jsonl", header(join(fx.proj, "packages", "x")), [user("m2", null, "sub", 1)]);
|
||
writeSession(fx.dir, "prefix.jsonl", header(fx.proj + "-evil"), [user("m3", null, "x", 1)]);
|
||
// Relative to the reader's own directory this names the project; it is still refused.
|
||
writeSession(fx.dir, "relative.jsonl", header(relative(process.cwd(), join(fx.proj, "packages")) || "."), [user("m5", null, "x", 1)]);
|
||
const linkRoot = join(fx.base, "compat");
|
||
symlinkSync(fx.proj, linkRoot);
|
||
writeSession(fx.dir, "link.jsonl", header(linkRoot), [user("m4", null, "via compat link", 1)]);
|
||
const reader = readerFor(fx);
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
const by = Object.fromEntries(cat.conversations.map((c) => [c.conversation, c]));
|
||
const root = rootsFromSpecs([fx.spec])[0];
|
||
for (const name of ["s.jsonl", "prefix.jsonl", "relative.jsonl"]) {
|
||
assert.deepEqual([by[conversationId(root, name)].availability, by[conversationId(root, name)].refusal], ["denied", "foreign-project"], name);
|
||
assert.deepEqual(openOne(fx, reader, name).refusal.code, "foreign-project", name);
|
||
}
|
||
assert.equal(openOne(fx, reader, "sub.jsonl").ok, true);
|
||
assert.equal(openOne(fx, reader, "link.jsonl").ok, true);
|
||
assert.equal(cwdInProject(undefined, fx.proj), false);
|
||
});
|
||
|
||
test("F11: parentSession renders with a marker and the parent is never opened", () => {
|
||
const fx = project();
|
||
const parent = forbidden(fx.base, "parent.jsonl");
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj, { parentSession: parent }), [user("n1", null, "forked", 1)]);
|
||
const r = openOne(fx, readerFor(fx));
|
||
assert.equal(r.ok, true);
|
||
assert.deepEqual(roles(r.page), ["notice", "user"]);
|
||
assert.match(texts(r.page)[0], /forked from an earlier session/);
|
||
assert.equal(r.view.forkedFromEarlierSession, true);
|
||
});
|
||
|
||
test("F12: two leaves: the default leaf is shown and the other branch reads alone", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("o1", null, "root", 1),
|
||
assistant("o2", "o1", "first answer", 2),
|
||
user("o3", "o2", "on branch one", 3),
|
||
assistant("o4", "o1", "second answer", 4),
|
||
]);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
// o1's earliest child continues "main"; the later child o4 starts "b.o4".
|
||
assert.deepEqual(texts(r.page), ["root", "second answer"]);
|
||
assert.equal(r.page.branch, "b.o4");
|
||
assert.deepEqual(r.view.branches.map((b) => [b.branch, b.isDefault]), [["main", false], ["b.o4", true]]);
|
||
const other = openOne(fx, reader, "s.jsonl", { branch: "main" });
|
||
assert.deepEqual(texts(other.page), ["root", "first answer", "on branch one"]);
|
||
assert.equal(other.page.branch, "main");
|
||
assert.ok(other.page.entries.every((e) => e.branch === "main"));
|
||
for (const inner of ["o2", "b.o2", "o3", "b.o3", "o4"]) assert.equal(openOne(fx, reader, "s.jsonl", { branch: inner }).refusal.code, "unknown-branch", `${inner} is not a branch name`);
|
||
// Following a non-default branch after the default moves on adds nothing.
|
||
const path = join(fx.dir, "s.jsonl");
|
||
appendFileSync(path, lines(user("o5", "o4", "more on default", 5)));
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: other.follow.id, conversation: other.page.conversation, branch: "main" }));
|
||
assert.deepEqual([f.ok, f.page.entries.length, f.page.branch, f.view.defaultBranch], [true, 0, "main", "b.o4"]);
|
||
// Following the default branch continues on it under the same name.
|
||
const g = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: "b.o4" }));
|
||
assert.deepEqual([texts(g.page), g.page.branch, g.view.defaultBranch], [["more on default"], "b.o4", "b.o4"]);
|
||
assert.ok([...r.page.entries, ...g.page.entries].every((e) => e.branch === "b.o4"), "one branch value across follows");
|
||
// The pre-growth name still opens, now with the growth.
|
||
assert.deepEqual(texts(openOne(fx, reader, "s.jsonl", { branch: "b.o4" }).page), ["root", "second answer", "more on default"]);
|
||
// A new default elsewhere: the old default view stays put.
|
||
appendFileSync(path, lines(assistant("o6", "o3", "back on branch one", 6)));
|
||
const h = noWrites(fx.base, () => reader.next({ cursor: g.follow.id, conversation: g.page.conversation, branch: "b.o4" }));
|
||
assert.deepEqual([h.page.entries.length, h.page.branch, h.view.defaultBranch], [0, "b.o4", "main"]);
|
||
// And the "main" view picks its growth up.
|
||
const k = noWrites(fx.base, () => reader.next({ cursor: f.follow.id, conversation: f.page.conversation, branch: "main" }));
|
||
assert.deepEqual([texts(k.page), k.page.branch], [["back on branch one"], "main"]);
|
||
});
|
||
|
||
test("F12: a follow refuses when an appended duplicate id changes the branch's earlier parts", () => {
|
||
// Where an id repeats the later entry wins, so an append can move an entry.
|
||
for (const [extra, why] of [
|
||
[[{ ...assistant("r2", "x1", "moved", 4) }], "r2 moves to the other root: main is shorter"],
|
||
[[{ ...assistant("r2", "x1", "moved", 4) }, assistant("r5", "r1", "in its place", 5)], "same length, another entry in r2's place"],
|
||
]) {
|
||
const fx = project();
|
||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("r1", null, "one", 1), user("x1", null, "other root", 2), assistant("r2", "r1", "two", 3)]);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader, "s.jsonl", { branch: "main" });
|
||
assert.deepEqual(texts(r.page), ["one", "two"]);
|
||
appendFileSync(path, lines(...extra));
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: "main" }));
|
||
assert.deepEqual([f.ok, f.refusal?.code, f.refusal?.reconcile], [false, "source-replaced", true], why);
|
||
}
|
||
});
|
||
|
||
test("F12: a second root (Pi's resetLeaf) starts its own branch", () => {
|
||
const fx = project();
|
||
const path = writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("r1", null, "first root", 1),
|
||
assistant("r2", "r1", "first reply", 2),
|
||
user("r3", null, "second root", 3),
|
||
]);
|
||
const reader = readerFor(fx);
|
||
const r = openOne(fx, reader);
|
||
assert.deepEqual([texts(r.page), r.page.branch], [["second root"], "b.r3"]);
|
||
assert.deepEqual(r.view.branches.map((b) => [b.branch, b.isDefault]), [["main", false], ["b.r3", true]]);
|
||
assert.deepEqual(texts(openOne(fx, reader, "s.jsonl", { branch: "main" }).page), ["first root", "first reply"]);
|
||
appendFileSync(path, lines(assistant("r4", "r3", "second reply", 4)));
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: r.follow.id, conversation: r.page.conversation, branch: "b.r3" }));
|
||
assert.deepEqual([texts(f.page), f.page.branch], [["second reply"], "b.r3"]);
|
||
});
|
||
|
||
test("F13: compaction is a marker in place, then the retained content", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [
|
||
user("p1", null, "early", 1),
|
||
assistant("p2", "p1", "early answer", 2),
|
||
{ type: "compaction", id: "p3", parentId: "p2", timestamp: time(3), summary: "they talked", firstKeptEntryId: "p2", tokensBefore: 1000 },
|
||
user("p4", "p3", "after", 4),
|
||
]);
|
||
const r = openOne(fx, readerFor(fx));
|
||
assert.deepEqual(roles(r.page), ["user", "assistant", "compaction", "user"]);
|
||
const marker = r.page.entries[2].content[0];
|
||
assert.deepEqual([marker.type, marker.summary, marker.nativeEntry], ["compaction", "they talked", "p3"]);
|
||
});
|
||
|
||
test("F14: long strings split into fragments and parts, reassemble exactly, and pages respect the byte cap", () => {
|
||
const fx = project();
|
||
const long = "x".repeat(LIMITS.chars + 1);
|
||
const emoji = "😀".repeat(LIMITS.chars + 3); // 4 bytes each: splits on bytes before characters
|
||
const blocks = Array.from({ length: 70 }, (_, i) => ({ type: "text", text: `block ${i}` }));
|
||
const wide = "€".repeat(200_000); // 3 bytes each, ~600 KB per part
|
||
const entries = [
|
||
user("q1", null, long, 1),
|
||
assistant("q2", "q1", emoji, 2),
|
||
msg("q3", "q2", "assistant", blocks, { stopReason: "stop" }, 3),
|
||
];
|
||
for (let i = 4; i < 44; i++) entries.push(user(`q${i}`, `q${i - 1}`, wide, i));
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), entries);
|
||
const reader = readerFor(fx);
|
||
const pages = readAll(fx, reader, openOne(fx, reader));
|
||
for (const p of pages) {
|
||
assert.ok(p.page.entries.length <= LIMITS.parts);
|
||
assert.ok(Buffer.byteLength(JSON.stringify(p.page), "utf8") <= LIMITS.pageBytes, "page within 8 MiB");
|
||
for (const e of p.page.entries) {
|
||
assert.ok(e.content.length <= LIMITS.blocks);
|
||
for (const b of e.content) assert.ok([...(b.text ?? "")].length <= LIMITS.chars);
|
||
}
|
||
}
|
||
assert.ok(pages.length >= 3, "the multibyte parts fill pages on bytes before 100 parts");
|
||
assert.ok(pages[1].page.entries.length < LIMITS.parts);
|
||
// Reassemble per native entry: parts in order, fragments in order.
|
||
const all = pages.flatMap((p) => p.page.entries);
|
||
const rebuilt = new Map();
|
||
for (const e of all) {
|
||
const blocksOf = rebuilt.get(e.nativeEntry) ?? [];
|
||
for (const b of e.content) blocksOf[b.block] = (blocksOf[b.block] ?? "") + b.text;
|
||
rebuilt.set(e.nativeEntry, blocksOf);
|
||
}
|
||
assert.equal(rebuilt.get("q1")[0], long);
|
||
assert.equal(rebuilt.get("q2")[0], emoji);
|
||
assert.deepEqual(rebuilt.get("q3"), blocks.map((b) => b.text));
|
||
assert.equal(rebuilt.get("q20")[0], wide);
|
||
const q1 = all.filter((e) => e.nativeEntry === "q1");
|
||
assert.deepEqual(q1.flatMap((e) => e.content.map((b) => [b.fragment, b.lastFragment])), [[0, false], [1, true]]);
|
||
const q3 = all.filter((e) => e.nativeEntry === "q3");
|
||
assert.deepEqual(q3.map((e) => [e.part, e.lastPart, e.content.length]), [[0, false, 64], [1, true, 6]]);
|
||
assert.equal(new Set(all.map((e) => e.id)).size, all.length);
|
||
});
|
||
|
||
test("fragments never cut a surrogate pair and keep an empty string", () => {
|
||
assert.deepEqual(fragments(""), [""]);
|
||
const s = "a" + "😀".repeat(LIMITS.chars);
|
||
const parts = fragments(s);
|
||
assert.equal(parts.join(""), s);
|
||
for (const p of parts) {
|
||
assert.ok(!/^[\udc00-\udfff]/.test(p) && !/[\ud800-\udbff]$/.test(p));
|
||
assert.ok(Buffer.byteLength(JSON.stringify(p)) <= 1024 * 1024 + 2);
|
||
}
|
||
});
|
||
|
||
test("F15: a Claude seat is an unsupported-harness placeholder whose directory is never read", () => {
|
||
const fx = project("pi-seat");
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("r1", null, "ok", 1)]);
|
||
// Rocko's case: a claude-code registration and no Pi sessions directory.
|
||
const claudeDir = join(fx.proj, ".pi", "state", "claude-seat", "sessions");
|
||
const regs = [{ version: 1, seat: "claude-seat", project: "proj", layout: "repo", harness: "claude-code", sessionsDir: claudeDir, startedAt: time(-1) }];
|
||
// And a spec'd seat with sessions whose registration says claude-code.
|
||
const both = join(fx.proj, ".pi", "state", "mixed", "sessions");
|
||
mkdirSync(both, { recursive: true });
|
||
writeFileSync(join(both, "s.jsonl"), lines(header(fx.proj), user("r2", null, "never read", 1)));
|
||
chmodSync(join(both, "s.jsonl"), 0o000);
|
||
regs.push({ version: 1, seat: "mixed", project: "proj", layout: "repo", harness: "claude-code", sessionsDir: both, startedAt: time(-1) });
|
||
const roots = rootsFromSpecs([fx.spec, { agent: "mixed", project: "proj", sessionsDir: both }], regs);
|
||
const reader = createReader({ roots });
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
const unsupported = cat.conversations.filter((c) => c.availability === "unsupported");
|
||
assert.deepEqual(unsupported.map((c) => [c.seat, c.harness, c.unsupportedReason, c.history]).sort(), [["claude-seat", "claude-code", UNSUPPORTED_HARNESS, null], ["mixed", "claude-code", UNSUPPORTED_HARNESS, null]]);
|
||
for (const c of unsupported) {
|
||
const r = noWrites(fx.base, () => reader.open({ conversation: c.conversation }));
|
||
assert.deepEqual([r.ok, r.refusal.code, r.refusal.reconcile], [false, UNSUPPORTED_HARNESS, false]);
|
||
}
|
||
});
|
||
|
||
test("unknown conversations, empty files and non-Pi files refuse", () => {
|
||
const fx = project();
|
||
writeFileSync(join(fx.dir, "empty.jsonl"), "");
|
||
writeFileSync(join(fx.dir, "notpi.jsonl"), lines({ hello: 1 }));
|
||
writeFileSync(join(fx.dir, "header-only.jsonl"), lines(header(fx.proj)));
|
||
const reader = readerFor(fx);
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
assert.deepEqual(cat.conversations.map((c) => [c.availability, c.refusal]).sort(), [["available", null], ["unavailable", "incomplete-header"], ["unavailable", "not-a-pi-session"]]);
|
||
assert.equal(openOne(fx, reader, "empty.jsonl").refusal.code, "incomplete-header");
|
||
assert.equal(openOne(fx, reader, "notpi.jsonl").refusal.code, "not-a-pi-session");
|
||
const empty = openOne(fx, reader, "header-only.jsonl");
|
||
assert.deepEqual([empty.ok, empty.page.entries.length, empty.page.branch, empty.view.branches], [true, 0, "main", [{ branch: "main", isDefault: true, lastActivityAt: null }]]);
|
||
appendFileSync(join(fx.dir, "header-only.jsonl"), lines(user("s1", null, "first", 1)));
|
||
const f = noWrites(fx.base, () => reader.next({ cursor: empty.follow.id, conversation: empty.page.conversation, branch: "main" }));
|
||
assert.deepEqual([texts(f.page), f.page.branch], [["first"], "main"]);
|
||
const u = noWrites(fx.base, () => reader.open({ conversation: "pi-" + "f".repeat(32) }));
|
||
assert.deepEqual([u.refusal.code, u.refusal.reconcile], ["unknown-conversation", true]);
|
||
});
|
||
|
||
test("an unreadable file or root inside the roots is refused per row, not a failed catalogue", () => {
|
||
const fx = project();
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("u1", null, "ok", 1)]);
|
||
writeSession(fx.dir, "locked.jsonl", header(fx.proj), [user("u2", null, "locked", 1)]);
|
||
chmodSync(join(fx.dir, "locked.jsonl"), 0o000);
|
||
const reader = readerFor(fx);
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
assert.deepEqual(cat.conversations.map((c) => [c.availability, c.refusal]).sort(), [["available", null], ["denied", "unreadable"]]);
|
||
assert.equal(openOne(fx, reader, "locked.jsonl").refusal.code, "unreadable");
|
||
chmodSync(fx.dir, 0o300);
|
||
try {
|
||
const cat2 = noWrites(fx.base, () => reader.catalogue());
|
||
assert.deepEqual([cat2.conversations, cat2.refusedRoots.map((r) => r.refusal)], [[], ["unreadable"]]);
|
||
} finally {
|
||
chmodSync(fx.dir, 0o700);
|
||
}
|
||
});
|
||
|
||
test("a seat directory without search permission refuses that root, not the catalogue", () => {
|
||
const fx = project("good");
|
||
writeSession(fx.dir, "s.jsonl", header(fx.proj), [user("p1", null, "readable", 1)]);
|
||
const badDir = join(fx.proj, ".pi", "state", "bad", "sessions");
|
||
mkdirSync(badDir, { recursive: true });
|
||
writeSession(badDir, "s.jsonl", header(fx.proj), [user("q1", null, "hidden", 1)]);
|
||
const bad = { agent: "bad", project: "proj", sessionsDir: badDir };
|
||
const goodId = conversationId(rootsFromSpecs([fx.spec])[0], "s.jsonl");
|
||
chmodSync(join(fx.proj, ".pi", "state", "bad"), 0o000);
|
||
try {
|
||
for (const specs of [[bad, fx.spec], [fx.spec, bad]]) {
|
||
const reader = createReader({ roots: rootsFromSpecs(specs) });
|
||
const cat = noWrites(fx.base, () => reader.catalogue());
|
||
assert.deepEqual([cat.conversations.map((c) => [c.seat, c.availability]), cat.refusedRoots], [[["good", "available"]], [{ seat: "bad", project: "proj", refusal: "unreadable" }]]);
|
||
const r = noWrites(fx.base, () => reader.open({ conversation: goodId }));
|
||
assert.deepEqual(texts(r.page), ["readable"]);
|
||
const u = noWrites(fx.base, () => reader.open({ conversation: "pi-" + "e".repeat(32) }));
|
||
assert.equal(u.refusal.code, "unknown-conversation");
|
||
}
|
||
} finally {
|
||
chmodSync(join(fx.proj, ".pi", "state", "bad"), 0o700);
|
||
}
|
||
});
|
||
|
||
test("every page and cursor is a valid CHAT-01 record", () => {
|
||
assert.ok(records.length > 50);
|
||
const script = `
|
||
import json, sys
|
||
from jsonschema import Draft202012Validator, FormatChecker
|
||
s = json.load(open(sys.argv[1]))
|
||
bad = []
|
||
for i, r in enumerate(json.load(sys.stdin)):
|
||
v = Draft202012Validator({"$defs": s["$defs"], "$ref": "#/$defs/" + r["kind"]}, format_checker=FormatChecker())
|
||
for e in v.iter_errors(r):
|
||
bad.append(f"{i} {r['kind']}: {e.message[:200]}")
|
||
break
|
||
print(json.dumps(bad))
|
||
`;
|
||
const run = spawnSync("python3", ["-c", script, schemaPath], { input: JSON.stringify(records), encoding: "utf8", maxBuffer: 1 << 30 });
|
||
assert.equal(run.status, 0, run.stderr);
|
||
assert.deepEqual(JSON.parse(run.stdout), []);
|
||
});
|