返回 DeepSeek-Reasonix
session-draft-store-races.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-draft-store-races.test.tsx
1 import assert from "node:assert/strict";
2 import React, { act } from "react";
3 import { createRoot } from "react-dom/client";
4 import { JSDOM } from "jsdom";
5
6 import { useSessionDraftSurface } from "../app-runtime/useSessionDraftSurface";
7 import type { PersistentComposerDraft } from "../components/Composer";
8 import type { SessionDraftSubmissionRequest, SessionDraftView } from "../generated/desktopContract.generated";
9 import { installDesktopHostStub } from "./desktopHostStub";
10
11 function deferred<T>() {
12 let resolve!: (value: T) => void;
13 const promise = new Promise<T>((next) => { resolve = next; });
14 return { promise, resolve };
15 }
16
17 const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
18 Object.assign(globalThis, {
19 window: dom.window,
20 document: dom.window.document,
21 IS_REACT_ACT_ENVIRONMENT: true,
22 requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0),
23 cancelAnimationFrame: (id: number) => clearTimeout(id),
24 });
25 dom.window.requestAnimationFrame = globalThis.requestAnimationFrame;
26 dom.window.cancelAnimationFrame = globalThis.cancelAnimationFrame;
27
28 const settings = {
29 model: "fixture/model",
30 modelSource: "default",
31 effort: "high",
32 mode: "normal",
33 collaborationMode: "normal",
34 toolApprovalMode: "ask",
35 disabledMcp: {},
36 mcpOrder: [],
37 };
38 const makeDraft = (id: string, root: string): SessionDraftView => ({
39 id,
40 workspaceId: `workspace-${id}`,
41 scope: "project",
42 workspaceRoot: root,
43 revision: 1,
44 contentJson: "{}",
45 settings: structuredClone(settings),
46 status: "active",
47 updatedAt: 1,
48 });
49 const drafts = new Map<string, SessionDraftView>([
50 ["draft-a", makeDraft("draft-a", "/workspace/a")],
51 ["draft-b", makeDraft("draft-b", "/workspace/b")],
52 ["draft-c", makeDraft("draft-c", "/workspace/c")],
53 ]);
54 const firstSaveStarted = deferred<void>();
55 const releaseFirstSave = deferred<void>();
56 const openC = deferred<SessionDraftView>();
57 const savePayloads: Array<{ id: string; text: string }> = [];
58 const submissions: SessionDraftSubmissionRequest[] = [];
59 let saveCount = 0;
60 let loseNextSaveAcknowledgement = false;
61 let submissionPolls = 0;
62 let operationCount = 0;
63 let saveDefaultModel: string | undefined;
64 const contextResponses: Array<Promise<SessionDraftView>> = [];
65
66 const stub = installDesktopHostStub({
67 ListSessionDraftSummaries: async () => [],
68 OpenSessionDraftForTarget: async (_scope: string, root: string) => {
69 if (root.endsWith("/c")) return openC.promise;
70 return structuredClone(root.endsWith("/a") ? drafts.get("draft-a") : drafts.get("draft-b"));
71 },
72 GetDraftContext: async (id: string) => ({ draft: await (contextResponses.shift() ?? Promise.resolve(structuredClone(drafts.get(id)!))), commands: [], servers: [] }),
73 GetSessionDraft: async (id: string) => structuredClone(drafts.get(id)!),
74 SetSessionDraftRestoreTarget: async () => {},
75 DismissSessionDraft: async () => {},
76 RestoreSessionDraft: async () => null,
77 ListTabs: async () => [{ id: "formal" }],
78 AttachmentDataURLForTarget: async () => "",
79 SaveSessionDraft: async (request: { draftId: string; revision: number; contentJson: string; settings: typeof settings }) => {
80 saveCount++;
81 const current = drafts.get(request.draftId)!;
82 savePayloads.push({ id: request.draftId, text: JSON.parse(request.contentJson).text ?? "" });
83 if (saveCount === 1) {
84 firstSaveStarted.resolve();
85 await releaseFirstSave.promise;
86 }
87 if (request.revision !== current.revision) {
88 return { draft: structuredClone(current), conflict: true, outcome: "conflict" };
89 }
90 const next = { ...current, revision: current.revision + 1, contentJson: request.contentJson, settings: structuredClone(request.settings) };
91 if (next.settings.modelSource === "default" && saveDefaultModel) next.settings.model = saveDefaultModel;
92 drafts.set(request.draftId, next);
93 if (loseNextSaveAcknowledgement) {
94 loseNextSaveAcknowledgement = false;
95 throw new Error("save acknowledgement lost");
96 }
97 return { draft: structuredClone(next), conflict: false, outcome: "saved" };
98 },
99 BeginDraftSubmission: async (request: SessionDraftSubmissionRequest) => {
100 submissions.push(structuredClone(request));
101 operationCount++;
102 return {
103 operationId: `operation-${operationCount}`,
104 draftId: request.draftId,
105 phase: "starting",
106 submissionId: "submission-goal",
107 session: { hostId: "local", sessionId: "session-goal" },
108 updatedAt: 2,
109 };
110 },
111 GetDraftSubmission: async () => {
112 submissionPolls++;
113 return {
114 operationId: `operation-${operationCount}`,
115 draftId: "draft-a",
116 phase: "accepted",
117 submissionId: "submission-goal",
118 session: { hostId: "local", sessionId: "session-goal" },
119 updatedAt: 3,
120 };
121 },
122 });
123
124 let owner!: ReturnType<typeof useSessionDraftSurface>;
125 function Probe() {
126 owner = useSessionDraftSurface({ onAccepted: () => {}, onChanged: () => {} });
127 return null;
128 }
129
130 const content = (text: string): PersistentComposerDraft => ({
131 text,
132 invocations: [],
133 attachments: [],
134 workspaceRefs: [],
135 pastedBlocks: [],
136 openPastedLabels: [],
137 sessionRefs: [],
138 selectedTextRefs: [],
139 });
140
141 const root = createRoot(document.getElementById("root")!);
142 try {
143 await act(async () => { root.render(<Probe />); });
144 await act(async () => { await owner.open("project", "/workspace/a"); });
145 act(() => owner.updateContent(content("version one")));
146 let flush!: Promise<SessionDraftView | null>;
147 act(() => { flush = owner.flush(); });
148 await firstSaveStarted.promise;
149 act(() => owner.updateContent(content("version two")));
150 await act(async () => { await owner.open("project", "/workspace/b"); });
151 assert.equal(owner.surface?.draft.id, "draft-b", "navigation is independent from A's active save");
152 releaseFirstSave.resolve();
153 await act(async () => { await flush; });
154 assert.deepEqual(savePayloads.slice(0, 2), [
155 { id: "draft-a", text: "version one" },
156 { id: "draft-a", text: "version two" },
157 ], "an edit made during save is persisted by the same draft's next CAS iteration");
158
159 await act(async () => { await owner.open("project", "/workspace/a"); });
160 assert.equal(owner.surface?.content.text, "version two", "returning to A keeps the newest confirmed content");
161
162 loseNextSaveAcknowledgement = true;
163 act(() => owner.updateContent(content("acknowledgement recovered")));
164 await act(async () => { await owner.flush(); });
165 assert.equal(owner.surface?.saveState, "saved", "read-back confirms a save whose acknowledgement was lost");
166 assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).text, "acknowledgement recovered");
167
168 const sourceDraftId = owner.surface!.draft.id;
169 const sourceGeneration = owner.surface!.generation;
170 const attachmentFinished = deferred<void>();
171 const attachmentTask = attachmentFinished.promise.then(() => owner.updateContentFor(sourceDraftId, sourceGeneration, {
172 ...content("acknowledgement recovered"),
173 attachments: [{ path: ".reasonix/attachments/background.txt", name: "background.txt", mime: "text/plain" }],
174 }));
175 act(() => { owner.trackTask(sourceDraftId, sourceGeneration, attachmentTask); });
176 await act(async () => { await owner.open("project", "/workspace/b"); });
177 await act(async () => {
178 attachmentFinished.resolve();
179 await attachmentTask;
180 });
181 await act(async () => { await owner.flushDraft(sourceDraftId); });
182 assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).attachments[0]?.path, ".reasonix/attachments/background.txt",
183 "an attachment completed in the background is persisted to its captured draft");
184
185 await act(async () => { await owner.open("project", "/workspace/a"); });
186 act(() => owner.updateContent(content("local conflict value")));
187 const firstRemote = drafts.get("draft-a")!;
188 drafts.set("draft-a", { ...firstRemote, revision: firstRemote.revision + 1, contentJson: JSON.stringify(content("remote revision two")) });
189 await act(async () => { await owner.flush(); });
190 assert.equal(owner.surface?.saveState, "conflict");
191 const secondRemote = drafts.get("draft-a")!;
192 drafts.set("draft-a", { ...secondRemote, revision: secondRemote.revision + 1, contentJson: JSON.stringify(content("remote revision three")) });
193 await act(async () => { await owner.keepLocalConflict(); });
194 assert.equal(owner.surface?.saveState, "conflict", "keep-local uses CAS and cannot overwrite a newer third-party revision");
195 assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).text, "remote revision three");
196 act(() => owner.useSavedConflict());
197 assert.equal(owner.surface?.content.text, "remote revision three", "the latest conflict snapshot remains available to resolve explicitly");
198
199 let staleOpen!: Promise<void>;
200 act(() => { staleOpen = owner.open("project", "/workspace/c"); });
201 await act(async () => { await owner.dismiss(); });
202 openC.resolve(structuredClone(drafts.get("draft-c")!));
203 await act(async () => { await staleOpen; });
204 assert.equal(owner.surface, null, "a pending draft open cannot reinstall after formal navigation dismisses it");
205
206 await act(async () => { await owner.open("project", "/workspace/a"); });
207 const inherited = drafts.get("draft-a")!;
208 drafts.set("draft-a", {
209 ...inherited,
210 settings: { ...inherited.settings, model: "fixture/default-next", modelSource: "default" },
211 });
212 await act(async () => {
213 window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed"));
214 await new Promise((resolve) => setTimeout(resolve, 0));
215 });
216 assert.equal(owner.surface?.settings.model, "fixture/default-next",
217 "an unpinned draft follows the current settings default when the model catalog changes");
218
219 const olderRefresh = deferred<SessionDraftView>();
220 const newerRefresh = deferred<SessionDraftView>();
221 // Only A inherits a default in this scenario, so each event issues one read.
222 await act(async () => { await owner.open("project", "/workspace/b"); });
223 act(() => owner.updateSettings({ model: "fixture/pinned-b", modelSource: "explicit" }));
224 await act(async () => { await owner.flush(); await owner.open("project", "/workspace/a"); });
225 contextResponses.push(olderRefresh.promise, newerRefresh.promise);
226 act(() => {
227 window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed"));
228 window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed"));
229 });
230 const refreshed = structuredClone(drafts.get("draft-a")!);
231 await act(async () => {
232 newerRefresh.resolve({ ...refreshed, settings: { ...refreshed.settings, model: "fixture/newest" } });
233 });
234 await act(async () => {
235 olderRefresh.resolve({ ...refreshed, settings: { ...refreshed.settings, model: "fixture/older" } });
236 });
237 assert.equal(owner.surface?.settings.model, "fixture/newest", "a late catalog response cannot replace the latest default");
238
239 const delayedRefresh = deferred<SessionDraftView>();
240 contextResponses.push(delayedRefresh.promise);
241 act(() => window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed")));
242 const reopened = { ...refreshed, settings: { ...refreshed.settings, model: "fixture/reopened" } };
243 drafts.set("draft-a", reopened);
244 await act(async () => { await owner.open("project", "/workspace/a"); });
245 await act(async () => { delayedRefresh.resolve(refreshed); });
246 assert.equal(owner.surface?.settings.model, "fixture/reopened", "a refresh predating reopen cannot overwrite its newer context");
247
248 saveDefaultModel = "fixture/changed-during-save";
249 loseNextSaveAcknowledgement = true;
250 act(() => owner.updateContent(content("default changed while saving")));
251 await act(async () => { await owner.flush(); });
252 assert.equal(owner.surface?.saveState, "saved", "live-default resolution must not create a false lost-acknowledgement conflict");
253 assert.equal(owner.surface?.settings.model, saveDefaultModel, "save reconciliation publishes the latest effective model");
254
255 // A default may change after capture but before the required save completes.
256 act(() => owner.updateContent(content("inherited first turn")));
257 saveDefaultModel = "fixture/changed-before-send";
258 await act(async () => { await owner.submit("inherited first turn"); });
259 assert.equal(submissions.length, 1, "an inherited mirror change cannot block first submission");
260 assert.equal(submissions[0]?.settings.modelSource, "default");
261 submissions.length = 0;
262 submissionPolls = 0;
263 saveDefaultModel = undefined;
264 await act(async () => { await owner.open("project", "/workspace/a"); });
265 act(() => owner.updateSettings({ model: "fixture/pinned", modelSource: "explicit" }));
266 await act(async () => {
267 window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed"));
268 await new Promise((resolve) => setTimeout(resolve, 0));
269 });
270 assert.equal(owner.surface?.settings.model, "fixture/pinned",
271 "an explicit draft model remains pinned across later catalog refreshes");
272 act(() => {
273 owner.updateSettings({ collaborationMode: "goal", goal: "" });
274 owner.updateContent(content("goal text"));
275 });
276 await act(async () => { await owner.submit("goal text", "task bytes"); });
277 assert.equal(submissions.length, 1);
278 assert.equal(submissions[0]?.draftId, "draft-a");
279 assert.equal(submissions[0]?.goal, "goal text", "initial Goal has a non-empty objective");
280 assert.equal(submissions[0]?.input, "/goal task bytes", "ordinary initial Goal preserves the established provider-visible prefix");
281 assert.equal(submissions[0]?.snapshotVersion, 5, "the operation uses the live-default-aware snapshot contract");
282 assert.equal(submissions[0]?.settings.model, "fixture/pinned", "the operation carries its frozen model settings");
283 assert.equal(submissions[0]?.settings.modelSource, "explicit", "the operation preserves explicit model provenance");
284 assert.equal(submissionPolls, 1, "a recoverable operation phase keeps polling the original OperationID");
285
286 await act(async () => { root.unmount(); });
287 console.log("session draft store races: save iteration, navigation fencing and initial Goal passed");
288 } finally {
289 stub.uninstall();
290 dom.window.close();
291 }
292
292 lines Plain Text