返回 DeepSeek-Reasonix
transcript-snapshot-optimistic-orphan.test.ts
根目录 / desktop / frontend / src / __tests__ / transcript-snapshot-optimistic-orphan.test.ts
1 // Run: tsx src/__tests__/transcript-snapshot-optimistic-orphan.test.ts
2
3 import { initialState, reducer } from "../lib/useController";
4 import { historyMessagesToItems } from "../lib/historyItems";
5 import { canonicalUserConfirmations, matchLocalSubmissions, orderedLocalSubmissions } from "../lib/localSubmissionState";
6 import { transcriptSnapshotState } from "../lib/transcriptSnapshotState";
7 import type { HistoryMessage } from "../lib/types";
8 import type { TranscriptSnapshot } from "../lib/transcriptProtocol";
9
10 type ReducerState = ReturnType<typeof reducer>;
11
12 let passed = 0;
13 let failed = 0;
14
15 function eq(actual: unknown, expected: unknown, label: string) {
16 if (actual === expected) {
17 process.stdout.write(` PASS ${label}\n`);
18 passed += 1;
19 } else {
20 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}\n`);
21 failed += 1;
22 }
23 }
24
25 function snapshotOf(messages: HistoryMessage[]): TranscriptSnapshot {
26 const records = messages.map((message, order) => ({ id: `r${order}`, order, message, refs: [] }));
27 return {
28 protocolVersion: 1,
29 snapshotId: "snap-1",
30 identity: { sessionId: "s", headId: "h", rewriteEpoch: 0, runtimeEpoch: "epoch-2" },
31 projectionRevision: 1,
32 coveredThroughSeq: 10,
33 records,
34 activeRecords: [],
35 runtime: { status: "completed", pendingEvents: [] },
36 activeAttempts: [],
37 before: 0,
38 hasOlder: false,
39 totalRecords: records.length,
40 totalTurns: records.length,
41 stale: false,
42 } as unknown as TranscriptSnapshot;
43 }
44
45 function submit(state: ReducerState, text: string, seq: number, submissionId: string): ReducerState {
46 return reducer(state, { type: "user", text, seq, submissionId });
47 }
48
49 type UserItem = Extract<ReducerState["items"][number], { kind: "user" }>;
50
51 // The optimistic echo is owned by localSubmissions, not by state.items; the
52 // chat view (chatViewSource.itemsWithLocalSubmissions) renders durable user
53 // items plus every echo no durable message id has matched. Assert at that
54 // seam so a duplicate bubble is measured the way the user sees it.
55 function visibleUserTexts(state: ReducerState): string[] {
56 const durable = state.items.filter((item): item is UserItem => item.kind === "user");
57 const echoes = orderedLocalSubmissions(state);
58 const matched = new Set(matchLocalSubmissions(echoes, canonicalUserConfirmations(state.items)).map((match) => match.submissionId));
59 return [...durable.map((item) => item.text), ...echoes.filter((echo) => !matched.has(echo.submissionId)).map((echo) => echo.text)];
60 }
61
62 function rebase(state: ReducerState, snap: TranscriptSnapshot): ReducerState {
63 return transcriptSnapshotState(state, snap, historyMessagesToItems, noopApply, Date.now());
64 }
65
66 const noopApply = ((state: ReducerState) => state) as never;
67
68 console.log("\ntranscript snapshot optimistic orphan reconciliation");
69
70 // The observed failure: a runtime rebuild (model switch, serve restart) re-bases
71 // the snapshot while a submission is pending, and history records carry no
72 // submission ids. The orphaned optimistic bubble must not survive as a
73 // duplicate turn stuck at "processing".
74 {
75 let state = submit(initialState, "7", 0, "s1");
76 const snap = snapshotOf([
77 { role: "user", content: "1", raw_content: "1" },
78 { role: "assistant", content: "1" },
79 { role: "user", content: "7", raw_content: "7" },
80 { role: "assistant", content: "7 ok" },
81 ] as unknown as HistoryMessage[]);
82 state = rebase(state, snap);
83 eq(visibleUserTexts(state).filter((text) => text === "7").length, 1, "id-less trailing user record absorbs the orphaned optimistic copy");
84 eq(state.running, false, "absorbed orphan no longer reports a running turn");
85 eq(state.pendingSubmissionId, undefined, "absorbed orphan clears the pending submission");
86 }
87
88 // A different trailing text means the server has not journaled the submission;
89 // the optimistic copy stays so an in-flight or lost message remains visible.
90 {
91 let state = submit(initialState, "8", 0, "s1");
92 const snap = snapshotOf([
93 { role: "user", content: "1", raw_content: "1" },
94 { role: "assistant", content: "1" },
95 ] as unknown as HistoryMessage[]);
96 state = rebase(state, snap);
97 eq(visibleUserTexts(state).includes("8"), true, "unmatched pending submission survives the rebase");
98 eq(state.running, true, "unmatched pending submission keeps the turn indicator");
99 }
100
101 // Snapshots that do carry the submission id keep linking through the id path.
102 {
103 let state = submit(initialState, "9", 0, "s1");
104 const snap = snapshotOf([
105 { role: "user", content: "9", raw_content: "9", submissionId: "s1" },
106 { role: "assistant", content: "9 ok" },
107 ] as unknown as HistoryMessage[]);
108 state = rebase(state, snap);
109 eq(visibleUserTexts(state).filter((text) => text === "9").length, 1, "id-linked record absorbs the optimistic copy");
110 eq(state.running, false, "id-linked absorption settles the turn");
111 }
112
113 // While the runtime still reports an active turn, the text fallback stays off:
114 // the trailing record may be an older sibling of the pending submission.
115 {
116 let state = submit(initialState, "7", 0, "s2");
117 const snap = snapshotOf([
118 { role: "user", content: "7", raw_content: "7" },
119 ] as unknown as HistoryMessage[]);
120 snap.runtime = { status: "in_progress", turnId: "t9", pendingEvents: [] };
121 state = rebase(state, snap);
122 eq(state.running, true, "active runtime keeps the optimistic submission pending");
123 eq(visibleUserTexts(state).filter((text) => text === "7").length, 2, "active runtime does not absorb by text");
124 }
125
126 // The id link must not depend on a message id: a record that repeats the
127 // submission id settles the echo even when the projection carries no
128 // messageId, and the echo is retired from the local-submission owner.
129 {
130 let state = submit(initialState, "9", 0, "s1");
131 state = rebase(state, snapshotOf([{ role: "user", content: "9", submissionId: "s1" }] as unknown as HistoryMessage[]));
132 eq(state.localSubmissionOrder.length, 0, "a submission-id match retires the echo without a message id");
133 }
134
135 // A confirmed send whose durable copy arrives only through an id-less rebase
136 // is the duplicate-bubble case with running already false: the echo still
137 // has to go.
138 {
139 let state = submit(initialState, "7", 0, "s1");
140 state = reducer(state, { type: "send_confirmed", submissionId: "s1" });
141 state = rebase(state, snapshotOf([
142 { role: "user", content: "7", raw_content: "7" },
143 { role: "assistant", content: "7 ok" },
144 ] as unknown as HistoryMessage[]));
145 eq(visibleUserTexts(state).filter((text) => text === "7").length, 1, "an accepted echo journaled without ids collapses into the durable copy");
146 eq(state.localSubmissionOrder.length, 0, "the accepted echo is retired from the local-submission owner");
147 }
148
149 // Bound: the newest durable record already existed when the second, identical
150 // message was typed. Absorbing it by text would silently drop a lost send, so
151 // the echo (and its turn indicator) must survive an idle rebase.
152 {
153 const journaled = snapshotOf([
154 { role: "user", content: "7", raw_content: "7" },
155 { role: "assistant", content: "7 ok" },
156 ] as unknown as HistoryMessage[]);
157 let state = rebase(initialState, journaled);
158 state = submit(state, "7", state.seq, "s2");
159 state = rebase(state, journaled);
160 eq(visibleUserTexts(state).filter((text) => text === "7").length, 2, "an earlier identical record cannot absorb a newer lost duplicate");
161 eq(state.running, true, "the lost duplicate keeps its pending turn indicator");
162 eq(state.pendingSubmissionId, "s2", "the lost duplicate stays the pending submission");
163 // Once the server journals the second copy, the count of durable copies
164 // exceeds the copies that predate the echo and the echo is absorbed.
165 state = rebase(state, snapshotOf([
166 { role: "user", content: "7", raw_content: "7" },
167 { role: "assistant", content: "7 ok" },
168 { role: "user", content: "7", raw_content: "7" },
169 { role: "assistant", content: "7 again" },
170 ] as unknown as HistoryMessage[]));
171 eq(visibleUserTexts(state).filter((text) => text === "7").length, 2, "the journaled second copy absorbs the echo instead of adding a third bubble");
172 eq(state.running, false, "absorbing the journaled duplicate settles the turn");
173 }
174
175 if (failed > 0) {
176 console.error(`\n${failed} check(s) failed`);
177 process.exit(1);
178 }
179 process.stdout.write(`\nall ${passed} checks passed\n`);
180
180 lines TYPESCRIPT