| 1 | import assert from "node:assert/strict"; |
| 2 | import test from "node:test"; |
| 3 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 4 | |
| 5 | Object.defineProperty(globalThis, "window", { configurable: true, value: {} }); |
| 6 | const commands: Record<string, unknown> = {}; |
| 7 | installDesktopHostStub(commands); |
| 8 | const { readBoundHistoryWindow, searchBoundHistory, releaseHistoryRead } = await import("../lib/historyReadBinding"); |
| 9 | const { TranscriptStore } = await import("../lib/transcriptStore"); |
| 10 | const { FakeBackend } = await import("./helpers/transcriptFakeBackend"); |
| 11 | function deferred<T>() { |
| 12 | let resolve!: (value: T) => void; |
| 13 | const promise = new Promise<T>(done => { resolve = done; }); |
| 14 | return { promise, resolve }; |
| 15 | } |
| 16 | const handle = (id: string) => ({ id, storageBackend: "canonical", sessionGeneration: 1, capabilities: ["history-read-binding-v1"] }); |
| 17 | const page = { status: "ready", messages: [], snapshotSequence: 1, coverageSequence: 1, generation: "g", totalTurns: 0, hasOlder: false, hasNewer: false }; |
| 18 | |
| 19 | test("late Begin is released without touching replacement navigation", async () => { |
| 20 | const first = deferred<ReturnType<typeof handle>>(); |
| 21 | const began = deferred<void>(); |
| 22 | const released: string[] = []; |
| 23 | let begins = 0; |
| 24 | commands.BeginSessionHistoryReadForTab = () => { |
| 25 | begins++; |
| 26 | if (begins === 1) { began.resolve(); return first.promise; } |
| 27 | return handle("new"); |
| 28 | }; |
| 29 | commands.ReleaseSessionHistoryRead = (id: string) => { released.push(id); }; |
| 30 | commands.ReadSessionHistoryWindow = () => page; |
| 31 | const old = readBoundHistoryWindow("A", { anchor: "newest", limit: 32 }); |
| 32 | await began.promise; |
| 33 | releaseHistoryRead("A"); |
| 34 | assert.equal((await readBoundHistoryWindow("A", { anchor: "newest", limit: 32 }))?.status, "ready"); |
| 35 | first.resolve(handle("old")); |
| 36 | assert.equal((await old)?.status, "stale_cursor"); |
| 37 | assert.deepEqual(released, ["old"]); |
| 38 | releaseHistoryRead("A"); |
| 39 | await Promise.resolve(); |
| 40 | assert.deepEqual(released, ["old", "new"]); |
| 41 | }); |
| 42 | |
| 43 | test("late page cannot publish into A after A to B to A", async () => { |
| 44 | const reply = deferred<typeof page>(); |
| 45 | const entered = deferred<void>(); |
| 46 | let reads = 0, begins = 0; |
| 47 | commands.BeginSessionHistoryReadForTab = () => handle(String(++begins)); |
| 48 | commands.ReadSessionHistoryWindow = () => { if (++reads === 1) { entered.resolve(); return reply.promise; } return page; }; |
| 49 | const old = readBoundHistoryWindow("A", { anchor: "newest", limit: 32 }); |
| 50 | await entered.promise; |
| 51 | releaseHistoryRead("A"); |
| 52 | await readBoundHistoryWindow("B", { anchor: "newest", limit: 32 }); |
| 53 | releaseHistoryRead("B"); |
| 54 | assert.equal((await readBoundHistoryWindow("A", { anchor: "newest", limit: 32 }))?.status, "ready"); |
| 55 | reply.resolve(page); |
| 56 | assert.equal((await old)?.status, "stale_cursor"); |
| 57 | releaseHistoryRead("A"); |
| 58 | }); |
| 59 | |
| 60 | test("a replaced source retires the stale preparation before a fresh window", async () => { |
| 61 | let begins = 0; |
| 62 | const released: string[] = []; |
| 63 | commands.BeginSessionHistoryReadForTab = () => handle(String(++begins)); |
| 64 | commands.ReleaseSessionHistoryRead = (id: string) => { released.push(id); }; |
| 65 | commands.ReadSessionHistoryWindow = (id: string) => ({ ...page, status: id === "1" ? "stale_cursor" : "ready" }); |
| 66 | assert.equal((await readBoundHistoryWindow("changed", { anchor: "newest" }))?.status, "stale_cursor"); |
| 67 | assert.equal((await readBoundHistoryWindow("changed", { anchor: "newest" }))?.status, "ready"); |
| 68 | assert.equal(begins, 2, "the new read cannot reuse a failed source generation forever"); |
| 69 | assert.deepEqual(released, ["1"]); |
| 70 | releaseHistoryRead("changed"); |
| 71 | }); |
| 72 | |
| 73 | test("native anchors carry the outline cut only after capability negotiation", async () => { |
| 74 | let navigation = false; |
| 75 | const requests: Record<string, unknown>[] = []; |
| 76 | commands.BeginSessionHistoryReadForTab = () => ({ ...handle("native"), storageBackend: "legacy", |
| 77 | capabilities: ["history-read-binding-v1", ...(navigation ? ["history-native-navigation-v1"] : [])] }); |
| 78 | commands.ReadSessionHistorySlice = (_id: string, req: Record<string, unknown>) => { |
| 79 | requests.push(req); |
| 80 | return { status: "ready", page: { entries: [], hasOlder: false, hasNewer: true, totalTurns: 200, startTurn: 17, endTurn: 17, revision: 9, revisionKnown: true, digest: "proof" } }; |
| 81 | }; |
| 82 | assert.equal((await readBoundHistoryWindow("native", { anchor: "turn", turn: 17 }))?.status, "unsupported"); |
| 83 | assert.equal(requests.length, 0); |
| 84 | releaseHistoryRead("native"); |
| 85 | navigation = true; |
| 86 | const result = await readBoundHistoryWindow("native", { anchor: "turn", turn: 17, generation: "bound-cut", snapshotSequence: 9, limit: 32 }); |
| 87 | assert.equal(result?.status, "ready"); |
| 88 | assert.equal(requests[requests.length - 1]?.anchor, "turn"); |
| 89 | assert.equal(requests[requests.length - 1]?.turn, 17); |
| 90 | assert.equal(requests[requests.length - 1]?.generation, "bound-cut"); |
| 91 | assert.equal(requests[requests.length - 1]?.snapshotSequence, 9); |
| 92 | await readBoundHistoryWindow("native", { anchor: "message", messageId: "sone:r9:m32:o0", generation: "bound-cut" }); |
| 93 | assert.equal(requests[requests.length - 1]?.messageId, "sone:r9:m32:o0"); |
| 94 | releaseHistoryRead("native"); |
| 95 | }); |
| 96 | |
| 97 | test("bound search negotiates native support and fences a late reply after navigation", async () => { |
| 98 | let enabled = false, reads = 0; |
| 99 | commands.BeginSessionHistoryReadForTab = () => ({ ...handle("search"), storageBackend: "legacy", |
| 100 | capabilities: ["history-read-binding-v1", ...(enabled ? ["history-native-search-v1"] : [])] }); |
| 101 | const result = { hits: [{ messageId: "old", preview: "old", position: 1, role: "user", eventSequence: 1 }], |
| 102 | status: "ready", snapshotSequence: 1, coverageSequence: 1, hasMore: false }; |
| 103 | const reply = deferred<typeof result>(), entered = deferred<void>(); |
| 104 | commands.SearchSessionHistoryRead = () => { reads++; entered.resolve(); return reply.promise; }; |
| 105 | assert.equal((await searchBoundHistory("search", "needle"))?.status, "unsupported"); |
| 106 | assert.equal(reads, 0); |
| 107 | releaseHistoryRead("search"); |
| 108 | enabled = true; |
| 109 | const old = searchBoundHistory("search", "needle"); |
| 110 | await entered.promise; |
| 111 | releaseHistoryRead("search"); |
| 112 | commands.SearchSessionHistoryRead = () => ({ ...result, hits: [], status: "preparing", coverageSequence: 0 }); |
| 113 | assert.equal((await searchBoundHistory("search", "needle"))?.status, "preparing"); |
| 114 | reply.resolve(result); |
| 115 | const late = await old; |
| 116 | assert.equal(late?.status, "stale_cursor"); |
| 117 | assert.deepEqual(late?.hits, []); |
| 118 | commands.SearchSessionHistoryRead = () => ({ ...result, hits: [], status: "stale_cursor" }); |
| 119 | assert.equal((await searchBoundHistory("search", "needle"))?.status, "stale_cursor"); |
| 120 | releaseHistoryRead("search"); |
| 121 | }); |
| 122 | |
| 123 | test("cache eviction releases its pending read without releasing a rebound tab", async () => { |
| 124 | const released: string[] = []; |
| 125 | const first = deferred<ReturnType<typeof handle>>(), began = deferred<void>(); |
| 126 | let begins = 0; |
| 127 | commands.BeginSessionHistoryReadForTab = () => { |
| 128 | if (++begins === 1) { began.resolve(); return first.promise; } |
| 129 | return handle(`read-${begins}`); |
| 130 | }; |
| 131 | commands.ReleaseSessionHistoryRead = (id: string) => { released.push(id); }; |
| 132 | commands.ReadSessionHistoryWindow = () => page; |
| 133 | const backend = new FakeBackend([{ role: "user", content: "history" }]); |
| 134 | const store = new TranscriptStore(backend, { maxResidentSessions: 1 }); |
| 135 | store.noteSessionBinding("evicted", "/old", "s\0old"); |
| 136 | await store.loadLatest("evicted", "/old"); |
| 137 | const oldRead = readBoundHistoryWindow("evicted", { anchor: "newest" }); |
| 138 | await began.promise; |
| 139 | store.noteSessionBinding("other", "/other", "s\0other"); |
| 140 | await store.loadLatest("other", "/other"); |
| 141 | assert.equal(store.isResident("evicted", "/old"), false); |
| 142 | first.resolve(handle("late-evicted")); |
| 143 | assert.equal((await oldRead)?.status, "stale_cursor"); |
| 144 | assert.deepEqual(released, ["late-evicted"], "eviction releases even a late Begin response"); |
| 145 | |
| 146 | store.noteSessionBinding("other", "/replacement", "s\0replacement"); |
| 147 | assert.equal((await readBoundHistoryWindow("other", { anchor: "newest" }))?.status, "ready"); |
| 148 | await store.loadLatest("other", "/replacement"); |
| 149 | assert.equal(store.isResident("other", "/other"), false); |
| 150 | assert.equal((await readBoundHistoryWindow("other", { anchor: "newest" }))?.status, "ready"); |
| 151 | assert.equal(begins, 2, "evicting the old cached source must retain the replacement binding"); |
| 152 | assert.deepEqual(released, ["late-evicted"]); |
| 153 | releaseHistoryRead("other"); |
| 154 | await Promise.resolve(); |
| 155 | assert.deepEqual(released, ["late-evicted", "read-2"]); |
| 156 | }); |
| 157 |