返回 DeepSeek-Reasonix
turn-event-projection-reset.test.ts
根目录 / desktop / frontend / src / __tests__ / turn-event-projection-reset.test.ts
1 import assert from "node:assert/strict";
2 import type { AppBindings } from "../lib/bridge";
3 import type { TurnEventReplayView } from "../lib/types";
4 import { installDesktopHostStub } from "./desktopHostStub";
5
6 let resetEntered!: () => void;
7 const resetStarted = new Promise<void>((resolve) => { resetEntered = resolve; });
8 let releaseReset!: () => void;
9 const resetReleased = new Promise<void>((resolve) => { releaseReset = resolve; });
10
11 const replay: TurnEventReplayView = {
12 events: [
13 {
14 turnId: "turn-reset",
15 seq: 11,
16 status: "in_progress",
17 event: { kind: "turn_started", turnId: "turn-reset", status: "in_progress" },
18 },
19 {
20 turnId: "turn-reset",
21 seq: 12,
22 status: "in_progress",
23 event: { kind: "text", turnId: "turn-reset", status: "in_progress", text: "durable" },
24 },
25 ],
26 floorSeq: 11,
27 latestSeq: 12,
28 nextAfterSeq: 12,
29 hasMore: false,
30 resetRequired: true,
31 transcriptRevision: 7,
32 transcriptDigest: "digest-7",
33 runtimeEpoch: "epoch-a",
34 };
35
36 const binding: Partial<AppBindings> = {
37 TurnEventsForTab: async () => replay,
38 };
39 Object.defineProperty(globalThis, "window", {
40 configurable: true,
41 value: {} as Window,
42 });
43 installDesktopHostStub(binding);
44
45 const [{ TurnEventProjector }, { initialState, reducer }] = await Promise.all([
46 import("../lib/turnEventProjection"),
47 import("../lib/useController"),
48 ]);
49
50 const projected: number[] = [];
51 const projector = new TurnEventProjector();
52 projector.bind((event) => projected.push(event.seq ?? 0));
53 projector.bindReset(async (_tabId, view) => {
54 assert.equal(view.transcriptRevision, 7);
55 resetEntered();
56 await resetReleased;
57 return true;
58 });
59 projector.observeRuntime("tab", "epoch-a", 1, 1, true);
60 assert.equal(projector.receiveLive("tab", { kind: "turn_status", seq: 3, runtimeEpoch: "epoch-a" }, "epoch-a"), false);
61 await resetStarted;
62 assert.equal(projector.receiveLive("tab", { kind: "turn_done", seq: 13, runtimeEpoch: "epoch-a", status: "completed" }, "epoch-a"), false);
63 releaseReset();
64 for (let attempt = 0; attempt < 40; attempt += 1) await Promise.resolve();
65 assert.deepEqual(projected, [11, 12, 13], "checkpoint replay is projected before the queued live tail");
66
67 let resolveStaleReplay!: (view: TurnEventReplayView) => void;
68 binding.TurnEventsForTab = async () => new Promise<TurnEventReplayView>((resolve) => { resolveStaleReplay = resolve; });
69 const staleProjection: number[] = [];
70 const releasedProjector = new TurnEventProjector();
71 releasedProjector.bind((event) => staleProjection.push(event.seq ?? 0));
72 releasedProjector.observeRuntime("released-tab", "epoch-old", 1, 1, true);
73 releasedProjector.receiveLive("released-tab", { kind: "turn_status", seq: 3, runtimeEpoch: "epoch-old" }, "epoch-old");
74 await Promise.resolve();
75 releasedProjector.release("released-tab");
76 resolveStaleReplay({ ...replay, resetRequired: false, floorSeq: 2, events: [replay.events[1]] });
77 for (let attempt = 0; attempt < 20; attempt += 1) await Promise.resolve();
78 assert.deepEqual(staleProjection, [], "a replay response that returns after tab release is discarded");
79
80 const persistedAssistant = { kind: "assistant", id: "assistant-live", text: "partial", reasoning: "", streaming: false } as const;
81 const persistedUser = { kind: "user", id: "persisted-user-live", text: "persisted question" } as const;
82 const oldHistory = { kind: "user", id: "history-old", text: "old question" } as const;
83 const optimisticState = reducer(initialState, { type: "user", text: "new question", seq: 0, submissionId: "optimistic-submission" });
84 const optimisticUser = optimisticState.localSubmissions["optimistic-submission"];
85 const state = {
86 ...optimisticState,
87 items: [oldHistory, persistedUser, persistedAssistant],
88 historyPrefixCount: 1,
89 historyRevision: 6,
90 };
91 const rebased = reducer(state, {
92 type: "history_rebase",
93 items: [
94 { kind: "user", id: "history-new", text: "newest persisted question" },
95 { kind: "user", id: "persisted-user-durable", text: "persisted question" },
96 persistedAssistant,
97 ],
98 startTurn: 5,
99 totalTurns: 7,
100 hasOlder: true,
101 revision: 7,
102 digest: "digest-7",
103 });
104 assert.equal(rebased.historyPrefixCount, 3);
105 assert.equal(rebased.items.filter((item) => item.id === "assistant-live").length, 1, "transcript/live overlap is deduplicated");
106 assert.equal(rebased.localSubmissions["optimistic-submission"], optimisticUser, "local submission identity survives rebase");
107 assert.equal(rebased.historyLayoutRevision, initialState.historyLayoutRevision + 1);
108
109 const older = reducer(rebased, {
110 type: "history_rebase",
111 items: [{ kind: "user", id: "stale", text: "stale" }],
112 startTurn: 0,
113 totalTurns: 1,
114 hasOlder: false,
115 revision: 6,
116 });
117 assert.equal(older, rebased, "an older transcript revision cannot replace the current projection");
118
119 const monotonicProjector = new TurnEventProjector();
120 monotonicProjector.observeRuntime("monotonic-tab", "epoch-stable", 12, 12, false);
121 assert.equal(
122 monotonicProjector.receiveLive("monotonic-tab", { kind: "turn_started", seq: 1, runtimeEpoch: "epoch-stable" }, "epoch-stable"),
123 false,
124 "a stale seq=1 event cannot reset a ledger cursor whose sequence is defined to be monotonic",
125 );
126
127 console.log("turn event projection reset tests passed");
128
129 const optimistic = reducer(initialState, { type: "user", text: "question", seq: 0, submissionId: "submission" });
130 const optimisticID = optimistic.localSubmissions.submission.localId;
131 const admitted = reducer(optimistic, { type: "event", e: { kind: "user_message", messageId: "backend-user", submissionId: "submission", text: "question" } });
132 assert.equal(admitted.items.filter((item) => item.kind === "user").length, 1);
133 assert.equal(admitted.items.find((item) => item.kind === "user")!.id, "m:backend-user", "admission installs the canonical user identity");
134 assert.equal(admitted.localSubmissions.submission, undefined, "admission retires the matched local submission");
135 assert.equal(optimisticID, "u0", "the local submission keeps its presentation identity until admission");
136 const userRebased = reducer(admitted, { type: "history_rebase", items: [{ kind: "user", id: "m:backend-user", text: "question" }], startTurn: 0, totalTurns: 1, hasOlder: false });
137 assert.equal(userRebased.items.filter((item) => item.kind === "user").length, 1, "history deduplicates the admitted user by identity");
138 assert.equal(userRebased.items[0].id, "m:backend-user", "history retains the canonical user identity");
139
140 // A snapshot and the live suffix share one ownership boundary. No runtime
141 // poll or pending replay may advance coverage before its rows are installed.
142 const snapshotIdentity = { sessionId: "session", headId: "head", rewriteEpoch: 1, runtimeEpoch: "runtime" };
143 const snapshotBoundary = { protocolVersion: 1 as const, snapshotId: "snapshot", identity: snapshotIdentity, projectionRevision: 5, coveredThroughSeq: 5 };
144 let snapshotReplayAfter = -1;
145 const snapshotCommits: string[] = [];
146 const snapshotProjector = new TurnEventProjector({ replay: async (_tab, afterSeq) => {
147 snapshotReplayAfter = afterSeq;
148 return { events: [], floorSeq: 1, latestSeq: afterSeq, nextAfterSeq: afterSeq, hasMore: false, resetRequired: false, runtimeEpoch: "runtime" };
149 } });
150 snapshotProjector.bind((event) => snapshotCommits.push(`event:${event.seq}`));
151 const firstSnapshotRequest = snapshotProjector.beginSnapshot("snapshot-tab", snapshotIdentity);
152 snapshotProjector.receiveLive("snapshot-tab", { kind: "text", seq: 5, runtimeEpoch: "runtime" });
153 snapshotProjector.receiveLive("snapshot-tab", { kind: "text", seq: 6, runtimeEpoch: "runtime" });
154 snapshotProjector.observeRuntime("snapshot-tab", "runtime", 6, 6, false);
155 assert.equal(snapshotReplayAfter, -1);
156 assert.equal(snapshotCommits.length, 0);
157 assert.throws(() => snapshotProjector.installSnapshot(firstSnapshotRequest, snapshotBoundary, () => { throw new Error("store rejected snapshot"); }), /store rejected snapshot/);
158 assert.equal(snapshotReplayAfter, -1, "failed snapshot commit does not release the queue");
159 assert.equal(snapshotProjector.installSnapshot(firstSnapshotRequest, snapshotBoundary, () => snapshotCommits.push("snapshot:5")), true);
160 for (let i = 0; i < 30; i++) await Promise.resolve();
161 assert.equal(snapshotReplayAfter, 5, "replay starts at actual snapshot coverage, never a runtime hint");
162 assert.deepEqual(snapshotCommits, ["snapshot:5", "event:6"]);
163 assert.equal(snapshotProjector.installSnapshot(firstSnapshotRequest, snapshotBoundary, () => assert.fail("snapshot committed twice")), false);
164
165 const staleSnapshotRequest = snapshotProjector.beginSnapshot("snapshot-tab", snapshotIdentity);
166 const replacementIdentity = { ...snapshotIdentity, sessionId: "replacement", headId: "replacement-head" };
167 const replacementRequest = snapshotProjector.beginSnapshot("snapshot-tab", replacementIdentity);
168 assert.equal(snapshotProjector.installSnapshot(staleSnapshotRequest, snapshotBoundary, () => assert.fail("stale response wrote to new session")), false);
169 assert.throws(() => snapshotProjector.installSnapshot(replacementRequest, snapshotBoundary, () => assert.fail("wrong identity committed")), /invalid transcript snapshot/);
170 assert.equal(snapshotProjector.installSnapshot(replacementRequest, { ...snapshotBoundary, snapshotId: "replacement-snapshot", identity: replacementIdentity, coveredThroughSeq: 0 }, () => {}), true);
171
172 const unboundProjector = new TurnEventProjector();
173 assert.throws(() => unboundProjector.receiveLive("unbound", { kind: "text", seq: 1 }), /no commit handler/);
174 const afterBinding: number[] = [];
175 unboundProjector.bind((event) => afterBinding.push(event.seq!));
176 unboundProjector.receiveLive("unbound", { kind: "text", seq: 1 });
177 assert.deepEqual(afterBinding, [1], "missing handler cannot consume coverage");
178
179 let overflowAfter = -1;
180 const overflowCommits: number[] = [];
181 const overflowProjector = new TurnEventProjector({ replay: async (_tab, afterSeq) => {
182 overflowAfter = afterSeq;
183 const end = Math.min(afterSeq + 512, 1200);
184 return { events: Array.from({ length: end - afterSeq }, (_, index) => ({
185 seq: afterSeq + index + 1, turnId: "overflow-turn", status: "in_progress" as const, event: { kind: "text", text: "x" },
186 })), floorSeq: 1, latestSeq: 1200, nextAfterSeq: end, hasMore: end < 1200, resetRequired: false, runtimeEpoch: "runtime" };
187 } });
188 overflowProjector.bind((event) => overflowCommits.push(event.seq!));
189 const overflowRequest = overflowProjector.beginSnapshot("overflow", snapshotIdentity);
190 for (let seq = 1; seq <= 1200; seq++) overflowProjector.receiveLive("overflow", { kind: "text", seq, text: "x", runtimeEpoch: "runtime" });
191 assert.equal(overflowAfter, -1);
192 overflowProjector.installSnapshot(overflowRequest, { ...snapshotBoundary, coveredThroughSeq: 0 }, () => {});
193 for (let i = 0; i < 50; i++) await Promise.resolve();
194 assert.deepEqual(overflowCommits, Array.from({ length: 1200 }, (_, index) => index + 1), "bounded queue overflow recovers every event exactly once from durable replay");
195
196 // Exercise the actual ingress/commit contract, not a handler that merely
197 // records replay callbacks. A queued event must never re-enter admission.
198 let completeGap!: (view: TurnEventReplayView) => void;
199 binding.TurnEventsForTab = async () => new Promise((resolve) => { completeGap = resolve; });
200 const ordered = new TurnEventProjector();
201 const committed: number[] = [];
202 ordered.bind((event) => { committed.push(event.seq!); });
203 ordered.observeRuntime("ordered", "epoch", 1, 1, false);
204 ordered.receiveLive("ordered", { kind: "text", seq: 3, text: "queued", runtimeEpoch: "epoch" }, "epoch");
205 completeGap({ ...replay, resetRequired: false, runtimeEpoch: "epoch", floorSeq: 1, latestSeq: 2, nextAfterSeq: 2,
206 events: [{ turnId: "turn", seq: 2, status: "in_progress", event: { kind: "text", text: "replayed" } }],
207 });
208 for (let i = 0; i < 40; i++) await Promise.resolve();
209 assert.deepEqual(committed, [2, 3], "durable replay precedes queued live commit exactly once");
210 ordered.receiveLive("ordered", { kind: "text", seq: 3, text: "queued", runtimeEpoch: "epoch" }, "epoch");
211 assert.deepEqual(committed, [2, 3], "a duplicate delivery cannot commit twice");
212
213 const failing = new TurnEventProjector();
214 failing.observeRuntime("failure", "epoch", 1, 1, false);
215 failing.bind(() => { throw new Error("commit failed"); });
216 assert.throws(() => failing.receiveLive("failure", { kind: "text", seq: 2, runtimeEpoch: "epoch" }, "epoch"), /commit failed/);
217 const retried: number[] = [];
218 failing.bind((event) => { retried.push(event.seq!); });
219 failing.receiveLive("failure", { kind: "text", seq: 2, runtimeEpoch: "epoch" }, "epoch");
220 assert.deepEqual(retried, [2], "failed commits never advance the applied cursor");
221
222 let queuedRepair!: (view: TurnEventReplayView) => void;
223 binding.TurnEventsForTab = async () => new Promise((resolve) => { queuedRepair = resolve; });
224 const queueFailure = new TurnEventProjector();
225 queueFailure.observeRuntime("queue-failure", "epoch", 1, 1, false);
226 let failQueued = true;
227 const queueCommits: number[] = [];
228 queueFailure.bind((event) => {
229 if (event.seq === 3 && failQueued) throw new Error("queued commit failed");
230 queueCommits.push(event.seq!);
231 });
232 queueFailure.receiveLive("queue-failure", { kind: "text", seq: 3, runtimeEpoch: "epoch" }, "epoch");
233 queuedRepair({ ...replay, resetRequired: false, runtimeEpoch: "epoch", floorSeq: 1, latestSeq: 2, nextAfterSeq: 2,
234 events: [{ turnId: "turn", seq: 2, status: "in_progress", event: { kind: "text" } }],
235 });
236 for (let i = 0; i < 40; i++) await Promise.resolve();
237 failQueued = false;
238 queueFailure.observeRuntime("queue-failure", "epoch", 3, 2, true);
239 queuedRepair({ ...replay, resetRequired: false, runtimeEpoch: "epoch", floorSeq: 1, latestSeq: 3, nextAfterSeq: 2, events: [] });
240 for (let i = 0; i < 40; i++) await Promise.resolve();
241 assert.deepEqual(queueCommits, [2, 3], "a failed queued commit remains owned until a later repair commits it");
242
243 const canonicalHistory = [
244 { kind: "assistant", id: "m:a", text: "same", reasoning: "thought", streaming: false },
245 { kind: "assistant", id: "m:b", text: "same", reasoning: "thought", streaming: false },
246 { kind: "assistant", id: "m:c", text: "later", reasoning: "", streaming: false },
247 ] as const;
248 const canonicalRebase = reducer({ ...initialState, items: canonicalHistory.slice(0, 2) }, {
249 type: "history_rebase", items: [...canonicalHistory], startTurn: 0, totalTurns: 1, hasOlder: false, revision: 1,
250 });
251 assert.deepEqual(canonicalRebase.items.map((item) => item.id), ["m:a", "m:b", "m:c"], "a page extending beyond the live prefix cannot duplicate that prefix");
252 const separateMessage = { ...canonicalHistory[0], id: "m:d" };
253 const repeatedText = reducer({ ...initialState, items: [separateMessage] }, {
254 type: "history_rebase", items: [...canonicalHistory], startTurn: 0, totalTurns: 1, hasOlder: false, revision: 1,
255 });
256 assert.deepEqual(repeatedText.items.map((item) => item.id), ["m:a", "m:b", "m:c", "m:d"], "equal text from distinct messages is preserved");
257
258 let owned = reducer(initialState, { type: "event", e: { kind: "reasoning", messageId: "a", text: "thought" } });
259 owned = reducer(owned, { type: "event", e: { kind: "message", messageId: "a", reasoning: "thought", text: "answer" } });
260 owned = reducer(owned, { type: "event", e: { kind: "reasoning", messageId: "b", text: "next" } });
261 assert.deepEqual(owned.items.filter((item) => item.kind === "assistant").map((item) => item.id), ["m:a", "m:b"]);
262 assert.equal(owned.live?.id, "m:b");
263
264 let discarded = reducer(initialState, { type: "event", e: { kind: "stream_attempt", messageId: "failed", streamAttempt: { id: "failed", action: "begin" } } });
265 discarded = reducer(discarded, { type: "event", e: { kind: "message", messageId: "failed", text: "rejected" } });
266 discarded = reducer(discarded, { type: "event", e: { kind: "stream_attempt", messageId: "failed", streamAttempt: { id: "failed", action: "discard" } } });
267 assert.equal(discarded.items.some((item) => item.id === "m:failed"), false, "discard removes the attempted message even if its full response was already emitted");
268 assert.equal(discarded.live, undefined);
269 discarded = reducer(discarded, { type: "event", e: { kind: "stream_attempt", messageId: "success", streamAttempt: { id: "success", action: "begin" } } });
270 discarded = reducer(discarded, { type: "event", e: { kind: "reasoning", messageId: "success", text: "accepted" } });
271 assert.equal(discarded.live?.id, "m:success");
272 assert.equal(discarded.live?.reasoning, "accepted");
273
273 lines TYPESCRIPT