| 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 |