返回 DeepSeek-Reasonix
hydrate-history-apply.test.ts
根目录 / desktop / frontend / src / __tests__ / hydrate-history-apply.test.ts
1 // Run: tsx src/__tests__/hydrate-history-apply.test.ts
2
3 import {
4 canAdoptUnboundLiveSurface,
5 duplicateLiveItemIds,
6 hasCachedLiveTurn,
7 hydratedHistoryApplyMode,
8 sameSessionHydrateIdentity,
9 sameSessionPlaceholderItems,
10 shouldPreferResidentHistory,
11 } from "../lib/hydrateHistoryApply";
12 import { activeTabHydrationPlan, coldHistoryRefreshProof } from "../lib/coldHistoryRefresh";
13 import type { Meta, TabMeta } from "../lib/types";
14
15 let passed = 0;
16 let failed = 0;
17
18 function ok(value: boolean, label: string) {
19 if (value) {
20 process.stdout.write(` PASS ${label}\n`);
21 passed += 1;
22 } else {
23 process.stdout.write(` FAIL ${label}\n`);
24 failed += 1;
25 }
26 }
27
28 console.log("\nhydrate history apply");
29
30 const mode = hydratedHistoryApplyMode;
31
32 ok(mode(true, true, false, { items: [] }) === "skip", "skipHistory blocks apply");
33 ok(mode(false, false, false, { items: [] }) === "skip", "missing projection blocks apply");
34 ok(mode(false, true, false, { items: [] }) === "replace", "idle empty surface applies history");
35 ok(mode(false, true, true, { running: true, items: [] }) === "replace", "running empty surface applies history");
36 ok(
37 mode(false, true, true, { running: true, live: { text: "partial" }, items: [] }) === "replace",
38 "a mid-stream surface with no rows yet applies history",
39 );
40 ok(
41 mode(false, true, true, { running: true, items: [{ kind: "user" }] }) === "prepend",
42 "a running turn with no history page behind it gets one prepended",
43 );
44 ok(
45 mode(false, true, true, { running: true, historyTotalTurns: 3, items: [{ kind: "user" }] }) === "skip",
46 "an already-hydrated running transcript is left alone",
47 );
48 ok(
49 hasCachedLiveTurn({
50 running: true,
51 historyTotalTurns: 2,
52 items: [{ kind: "assistant", streaming: true }],
53 }),
54 "streaming assistant counts as a cached live turn",
55 );
56 ok(
57 !hasCachedLiveTurn({ running: true, items: [{ kind: "assistant", streaming: true }] }),
58 "a live turn with no history page behind it is not cached",
59 );
60 ok(
61 duplicateLiveItemIds(
62 [{ kind: "user", id: "h1", text: "ask" }],
63 [{ kind: "user", id: "l1", text: "ask" }, { kind: "assistant", id: "l2", text: "" }],
64 ).join(",") === "l1",
65 "a live row the page already carries is dropped",
66 );
67 ok(
68 duplicateLiveItemIds(
69 [{ kind: "user", id: "h1", text: "ask" }],
70 [{ kind: "assistant", id: "l2", text: "" }],
71 ).length === 0,
72 "a live tail the page does not carry is kept",
73 );
74 ok(
75 sameSessionPlaceholderItems({ sessionPath: "a.jsonl" }, { meta: { sessionPath: "b.jsonl" }, items: [{ kind: "user" }] }) === undefined,
76 "foreign session items are not placeholders",
77 );
78 ok(
79 (sameSessionPlaceholderItems({ sessionPath: "a.jsonl" }, { meta: { sessionPath: "a.jsonl" }, items: [{ kind: "user" }] }) ?? []).length === 1,
80 "same-session items stay placeholders",
81 );
82 ok(
83 sameSessionHydrateIdentity(
84 { sessionPath: "a.jsonl", sessionGeneration: 3 },
85 { sessionPath: "a.jsonl", sessionGeneration: 3 },
86 ),
87 "same path and generation prove the same session",
88 );
89 ok(
90 !sameSessionHydrateIdentity(
91 { sessionPath: "a.jsonl", sessionGeneration: 4 },
92 { sessionPath: "a.jsonl", sessionGeneration: 3 },
93 ),
94 "different generations reject placeholders even when paths match",
95 );
96 ok(
97 !sameSessionHydrateIdentity({ sessionPath: "" }, { sessionPath: "" }),
98 "empty identities cannot prove the same session",
99 );
100 ok(
101 sameSessionHydrateIdentity(
102 { session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "", sessionGeneration: 3 },
103 { session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "", sessionGeneration: 3 },
104 ),
105 "matching SessionRefs prove a canonical session even when both paths are empty",
106 );
107 ok(
108 !sameSessionHydrateIdentity(
109 { session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "" },
110 { session: { hostId: "local", sessionId: "canonical-b" }, sessionPath: "" },
111 ),
112 "different canonical SessionRefs never share an empty-path surface",
113 );
114 ok(
115 !sameSessionHydrateIdentity(
116 { session: { hostId: "local", sessionId: "canonical-a" }, sessionPath: "same.jsonl" },
117 { session: { hostId: "local", sessionId: "canonical-b" }, sessionPath: "same.jsonl" },
118 ),
119 "SessionRef disagreement outranks a matching compatibility path",
120 );
121 const sameSessionPlan = activeTabHydrationPlan(
122 { sessionPath: "a.jsonl", sessionGeneration: 3, sessionRevision: 8, sessionDigest: "rev-8" },
123 { sessionPath: "a.jsonl", sessionGeneration: 3 },
124 false,
125 );
126 ok(sameSessionPlan.surfacePolicy === "preserve-current", "backend sync preserves a proven same-session surface");
127 ok(sameSessionPlan.loadOptions.preserveCachedHistory, "same-session backend sync may reuse its resident history");
128 const reboundPlan = activeTabHydrationPlan(
129 { sessionPath: "a.jsonl", sessionGeneration: 4, sessionRevision: 9, sessionDigest: "rev-9" },
130 { sessionPath: "a.jsonl", sessionGeneration: 3 },
131 false,
132 );
133 ok(reboundPlan.surfacePolicy === "replace-surface", "backend sync replaces a generation-rebound surface");
134 ok(reboundPlan.loadOptions.sessionGeneration === 4, "replace-surface hydration carries the target generation fence");
135 ok(
136 canAdoptUnboundLiveSurface(
137 { sessionPath: "a.jsonl", sessionGeneration: 3 },
138 undefined,
139 { running: true, live: { text: "partial" }, items: [{ kind: "assistant", streaming: true }] },
140 true,
141 ),
142 "an unbound live runtime tail can be adopted before its first metadata snapshot",
143 );
144 ok(
145 !canAdoptUnboundLiveSurface(
146 { sessionPath: "a.jsonl" },
147 { sessionPath: "b.jsonl" },
148 { running: true, live: { text: "stale" }, items: [{ kind: "assistant", streaming: true }] },
149 true,
150 ),
151 "a differently identified surface cannot be adopted as a live tail",
152 );
153 ok(
154 !canAdoptUnboundLiveSurface(
155 { sessionPath: "a.jsonl" },
156 undefined,
157 { running: true, historyTotalTurns: 1, hydrateHistoryLoaded: true, items: [{ kind: "assistant", streaming: true }] },
158 true,
159 ),
160 "a surface with persisted history is never adopted without session identity",
161 );
162 ok(
163 !canAdoptUnboundLiveSurface(
164 { sessionPath: "a.jsonl" },
165 undefined,
166 { running: true, live: { text: "stale epoch" }, items: [{ kind: "assistant", streaming: true }] },
167 true,
168 "runtime-new",
169 "runtime-old",
170 ),
171 "a mismatched runtime epoch rejects an unbound live tail",
172 );
173
174 ok(
175 !shouldPreferResidentHistory(false, false),
176 "retry / explicit no-cache hydrates must not serve the resident snapshot",
177 );
178 ok(shouldPreferResidentHistory(false, true), "preserveCachedHistory still allows a resident hit");
179 ok(shouldPreferResidentHistory(false, undefined), "unspecified preserveCachedHistory still allows a resident hit");
180 ok(!shouldPreferResidentHistory(true, true), "reset hydrates never prefer the resident snapshot");
181
182 const liveIdle = {
183 items: [{ kind: "user" }, { kind: "assistant" }, { kind: "user" }],
184 historyRevision: 10,
185 historyDigest: "rev-10",
186 };
187 ok(
188 mode(false, true, false, liveIdle, {
189 items: [{ kind: "user" }],
190 revision: 10,
191 digest: "rev-10",
192 }) === "skip",
193 "idle transcript is not replaced by a shorter same-fingerprint resident snapshot",
194 );
195 ok(
196 mode(false, true, false, liveIdle, {
197 items: [{ kind: "user" }, { kind: "assistant" }, { kind: "user" }, { kind: "assistant" }],
198 revision: 11,
199 digest: "rev-11",
200 }) === "replace",
201 "a newer backend page still replaces the idle transcript",
202 );
203 ok(
204 mode(false, true, false, { items: [] }, {
205 items: [{ kind: "user" }],
206 revision: 10,
207 digest: "rev-10",
208 }) === "replace",
209 "empty idle surface still applies history",
210 );
211
212 const coldTarget = { sessionPath: "/fixture/legacy.jsonl", sessionGeneration: 4 } as TabMeta;
213 const coldState = { meta: { ...coldTarget, eventChannel: "agent:event" } as Meta, historyDigest: "verified-cut", historyRevision: 7 };
214 ok(coldHistoryRefreshProof(coldTarget, coldState, true)?.digest === "verified-cut", "passive metadata without a digest retains the existing certified cut");
215 ok(!coldHistoryRefreshProof(coldTarget, coldState, false), "explicit reset cannot reuse passive proof");
216 ok(!coldHistoryRefreshProof({ ...coldTarget, sessionGeneration: 5 }, coldState, true), "a new storage generation cannot reuse the old cut");
217 ok(!coldHistoryRefreshProof({ ...coldTarget, sessionDigest: "replacement" }, coldState, true), "a changed content proof requires a fresh read");
218 ok(!coldHistoryRefreshProof({ ...coldTarget, sessionRevision: 8 }, coldState, true), "a changed content revision requires a fresh read");
219 ok(!coldHistoryRefreshProof(coldTarget, { ...coldState, historyDigest: "" }, true), "an unproven window cannot be retained");
220 ok(!coldHistoryRefreshProof(coldTarget, { ...coldState, hydrating: true }, true), "unfinished preparation still joins the reader task");
221 ok(!coldHistoryRefreshProof(coldTarget, { ...coldState, hydrateError: "damaged" }, true), "failed history is not certified by metadata refresh");
222
223 console.log(`\n${passed} passed, ${failed} failed`);
224 if (failed > 0) process.exit(1);
225
225 lines TYPESCRIPT