返回 DeepSeek-Reasonix
transcript-outline-store.test.ts
根目录 / desktop / frontend / src / __tests__ / transcript-outline-store.test.ts
1 import assert from "node:assert/strict";
2 import { OutlineUnsupported, TranscriptOutlineStore, type OutlineRead } from "../lib/transcriptOutlineStore";
3 import type { TranscriptOutlineEntry, TranscriptOutlinePage, TranscriptOutlineRequest } from "../lib/transcriptProtocol";
4
5 function entry(index: number, extra: Partial<TranscriptOutlineEntry> = {}): TranscriptOutlineEntry {
6 return { id: `m:${index}`, messageId: `${index}`, turn: index, order: index, prompt: `prompt ${index}`, answer: `answer ${index}`, ...extra };
7 }
8
9 function page(snapshotId: string, entries: TranscriptOutlineEntry[], nextOffset: number, done: boolean): TranscriptOutlinePage {
10 return { protocolVersion: 1, snapshotId, entries, nextOffset, done, total: entries.length, stale: false };
11 }
12
13 /** Answers from a scripted page table and records every request. */
14 function scripted(snapshotId: string, pages: Map<number, TranscriptOutlinePage>) {
15 const requests: TranscriptOutlineRequest[] = [];
16 const read: OutlineRead = async (_tabId, request) => {
17 requests.push(request);
18 const found = pages.get(request.offset ?? 0);
19 if (!found) throw new Error(`unexpected outline offset ${request.offset}`);
20 return found;
21 };
22 return { read, requests, snapshotId };
23 }
24
25 async function main() {
26 {
27 // Complete multi-page outline, assembled in offset order regardless of how
28 // the host ordered the pages.
29 const store = new TranscriptOutlineStore();
30 store.register("tab", scripted("s1", new Map([
31 [0, page("s1", [entry(1), entry(2)], 2, false)],
32 [2, page("s1", [entry(3)], 3, true)],
33 ])).read);
34 await store.sync("tab", "s1");
35 const view = store.getView("tab");
36 assert.equal(view.mode, "ready", "multi-page outline is ready");
37 assert.deepEqual(view.entries.map(item => item.id), ["m:1", "m:2", "m:3"], "every page is assembled in order");
38 }
39
40 {
41 // Re-reading the same snapshot must not issue another request.
42 const plan = scripted("s1", new Map([[0, page("s1", [entry(1)], 1, true)]]));
43 const store = new TranscriptOutlineStore();
44 store.register("tab", plan.read);
45 await store.sync("tab", "s1");
46 await store.sync("tab", "s1");
47 assert.equal(plan.requests.length, 1, "an unchanged snapshot reuses the index");
48 await store.sync("tab", "s2").catch(() => undefined);
49 assert.equal(plan.requests.length, 2, "a replaced snapshot re-reads");
50 }
51
52 {
53 // Duplicate identity would make the rail ambiguous.
54 const store = new TranscriptOutlineStore();
55 store.register("tab", async () => page("s1", [entry(1), entry(1, { prompt: "duplicate" })], 2, true));
56 await store.sync("tab", "s1");
57 assert.deepEqual(store.getView("tab").entries.map(item => item.id), ["m:1"], "duplicate entries collapse to one mark");
58 }
59
60 {
61 // A recycled cut must be reported, never silently answered from the newest
62 // revision: the caller has to re-resolve the target against a fresh one.
63 const store = new TranscriptOutlineStore();
64 store.register("tab", async () => ({ ...page("s1", [], 0, true), stale: true }));
65 await store.sync("tab", "s1");
66 const view = store.getView("tab");
67 assert.equal(view.mode, "error", "a recycled cut is an error, not an empty outline");
68 assert.equal(view.entries.length, 0);
69 }
70
71 {
72 // A cursor that does not advance would page forever.
73 const store = new TranscriptOutlineStore();
74 store.register("tab", async () => page("s1", [entry(1)], 0, false));
75 await store.sync("tab", "s1");
76 assert.equal(store.getView("tab").mode, "error", "a stalled cursor ends the read");
77 }
78
79 {
80 // Unimplemented capability is compatibility, not failure.
81 const store = new TranscriptOutlineStore();
82 store.register("tab", async () => { throw new OutlineUnsupported("missing"); });
83 await store.sync("tab", "s1");
84 const view = store.getView("tab");
85 assert.equal(view.mode, "legacy", "an absent capability falls back to loaded turns");
86 assert.equal(view.error, undefined, "an absent capability is not shown as a retryable error");
87 }
88
89 {
90 // A real failure keeps its message so the rail can offer a retry.
91 const store = new TranscriptOutlineStore();
92 store.register("tab", async () => { throw new Error("network down"); });
93 await store.sync("tab", "s1");
94 assert.equal(store.getView("tab").mode, "error");
95 assert.equal(store.getView("tab").error, "network down");
96 }
97
98 {
99 // Releasing a tab fences a read still in flight: its result must not
100 // resurrect the outline of a session that is gone.
101 let release!: () => void;
102 const barrier = new Promise<void>(resolve => { release = resolve; });
103 const store = new TranscriptOutlineStore();
104 store.register("tab", async () => {
105 await barrier;
106 return page("s1", [entry(1)], 1, true);
107 });
108 const pending = store.sync("tab", "s1");
109 store.release("tab");
110 release();
111 await pending;
112 assert.equal(store.getView("tab").mode, "legacy", "a released tab keeps no outline from a late response");
113 }
114
115 {
116 // Replacing a cut hides and fences the old outline without unbinding its
117 // owner. A transient snapshot refresh failure must leave a second retry
118 // able to call the same refresher and rebuild the index.
119 const store = new TranscriptOutlineStore();
120 let snapshotId = "s1";
121 let refreshes = 0;
122 store.register("tab", async (_tabId, request) => page(request.snapshotId, [entry(1)], 1, true), async () => {
123 refreshes += 1;
124 if (refreshes === 1) throw new Error("network down");
125 snapshotId = "s2";
126 await store.load("tab", snapshotId);
127 });
128 await store.sync("tab", snapshotId);
129 store.invalidate("tab");
130 assert.equal(store.getView("tab").mode, "legacy", "the replaced cut is hidden during refresh");
131 await assert.rejects(store.refresh("tab"), /network down/);
132 await store.refresh("tab");
133 assert.equal(refreshes, 2, "the failed refresh did not discard the owner binding");
134 assert.equal(store.getView("tab").snapshotId, "s2");
135 assert.deepEqual(store.getView("tab").entries.map(item => item.id), ["m:1"]);
136 }
137
138 {
139 // A hostile or buggy host that never finishes must not drive an unbounded
140 // request-and-append loop. Each page advances the cursor by one and claims
141 // there is more, so only the page cap can stop it.
142 let requests = 0;
143 const store = new TranscriptOutlineStore();
144 store.register("tab", async (_tabId, request) => {
145 requests += 1;
146 const offset = request.offset ?? 0;
147 return page("s1", [entry(offset)], offset + 1, false);
148 });
149 await store.sync("tab", "s1");
150 const view = store.getView("tab");
151 assert.ok(requests <= 64, `endless host issued ${requests} requests`);
152 assert.equal(view.truncated, true, "an endless host is cut off");
153 assert.equal(view.mode, "ready", "the turns that did arrive still navigate");
154 }
155
156 {
157 // Running out of budget keeps what was indexed and says it is partial: a
158 // huge conversation must still navigate instead of losing its rail.
159 const store = new TranscriptOutlineStore();
160 store.register("tab", async (_tabId, request) => {
161 const offset = request.offset ?? 0;
162 const entries = Array.from({ length: 1000 }, (_, index) => entry(offset + index));
163 return page("s1", entries, offset + entries.length, false);
164 });
165 await store.sync("tab", "s1");
166 const view = store.getView("tab");
167 assert.equal(view.mode, "ready", "an oversized outline still navigates");
168 assert.equal(view.truncated, true, "the view reports that it is partial");
169 assert.ok(view.entries.length > 0 && view.entries.length <= 20_000, `kept ${view.entries.length} entries`);
170 }
171
172 {
173 // An unregistered tab never claims the capability.
174 const store = new TranscriptOutlineStore();
175 await store.sync("unbound", "s1");
176 assert.equal(store.getView("unbound").mode, "legacy", "a tab this host never loaded stays legacy");
177 }
178
179 console.log("transcript outline store: paging, dedup, stale, stall, compatibility and release fencing passed");
180 }
181
182 await main();
183
183 lines TYPESCRIPT