| 1 | import assert from "node:assert/strict"; |
| 2 | import test from "node:test"; |
| 3 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 4 | import type { TranscriptSnapshot } from "../lib/transcriptProtocol"; |
| 5 | |
| 6 | Object.defineProperty(globalThis, "window", { configurable: true, value: {} }); |
| 7 | const commands: Record<string, unknown> = {}; |
| 8 | installDesktopHostStub(commands); |
| 9 | const { bindNativeTranscriptHistory, nativeSnapshotWindow, readNativeTranscriptWindow, readNativeTranscriptOutline } = await import("../lib/nativeTranscriptHistory"); |
| 10 | |
| 11 | function snapshot(before: number, count: number, total = 100): TranscriptSnapshot { |
| 12 | const start = Math.max(0, before - count); |
| 13 | return { protocolVersion: 1, snapshotId: "cut", identity: { sessionId: "old", headId: "main", rewriteEpoch: 0, runtimeEpoch: "epoch" }, |
| 14 | coveredThroughSeq: 7, projectionRevision: 7, runtime: { pendingEvents: [] }, activeRecords: [], activeAttempts: [], |
| 15 | records: Array.from({ length: before - start }, (_, index) => ({ id: `m:${start + index}`, order: start + index, |
| 16 | message: { role: "user", messageId: String(start + index), content: "text", historyTurn: start + index + 1 }, refs: [] })), |
| 17 | before: start, hasOlder: start > 0, totalRecords: total, totalTurns: total, stale: false }; |
| 18 | } |
| 19 | |
| 20 | test("native pages remain bounded and reachable in both directions", async () => { |
| 21 | commands.TranscriptPageForTab = async (_: string, req: { before: number; records: number }) => snapshot(Math.min(100, req.before), req.records); |
| 22 | const initial = snapshot(100, 32); |
| 23 | const release = bindNativeTranscriptHistory("native", initial, false); |
| 24 | let page = nativeSnapshotWindow(initial); |
| 25 | const seen = new Set(page.entries.map(entry => entry.entryId)); |
| 26 | while (page.hasOlder) { |
| 27 | page = (await readNativeTranscriptWindow("native", { anchor: "cursor", cursor: page.olderCursor, limit: 32 }))!; |
| 28 | assert.ok(page.entries.length <= 32); |
| 29 | page.entries.forEach(entry => seen.add(entry.entryId)); |
| 30 | } |
| 31 | assert.equal(seen.size, 100); |
| 32 | let nextOrder = page.entries[page.entries.length - 1]!.order + 1; |
| 33 | while (page.hasNewer) { |
| 34 | page = (await readNativeTranscriptWindow("native", { anchor: "cursor", cursor: page.newerCursor, limit: 32 }))!; |
| 35 | assert.ok(page.entries.length <= 32); |
| 36 | assert.equal(page.entries[0].order, nextOrder, "forward pages neither overlap nor skip"); |
| 37 | nextOrder = page.entries[page.entries.length - 1]!.order + 1; |
| 38 | } |
| 39 | assert.equal(page.entries[page.entries.length - 1]?.entryId, "m:99"); |
| 40 | release(); |
| 41 | }); |
| 42 | |
| 43 | test("a requested fixed cut is retained and conflicting cursors are stale", async () => { |
| 44 | commands.TranscriptPageForTab = async (_: string, req: { snapshotId: string; before: number; records: number }) => { |
| 45 | assert.equal(req.snapshotId, "previous-cut"); |
| 46 | assert.equal(req.before, undefined, "newest uses the requested cut's end, not the live binding's size"); |
| 47 | return { ...snapshot(150, req.records, 150), snapshotId: req.snapshotId }; |
| 48 | }; |
| 49 | const release = bindNativeTranscriptHistory("fixed", snapshot(100, 32), false); |
| 50 | const fixed = await readNativeTranscriptWindow("fixed", { generation: "previous-cut", anchor: "newest" }); |
| 51 | assert.equal(fixed?.digest, "previous-cut"); |
| 52 | assert.equal(fixed?.entries[fixed.entries.length - 1]?.order, 149); |
| 53 | const old = nativeSnapshotWindow(snapshot(100, 32)); |
| 54 | assert.equal((await readNativeTranscriptWindow("fixed", { anchor: "cursor", generation: "previous-cut", cursor: old.olderCursor }))?.status, "stale_cursor"); |
| 55 | assert.equal((await readNativeTranscriptWindow("fixed", { anchor: "cursor", cursor: "broken" }))?.status, "stale_cursor"); |
| 56 | release(); |
| 57 | }); |
| 58 | |
| 59 | test("late native pages cannot publish after replacement and never fall back to another host", async () => { |
| 60 | let finish!: (value: TranscriptSnapshot) => void; |
| 61 | commands.TranscriptPageForTab = () => new Promise<TranscriptSnapshot>(resolve => { finish = resolve; }); |
| 62 | commands.RemoteTranscriptPageForTab = () => { throw new Error("wrong host"); }; |
| 63 | const releaseOld = bindNativeTranscriptHistory("switched", snapshot(100, 32), false); |
| 64 | const pending = readNativeTranscriptWindow("switched", { anchor: "newest", limit: 32 }); |
| 65 | const releaseNew = bindNativeTranscriptHistory("switched", snapshot(100, 32), false); |
| 66 | releaseOld(); |
| 67 | finish(snapshot(100, 32)); |
| 68 | assert.equal((await pending)?.status, "stale_cursor"); |
| 69 | commands.TranscriptPageForTab = () => { throw new Error("disk error"); }; |
| 70 | await assert.rejects(readNativeTranscriptWindow("switched", { anchor: "newest" }), /disk error/); |
| 71 | releaseNew(); |
| 72 | assert.equal(await readNativeTranscriptWindow("switched", { anchor: "newest" }), undefined); |
| 73 | }); |
| 74 | |
| 75 | test("a byte-limited forward page does not skip oversized records", async () => { |
| 76 | commands.TranscriptPageForTab = async (_: string, req: { before: number }) => snapshot(req.before, 1); |
| 77 | const release = bindNativeTranscriptHistory("large", snapshot(100, 32), false); |
| 78 | const older = nativeSnapshotWindow(snapshot(32, 32)); |
| 79 | const next = await readNativeTranscriptWindow("large", { anchor: "cursor", cursor: older.newerCursor, limit: 32 }); |
| 80 | assert.equal(next?.entries[0].order, 32); |
| 81 | release(); |
| 82 | }); |
| 83 | |
| 84 | test("late missing-turn lookup cannot publish into a replacement binding", async () => { |
| 85 | let finish!: (value: unknown) => void; |
| 86 | commands.TranscriptOutlineForTab = () => new Promise(resolve => { finish = resolve; }); |
| 87 | commands.TranscriptPageForTab = () => { throw new Error("retired lookup continued"); }; |
| 88 | const releaseOld = bindNativeTranscriptHistory("turn-switch", snapshot(100, 32), false); |
| 89 | const pending = readNativeTranscriptWindow("turn-switch", { anchor: "turn", turn: 1000 }); |
| 90 | const releaseNew = bindNativeTranscriptHistory("turn-switch", snapshot(100, 32), false); |
| 91 | releaseOld(); |
| 92 | finish({ entries: [], stale: false }); |
| 93 | assert.equal((await pending)?.status, "stale_cursor"); |
| 94 | releaseNew(); |
| 95 | }); |
| 96 | |
| 97 | test("remote native message and outline lookup use the negotiated host and fixed cut", async () => { |
| 98 | commands.TranscriptPageForTab = () => { throw new Error("wrong host"); }; |
| 99 | commands.TranscriptOutlineForTab = () => { throw new Error("wrong host"); }; |
| 100 | commands.RemoteTranscriptPageForTab = async (_: string, req: { snapshotId: string; messageId: string }) => { |
| 101 | assert.equal(req.snapshotId, "cut"); |
| 102 | assert.equal(req.messageId, "50"); |
| 103 | return snapshot(51, 1); |
| 104 | }; |
| 105 | commands.RemoteTranscriptOutlineForTab = async (_: string, req: { snapshotId: string; offset: number }) => { |
| 106 | assert.equal(req.snapshotId, "cut"); |
| 107 | assert.equal(req.offset, 50); |
| 108 | return { entries: [{ id: "m:50", messageId: "50", turn: 51, order: 50, prompt: "question", answer: "answer" }], total: 100, nextOffset: 51, done: false, stale: false }; |
| 109 | }; |
| 110 | const release = bindNativeTranscriptHistory("remote-native", snapshot(100, 32), true); |
| 111 | const message = await readNativeTranscriptWindow("remote-native", { anchor: "message", messageId: "50", generation: "cut" }); |
| 112 | assert.equal(message?.entries[0].entryId, "m:50"); |
| 113 | const outline = await readNativeTranscriptOutline("remote-native", { generation: "cut", startTurn: 51, limit: 1 }); |
| 114 | assert.equal(outline?.entries[0].messageId, "50"); |
| 115 | assert.equal(outline?.nextTurn, 52); |
| 116 | release(); |
| 117 | }); |
| 118 |