返回 DeepSeek-Reasonix
chat-turn-jump.test.ts
根目录 / desktop / frontend / src / __tests__ / chat-turn-jump.test.ts
1 import assert from "node:assert/strict";
2 import { ChatMountedOrder } from "../lib/chatMountedOrder";
3 import { ChatTurnJump } from "../lib/chatTurnJump";
4 import type { ChatScrollController } from "../lib/chatScrollController";
5 import { alignOutlineEntries, findLoadedTurn, indexLoadedTurns, recordIdOf } from "../lib/chatTurnRail";
6 import type { TranscriptOutlineEntry } from "../lib/transcriptProtocol";
7
8 // Node has no animation frame; the mount-settle path is driven by the mounted
9 // store in these tests, so a recorded no-op keeps it deterministic.
10 const frames: FrameRequestCallback[] = [];
11 (globalThis as unknown as { requestAnimationFrame: (cb: FrameRequestCallback) => number }).requestAnimationFrame =
12 (callback) => frames.push(callback);
13 (globalThis as unknown as { cancelAnimationFrame: (id: number) => void }).cancelAnimationFrame = () => {};
14
15 function flushFrames(): void {
16 const pending = frames.splice(0);
17 for (const callback of pending) callback(0);
18 }
19
20 function deferred<T>() {
21 let resolve!: (value: T) => void;
22 let reject!: (error: unknown) => void;
23 const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
24 return { promise, resolve, reject };
25 }
26
27 /** Records the writes a jump asks the shared gateway to make. */
28 function fakeScroll() {
29 const jumps: string[] = [];
30 const readers = new Set<() => void>();
31 let stopped = false;
32 const scroll = {
33 stopFollowing: () => { stopped = true; },
34 jump: (key: string) => { jumps.push(key); },
35 subscribeReaderIntent: (listener: () => void) => { readers.add(listener); return () => { readers.delete(listener); }; },
36 subscribe: () => () => {},
37 getSnapshot: () => ({ following: false, activeKey: "" }),
38 };
39 return {
40 scroll: scroll as unknown as ChatScrollController,
41 jumps,
42 stopped: () => stopped,
43 // Reader intent is what a wheel, touch, key press, or return-to-bottom
44 // reports through the controller's dedicated channel.
45 readerIntent: () => { for (const listener of [...readers]) listener(); },
46 readerCount: () => readers.size,
47 };
48 }
49
50 function jumpFor(mounts: ChatMountedOrder, options: {
51 mounted: Set<string>;
52 pages?: string[][];
53 hasOlder?: () => boolean;
54 current?: () => boolean;
55 snapshotId?: () => string;
56 refresh?: (entry: TranscriptOutlineEntry) => Promise<TranscriptOutlineEntry | undefined>;
57 drainMs?: number;
58 }) {
59 const fake = fakeScroll();
60 let pageIndex = 0;
61 const loads: number[] = [];
62 const jump = new ChatTurnJump({
63 mounts,
64 scroll: fake.scroll,
65 loadOlder: async () => {
66 loads.push(pageIndex);
67 const revealed = options.pages?.[pageIndex] ?? [];
68 pageIndex += 1;
69 for (const key of revealed) options.mounted.add(key);
70 // One page also advances the progressive mount.
71 mounts.publish([...options.mounted]);
72 return revealed.length > 0 ? "loaded" as const : "empty" as const;
73 },
74 hasOlder: options.hasOlder ?? (() => true),
75 resolveKey: (entry) => (options.mounted.has(entry.id) ? entry.id : undefined),
76 currentSnapshotId: options.snapshotId ?? (() => "cut"),
77 refreshSnapshot: options.refresh ?? (async (entry) => entry),
78 isCurrent: options.current ?? (() => true),
79 drainMs: options.drainMs,
80 });
81 return { jump, fake, loads, mounted: options.mounted };
82 }
83
84 function target(id: string): TranscriptOutlineEntry {
85 return { id, messageId: id.replace("m:", ""), turn: 1, order: 0, prompt: "p", answer: "a" };
86 }
87
88 async function main() {
89 {
90 // An already-mounted target must not page at all.
91 const mounts = new ChatMountedOrder();
92 const state = jumpFor(mounts, { mounted: new Set(["m:1"]) });
93 await state.jump.jump(target("m:1"));
94 assert.deepEqual(state.fake.jumps, ["m:1"], "a loaded target scrolls immediately");
95 assert.deepEqual(state.loads, [], "a loaded target does not page history");
96 assert.equal(state.jump.getSnapshot().status, "idle", "the jump completes");
97 assert.ok(state.fake.stopped(), "a jump leaves tail following before it writes");
98 }
99
100 {
101 // An unloaded target pages until its node is really mounted, then scrolls.
102 const mounts = new ChatMountedOrder();
103 const state = jumpFor(mounts, { mounted: new Set(["m:9"]), pages: [["m:5"], ["m:3"], ["m:1"]] });
104 await state.jump.jump(target("m:1"));
105 assert.deepEqual(state.loads, [0, 1, 2], "history is paged one batch at a time");
106 assert.deepEqual(state.fake.jumps, ["m:1"], "the write happens only after the node mounts");
107 assert.equal(state.jump.getSnapshot().status, "idle");
108 }
109
110 {
111 // Exhausting history without reaching the target is a specific failure.
112 const mounts = new ChatMountedOrder();
113 const state = jumpFor(mounts, { mounted: new Set(["m:9"]), pages: [["m:9"], ["m:8"]] });
114 await state.jump.jump(target("m:404"));
115 assert.deepEqual(state.fake.jumps, [], "an unreachable target never moves the viewport");
116 assert.equal(state.jump.getSnapshot().status, "failed");
117 assert.equal(state.jump.getSnapshot().reason, "turnUnavailable");
118 }
119
120 {
121 // No older history at all fails instead of looping. The wait for a
122 // progressively mounting last page is still honoured first.
123 const mounts = new ChatMountedOrder();
124 const state = jumpFor(mounts, { mounted: new Set(), hasOlder: () => false, drainMs: 0 });
125 await state.jump.jump(target("m:404"));
126 assert.deepEqual(state.loads, [], "a missing target with no history is not retried");
127 assert.equal(state.jump.getSnapshot().status, "failed");
128 assert.equal(state.jump.getSnapshot().reason, "turnUnavailable");
129 }
130
131 {
132 // Exhausted history is not the same as an unreachable turn: the last page
133 // mounts progressively, so the target can still appear afterwards.
134 const mounts = new ChatMountedOrder();
135 const state = jumpFor(mounts, { mounted: new Set(["m:9"]), pages: [["m:5"]], hasOlder: () => false });
136 const pending = state.jump.jump(target("m:1"));
137 let settled = false;
138 void pending.then(() => { settled = true; });
139 // The reveal lands several frames after the page that carried it.
140 for (let i = 0; i < 50 && !settled; i++) {
141 await Promise.resolve();
142 state.mounted.add("m:1");
143 flushFrames();
144 }
145 await pending;
146 assert.deepEqual(state.fake.jumps, ["m:1"], "a target that mounts after the last page is still reached");
147 assert.equal(state.jump.getSnapshot().status, "idle");
148 }
149
150 {
151 // Budget exhaustion is reported as such, not as a missing turn.
152 const mounts = new ChatMountedOrder();
153 const state = jumpFor(mounts, { mounted: new Set(), pages: Array.from({ length: 500 }, (_, index) => [`m:${index}`]) });
154 await state.jump.jump(target("m:nowhere"));
155 assert.equal(state.jump.getSnapshot().status, "failed");
156 assert.equal(state.jump.getSnapshot().reason, "pageBudgetExhausted", "a page budget is not a missing turn");
157 }
158
159 {
160 // A recycled snapshot ends the jump instead of silently replacing the body.
161 const mounts = new ChatMountedOrder();
162 const fake = fakeScroll();
163 const jump = new ChatTurnJump({
164 mounts, scroll: fake.scroll,
165 loadOlder: async () => "stale" as const,
166 hasOlder: () => true,
167 resolveKey: () => undefined,
168 currentSnapshotId: () => "cut",
169 refreshSnapshot: async (entry) => entry,
170 isCurrent: () => true,
171 });
172 await jump.jump(target("m:1"));
173 assert.equal(jump.getSnapshot().status, "failed");
174 assert.equal(jump.getSnapshot().reason, "snapshotExpired", "a recycled cut is its own outcome");
175 assert.deepEqual(fake.jumps, [], "a recycled cut never moves the viewport");
176 }
177
178 {
179 // Retrying a recycled cut must install a fresh snapshot first: re-running
180 // the same jump against the same dead cut just fails again.
181 const mounts = new ChatMountedOrder();
182 let snapshot = "cut";
183 let refreshes = 0;
184 let stale = true;
185 const fake = fakeScroll();
186 let mounted: string | undefined;
187 const jump = new ChatTurnJump({
188 mounts, scroll: fake.scroll,
189 loadOlder: async () => {
190 if (stale) return "stale" as const;
191 // The retry's page is productive and advances the mount, as a real one
192 // does; the recycled cut never got this far.
193 mounted = "m:1";
194 mounts.publish(["m:1"]);
195 return "loaded" as const;
196 },
197 hasOlder: () => true,
198 resolveKey: () => mounted,
199 currentSnapshotId: () => snapshot,
200 refreshSnapshot: async (entry) => { refreshes += 1; stale = false; snapshot = "cut:2"; return entry; },
201 isCurrent: () => true,
202 drainMs: 0,
203 });
204 await jump.jump(target("m:1"));
205 assert.equal(jump.getSnapshot().reason, "snapshotExpired", "the recycled cut is reported");
206 await jump.retry();
207 assert.equal(refreshes, 1, "the retry installs a fresh snapshot");
208 assert.deepEqual(fake.jumps, ["m:1"], "the retry reaches the target on the new cut");
209 assert.equal(jump.getSnapshot().status, "idle", "a successful retry ends the transaction");
210 }
211
212 {
213 // A newer click during the refresh abandons the retry, like any other
214 // pending transaction.
215 const mounts = new ChatMountedOrder();
216 const refreshed = deferred<TranscriptOutlineEntry | undefined>();
217 let refreshStarted = false;
218 const fake = fakeScroll();
219 const jump = new ChatTurnJump({
220 mounts, scroll: fake.scroll,
221 loadOlder: async () => "stale" as const,
222 hasOlder: () => true,
223 resolveKey: () => undefined,
224 currentSnapshotId: () => "cut",
225 refreshSnapshot: async () => { refreshStarted = true; return refreshed.promise; },
226 isCurrent: () => true,
227 });
228 const entry = target("m:1");
229 await jump.jump(entry);
230 const pending = jump.retry();
231 assert.equal(refreshStarted, true, "the retry is waiting inside snapshot refresh");
232 jump.jumpTo("u9");
233 refreshed.resolve(entry);
234 await pending;
235 assert.deepEqual(fake.jumps, ["u9"], "a click during the refresh wins");
236 assert.equal(jump.getSnapshot().status, "idle");
237 }
238
239 {
240 // A transient refresh failure keeps the original target retryable. The
241 // next retry must call the refresher again and can then reach the turn.
242 const mounts = new ChatMountedOrder();
243 const fake = fakeScroll();
244 let refreshes = 0;
245 let stale = true;
246 let mounted: string | undefined;
247 const jump = new ChatTurnJump({
248 mounts, scroll: fake.scroll,
249 loadOlder: async () => {
250 if (stale) return "stale" as const;
251 mounted = "m:1";
252 mounts.publish(["m:1"]);
253 return "loaded" as const;
254 },
255 hasOlder: () => true,
256 resolveKey: () => mounted,
257 currentSnapshotId: () => stale ? "cut" : "cut:2",
258 refreshSnapshot: async (entry) => {
259 refreshes += 1;
260 if (refreshes === 1) throw new Error("network down");
261 stale = false;
262 return entry;
263 },
264 isCurrent: () => true,
265 });
266 await jump.jump(target("m:1"));
267 await jump.retry();
268 assert.equal(jump.getSnapshot().status, "failed", "a failed refresh returns to a retryable state");
269 assert.equal(jump.getSnapshot().reason, "snapshotExpired");
270 assert.ok(jump.getSnapshot().retry, "the failed refresh retains its target");
271 await jump.retry();
272 assert.equal(refreshes, 2, "the second retry invokes snapshot refresh again");
273 assert.deepEqual(fake.jumps, ["m:1"], "the second retry can reach the refreshed target");
274 }
275
276 {
277 // A replaced snapshot invalidates the locators this jump was resolved
278 // against, so it stops rather than continuing against the new body.
279 const mounts = new ChatMountedOrder();
280 let snapshot = "cut";
281 const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]], snapshotId: () => snapshot });
282 const pending = state.jump.jump(target("m:1"));
283 await Promise.resolve();
284 snapshot = "cut:2";
285 await pending;
286 assert.deepEqual(state.fake.jumps, [], "a replaced snapshot cancels the pending jump");
287 assert.equal(state.loads.length, 1, "no page is requested against the replaced snapshot");
288 // Stopping is not enough: the transaction must also release the state it
289 // still owns, or the mark pulses forever and the reader stays subscribed.
290 assert.equal(state.jump.getSnapshot().status, "idle", "a replaced snapshot ends the loading state");
291 assert.equal(state.fake.readerCount(), 0, "a replaced snapshot releases the reader subscription");
292 }
293
294 {
295 // Clicking an already-loaded turn must supersede a pending jump rather than
296 // race it: both go through the same transaction.
297 const mounts = new ChatMountedOrder();
298 const state = jumpFor(mounts, { mounted: new Set(["u4"]), pages: [["m:5"], ["m:1"]] });
299 const pending = state.jump.jump(target("m:1"));
300 await Promise.resolve();
301 state.jump.jumpTo("u4");
302 await pending;
303 assert.deepEqual(state.fake.jumps, ["u4"], "the newest click wins and the pending jump is abandoned");
304 assert.equal(state.jump.getSnapshot().status, "idle");
305 }
306
307 {
308 // A page that adds nothing stops the loop rather than spinning the network.
309 const mounts = new ChatMountedOrder();
310 const state = jumpFor(mounts, { mounted: new Set(), pages: [[]] });
311 await state.jump.jump(target("m:404"));
312 assert.equal(state.loads.length, 1, "an unproductive page ends the jump");
313 assert.equal(state.jump.getSnapshot().status, "failed");
314 }
315
316 {
317 // Reader intent preempts a pending jump and no later page takes the viewport.
318 const mounts = new ChatMountedOrder();
319 const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]] });
320 const pending = state.jump.jump(target("m:1"));
321 await Promise.resolve();
322 state.fake.readerIntent();
323 await pending;
324 assert.deepEqual(state.fake.jumps, [], "a preempted jump never scrolls");
325 assert.equal(state.loads.length, 1, "no page is requested after the reader takes over");
326 assert.equal(state.jump.getSnapshot().status, "idle", "preemption clears the busy state");
327 assert.equal(state.fake.readerCount(), 0, "the reader subscription is released");
328 }
329
330 {
331 // A newer target supersedes the pending one; only the newest may scroll.
332 const mounts = new ChatMountedOrder();
333 const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:2"], ["m:7"]] });
334 const first = state.jump.jump(target("m:1"));
335 await Promise.resolve();
336 const second = state.jump.jump(target("m:7"));
337 await Promise.all([first, second]);
338 assert.deepEqual(state.fake.jumps, ["m:7"], "only the newest target takes scroll control");
339 assert.equal(state.jump.getSnapshot().status, "idle");
340 }
341
342 {
343 // A replaced session leaves no late callback able to move the viewport.
344 const mounts = new ChatMountedOrder();
345 let current = true;
346 const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]], current: () => current });
347 const pending = state.jump.jump(target("m:1"));
348 await Promise.resolve();
349 current = false;
350 await pending;
351 assert.deepEqual(state.fake.jumps, [], "a replaced session cannot take scroll control back");
352 assert.equal(state.loads.length, 1, "no further page is requested for a replaced session");
353 }
354
355 {
356 // Cancelling explicitly ends the pending transaction.
357 const mounts = new ChatMountedOrder();
358 const state = jumpFor(mounts, { mounted: new Set(), pages: [["m:5"], ["m:1"]] });
359 const pending = state.jump.jump(target("m:1"));
360 await Promise.resolve();
361 state.jump.cancel();
362 await pending;
363 assert.deepEqual(state.fake.jumps, [], "a cancelled jump never scrolls");
364 assert.equal(state.jump.getSnapshot().status, "idle");
365 }
366
367 {
368 // The bounded frame budget resolves the settle wait even when nothing
369 // publishes, so a jump cannot hang on an idle mount.
370 const mounts = new ChatMountedOrder();
371 const state = jumpFor(mounts, { mounted: new Set() });
372 let pages = 0;
373 const jump = new ChatTurnJump({
374 mounts,
375 scroll: state.fake.scroll,
376 loadOlder: async () => { pages += 1; return "loaded" as const; },
377 hasOlder: () => pages < 3,
378 resolveKey: () => undefined,
379 currentSnapshotId: () => "cut",
380 refreshSnapshot: async (entry) => entry,
381 isCurrent: () => true,
382 });
383 let settled = false;
384 const pending = jump.jump(target("m:1")).then(() => { settled = true; });
385 // Drive microtasks and frames together: the settle wait must expire on its
386 // own frame budget even though nothing ever publishes a mount.
387 for (let i = 0; i < 20_000 && !settled; i++) {
388 await Promise.resolve();
389 flushFrames();
390 }
391 await pending;
392 assert.ok(settled, "the mount wait is bounded and the jump terminates");
393 assert.equal(pages, 3, "paging stops as soon as history is exhausted");
394 assert.equal(jump.getSnapshot().status, "failed", "an unreachable target ends as a failure, not a hang");
395 }
396
397 {
398 // Identity resolution. The node's own identity decides, never the shape of
399 // its anchor key: a question written in this app session keeps its
400 // optimistic `u<seq>` id after the authoritative message arrives and only
401 // gains a messageId, so a key-shaped match would miss the turns the reader
402 // just wrote — the exact regression this covers.
403 const settled: TranscriptOutlineEntry = { id: "m:abc", messageId: "abc", turn: 1, order: 0, prompt: "", answer: "" };
404 const nodes = new Map<string, { id: string; messageId?: string }>([
405 ["u7", { id: "u7", messageId: "abc" }],
406 ["m:other", { id: "m:other" }],
407 ]);
408 const index = (order: string[]) => indexLoadedTurns(order, (key) => nodes.get(key));
409 assert.equal(findLoadedTurn(index(["u7"]), settled), "u7", "an optimistically submitted question is found by its message ID");
410 assert.equal(findLoadedTurn(index([]), settled), undefined, "an unmounted question has no key");
411 assert.equal(findLoadedTurn(index(["m:other"]), settled), undefined, "an unrelated node is not claimed");
412
413 const uncommitted: TranscriptOutlineEntry = { id: "m:tmp", turn: 2, order: 2, prompt: "", answer: "" };
414 nodes.set("m:tmp", { id: "m:tmp" });
415 assert.equal(findLoadedTurn(index(["m:tmp"]), uncommitted), "m:tmp", "an uncommitted question resolves by record ID");
416
417 // History that carries no message id is keyed `record:<recordId>` by the
418 // transcript, while the outline carries the bare record id. Comparing the
419 // two item keys directly never matches, so the conversion is part of the
420 // contract rather than an accident of the fixture.
421 nodes.set("record:m:xyz", { id: "record:m:xyz" });
422 const historical: TranscriptOutlineEntry = { id: "m:xyz", turn: 3, order: 4, prompt: "", answer: "" };
423 assert.equal(findLoadedTurn(index(["record:m:xyz"]), historical), "record:m:xyz",
424 "a history record without a message ID resolves through its record key");
425 assert.equal(recordIdOf({ id: "record:m:xyz" }), "m:xyz", "the item key converts back to the outline identity");
426 assert.equal(recordIdOf({ id: "u7", messageId: "abc" }), "m:abc", "a settled question converts through its message ID");
427
428 // A message ID match wins even when a record-ID-only match appears earlier
429 // in the mounted order, so settlement cannot move a mark to a stale node.
430 nodes.set("m:abc:legacy", { id: "m:abc" });
431 assert.equal(findLoadedTurn(index(["m:abc:legacy", "u7"]), settled), "u7",
432 "a message ID match outranks an earlier record ID match");
433
434 // Indexing once and looking up per entry is what keeps a long conversation
435 // linear instead of quadratic.
436 const wide = Array.from({ length: 4000 }, (_, i) => `u${i}`);
437 for (let i = 0; i < 4000; i++) nodes.set(`u${i}`, { id: `u${i}`, messageId: `${i}` });
438 const wideIndex = indexLoadedTurns(wide, (key) => nodes.get(key));
439 const started = Date.now();
440 for (let i = 0; i < 4000; i++) {
441 findLoadedTurn(wideIndex, { id: `m:${i}`, messageId: `${i}`, turn: i, order: i, prompt: "", answer: "" });
442 }
443 const elapsed = Date.now() - started;
444 assert.ok(elapsed < 500, `4000 lookups over 4000 nodes took ${elapsed}ms; the merge must not rescan per entry`);
445
446 const aligned = alignOutlineEntries([
447 { id: "m:b", turn: 2, order: 2, prompt: "", answer: "" },
448 { id: "m:a", turn: 1, order: 0, prompt: "", answer: "" },
449 { id: "m:b", turn: 2, order: 2, prompt: "duplicate", answer: "" },
450 ]);
451 assert.deepEqual(aligned.map(item => item.id), ["m:a", "m:b"], "entries are ordered by snapshot position and de-duplicated");
452 }
453
454 console.log("chat turn jump: mount-confirmed paging, preemption, supersession, replacement, cancel and rail identity passed");
455 }
456
457 await main();
458
458 lines TYPESCRIPT