返回 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 messages: HistoryMessage[] = [
139 { role: "user", content: "read all" },
140 { role: "assistant", content: "candidate answer" },
141 { role: "notice", content: "", code: "incomplete_read", readPause: { id: "run", reads: [{ readId: "r", path: "fixture.txt", reason: "no_progress" }] } },
142 ];
143 const store = new TranscriptStore(new FakeBackend(messages));
144 const first = await store.loadLatest("read", "/read.jsonl", { turns: 12 });
145 const expected = historyMessagesToItems(messages, "history").items.find(i => i.kind === "notice");
146 const actual = first?.items.find(i => i.kind === "notice");
147 eq(JSON.stringify(actual), JSON.stringify(expected), "paged read pause equals live and legacy history presentation");
148 const replay = await store.loadLatest("read", "/read.jsonl", { turns: 12 });
149 eq(replay?.items.filter(i => i.kind === "notice").length, 1, "reloading a pause does not duplicate its card");
150 }
151
152 // ── page concatenation equals single-shot conversion ────────────────────────
153 // This is a conversion-fidelity property, not a residency one: paging a whole
154 // transcript in must project exactly what one single-shot conversion produces.
155 // The window is deliberately unbounded here so the comparison sees every page;
156 // the bounded-window behaviour is covered separately below.
157 {
158 const messages = bigTranscript(46);
159 const backend = new FakeBackend(messages);
160 const store = new TranscriptStore(backend, { windowMaxPages: 1_000 });
161 const first = await store.loadLatest("tab-1", "/s/one.jsonl", { turns: 12 });
162 ok(!!first && first.items.length > 0, "latest page projects items");
163 eq(first?.hasOlder, true, "latest page reports older history");
164 const projectedTurns = (first?.items ?? [])
165 .filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user")
166 .map((item) => item.historyTurn);
167 eq(projectedTurns[projectedTurns.length - 1], 46, "history user items retain their absolute turn for complete-session navigation");
168 ok(projectedTurns.every((turn) => Number.isInteger(turn) && (turn ?? 0) > 0), "every paged user item carries an absolute history turn");
169 const firstIds = (first?.items ?? []).map((item) => item.id);
170 await drainOlder(store, "tab-1", "/s/one.jsonl", 12);
171 const full = store.peek("tab-1", "/s/one.jsonl");
172 const singleShot = historyMessagesToItems(messages, "h").items;
173 ok(canonEqual(full?.items ?? [], singleShot), `paged concatenation equals single-shot conversion (${singleShot.length} items from ${messages.length} messages)`);
174 const fullIds = (full?.items ?? []).map((item) => item.id);
175 eq(JSON.stringify(fullIds.slice(fullIds.length - firstIds.length)), JSON.stringify(firstIds), "newest page item ids are stable across prepends");
176 const unique = new Set(fullIds);
177 eq(unique.size, fullIds.length, "item ids are unique across the full projection");
178 }
179
180 // ── 10,000-turn targeted paging stays bounded ──────────────────────────────
181 {
182 const messages = longArchivedTranscript(10_000);
183 const backend = new FakeBackend(messages, new Map(), "stress");
184 const store = new TranscriptStore(backend);
185 const startedAt = performance.now();
186 let projection = await store.loadLatest("tab-stress", "/s/stress.jsonl", { turns: 60 });
187 let pages = 1;
188 while (projection?.hasOlder && pages <= 40) {
189 const budget = historyPageRequestBudget(projection.startTurn, projection.totalTurns, 1);
190 const older = await store.loadOlder("tab-stress", "/s/stress.jsonl", budget);
191 if (!older) break;
192 projection = older;
193 pages += 1;
194 }
195 const elapsedMs = performance.now() - startedAt;
196 const users = (projection?.items ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
197 eq(projection?.hasOlder, false, "10,000-turn target paging reaches the first page");
198 eq(pages, 32, "10,000-turn target paging respects both turn and production entry bounds");
199 eq(backend.sliceCalls.length, 32, "10,000-turn target paging performs the expected bounded backend calls");
200 ok(backend.sliceCalls.slice(1).every((request) => request.entries === 1000), "targeted pages use the backend's bounded 1000-entry capacity");
201 eq(users[0]?.historyTurn, 1, "10,000-turn target paging lands on absolute turn one");
202 ok(new Set(projection?.items.map((item) => item.id)).size === projection?.items.length, "10,000-turn target paging keeps item ids unique");
203 const stats = store.stats();
204 ok(stats.bodyBytes <= stats.bodyBudgetBytes, "10,000-turn transcript stays within the production history body budget");
205 ok(elapsedMs < 10_000, `10,000-turn targeted paging completes within 10s (${elapsedMs.toFixed(1)}ms)`);
206
207 // Reading 32 pages deep leaves a bounded window, not the whole session. The
208 // reclaimed range is reported as still-newer rather than lost, and paging
209 // forward from it restores the tail — full reachability, bounded residency.
210 ok(stats.residentWindowEntries <= stats.windowMaxPages * 1000, `window residency is bounded (${stats.residentWindowEntries} entries, max ${stats.windowMaxPages * 1000})`);
211 ok(stats.reclaimedPages > 0, "deep paging reclaimed pages instead of holding every page");
212 eq(projection?.hasNewer, true, "the reclaimed tail is reported as still newer");
213 const forward = await store.loadNewer("tab-stress", "/s/stress.jsonl", { entries: 1000 });
214 eq(forward?.kind, "append", "paging forward appends into the same window");
215 const forwardUsers = (forward?.appendItems ?? []).filter((item): item is Extract<Item, { kind: "user" }> => item.kind === "user");
216 ok(forwardUsers.length > 0, "paging forward restores newer history after a reclaim");
217 const lastForward = forwardUsers[forwardUsers.length - 1];
218 const lastExisting = users[users.length - 1];
219 ok((lastForward?.historyTurn ?? 0) > (lastExisting?.historyTurn ?? 0), "paging forward moves the window toward the live tail");
220 }
221
222 // ── cross-page tool call/result merge ───────────────────────────────────────
223 {
224 const messages: HistoryMessage[] = [
225 { role: "user", content: "p1" },
226 { role: "assistant", content: "", toolCalls: [{ id: "call-1", name: "bash", arguments: "ls" }] },
227 { role: "tool", toolCallId: "call-1", toolName: "bash", content: "/root" },
228 { role: "user", content: "p2" },
229 { role: "assistant", content: "done" },
230 ];
231 // Cut between the call and its result: newest page starts at the result row.
232 const backend = new FakeBackend(messages);
233 backend.HistorySliceForTab = async (tabID, req) => {
234 void tabID;
235 if (!req.cursor) return backend.slice(2, messages.length);
236 const decoded = JSON.parse(atob(req.cursor)) as { before?: number };
237 return backend.slice(0, Math.min(decoded.before ?? 0, 2));
238 };
239 const store = new TranscriptStore(backend);
240 const first = await store.loadLatest("tab-x", "/s/x.jsonl", { turns: 12 });
241 const standalone = (first?.items ?? []).filter((item) => item.kind === "tool");
242 eq(standalone.length, 1, "result row converts standalone before its call pages in");
243 eq(standalone[0]?.kind === "tool" && standalone[0].id, "call-1", "standalone result keeps the toolCallId item id");
244 const older = await store.loadOlder("tab-x", "/s/x.jsonl", { turns: 12 });
245 eq(older?.kind, "prepend", "older page prepends");
246 eq(older?.removeIds.length, 1, "the standalone result item is superseded by the merged call item");
247 const merged = (older?.items ?? []).filter((item) => item.kind === "tool");
248 eq(merged.length, 1, "exactly one tool item after the merge (no duplicate)");
249 const tool = merged[0]?.kind === "tool" ? merged[0] : undefined;
250 eq(tool?.args, "ls", "merged tool item takes the call's args");
251 eq(tool?.output, "/root", "merged tool item takes the result's output");
252 eq(tool?.status, "done", "merged tool item is done");
253 const singleShot = historyMessagesToItems(messages, "h").items;
254 ok(canonEqual(older?.items ?? [], singleShot), "merged projection equals single-shot conversion");
255 }
256
257 // ── append (live tail) ──────────────────────────────────────────────────────
258 {
259 const messages: HistoryMessage[] = [
260 { role: "user", content: "p1" },
261 { role: "assistant", content: "a1" },
262 ];
263 const backend = new FakeBackend(messages);
264 const store = new TranscriptStore(backend);
265 const first = await store.loadLatest("tab-a", "/s/a.jsonl", { turns: 12 });
266 const baseIds = (first?.items ?? []).map((item) => item.id);
267 const appended = store.appendEntries("tab-a", "/s/a.jsonl", [
268 { entryId: "s1:r0:m2:o0", turn: 2, order: 2, message: { role: "user", content: "p2" }, refs: [] },
269 { entryId: "s1:r0:m3:o0", turn: 2, order: 3, message: { role: "assistant", content: "a2" }, refs: [] },
270 ]);
271 eq(appended?.items.length, baseIds.length + 2, "append contributes the new rows' items");
272 const projection = store.peek("tab-a", "/s/a.jsonl");
273 eq(JSON.stringify((projection?.items ?? []).slice(0, baseIds.length).map((item) => item.id)), JSON.stringify(baseIds), "append keeps existing item ids");
274 eq(projection?.items.length, baseIds.length + 2, "append grows the projection");
275 }
276
277 // ── long-running live tail uses the same three-page residency budget ────────
278 {
279 const backend = new FakeBackend([{ role: "user", content: "seed" }, { role: "assistant", content: "seed answer" }]);
280 const store = new TranscriptStore(backend, { windowMaxPages: 3, windowPageEntries: 4 });
281 await store.loadLatest("tab-live", "/s/live.jsonl", { turns: 12 });
282 let reclaimed = 0;
283 for (let batch = 0; batch < 8; batch += 1) {
284 const turn = batch + 2;
285 const result = store.appendEntries("tab-live", "/s/live.jsonl", [
286 { entryId: `live-u-${turn}`, turn, order: turn * 2, message: { role: "user", content: `p${turn}` }, refs: [] },
287 { entryId: `live-a-${turn}`, turn, order: turn * 2 + 1, message: { role: "assistant", content: `a${turn}` }, refs: [] },
288 ]);
289 reclaimed += result?.removeIds.length ?? 0;
290 }
291 const projection = store.peek("tab-live", "/s/live.jsonl");
292 ok((projection?.items.length ?? 0) <= 12, "live tail remains inside three four-entry pages");
293 ok(reclaimed > 0, "live append reports mounted ids reclaimed from the oldest edge");
294 ok((projection?.startTurn ?? 0) > 1, "live window advances its visible start turn after reclaim");
295 eq(projection?.endTurn, 9, "live window retains the latest settled turn");
296 }
297
298 // ── weighted LRU: count, pin, byte budget, re-open ──────────────────────────
299 {
300 const store = new TranscriptStore(new FakeBackend([]));
301 store.installSlice("tab-aba", "/s/same.jsonl", {
302 entries: [{ entryId: "m:old", turn: 1, order: 0, message: { role: "user", content: "old generation" }, refs: [] }],
303 nextCursor: "", newerCursor: "", hasOlder: false, hasNewer: false,
304 startTurn: 1, endTurn: 1, totalTurns: 1, revision: 4, digest: "digest-v4", stale: false,
305 });
306 ok(Boolean(store.peek("tab-aba", "/s/same.jsonl", { revision: 4, digest: "digest-v4" })), "matching fingerprint serves the resident projection");
307 eq(store.peek("tab-aba", "/s/same.jsonl", { revision: 5, digest: "digest-v5" }), undefined, "same-path ABA fingerprint mismatch is a cache miss");
308 }
309
310 {
311 const store = new TranscriptStore(new FakeBackend([]));
312 const initial = store.installSlice("reader-v2", "/reader", {
313 entries: [{ entryId: "m:old", turn: 1, order: 0, message: { role: "user", content: "old reader page" }, refs: [] }],
314 nextCursor: "", newerCursor: "newer", hasOlder: false, hasNewer: true,
315 startTurn: 1, endTurn: 1, totalTurns: 100, revision: 1, digest: "generation", stale: false,
316 });
317 for (let sequence = 2; sequence < 130; sequence++) {
318 const updated = store.upsertEntries("reader-v2", "/reader", [{ entryId: `m:tail-${sequence}`, turn: sequence, order: sequence,
319 message: { role: "assistant", content: "committed tail" }, refs: [] }], sequence);
320 eq(updated?.items.length, initial.items.length, "distant commits do not replace or grow the reader window");
321 eq(updated?.items[0]?.id, initial.items[0]?.id, "reader anchor survives distant commits");
322 }
323 eq(store.peek("reader-v2", "/reader")?.hasNewer, true, "committed tail remains reachable by forward pagination");
324 }
325
326 {
327 const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
328 const store = new TranscriptStore(backend, { maxResidentSessions: 3 });
329 await store.loadLatest("tab-1", "/s/1.jsonl");
330 await store.loadLatest("tab-2", "/s/2.jsonl");
331 await store.loadLatest("tab-3", "/s/3.jsonl");
332 eq(store.residentSessionCount(), 3, "three sessions resident at the cap");
333 await store.loadLatest("tab-4", "/s/4.jsonl");
334 eq(store.isResident("tab-1", "/s/1.jsonl"), false, "fourth session evicts the least-recently-used one");
335 eq(store.isResident("tab-4", "/s/4.jsonl"), true, "new session stays resident");
336
337 store.setPinned("tab-2", true); // live/running tab: pinned out of the LRU count
338 await store.loadLatest("tab-5", "/s/5.jsonl");
339 eq(store.isResident("tab-3", "/s/3.jsonl"), true, "pinned sessions do not count toward the resident cap");
340 await store.loadLatest("tab-6", "/s/6.jsonl");
341 eq(store.isResident("tab-2", "/s/2.jsonl"), true, "pinned live session survives eviction");
342 eq(store.isResident("tab-3", "/s/3.jsonl"), false, "oldest unpinned session evicts instead");
343 store.setPinned("tab-2", false);
344
345 const callsBeforeReopen = backend.sliceCalls.length;
346 const reopened = await store.loadLatest("tab-1", "/s/1.jsonl");
347 ok(backend.sliceCalls.length > callsBeforeReopen, "evicted session re-opens via a fresh slice fetch");
348 eq(reopened?.items.length, 2, "re-opened session restores its full projection");
349 }
350
351 {
352 const big = "x".repeat(600);
353 const backend = new FakeBackend([{ role: "user", content: big }, { role: "assistant", content: big }]);
354 const store = new TranscriptStore(backend, { maxResidentSessions: 10, historyBodyBudgetBytes: 4096 });
355 await store.loadLatest("tab-1", "/s/1.jsonl");
356 await store.loadLatest("tab-2", "/s/2.jsonl");
357 await store.loadLatest("tab-3", "/s/3.jsonl");
358 ok(store.totalBodyBytes() <= 4096, "history body budget holds across sessions");
359 eq(store.isResident("tab-1", "/s/1.jsonl"), false, "byte budget evicts the oldest by weight");
360 eq(store.isResident("tab-3", "/s/3.jsonl"), true, "newest session survives byte-budget eviction");
361 }
362
363 // ── markdown cache budget + LRU ─────────────────────────────────────────────
364 {
365 const store = new TranscriptStore(new FakeBackend([]), { markdownBudgetBytes: 120 });
366 const parsed = (text: string) => ({
367 source: text,
368 blocks: [],
369 selectionText: text,
370 selectionRevision: 1,
371 bytes: text.length * 2,
372 });
373 store.setMarkdown("e1", 1, parsed("a".repeat(20))); // 40 bytes
374 store.setMarkdown("e2", 1, parsed("b".repeat(20)));
375 store.setMarkdown("e3", 1, parsed("c".repeat(20)));
376 eq(store.getMarkdown("e1", 1)?.source, "a".repeat(20), "markdown cache returns stored value");
377 store.setMarkdown("e4", 1, parsed("d".repeat(20))); // 160 > 120 → evict oldest (e2: e1 was touched)
378 eq(store.getMarkdown("e2", 1), undefined, "markdown LRU evicts the least-recently-used entry");
379 ok(store.getMarkdown("e1", 1) !== undefined, "recently read markdown entry survives");
380 eq(store.getMarkdown("e1", 2), undefined, "markdown entries key on entryId + revision");
381
382 const release = store.pinMarkdown("e1", 1);
383 store.setMarkdown("e5", 1, parsed("e".repeat(50)));
384 ok(store.getMarkdown("e1", 1) !== undefined, "active selection pins its markdown projection");
385 release();
386 }
387
388 // ── lazy content refs ───────────────────────────────────────────────────────
389 {
390 const full = "FULL-".repeat(40); // 200 chars
391 const refs: RefTable = new Map([["s1:r0:m1:o0:content", full]]);
392 const backend = new FakeBackend(
393 [{ role: "user", content: "p1" }, { role: "assistant", content: "placeholder" }],
394 refs,
395 );
396 const store = new TranscriptStore(backend);
397 const changes: string[] = [];
398 store.subscribe("tab-c", (change) => changes.push(...Object.keys(change.patches)));
399 const first = await store.loadLatest("tab-c", "/s/c.jsonl", { turns: 12 });
400 // ChatContentLoader owns automatic body reads and the four-request budget.
401 // The store must not eagerly bypass it or load closed thought/tool fields.
402 await new Promise((resolve) => setTimeout(resolve, 0));
403 eq(backend.contentCalls.length, 0, "newest-page references stay lazy until the view requests them");
404 eq(store.hasContentReference("tab-c", "s1:r0:m1:o0", "content"), true, "the view can distinguish a missing full value from an unreferenced body");
405 await store.requestFullContent("tab-c", "s1:r0:m1:o0", "content");
406 eq(first?.hasOlder, false, "fixture fits in one page");
407 const assistant = (store.peek("tab-c", "/s/c.jsonl")?.items ?? []).find((item) => item.kind === "assistant");
408 eq(assistant?.kind === "assistant" && assistant.text, full, "resolved full content replaces the inline preview");
409 ok(changes.includes("he:s1:r0:m1:o0"), "content resolution notifies subscribers with item patches");
410 const again = await store.requestFullContent("tab-c", "s1:r0:m1:o0", "content");
411 eq(again, full, "resolved content is served from the record");
412 eq(backend.contentCalls.length, 2, "resolved content is not re-fetched");
413 }
414
415 {
416 // Stale content fetch: ref marked stale, preview kept.
417 const refs: RefTable = new Map([["s1:r0:m1:o0:content", "z".repeat(100)]]);
418 const backend = new FakeBackend(
419 [{ role: "user", content: "p1" }, { role: "assistant", content: "placeholder" }],
420 refs,
421 );
422 backend.HistoryContentForTab = async (_tab, ref, chunk) => ({ entryId: ref.entryId, field: ref.field, chunk, chunks: 2, data: "", done: false, stale: true });
423 const store = new TranscriptStore(backend);
424 const first = await store.loadLatest("tab-s", "/s/s.jsonl", { turns: 12 });
425 await new Promise((resolve) => setTimeout(resolve, 0));
426 const assistant = (store.peek("tab-s", "/s/s.jsonl")?.items ?? []).find((item) => item.kind === "assistant");
427 eq(assistant?.kind === "assistant" && assistant.text, "z".repeat(16), "stale ref keeps the inline preview");
428 eq(first !== undefined, true, "latest page still projects");
429 }
430
431 // ── generation: superseded / evicted loads discard late responses ───────────
432 {
433 const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
434 const store = new TranscriptStore(backend);
435 backend.sliceGate = deferred<HistorySlice>();
436 const firstGate = backend.sliceGate;
437 const p1 = store.loadLatest("tab-g", "/s/g.jsonl");
438 backend.sliceGate = deferred<HistorySlice>();
439 const secondGate = backend.sliceGate;
440 const p2 = store.loadLatest("tab-g", "/s/g.jsonl"); // supersedes: bumps generation
441 firstGate.resolve(backend.slice(0, 2));
442 eq(await p1, undefined, "superseded load discards its late response");
443 secondGate.resolve(backend.slice(0, 2));
444 const projection = await p2;
445 eq(projection?.items.length, 2, "the latest load wins");
446
447 backend.sliceGate = deferred<HistorySlice>();
448 const gate = backend.sliceGate;
449 const p3 = store.loadLatest("tab-h", "/s/h.jsonl");
450 store.evictTab("tab-h"); // pruned/closed before the response lands
451 gate.resolve(backend.slice(0, 2));
452 eq(await p3, undefined, "evicted session discards its late response");
453 eq(store.isResident("tab-h", "/s/h.jsonl"), false, "evicted records never land");
454 }
455
456 {
457 // A content request spanning a fresh load discards the old chunk and
458 // transparently retries against the replacement generation.
459 const full = "y".repeat(80);
460 const refs: RefTable = new Map([["s1:r0:m1:o0:content", full]]);
461 const backend = new FakeBackend(
462 [{ role: "user", content: "p1" }, { role: "assistant", content: "placeholder" }],
463 refs,
464 );
465 const store = new TranscriptStore(backend);
466 backend.contentGate = deferred<HistoryContentChunk>();
467 const staleGate = backend.contentGate;
468 await store.loadLatest("tab-l", "/s/l.jsonl", { turns: 12 });
469 const first = store.requestFullContent("tab-l", "s1:r0:m1:o0", "content");
470 await new Promise((resolve) => setTimeout(resolve, 0));
471 eq(backend.contentCalls.length, 1, "the requested first-generation content is in flight");
472 // A fresh load (session switch/rebind) bumps the generation while the first
473 // load's content request is still awaiting its chunk.
474 const reload = store.loadLatest("tab-l", "/s/l.jsonl", { turns: 12 });
475 staleGate.resolve({ entryId: "s1:r0:m1:o0", field: "content", chunk: 0, chunks: 2, data: "STALE", done: true, stale: false });
476 const resolved = await first;
477 await reload;
478 const assistant = (store.peek("tab-l", "/s/l.jsonl")?.items ?? []).find((item) => item.kind === "assistant");
479 eq(resolved, full, "generation rollover retries the original request against the replacement record");
480 eq(backend.contentCalls.length, 3, "the replacement generation fetches both content chunks once");
481 eq(assistant?.kind === "assistant" && assistant.text, full, "late content chunk from a previous generation is discarded");
482 }
483
484 // ── stale cursor reloads from the latest page ───────────────────────────────
485 {
486 const messages: HistoryMessage[] = [];
487 for (let i = 0; i < 30; i += 1) {
488 messages.push({ role: "user", content: `p${i}` });
489 messages.push({ role: "assistant", content: `a${i}` });
490 }
491 const backend = new FakeBackend(messages);
492 const store = new TranscriptStore(backend);
493 await store.loadLatest("tab-r", "/s/r.jsonl", { turns: 10 });
494 backend.staleNextCursor = true; // the session was rewritten behind the cursor
495 const result = await store.loadOlder("tab-r", "/s/r.jsonl", { turns: 10 });
496 eq(result?.kind, "reload", "stale cursor triggers a latest-page reload");
497 eq(result?.items.length, 20, "reload replaces with the fresh newest page");
498 backend.staleNextCursor = false;
499 const older = await store.loadOlder("tab-r", "/s/r.jsonl", { turns: 10 });
500 eq(older?.kind, "prepend", "paging resumes after the reload");
501 eq(older?.prependItems.length, 20, "older page prepends after reload");
502 }
503
504 // ── same-path resident identity ────────────────────────────────────────────
505 {
506 const backend = new FakeBackend([{ role: "user", content: "u" }, { role: "assistant", content: "a" }]);
507 const store = new TranscriptStore(backend);
508 await store.loadLatest("tab-fp", "/s/fp.jsonl", { expectedRevision: 1, expectedDigest: "digest-1" });
509 const callsAfterFirstLoad = backend.sliceCalls.length;
510 const resident = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
511 preferResident: true,
512 expectedRevision: 1,
513 expectedDigest: "digest-1",
514 });
515 eq(backend.sliceCalls.length, callsAfterFirstLoad, "matching canonical fingerprint reuses the resident projection");
516 eq(resident?.revision, 1, "resident projection retains its canonical revision");
517
518 backend.revision = 2;
519 backend.digest = "digest-2";
520 const refreshed = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
521 preferResident: true,
522 expectedRevision: 2,
523 expectedDigest: "digest-2",
524 });
525 eq(backend.sliceCalls.length, callsAfterFirstLoad + 1, "changed same-path fingerprint bypasses the resident projection");
526 eq(refreshed?.digest, "digest-2", "fresh projection adopts the advanced canonical digest");
527
528 backend.HistorySliceForTab = async () => ({ ...backend.slice(0, 2), revision: 3, revisionKnown: undefined, digest: "digest-3" });
529 const compatible = await store.loadLatest("tab-fp", "/s/fp.jsonl", {
530 preferResident: true,
531 expectedRevision: 3,
532 expectedDigest: "digest-3",
533 });
534 eq(compatible?.revisionKnown, true, "positive legacy slice revision implies a known canonical identity");
535 }
536
537 // ── canonical ownership survives ephemeral tab replacement ────────────────
538 {
539 const backend = new FakeBackend([{ role: "user", content: "warm A" }, { role: "assistant", content: "answer A" }]);
540 const store = new TranscriptStore(backend);
541 const sessionA = "s\0local\0session-a\0" + "0";
542 store.noteSessionBinding("tab-a-1", "/same/path.jsonl", sessionA);
543 await store.loadLatest("tab-a-1", "/same/path.jsonl", {
544 expectedRevision: 1,
545 expectedDigest: "digest-1",
546 });
547 const callsAfterWarm = backend.sliceCalls.length;
548 store.evictTab("tab-a-1");
549
550 store.noteSessionBinding("tab-a-3", "/same/path.jsonl", sessionA);
551 const rebound = store.peek("tab-a-3", "/same/path.jsonl", {
552 revision: 1,
553 digest: "digest-1",
554 });
555 eq(rebound?.items.find(item => item.kind === "user")?.id, "he:s1:r0:m0:o0", "new tab id reuses the stable session resident projection");
556 eq(backend.sliceCalls.length, callsAfterWarm, "stable session rebind paints without a full history read");
557 eq(store.residentSessionCount(), 1, "stable rebind does not duplicate the resident session");
558 eq(store.peek("tab-a-1", "/same/path.jsonl"), undefined, "old tab binding cannot address the rebound resident session");
559
560 const staleFollowerAppend = store.appendEntries("tab-a-1", "/same/path.jsonl", [{
561 entryId: "old:follower", turn: 2, order: 2, message: { role: "user", content: "late" }, refs: [],
562 }]);
563 eq(staleFollowerAppend, undefined, "old follower events are fenced after the tab rebind");
564
565 backend.revision = 2;
566 backend.digest = "digest-2";
567 const refreshed = await store.loadLatest("tab-a-3", "/same/path.jsonl", {
568 preferResident: true,
569 expectedRevision: 2,
570 expectedDigest: "digest-2",
571 });
572 eq(backend.sliceCalls.length, callsAfterWarm + 1, "changed canonical fingerprint reloads after a stable rebind");
573 eq(refreshed?.digest, "digest-2", "rebound session installs the new canonical fingerprint");
574
575 store.evictTab("tab-a-3");
576 store.noteSessionBinding("tab-b", "/same/path.jsonl", "s\0local\0session-b\0" + "0");
577 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");
578 eq(store.peek("tab-b", "/same/path.jsonl", {}), undefined, "missing canonical fingerprint cannot manufacture a warm hit");
579 }
580
581 // A lazy body request belongs to the tab binding that started it, not merely
582 // to the stable resident object retained for the next tab.
583 {
584 const full = "canonical body ".repeat(16);
585 const refs = new Map<string, string>([["s1:r0:m0:o0:content", full]]);
586 const backend = new FakeBackend([{ role: "assistant", content: full }], refs);
587 const store = new TranscriptStore(backend);
588 const stable = "s\0local\0session-content\0" + "0";
589 store.noteSessionBinding("content-old", "/content.jsonl", stable);
590 await store.loadLatest("content-old", "/content.jsonl");
591 const contentGate = deferred<HistoryContentChunk>();
592 backend.contentGate = contentGate;
593 const pending = store.requestFullContent("content-old", "s1:r0:m0:o0", "content");
594 store.evictTab("content-old");
595 store.noteSessionBinding("content-new", "/content.jsonl", stable);
596 contentGate.resolve({
597 entryId: "s1:r0:m0:o0", field: "content", chunk: 0, chunks: 2, data: full, done: true, stale: false,
598 });
599 eq(await pending, undefined, "late lazy content from the old tab is discarded after canonical rebind");
600 }
601
602 // Legacy tool references are call-specific and never expand hidden siblings or
603 // retain fetched full bodies in the controller's contribution map.
604 {
605 const args = "a".repeat(70000), output = "o".repeat(80000);
606 const backend = new FakeBackend([
607 { role: "user", content: "read" },
608 { role: "assistant", content: "", toolCalls: [
609 { id: "one", name: "bash", arguments: "args preview" }, { id: "two", name: "bash", arguments: "other preview" },
610 ] },
611 { role: "tool", toolCallId: "one", content: "output preview" },
612 ]);
613 const slice = backend.slice(0, 3);
614 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" }));
615 slice.entries![2].refs = [{ entryId: "s1:r0:m2:o0", field: "content", size: output.length, chunks: 1, revision: 1, digest: "d" }];
616 backend.HistorySliceForTab = async () => slice;
617 let reads = 0;
618 backend.HistoryContentForTab = async (_, ref) => {
619 if (ref.toolCallId === "two") throw new Error("unopened call must stay lazy");
620 reads++;
621 return { entryId: ref.entryId, field: ref.field, chunk: 0, chunks: 1, data: ref.field === "content" ? output : args, done: true, stale: false };
622 };
623 const store = new TranscriptStore(backend);
624 const view = await store.loadLatest("legacy", "/legacy");
625 const item = view?.items.find((item): item is Extract<Item, { kind: "tool" }> => item.kind === "tool" && item.id === "one");
626 if (!item) throw new Error("legacy tool missing");
627 for (let attempt = 0; attempt < 2; attempt++) {
628 const value = JSON.parse((await store.requestToolContent("legacy", item, { args: item.args, output: item.output }))!);
629 eq(value.args, args, "legacy tool parameters load completely");
630 eq(value.output, output, "legacy tool output loads completely");
631 }
632 eq(reads, 4, "reopening reads only the selected tool's two references");
633 eq(store.peek("legacy", "/legacy")?.items.find(candidate => candidate.id === "one"), item, "full details leave the preview Item unchanged");
634 }
635
636 // ── reclaiming a page never strands a tool result ──────────────────────────
637 // A result row whose call was reclaimed names a call the reader can no longer
638 // see. Pages here are 2 messages wide over 3-message turns, so page boundaries
639 // fall between a call and its result and the reclaim has to widen past it.
640 {
641 const messages: HistoryMessage[] = [];
642 for (let i = 0; i < 12; i += 1) {
643 messages.push({ role: "user", content: `q${i}` });
644 messages.push({ role: "assistant", content: "", toolCalls: [{ id: `call-${i}`, name: "bash", arguments: `run ${i}` }] });
645 messages.push({ role: "tool", toolCallId: `call-${i}`, toolName: "bash", content: `out ${i}` });
646 }
647 const backend = new FakeBackend(messages);
648 const store = new TranscriptStore(backend, { windowMaxPages: 2 });
649 const residentIds = () => new Set((store.peek("tab-tool", "/s/tool.jsonl")?.items ?? []).map((item) => item.id));
650
651 // Page back to the head. Page [0,2) holds turn 0's call; the page after it
652 // starts with that call's result, so the boundary splits the pair.
653 await store.loadLatest("tab-tool", "/s/tool.jsonl", { entries: 2 });
654 for (let page = 0; page < 40; page += 1) {
655 if (!await store.loadOlder("tab-tool", "/s/tool.jsonl", { entries: 2 })) break;
656 }
657 const atHead = residentIds();
658 ok(atHead.size > 0, "paging reaches the head of the transcript");
659
660 // Growing forward reclaims the head page. The result that belonged to a call
661 // on that page has to go with it, or the reader keeps an output row whose
662 // call is no longer on screen.
663 const newer = await store.loadNewer("tab-tool", "/s/tool.jsonl", { entries: 2 });
664 ok(newer?.kind === "append", "paging forward appends after reaching the head");
665 ok(store.stats().reclaimedPages > 0, "growing forward reclaimed a page");
666 const afterReclaim = residentIds();
667 for (const id of atHead) {
668 if (!/^call-\d+$/.test(id)) continue;
669 ok(!afterReclaim.has(id), `reclaimed call ${id} did not leave its result behind`);
670 }
671 ok(store.stats().residentWindowEntries <= 2 * 2, "the window stayed at its page budget");
672 }
673
674 // A completed result may live outside the resident page. Its locator supplies
675 // execution evidence, while the body is fetched only when explicitly expanded.
676 {
677 const output = "跨页结果✓".repeat(30);
678 const bytes = new TextEncoder().encode(JSON.stringify({content:output,tool_execution:{state:"completed"}}));
679 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"}}}]}]);
680 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});
681 const store = new TranscriptStore(backend,{windowMaxPages:1});
682 const view = await store.loadLatest("detached-tab","/detached");
683 const item=view?.items.find((item):item is Extract<Item,{kind:"tool"}>=>item.kind==="tool");
684 if(!item) throw new Error("detached tool missing");
685 eq(item.status,"done","cross-page completion is authoritative");
686 eq(item.contentState,"unloaded","body is separately unloaded");
687 const before=store.stats().residentWindowEntries;
688 const loaded=JSON.parse((await store.requestToolContent("detached-tab",item,{}))!);
689 eq(loaded.output,output,"detached UTF-8 output is complete");
690 eq(store.stats().residentWindowEntries,before,"detached results do not grow the resident window");
691 backend.HistoryContentForTab=async()=>{throw new Error("unreadable result")};
692 let failed=false;try{await store.requestToolContent("detached-tab",item,{})}catch{failed=true}
693 ok(failed,"unreadable content remains an error");
694 eq(item.status,"done","read failure does not turn completion into cancellation");
695 }
696
697 await verifyTranscriptContentOwnership();
698 console.log(`\n${passed} passed, ${failed} failed; content ownership interleavings passed`);
699 if (failed > 0) process.exit(1);
700
700 lines TYPESCRIPT