返回 DeepSeek-Reasonix
transcript-store.test.ts
根目录 / desktop / frontend / src / __tests__ / transcript-store.test.ts
1 import { FakeBackend, type RefTable } from "./helpers/transcriptFakeBackend";
2 // Run: tsx src/__tests__/transcript-store.test.ts
3 //
4 // TranscriptStore unit tests over a fake slice backend: stable ids, page
5 // concatenation fidelity vs the single-shot conversion, weighted LRU
6 // eviction, generation-bound request discard, stale cursors, lazy content
7 // refs, and the markdown cache budget.
8
9 import { TranscriptStore } from "../lib/transcriptStore";
10 import { verifyTranscriptContentOwnership } from "./helpers/transcriptContentOwnership";
11 import { historyPageRequestBudget } from "../lib/historyPaging";
12 import { historyMessagesToItems, type Item } from "../lib/useController";
13 import type {
14 HistoryContentChunk,
15 HistoryMessage,
16 HistorySlice,
17 } from "../lib/types";
18
19 let passed = 0;
20 let failed = 0;
21
22 function ok(value: boolean, label: string) {
23 if (value) {
24 process.stdout.write(` PASS ${label}\n`);
25 passed += 1;
26 } else {
27 process.stdout.write(` FAIL ${label}\n`);
28 failed += 1;
29 }
30 }
31
32 function eq(actual: unknown, expected: unknown, label: string) {
33 ok(actual === expected, `${label}${actual === expected ? "" : `: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`}`);
34 }
35
36 function deferred<T>() {
37 let resolve!: (value: T) => void;
38 let reject!: (reason?: unknown) => void;
39 const promise = new Promise<T>((res, rej) => {
40 resolve = res;
41 reject = rej;
42 });
43 return { promise, resolve, reject };
44 }
45
46 // ── fake slice backend ──────────────────────────────────────────────────────
47
48
49
50 // ── fixtures ────────────────────────────────────────────────────────────────
51
52 function bigTranscript(turns: number): HistoryMessage[] {
53 const messages: HistoryMessage[] = [];
54 for (let i = 0; i < turns; i += 1) {
55 messages.push({ role: "user", content: `prompt ${i}` });
56 messages.push({ role: "assistant", content: `answer ${i}`, reasoning: `think ${i}` });
57 messages.push({
58 role: "assistant",
59 content: "",
60 toolCalls: [
61 { id: `c${i}a`, name: "read_file", arguments: `{"path":"f${i}"}` },
62 { id: `c${i}b`, name: "bash", arguments: `echo ${i}` },
63 ],
64 });
65 messages.push({ role: "tool", toolCallId: `c${i}a`, toolName: "read_file", content: `read result ${i}` });
66 messages.push({ role: "tool", toolCallId: `c${i}b`, toolName: "bash", content: `bash output ${i}` });
67 if (i % 4 === 1) {
68 // Positional (id-less) call/result pair.
69 messages.push({ role: "assistant", content: "", toolCalls: [{ id: "", name: "grep", arguments: `needle ${i}` }] });
70 messages.push({ role: "tool", toolName: "grep", content: `grep output ${i}` });
71 }
72 if (i % 3 === 0) messages.push({ role: "phase", content: `phase ${i}` });
73 if (i % 5 === 2) messages.push({ role: "notice", level: "info", content: `note ${i}` });
74 if (i % 7 === 3) messages.push({ role: "compaction", content: "", trigger: "auto", messages: 12, summary: `sum ${i}`, archive: `arch ${i}` });
75 }
76 return messages;
77 }
78
79 function longArchivedTranscript(turns: number): HistoryMessage[] {
80 const messages: HistoryMessage[] = [];
81 for (let turn = 1; turn <= turns; turn += 1) {
82 const callId = `archived-${turn}`;
83 messages.push({ role: "user", content: `prompt ${turn}` });
84 messages.push({
85 role: "assistant",
86 content: `answer ${turn}`,
87 toolCalls: [{
88 id: callId,
89 name: "bash",
90 arguments: "",
91 argumentsArchived: true,
92 subject: `command ${turn}`,
93 summary: "1 line",
94 }],
95 });
96 messages.push({
97 role: "tool",
98 toolCallId: callId,
99 toolName: "bash",
100 content: "",
101 toolResultArchived: true,
102 });
103 }
104 return messages;
105 }
106
107 // Canonical shape for cross-scheme equality (ids are scheme-dependent and
108 // verified separately).
109 function canon(items: Item[]): unknown[] {
110 return items.map((it) => {
111 switch (it.kind) {
112 case "user": return ["user", it.text, it.submitText ?? null];
113 case "assistant": return ["assistant", it.text, it.reasoning];
114 case "phase": return ["phase", it.text];
115 case "notice": return ["notice", it.level, it.text, it.detail ?? null];
116 case "compaction": return ["compaction", it.trigger, it.summary, it.archive];
117 case "tool": return ["tool", it.name, it.args, it.output ?? null, it.error ?? null, it.status, it.subject ?? null, it.summary ?? null];
118 case "extension": return ["extension", it.surfaceKey];
119 }
120 });
121 }
122
123 function canonEqual(a: Item[], b: Item[]): boolean {
124 return JSON.stringify(canon(a)) === JSON.stringify(canon(b));
125 }
126
127 async function drainOlder(store: TranscriptStore, tabId: string, path: string, turns: number): Promise<void> {
128 for (let guard = 0; guard < 100; guard += 1) {
129 const result = await store.loadOlder(tabId, path, { turns });
130 if (!result || result.kind !== "prepend" || !result.hasOlder) return;
131 }
132 throw new Error("paging did not terminate");
133 }
134
135 console.log("\ntranscript store");
136
137 {
138 const store = new TranscriptStore(new FakeBackend([]));
139 const projection = store.installSlice("tool-alias", "/tool-alias", {
140 entries: [
141 { entryId: "m:user", turn: 1, order: 0, message: { role: "user", messageId: "user", content: "build" }, refs: [] },
142 { entryId: "m:owner", turn: 1, order: 1, message: { role: "assistant", messageId: "owner", content: "", toolCalls: [{ id: "call-1", name: "edit_file", arguments: '{"path":"blackhole.html"}' }] }, refs: [] },
143 { entryId: "m:result", turn: 1, order: 2, message: { role: "tool", messageId: "result", toolCallId: "call-1", toolName: "edit_file", content: "written", execution: { state: "completed", durationMs: 8 } }, refs: [] },
144 { entryId: "m:final", turn: 1, order: 3, message: { role: "assistant", messageId: "final", content: "done", turnFinal: true }, refs: [] },
145 { entryId: "tool:call-1", turn: 1, order: 4, message: { role: "tool", toolCallId: "call-1", toolName: "edit_file", content: "written" }, refs: [] },
146 ], nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false, totalTurns: 1, startTurn: 1, endTurn: 1,
147 revision: 1, revisionKnown: true, digest: "tool-alias", stale: false,
148 });
149 const tools = projection.items.filter(item => item.kind === "tool" && item.id === "call-1");
150 eq(tools.length, 1, "formal tool result and event alias project one stable node");
151 eq(tools[0]?.kind === "tool" && tools[0].args, '{"path":"blackhole.html"}', "coalescing preserves full call arguments");
152 eq(tools[0]?.kind === "tool" && tools[0].execution?.durationMs, 8, "coalescing preserves formal execution metadata");
153 ok(projection.items.findIndex(item => item.id === "call-1") < projection.items.findIndex(item => item.id === "m:final"), "tool node remains at its call position before the final answer");
154 }
155
156 {
157 const store = new TranscriptStore(new FakeBackend([]));
158 store.installSlice("alias-boundary", "/alias-boundary", {
159 entries: [
160 { entryId: "m:final", turn: 1, order: 2, message: { role: "assistant", messageId: "final", content: "done" }, refs: [] },
161 { entryId: "tool:boundary", turn: 1, order: 3, message: { role: "tool", toolCallId: "boundary", toolName: "bash", content: "preview" }, refs: [] },
162 ], nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false, totalTurns: 1, startTurn: 1, endTurn: 1,
163 revision: 1, revisionKnown: true, digest: "boundary", stale: false,
164 });
165 const projection = store.appendEntries("alias-boundary", "/alias-boundary", [
166 { entryId: "m:owner", turn: 1, order: 0, message: { role: "assistant", messageId: "owner", content: "", toolCalls: [{ id: "boundary", name: "bash", arguments: "echo ok" }] }, refs: [] },
167 { entryId: "m:result", turn: 1, order: 1, message: { role: "tool", messageId: "result", toolCallId: "boundary", toolName: "bash", content: "ok", execution: { state: "completed", exitCode: 0 } }, refs: [] },
168 ]);
169 eq(projection?.items.filter(item => item.id === "boundary").length, 1, "formal result crossing a window boundary supersedes its resident alias");
170 const tool = projection?.items.find((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool" && item.id === "boundary");
171 eq(tool?.args, "echo ok", "cross-boundary merge keeps call arguments");
172 eq(tool?.execution?.exitCode, 0, "cross-boundary merge adopts formal execution metadata");
173 ok((projection?.items.findIndex(item => item.id === "boundary") ?? -1) < (projection?.items.findIndex(item => item.id === "m:final") ?? -1), "cross-boundary merge restores call order");
174 }
175
176 {
177 const store = new TranscriptStore(new FakeBackend([]));
178 store.installSlice("identity-conflict", "/identity-conflict", {
179 entries: [
180 { entryId: "m:result-a", turn: 1, order: 0, message: { role: "tool", messageId: "result-a", toolCallId: "conflict", content: "a" }, refs: [] },
181 { entryId: "m:result-b", turn: 1, order: 1, message: { role: "tool", messageId: "result-b", toolCallId: "conflict", content: "b" }, refs: [] },
182 ], nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false, totalTurns: 1, startTurn: 1, endTurn: 1,
183 revision: 1, revisionKnown: true, digest: "conflict", stale: false,
184 });
185 eq(store.residentWindowEntries(), 2, "identity conflict does not silently discard a formal record");
186 }
187
188 {
189 const backend = new FakeBackend([]);
190 const canonical = JSON.stringify({ id: "owner", role: "assistant", content: "",
191 tool_calls: [{ id: "lazy-call", name: "write_file", arguments: '{"path":"late.html"}' }] });
192 backend.HistoryContentForTab = async (_tab, ref, chunk) => ({ entryId: ref.entryId, field: ref.field, chunk,
193 chunks: 1, data: canonical, done: true, stale: false });
194 const store = new TranscriptStore(backend);
195 const changes: import("../lib/transcriptStore").TranscriptContentChange[] = [];
196 store.subscribe("lazy-owner", change => changes.push(change));
197 const before = store.installSlice("lazy-owner", "/lazy-owner", {
198 entries: [
199 { entryId: "m:owner", turn: 1, order: 0, message: { role: "assistant", messageId: "owner", content: "" },
200 refs: [{ entryId: "m:owner", field: "canonicalMessage", size: canonical.length, chunks: 1, revision: 1, digest: "lazy" }] },
201 { entryId: "m:final", turn: 1, order: 1, message: { role: "assistant", messageId: "final", content: "done", turnFinal: true }, refs: [] },
202 { entryId: "m:result", turn: 1, order: 2, message: { role: "tool", messageId: "result", toolCallId: "lazy-call", toolName: "write_file", content: "written" }, refs: [] },
203 ], nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false, totalTurns: 1, startTurn: 1, endTurn: 1,
204 revision: 1, revisionKnown: true, digest: "lazy", stale: false,
205 });
206 ok(before.items.findIndex(item => item.id === "lazy-call") > before.items.findIndex(item => item.id === "m:final"), "unresolved call initially leaves its result at the historical result position");
207 await store.requestFullContent("lazy-owner", "m:owner", "canonicalMessage");
208 const after = store.peek("lazy-owner", "/lazy-owner")!;
209 eq(after.items.filter(item => item.id === "lazy-call").length, 1, "lazy canonical body keeps one tool key after ownership changes");
210 ok(after.items.findIndex(item => item.id === "lazy-call") < after.items.findIndex(item => item.id === "m:final"), "lazy canonical body moves the tool to its call position");
211 eq(after.items.find(item => item.id === "lazy-call")?.kind === "tool" && (after.items.find(item => item.id === "lazy-call") as Extract<Item, { kind: "tool" }>).args,
212 '{"path":"late.html"}', "lazy canonical body restores full call arguments");
213 ok(Boolean(changes[changes.length - 1]?.projection), "lazy ownership changes publish one authoritative structural projection");
214 }
215
216 {
217 const messages: HistoryMessage[] = [
218 { role: "user", content: "read all" },
219 { role: "assistant", content: "candidate answer" },
220 { role: "notice", content: "", code: "incomplete_read", readPause: { id: "run", reads: [{ readId: "r", path: "fixture.txt", reason: "no_progress" }] } },
221 ];
222 const store = new TranscriptStore(new FakeBackend(messages));
223 const first = await store.loadLatest("read", "/read.jsonl", { turns: 12 });
224 const expected = historyMessagesToItems(messages, "history").items.find(i => i.kind === "notice");
225 const actual = first?.items.find(i => i.kind === "notice");
226 eq(JSON.stringify(actual), JSON.stringify(expected), "paged read pause equals live and legacy history presentation");
227 const replay = await store.loadLatest("read", "/read.jsonl", { turns: 12 });
228 eq(replay?.items.filter(i => i.kind === "notice").length, 1, "reloading a pause does not duplicate its card");
229 }
230
231 // ── page concatenation equals single-shot conversion ────────────────────────
232 // This is a conversion-fidelity property, not a residency one: paging a whole
233 // transcript in must project exactly what one single-shot conversion produces.
234 // The window is deliberately unbounded here so the comparison sees every page;
235 // the bounded-window behaviour is covered separately below.
236 {
237 const messages = bigTranscript(46);
238 const backend = new FakeBackend(messages);
239 const store = new TranscriptStore(backend, { windowMaxPages: 1_000 });
240 const first = await store.loadLatest("tab-1", "/s/one.jsonl", { turns: 12 });
241 ok(!!first && first.items.length > 0, "latest page projects items");
242 eq(first?.hasOlder, true, "latest page reports older history");
243 const projectedTurns = (first?.items ?? [])
244 .filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user")
245 .map((item) => item.historyTurn);
246 eq(projectedTurns[projectedTurns.length - 1], 46, "history user items retain their absolute turn for complete-session navigation");
247 ok(projectedTurns.every((turn) => Number.isInteger(turn) && (turn ?? 0) > 0), "every paged user item carries an absolute history turn");
248 const firstIds = (first?.items ?? []).map((item) => item.id);
249 await drainOlder(store, "tab-1", "/s/one.jsonl", 12);
250 const full = store.peek("tab-1", "/s/one.jsonl");
251 const singleShot = historyMessagesToItems(messages, "h").items;
252 ok(canonEqual(full?.items ?? [], singleShot), `paged concatenation equals single-shot conversion (${singleShot.length} items from ${messages.length} messages)`);
253 const fullIds = (full?.items ?? []).map((item) => item.id);
254 eq(JSON.stringify(fullIds.slice(fullIds.length - firstIds.length)), JSON.stringify(firstIds), "newest page item ids are stable across prepends");
255 const unique = new Set(fullIds);
256 eq(unique.size, fullIds.length, "item ids are unique across the full projection");
257 }
258
259 // ── 10,000-turn targeted paging stays bounded ──────────────────────────────
260 {
261 const messages = longArchivedTranscript(10_000);
262 const backend = new FakeBackend(messages, new Map(), "stress");
263 const store = new TranscriptStore(backend);
264 const startedAt = performance.now();
265 let projection = await store.loadLatest("tab-stress", "/s/stress.jsonl", { turns: 60 });
266 let pages = 1;
267 while (projection?.hasOlder && pages <= 40) {
268 const budget = historyPageRequestBudget(projection.startTurn, projection.totalTurns, 1);
269 const older = await store.loadOlder("tab-stress", "/s/stress.jsonl", budget);
270 if (!older) break;
271 projection = older;
272 pages += 1;
273 }
274 const elapsedMs = performance.now() - startedAt;
275 const users = (projection?.items ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
276 eq(projection?.hasOlder, false, "10,000-turn target paging reaches the first page");
277 eq(pages, 32, "10,000-turn target paging respects both turn and production entry bounds");
278 eq(backend.sliceCalls.length, 32, "10,000-turn target paging performs the expected bounded backend calls");
279 ok(backend.sliceCalls.slice(1).every((request) => request.entries === 1000), "targeted pages use the backend's bounded 1000-entry capacity");
280 eq(users[0]?.historyTurn, 1, "10,000-turn target paging lands on absolute turn one");
281 ok(new Set(projection?.items.map((item) => item.id)).size === projection?.items.length, "10,000-turn target paging keeps item ids unique");
282 const stats = store.stats();
283 ok(stats.bodyBytes <= stats.bodyBudgetBytes, "10,000-turn transcript stays within the production history body budget");
284 ok(elapsedMs < 10_000, `10,000-turn targeted paging completes within 10s (${elapsedMs.toFixed(1)}ms)`);
285
286 // Reading 32 pages deep leaves a bounded window, not the whole session. The
287 // reclaimed range is reported as still-newer rather than lost, and paging
288 // forward from it restores the tail — full reachability, bounded residency.
289 ok(stats.residentWindowEntries <= stats.windowMaxPages * 1000, `window residency is bounded (${stats.residentWindowEntries} entries, max ${stats.windowMaxPages * 1000})`);
290 ok(stats.reclaimedPages > 0, "deep paging reclaimed pages instead of holding every page");
291 eq(projection?.hasNewer, true, "the reclaimed tail is reported as still newer");
292 const forward = await store.loadNewer("tab-stress", "/s/stress.jsonl", { entries: 1000 });
293 eq(forward?.kind, "append", "paging forward appends into the same window");
294 const forwardUsers = (forward?.appendItems ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
295 ok(forwardUsers.length > 0, "paging forward restores newer history after a reclaim");
296 const lastForward = forwardUsers[forwardUsers.length - 1];
297 const lastExisting = users[users.length - 1];
298 ok((lastForward?.historyTurn ?? 0) > (lastExisting?.historyTurn ?? 0), "paging forward moves the window toward the live tail");
299 }
300
301 // ── cross-page tool call/result merge ───────────────────────────────────────
302 {
303 const messages: HistoryMessage[] = [
304 { role: "user", content: "p1" },
305 { role: "assistant", content: "", toolCalls: [{ id: "call-1", name: "bash", arguments: "ls" }] },
306 { role: "tool", toolCallId: "call-1", toolName: "bash", content: "/root" },
307 { role: "user", content: "p2" },
308 { role: "assistant", content: "done" },
309 ];
310 // Cut between the call and its result: newest page starts at the result row.
311 const backend = new FakeBackend(messages);
312 backend.HistorySliceForTab = async (tabID, req) => {
313 void tabID;
314 if (!req.cursor) return backend.slice(2, messages.length);
315 const decoded = JSON.parse(atob(req.cursor)) as { before?: number };
316 return backend.slice(0, Math.min(decoded.before ?? 0, 2));
317 };
318 const store = new TranscriptStore(backend);
319 const first = await store.loadLatest("tab-x", "/s/x.jsonl", { turns: 12 });
320 const standalone = (first?.items ?? []).filter((item) => item.kind === "tool");
321 eq(standalone.length, 1, "result row converts standalone before its call pages in");
322 eq(standalone[0]?.kind === "tool" && standalone[0].id, "call-1", "standalone result keeps the toolCallId item id");
323 const older = await store.loadOlder("tab-x", "/s/x.jsonl", { turns: 12 });
324 eq(older?.kind, "prepend", "older page prepends");
325 eq(older?.removeIds.length, 1, "the standalone result item is superseded by the merged call item");
326 const merged = (older?.items ?? []).filter((item) => item.kind === "tool");
327 eq(merged.length, 1, "exactly one tool item after the merge (no duplicate)");
328 const tool = merged[0]?.kind === "tool" ? merged[0] : undefined;
329 eq(tool?.args, "ls", "merged tool item takes the call's args");
330 eq(tool?.output, "/root", "merged tool item takes the result's output");
331 eq(tool?.status, "done", "merged tool item is done");
332 const singleShot = historyMessagesToItems(messages, "h").items;
333 ok(canonEqual(older?.items ?? [], singleShot), "merged projection equals single-shot conversion");
334 }
335
336 // ── append (live tail) ──────────────────────────────────────────────────────
337 {
338 const messages: HistoryMessage[] = [
339 { role: "user", content: "p1" },
340 { role: "assistant", content: "a1" },
341 ];
342 const backend = new FakeBackend(messages);
343 const store = new TranscriptStore(backend);
344 const first = await store.loadLatest("tab-a", "/s/a.jsonl", { turns: 12 });
345 const baseIds = (first?.items ?? []).map((item) => item.id);
346 const appended = store.appendEntries("tab-a", "/s/a.jsonl", [
347 { entryId: "s1:r0:m2:o0", turn: 2, order: 2, message: { role: "user", content: "p2" }, refs: [] },
348 { entryId: "s1:r0:m3:o0", turn: 2, order: 3, message: { role: "assistant", content: "a2" }, refs: [] },
349 ]);
350 eq(appended?.items.length, baseIds.length + 2, "append contributes the new rows' items");
351 const projection = store.peek("tab-a", "/s/a.jsonl");
352 eq(JSON.stringify((projection?.items ?? []).slice(0, baseIds.length).map((item) => item.id)), JSON.stringify(baseIds), "append keeps existing item ids");
353 eq(projection?.items.length, baseIds.length + 2, "append grows the projection");
354 }
355
356 // ── long-running live tail uses the same three-page residency budget ────────
357 {
358 const backend = new FakeBackend([{ role: "user", content: "seed" }, { role: "assistant", content: "seed answer" }]);
359 const store = new TranscriptStore(backend, { windowMaxPages: 3, windowPageEntries: 4 });
360 await store.loadLatest("tab-live", "/s/live.jsonl", { turns: 12 });
361 let reclaimed = 0;
362 for (let batch = 0; batch < 8; batch += 1) {
363 const turn = batch + 2;
364 const result = store.appendEntries("tab-live", "/s/live.jsonl", [
365 { entryId: `live-u-${turn}`, turn, order: turn * 2, message: { role: "user", content: `p${turn}` }, refs: [] },
366 { entryId: `live-a-${turn}`, turn, order: turn * 2 + 1, message: { role: "assistant", content: `a${turn}` }, refs: [] },
367 ]);
368 reclaimed += result?.removeIds.length ?? 0;
369 }
370 const projection = store.peek("tab-live", "/s/live.jsonl");
371 ok((projection?.items.length ?? 0) <= 12, "live tail remains inside three four-entry pages");
372 ok(reclaimed > 0, "live append reports mounted ids reclaimed from the oldest edge");
373 ok((projection?.startTurn ?? 0) > 1, "live window advances its visible start turn after reclaim");
374 eq(projection?.endTurn, 9, "live window retains the latest settled turn");
375 }
376
377 // ── weighted LRU: count, pin, byte budget, re-open ──────────────────────────
378 {
379 const store = new TranscriptStore(new FakeBackend([]));
380 store.installSlice("tab-aba", "/s/same.jsonl", {
381 entries: [{ entryId: "m:old", turn: 1, order: 0, message: { role: "user", content: "old generation" }, refs: [] }],
382 nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false,
383 startTurn: 1, endTurn: 1, totalTurns: 1, revision: 4, digest: "digest-v4", stale: false,
384 });
385 ok(Boolean(store.peek("tab-aba", "/s/same.jsonl", { revision: 4, digest: "digest-v4" })), "matching fingerprint serves the resident projection");
386 eq(store.peek("tab-aba", "/s/same.jsonl", { revision: 5, digest: "digest-v5" }), undefined, "same-path ABA fingerprint mismatch is a cache miss");
387 }
388
389 {
390 const store = new TranscriptStore(new FakeBackend([]));
391 const initial = store.installSlice("reader-v2", "/reader", {
392 entries: [{ entryId: "m:old", turn: 1, order: 0, message: { role: "user", content: "old reader page" }, refs: [] }],
393 nextCursor: "", newerCursor: "newer", hasOlder: false, hasNewer: true,
394 startTurn: 1, endTurn: 1, totalTurns: 100, revision: 1, digest: "generation", stale: false,
395 });
396 for (let sequence = 2; sequence < 130; sequence++) {
397 const updated = store.upsertEntries("reader-v2", "/reader", [{ entryId: `m:tail-${sequence}`, turn: sequence, order: sequence,
398 message: { role: "assistant", content: "committed tail" }, refs: [] }], sequence);
399 eq(updated?.items.length, initial.items.length, "distant commits do not replace or grow the reader window");
400 eq(updated?.items[0]?.id, initial.items[0]?.id, "reader anchor survives distant commits");
401 }
402 eq(store.peek("reader-v2", "/reader")?.hasNewer, true, "committed tail remains reachable by forward pagination");
403 }
404
405 {
406 const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
407 const store = new TranscriptStore(backend, { maxResidentSessions: 3 });
408 await store.loadLatest("tab-1", "/s/1.jsonl");
409 await store.loadLatest("tab-2", "/s/2.jsonl");
410 await store.loadLatest("tab-3", "/s/3.jsonl");
411 eq(store.residentSessionCount(), 3, "three sessions resident at the cap");
412 await store.loadLatest("tab-4", "/s/4.jsonl");
413 eq(store.isResident("tab-1", "/s/1.jsonl"), false, "fourth session evicts the least-recently-used one");
414 eq(store.isResident("tab-4", "/s/4.jsonl"), true, "new session stays resident");
415
416 store.setPinned("tab-2", true); // live/running owner cannot be evicted
417 await store.loadLatest("tab-5", "/s/5.jsonl");
418 eq(store.isResident("tab-3", "/s/3.jsonl"), false, "pinned windows consume capacity without being eviction candidates");
419 eq(store.residentSessionCount(), 3, "resident cap includes the pinned window");
420 await store.loadLatest("tab-6", "/s/6.jsonl");
421 eq(store.isResident("tab-2", "/s/2.jsonl"), true, "pinned live session survives eviction");
422 eq(store.isResident("tab-4", "/s/4.jsonl"), false, "oldest unpinned session evicts instead");
423 store.setPinned("tab-2", false);
424
425 const callsBeforeReopen = backend.sliceCalls.length;
426 const reopened = await store.loadLatest("tab-1", "/s/1.jsonl");
427 ok(backend.sliceCalls.length > callsBeforeReopen, "evicted session re-opens via a fresh slice fetch");
428 eq(reopened?.items.length, 2, "re-opened session restores its full projection");
429 }
430
431
432 {
433 const big = "x".repeat(600);
434 const backend = new FakeBackend([{ role: "user", content: big }, { role: "assistant", content: big }]);
435 const store = new TranscriptStore(backend, { maxResidentSessions: 10, historyBodyBudgetBytes: 4096 });
436 await store.loadLatest("tab-1", "/s/1.jsonl");
437 await store.loadLatest("tab-2", "/s/2.jsonl");
438 await store.loadLatest("tab-3", "/s/3.jsonl");
439 ok(store.totalBodyBytes() <= 4096, "history body budget holds across sessions");
440 eq(store.isResident("tab-1", "/s/1.jsonl"), false, "byte budget evicts the oldest by weight");
441 eq(store.isResident("tab-3", "/s/3.jsonl"), true, "newest session survives byte-budget eviction");
442 }
443
444 // ── markdown cache budget + LRU ─────────────────────────────────────────────
445 {
446 const store = new TranscriptStore(new FakeBackend([]), { markdownBudgetBytes: 120 });
447 const parsed = (text: string) => ({
448 source: text,
449 blocks: [],
450 selectionText: text,
451 selectionRevision: 1,
452 bytes: text.length * 2,
453 });
454 store.setMarkdown(1, parsed("a".repeat(20))); // 40 bytes
455 store.setMarkdown(2, parsed("b".repeat(20)));
456 store.setMarkdown(3, parsed("c".repeat(20)));
457 eq(store.getMarkdown("a".repeat(20), 1)?.source, "a".repeat(20), "markdown cache returns stored value");
458 store.setMarkdown(4, parsed("d".repeat(20))); // 160 > 120 → evict oldest (2: 1 was touched)
459 eq(store.getMarkdown("b".repeat(20), 2), undefined, "markdown LRU evicts the least-recently-used entry");
460 ok(store.getMarkdown("a".repeat(20), 1) !== undefined, "recently read markdown entry survives");
461 eq(store.getMarkdown("x".repeat(20), 1), undefined, "a revision collision with different source text is a miss");
462
463 const release = store.pinMarkdown(1);
464 store.setMarkdown(5, parsed("e".repeat(50)));
465 ok(store.getMarkdown("a".repeat(20), 1) !== undefined, "active selection pins its markdown projection");
466 release();
467 }
468
469 // ── lazy content refs ───────────────────────────────────────────────────────
470 {
471 const full = "FULL-".repeat(40); // 200 chars
472 const refs: RefTable = new Map([["s1:r0:m1:o0:content", full]]);
473 const backend = new FakeBackend(
474 [{ role: "user", content: "p1" }, { role: "assistant", content: "placeholder" }],
475 refs,
476 );
477 const store = new TranscriptStore(backend);
478 const changes: string[] = [];
479 store.subscribe("tab-c", (change) => changes.push(...(change.projection?.items.map(item => item.id) ?? Object.keys(change.patches))));
480 const first = await store.loadLatest("tab-c", "/s/c.jsonl", { turns: 12 });
481 // ChatContentLoader owns automatic body reads and the four-request budget.
482 // The store must not eagerly bypass it or load closed thought/tool fields.
483 await new Promise((resolve) => setTimeout(resolve, 0));
484 eq(backend.contentCalls.length, 0, "newest-page references stay lazy until the view requests them");
485 eq(store.hasContentReference("tab-c", "s1:r0:m1:o0", "content"), true, "the view can distinguish a missing full value from an unreferenced body");
486 await store.requestFullContent("tab-c", "s1:r0:m1:o0", "content");
487 eq(first?.hasOlder, false, "fixture fits in one page");
488 const assistant = (store.peek("tab-c", "/s/c.jsonl")?.items ?? []).find((item) => item.kind === "assistant");
489 eq(assistant?.kind === "assistant" && assistant.text, full, "resolved full content replaces the inline preview");
490 ok(changes.includes("he:s1:r0:m1:o0"), "content resolution notifies subscribers with item patches");
491 const again = await store.requestFullContent("tab-c", "s1:r0:m1:o0", "content");
492 eq(again, full, "resolved content is served from the record");
493 eq(backend.contentCalls.length, 2, "resolved content is not re-fetched");
494 }
495
496 {
497 // Stale content fetch: ref marked stale, preview kept.
498 const refs: RefTable = new Map([["s1:r0:m1:o0:content", "z".repeat(100)]]);
499 const backend = new FakeBackend(
500 [{ role: "user", content: "p1" }, { role: "assistant", content: "placeholder" }],
501 refs,
502 );
503 backend.HistoryContentForTab = async (_tab, ref, chunk) => ({ entryId: ref.entryId, field: ref.field, chunk, chunks: 2, data: "", done: false, stale: true });
504 const store = new TranscriptStore(backend);
505 const first = await store.loadLatest("tab-s", "/s/s.jsonl", { turns: 12 });
506 await new Promise((resolve) => setTimeout(resolve, 0));
507 const assistant = (store.peek("tab-s", "/s/s.jsonl")?.items ?? []).find((item) => item.kind === "assistant");
508 eq(assistant?.kind === "assistant" && assistant.text, "z".repeat(16), "stale ref keeps the inline preview");
509 eq(first !== undefined, true, "latest page still projects");
510 }
511
512 // ── generation: superseded / evicted loads discard late responses ───────────
513 {
514 const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
515 const store = new TranscriptStore(backend);
516 backend.sliceGate = deferred<HistorySlice>();
517 const firstGate = backend.sliceGate;
518 const p1 = store.loadLatest("tab-g", "/s/g.jsonl");
519 backend.sliceGate = deferred<HistorySlice>();
520 const secondGate = backend.sliceGate;
521 const p2 = store.loadLatest("tab-g", "/s/g.jsonl"); // supersedes: bumps generation
522 firstGate.resolve(backend.slice(0, 2));
523 eq(await p1, undefined, "superseded load discards its late response");
524 secondGate.resolve(backend.slice(0, 2));
525 const projection = await p2;
526 eq(projection?.items.length, 2, "the latest load wins");
527
528 backend.sliceGate = deferred<HistorySlice>();
529 const gate = backend.sliceGate;
530 const p3 = store.loadLatest("tab-h", "/s/h.jsonl");
531 store.evictTab("tab-h"); // pruned/closed before the response lands
532 gate.resolve(backend.slice(0, 2));
533 eq(await p3, undefined, "evicted session discards its late response");
534 eq(store.isResident("tab-h", "/s/h.jsonl"), false, "evicted records never land");
535 }
536
537 {
538 // A content request spanning a fresh load discards the old chunk and
539 // transparently retries against the replacement generation.
540 const full = "y".repeat(80);
541 const refs: RefTable = new Map([["s1:r0:m1:o0:content", full]]);
542 const backend = new FakeBackend(
543 [{ role: "user", content: "p1" }, { role: "assistant", content: "placeholder" }],
544 refs,
545 );
546 const store = new TranscriptStore(backend);
547 backend.contentGate = deferred<HistoryContentChunk>();
548 const staleGate = backend.contentGate;
549 await store.loadLatest("tab-l", "/s/l.jsonl", { turns: 12 });
550 const first = store.requestFullContent("tab-l", "s1:r0:m1:o0", "content");
551 await new Promise((resolve) => setTimeout(resolve, 0));
552 eq(backend.contentCalls.length, 1, "the requested first-generation content is in flight");
553 // A fresh load (session switch/rebind) bumps the generation while the first
554 // load's content request is still awaiting its chunk.
555 const reload = store.loadLatest("tab-l", "/s/l.jsonl", { turns: 12 });
556 staleGate.resolve({ entryId: "s1:r0:m1:o0", field: "content", chunk: 0, chunks: 2, data: "STALE", done: true, stale: false });
557 const resolved = await first;
558 await reload;
559 const assistant = (store.peek("tab-l", "/s/l.jsonl")?.items ?? []).find((item) => item.kind === "assistant");
560 eq(resolved, full, "generation rollover retries the original request against the replacement record");
561 eq(backend.contentCalls.length, 3, "the replacement generation fetches both content chunks once");
562 eq(assistant?.kind === "assistant" && assistant.text, full, "late content chunk from a previous generation is discarded");
563 }
564
565 // ── stale cursor reloads from the latest page ───────────────────────────────
566 {
567 const messages: HistoryMessage[] = [];
568 for (let i = 0; i < 30; i += 1) {
569 messages.push({ role: "user", content: `p${i}` });
570 messages.push({ role: "assistant", content: `a${i}` });
571 }
572 const backend = new FakeBackend(messages);
573 const store = new TranscriptStore(backend);
574 await store.loadLatest("tab-r", "/s/r.jsonl", { turns: 10 });
575 backend.staleNextCursor = true; // the session was rewritten behind the cursor
576 const result = await store.loadOlder("tab-r", "/s/r.jsonl", { turns: 10 });
577 eq(result?.kind, "reload", "stale cursor triggers a latest-page reload");
578 eq(result?.items.length, 20, "reload replaces with the fresh newest page");
579 backend.staleNextCursor = false;
580 const older = await store.loadOlder("tab-r", "/s/r.jsonl", { turns: 10 });
581 eq(older?.kind, "prepend", "paging resumes after the reload");
582 eq(older?.prependItems.length, 20, "older page prepends after reload");
583 }
584
585 // ── same-path resident identity ────────────────────────────────────────────
586 {
587 const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
588 const store = new TranscriptStore(backend);
589 await store.loadLatest("tab-fp", "/s/fp.jsonl", { expectedRevision: 1, expectedDigest: "digest-1" });
590 const callsAfterFirstLoad = backend.sliceCalls.length;
591 const resident = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
592 preferResident: true,
593 expectedRevision: 1,
594 expectedDigest: "digest-1",
595 });
596 eq(backend.sliceCalls.length, callsAfterFirstLoad, "matching canonical fingerprint reuses the resident projection");
597 eq(resident?.revision, 1, "resident projection retains its canonical revision");
598
599 backend.revision = 2;
600 backend.digest = "digest-2";
601 const refreshed = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
602 preferResident: true,
603 expectedRevision: 2,
604 expectedDigest: "digest-2",
605 });
606 eq(backend.sliceCalls.length, callsAfterFirstLoad + 1, "changed same-path fingerprint bypasses the resident projection");
607 eq(refreshed?.digest, "digest-2", "fresh projection adopts the advanced canonical digest");
608
609 backend.HistorySliceForTab = async () => ({ ...backend.slice(0, 2), revision: 3, revisionKnown: undefined, digest: "digest-3" });
610 const compatible = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
611 preferResident: true,
612 expectedRevision: 3,
613 expectedDigest: "digest-3",
614 });
615 eq(compatible?.revisionKnown, true, "positive legacy slice revision implies a known canonical identity");
616 }
617
618 // ── canonical ownership survives ephemeral tab replacement ────────────────
619 {
620 const backend = new FakeBackend([{ role: "user", content: "warm A" }, { role: "assistant", content: "answer A" }]);
621 const store = new TranscriptStore(backend);
622 const sessionA = "s\0local\0session-a\0" + "0";
623 store.noteSessionBinding("tab-a-1", "/same/path.jsonl", sessionA);
624 await store.loadLatest("tab-a-1", "/same/path.jsonl", {
625 expectedRevision: 1,
626 expectedDigest: "digest-1",
627 });
628 const callsAfterWarm = backend.sliceCalls.length;
629 store.evictTab("tab-a-1");
630
631 store.noteSessionBinding("tab-a-3", "/same/path.jsonl", sessionA);
632 const rebound = store.peek("tab-a-3", "/same/path.jsonl", {
633 revision: 1,
634 digest: "digest-1",
635 });
636 eq(rebound?.items.find(item => item.kind === "user")?.id, "he:s1:r0:m0:o0", "new tab id reuses the stable session resident projection");
637 eq(backend.sliceCalls.length, callsAfterWarm, "stable session rebind paints without a full history read");
638 eq(store.residentSessionCount(), 1, "stable rebind does not duplicate the resident session");
639 eq(store.peek("tab-a-1", "/same/path.jsonl"), undefined, "old tab binding cannot address the rebound resident session");
640
641 const staleFollowerAppend = store.appendEntries("tab-a-1", "/same/path.jsonl", [{
642 entryId: "old:follower", turn: 2, order: 2, message: { role: "user", content: "late" }, refs: [],
643 }]);
644 eq(staleFollowerAppend, undefined, "old follower events are fenced after the tab rebind");
645
646 backend.revision = 2;
647 backend.digest = "digest-2";
648 const refreshed = await store.loadLatest("tab-a-3", "/same/path.jsonl", {
649 preferResident: true,
650 expectedRevision: 2,
651 expectedDigest: "digest-2",
652 });
653 eq(backend.sliceCalls.length, callsAfterWarm + 1, "changed canonical fingerprint reloads after a stable rebind");
654 eq(refreshed?.digest, "digest-2", "rebound session installs the new canonical fingerprint");
655
656 store.evictTab("tab-a-3");
657 store.noteSessionBinding("tab-b", "/same/path.jsonl", "s\0local\0session-b\0" + "0");
658 eq(store.peek("tab-b", "/same/path.jsonl", { revision: 2, digest: "digest-2" }), undefined, "same path with a different SessionID never reuses the resident projection");
659 eq(store.peek("tab-b", "/same/path.jsonl", {}), undefined, "missing canonical fingerprint cannot manufacture a warm hit");
660 }
661
662 // A lazy body request belongs to the tab binding that started it, not merely
663 // to the stable resident object retained for the next tab.
664 {
665 const full = "canonical body ".repeat(16);
666 const refs = new Map<string, string>([["s1:r0:m0:o0:content", full]]);
667 const backend = new FakeBackend([{ role: "assistant", content: full }], refs);
668 const store = new TranscriptStore(backend);
669 const stable = "s\0local\0session-content\0" + "0";
670 store.noteSessionBinding("content-old", "/content.jsonl", stable);
671 await store.loadLatest("content-old", "/content.jsonl");
672 const contentGate = deferred<HistoryContentChunk>();
673 backend.contentGate = contentGate;
674 const pending = store.requestFullContent("content-old", "s1:r0:m0:o0", "content");
675 store.evictTab("content-old");
676 store.noteSessionBinding("content-new", "/content.jsonl", stable);
677 contentGate.resolve({
678 entryId: "s1:r0:m0:o0", field: "content", chunk: 0, chunks: 2, data: full, done: true, stale: false,
679 });
680 eq(await pending, undefined, "late lazy content from the old tab is discarded after canonical rebind");
681 }
682
683 // Legacy tool references are call-specific and never expand hidden siblings or
684 // retain fetched full bodies in the controller's contribution map.
685 {
686 const args = "a".repeat(70000), output = "o".repeat(80000);
687 const backend = new FakeBackend([
688 { role: "user", content: "read" },
689 { role: "assistant", content: "", toolCalls: [
690 { id: "one", name: "bash", arguments: "args preview" }, { id: "two", name: "bash", arguments: "other preview" },
691 ] },
692 { role: "tool", toolCallId: "one", content: "output preview" },
693 ]);
694 const slice = backend.slice(0, 3);
695 slice.entries![1].refs = ["one", "two"].map(toolCallId => ({ entryId: "s1:r0:m1:o0", toolCallId, field: "toolArguments", size: args.length, chunks: 1, revision: 1, digest: "d" }));
696 slice.entries![2].refs = [{ entryId: "s1:r0:m2:o0", field: "content", size: output.length, chunks: 1, revision: 1, digest: "d" }];
697 backend.HistorySliceForTab = async () => slice;
698 let reads = 0;
699 backend.HistoryContentForTab = async (_, ref) => {
700 if (ref.toolCallId === "two") throw new Error("unopened call must stay lazy");
701 reads++;
702 return { entryId: ref.entryId, field: ref.field, chunk: 0, chunks: 1, data: ref.field === "content" ? output : args, done: true, stale: false };
703 };
704 const store = new TranscriptStore(backend);
705 const view = await store.loadLatest("legacy", "/legacy");
706 const item = view?.items.find((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool" && item.id === "one");
707 if (!item) throw new Error("legacy tool missing");
708 for (let attempt = 0; attempt < 2; attempt++) {
709 const value = JSON.parse((await store.requestToolContent("legacy", item, { args: item.args, output: item.output }))!);
710 eq(value.args, args, "legacy tool parameters load completely");
711 eq(value.output, output, "legacy tool output loads completely");
712 }
713 eq(reads, 4, "reopening reads only the selected tool's two references");
714 eq(store.peek("legacy", "/legacy")?.items.find(candidate => candidate.id === "one"), item, "full details leave the preview Item unchanged");
715 }
716
717 // ── reclaiming a page never strands a tool result ──────────────────────────
718 // A result row whose call was reclaimed names a call the reader can no longer
719 // see. Pages here are 2 messages wide over 3-message turns, so page boundaries
720 // fall between a call and its result and the reclaim has to widen past it.
721 {
722 const messages: HistoryMessage[] = [];
723 for (let i = 0; i < 12; i += 1) {
724 messages.push({ role: "user", content: `q${i}` });
725 messages.push({ role: "assistant", content: "", toolCalls: [{ id: `call-${i}`, name: "bash", arguments: `run ${i}` }] });
726 messages.push({ role: "tool", toolCallId: `call-${i}`, toolName: "bash", content: `out ${i}` });
727 }
728 const backend = new FakeBackend(messages);
729 const store = new TranscriptStore(backend, { windowMaxPages: 2 });
730 const residentIds = () => new Set((store.peek("tab-tool", "/s/tool.jsonl")?.items ?? []).map((item) => item.id));
731
732 // Page back to the head. Page [0,2) holds turn 0's call; the page after it
733 // starts with that call's result, so the boundary splits the pair.
734 await store.loadLatest("tab-tool", "/s/tool.jsonl", { entries: 2 });
735 for (let page = 0; page < 40; page += 1) {
736 if (!await store.loadOlder("tab-tool", "/s/tool.jsonl", { entries: 2 })) break;
737 }
738 const atHead = residentIds();
739 ok(atHead.size > 0, "paging reaches the head of the transcript");
740
741 // Growing forward reclaims the head page. The result that belonged to a call
742 // on that page has to go with it, or the reader keeps an output row whose
743 // call is no longer on screen.
744 const newer = await store.loadNewer("tab-tool", "/s/tool.jsonl", { entries: 2 });
745 ok(newer?.kind === "append", "paging forward appends after reaching the head");
746 ok(store.stats().reclaimedPages > 0, "growing forward reclaimed a page");
747 const afterReclaim = residentIds();
748 for (const id of atHead) {
749 if (!/^call-\d+$/.test(id)) continue;
750 ok(!afterReclaim.has(id), `reclaimed call ${id} did not leave its result behind`);
751 }
752 ok(store.stats().residentWindowEntries <= 2 * 2, "the window stayed at its page budget");
753 }
754
755 // A completed result may live outside the resident page. Its locator supplies
756 // execution evidence, while the body is fetched only when explicitly expanded.
757 {
758 const output = "跨页结果✓".repeat(30);
759 const bytes = new TextEncoder().encode(JSON.stringify({content:output,tool_execution:{state:"completed"}}));
760 const backend = new FakeBackend([{role:"assistant",content:"",toolCalls:[{id:"detached",name:"bash",arguments:"{}",resultObservation:{state:"completed",messageId:"outside",version:1,contentRef:{digest:"detached-digest",bytes:bytes.length,indexDigest:"",mediaType:"application/json"}}}]}]);
761 backend.HistoryContentForTab = async (_,ref) => ({entryId:ref.entryId,field:ref.field,chunk:0,chunks:1,data:Array.from(bytes,b=>String.fromCharCode(b)).join(""),done:true,stale:false});
762 const store = new TranscriptStore(backend,{windowMaxPages:1});
763 const view = await store.loadLatest("detached-tab","/detached");
764 const item=view?.items.find((item):item is Extract<Item,{kind:"tool"}>=>item.kind==="tool");
765 if(!item) throw new Error("detached tool missing");
766 eq(item.status,"done","cross-page completion is authoritative");
767 eq(item.contentState,"unloaded","body is separately unloaded");
768 const before=store.stats().residentWindowEntries;
769 const loaded=JSON.parse((await store.requestToolContent("detached-tab",item,{}))!);
770 eq(loaded.output,output,"detached UTF-8 output is complete");
771 eq(store.stats().residentWindowEntries,before,"detached results do not grow the resident window");
772 backend.HistoryContentForTab=async()=>{throw new Error("unreadable result")};
773 let failed=false;try{await store.requestToolContent("detached-tab",item,{})}catch{failed=true}
774 ok(failed,"unreadable content remains an error");
775 eq(item.status,"done","read failure does not turn completion into cancellation");
776 }
777
778 await verifyTranscriptContentOwnership();
779 console.log(`\n${passed} passed, ${failed} failed; content ownership interleavings passed`);
780 if (failed > 0) process.exit(1);
781
781 lines TYPESCRIPT