返回 DeepSeek-Reasonix
session-composer-races.test.tsx
根目录 / desktop / frontend / src / __tests__ / session-composer-races.test.tsx
1 import assert from "node:assert/strict";
2 import test from "node:test";
3 import React, { act } from "react";
4 import { createRoot } from "react-dom/client";
5 import { JSDOM } from "jsdom";
6 import { installDesktopHostStub } from "./desktopHostStub";
7 import { makeSessionUIMock } from "../lib/sessionUIMock";
8 import { sendPersistedComposer, useSessionComposerPersistence } from "../lib/sessionComposerPersistence";
9 import type { SessionRef } from "../lib/sessionRef";
10
11 function deferred() {
12 let resolve!: () => void;
13 const promise = new Promise<void>(done => { resolve = done; });
14 return { promise, resolve };
15 }
16
17 test("composer submission boundaries", async t => {
18 const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
19 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
20 const root = createRoot(document.getElementById("root")!);
21 const backend = makeSessionUIMock(async () => {});
22 const host = installDesktopHostStub({ ...backend });
23 let editor!: ReturnType<typeof useSessionComposerPersistence>;
24 function Probe({ session }: { session: string }) {
25 editor = useSessionComposerPersistence({ hostId: "local", sessionId: session }, "race-tab");
26 return null;
27 }
28 const paint = (session: string) => act(async () => {
29 root.render(<Probe session={session} />);
30 await new Promise(resolve => setTimeout(resolve, 0));
31 });
32 const patch = (text: string) => {
33 const target = editor.target!;
34 target.onPatch!(target.draftId, target.generation, { text });
35 };
36 try {
37 await t.test("registration freezes the captured input before the RPC returns", async () => {
38 await paint("begin-race");
39 await act(async () => patch("submitted text"));
40 const entered = deferred(), release = deferred();
41 host.commands.BeginSessionComposerSubmission = async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => {
42 const result = await backend.BeginSessionComposerSubmission(...args);
43 entered.resolve(); await release.promise; return result;
44 };
45 let pending!: Promise<unknown>;
46 try {
47 await act(async () => {
48 pending = sendPersistedComposer("race-tab", "submitted text", "submitted text", "begin-race-id", async () => {});
49 await entered.promise;
50 });
51 assert.equal(editor.blocked, true);
52 await act(async () => patch("must not silently replace captured input"));
53 assert.equal(editor.target!.initial.text, "submitted text");
54 } finally {
55 await act(async () => { release.resolve(); await pending; });
56 host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission;
57 }
58 });
59
60 await t.test("late acceptance preserves edits made after receipt reconciliation", async () => {
61 const ref: SessionRef = { hostId: "local", sessionId: "receipt-race" };
62 await paint(ref.sessionId);
63 await act(async () => patch("first input"));
64 const sent = deferred(), response = deferred();
65 let pending!: Promise<unknown>;
66 await act(async () => {
67 pending = sendPersistedComposer("race-tab", "first input", "first input", "receipt-race-id", async () => {
68 await backend.CompleteSessionComposerSubmission(ref, "receipt-race-id", "accepted");
69 sent.resolve(); await response.promise;
70 });
71 await sent.promise;
72 });
73 await act(async () => editor.retry());
74 assert.equal(editor.blocked, false);
75 await act(async () => patch("new unsent input"));
76 await act(async () => { response.resolve(); await pending; });
77 assert.equal(editor.target!.initial.text, "new unsent input");
78 await act(async () => editor.retry());
79 assert.equal(JSON.parse((await backend.GetSessionComposerState(ref)).contentJson).text, "new unsent input");
80 });
81
82 await t.test("departing and returning cannot revive an old send intent", async () => {
83 await paint("navigation-race");
84 await act(async () => patch("do not send after leaving"));
85 const entered = deferred(), release = deferred();
86 host.commands.BeginSessionComposerSubmission = async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => {
87 const result = await backend.BeginSessionComposerSubmission(...args);
88 entered.resolve(); await release.promise; return result;
89 };
90 let pending!: Promise<unknown>, sends = 0;
91 await act(async () => {
92 pending = sendPersistedComposer("race-tab", "old", "old", "navigation-race-id", async () => { sends++; });
93 await entered.promise;
94 });
95 await paint("other-session");
96 await paint("navigation-race");
97 await act(async () => {
98 release.resolve();
99 await assert.rejects(pending, /inbox_not_submitted/);
100 });
101 assert.equal(sends, 0);
102 assert.equal(editor.target!.initial.text, "do not send after leaving");
103 host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission;
104 });
105
106 await t.test("rejected registration automatically recovers without replacing current input", async () => {
107 const ref: SessionRef = { hostId: "local", sessionId: "registration-conflict" };
108 await paint(ref.sessionId);
109 await act(async () => patch("keep this window input"));
110 host.commands.BeginSessionComposerSubmission = async () => {
111 const current = await backend.GetSessionComposerState(ref);
112 await backend.SaveSessionComposerState({ ref, expectedRevision: current.revision, contentVersion: 1, contentJson: '{"text":"other window input"}' });
113 throw new Error("session_operation:input_conflict:The input changed. Both versions are preserved; review before continuing.");
114 };
115 let sends = 0;
116 try {
117 await act(async () => {
118 await assert.rejects(sendPersistedComposer("race-tab", "keep this window input", "keep this window input", "conflicted-guidance", async () => { sends++; }, undefined, undefined, "guidance"), /input_conflict/);
119 });
120 assert.equal(editor.blocked, false, "automatic recovery must restore editability");
121 assert.equal(editor.target!.initial.text, "keep this window input");
122 await act(async () => editor.retry());
123 assert.equal(JSON.parse((await backend.GetSessionComposerState(ref)).contentJson).text, "keep this window input");
124 assert.equal(sends, 0, "conflict recovery never submits or replays the message");
125 } finally {
126 host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission;
127 }
128 });
129
130 await t.test("lost registration response releases only the registration that was never sent", async () => {
131 const ref: SessionRef = { hostId: "local", sessionId: "lost-registration" };
132 await paint(ref.sessionId);
133 await act(async () => { editor.setGoalDraft(true); patch("retained goal input"); });
134 host.commands.BeginSessionComposerSubmission = async (...args: Parameters<typeof backend.BeginSessionComposerSubmission>) => {
135 await backend.BeginSessionComposerSubmission(...args);
136 throw new Error("lost registration response");
137 };
138 let sends = 0;
139 await act(async () => {
140 await assert.rejects(sendPersistedComposer("race-tab", "goal", "goal", "lost-registration-id", async () => { sends++; }), /lost registration/);
141 });
142 assert.equal(sends, 0);
143 assert.equal(editor.blocked, false);
144 assert.equal(editor.target!.initial.text, "retained goal input");
145 assert.equal(editor.target!.initial.goalDraft, true);
146 assert.equal(sends, 0);
147 await act(async () => editor.retry());
148 assert.equal(editor.blocked, false);
149 assert.equal((await backend.GetSessionComposerState(ref)).submissionId, undefined);
150 assert.equal(editor.target!.initial.goalDraft, true);
151 host.commands.BeginSessionComposerSubmission = backend.BeginSessionComposerSubmission;
152 });
153
154 await t.test("unknown delivery is automatically reconciled without a second send", async () => {
155 const ref: SessionRef = { hostId: "local", sessionId: "automatic-receipt" };
156 await paint(ref.sessionId);
157 await act(async () => patch("accepted once"));
158 let sends = 0;
159 host.commands.GetSessionComposerState = async value => {
160 const state = await backend.GetSessionComposerState(value);
161 if (value.sessionId === ref.sessionId && state.submissionId) return backend.CompleteSessionComposerSubmission(value, state.submissionId, "accepted");
162 return state;
163 };
164 try {
165 await act(async () => {
166 await assert.rejects(sendPersistedComposer("race-tab", "accepted once", "accepted once", "automatic-receipt-id", async () => { sends++; throw Error("reply lost"); }), /reply lost/);
167 });
168 assert.equal(sends, 1);
169 assert.equal(editor.blocked, false);
170 assert.equal(editor.target!.initial.text, "");
171 assert.equal(editor.settledId, "automatic-receipt-id");
172 } finally { host.commands.GetSessionComposerState = backend.GetSessionComposerState; }
173 });
174
175 await t.test("history changes retain input and are acknowledged on explicit send", async () => {
176 const ref: SessionRef = { hostId: "local", sessionId: "history-recovery" };
177 await backend.SaveSessionComposerState({ ref, expectedRevision: "0", contentVersion: 1, contentJson: '{"text":"next instruction"}' });
178 host.commands.GetSessionComposerState = async value => ({ ...await backend.GetSessionComposerState(value), historyChanged: value.sessionId === ref.sessionId });
179 let acknowledged = false, sends = 0;
180 host.commands.SaveSessionComposerState = async req => {
181 if (req.ref.sessionId === ref.sessionId) acknowledged = req.acknowledgeHistory === true;
182 return backend.SaveSessionComposerState(req);
183 };
184 try {
185 await paint(ref.sessionId);
186 assert.equal(editor.blocked, false);
187 assert.equal(editor.target!.initial.text, "next instruction");
188 await act(async () => { await sendPersistedComposer("race-tab", "next instruction", "next instruction", "history-recovery-id", async () => { sends++; }); });
189 assert.equal(acknowledged, true);
190 assert.equal(sends, 1);
191 } finally {
192 host.commands.GetSessionComposerState = backend.GetSessionComposerState;
193 host.commands.SaveSessionComposerState = backend.SaveSessionComposerState;
194 }
195 });
196
197 await t.test("another pending submission cannot consume this window's unsent input", async () => {
198 const ref: SessionRef = { hostId: "local", sessionId: "other-pending" };
199 await paint(ref.sessionId);
200 await act(async () => patch("my unsent input"));
201 const saved = await backend.SaveSessionComposerState({ ref, expectedRevision: "0", contentVersion: 1, contentJson: '{"text":"other instruction"}' });
202 await backend.BeginSessionComposerSubmission(ref, saved.revision, "other-submission", "{}");
203 let sends = 0;
204 await act(async () => {
205 await assert.rejects(sendPersistedComposer("race-tab", "my unsent input", "my unsent input", "unsent-id", async () => { sends++; }));
206 });
207 assert.equal(editor.blocked, true);
208 assert.equal(editor.target!.initial.text, "my unsent input");
209 await backend.CompleteSessionComposerSubmission(ref, "other-submission", "accepted");
210 await act(async () => editor.retry());
211 assert.equal(editor.blocked, false);
212 assert.equal(editor.target!.initial.text, "my unsent input");
213 assert.equal(sends, 0);
214 });
215
216 await t.test("lost settlement response follows durable content instead of reviving input", async () => {
217 await paint("lost-settlement");
218 await act(async () => patch("already received input"));
219 host.commands.CompleteSessionComposerSubmission = async (...args: Parameters<typeof backend.CompleteSessionComposerSubmission>) => {
220 const result = await backend.CompleteSessionComposerSubmission(...args);
221 if (args[2] === "accepted") throw new Error("lost settlement response");
222 return result;
223 };
224 let sends = 0;
225 await act(async () => {
226 await assert.rejects(sendPersistedComposer("race-tab", "received", "received", "lost-settlement-id", async () => { sends++; }), /lost settlement/);
227 });
228 assert.equal(sends, 1);
229 assert.equal(editor.blocked, false);
230 assert.equal(editor.target!.initial.text, "");
231 host.commands.CompleteSessionComposerSubmission = backend.CompleteSessionComposerSubmission;
232 });
233
234 await t.test("a stale settlement snapshot cannot overwrite a newer recovered revision", async () => {
235 const ref: SessionRef = { hostId: "local", sessionId: "stale-settlement" };
236 await paint(ref.sessionId);
237 await act(async () => patch("first input"));
238 const settled = deferred(), response = deferred();
239 host.commands.CompleteSessionComposerSubmission = async (...args: Parameters<typeof backend.CompleteSessionComposerSubmission>) => {
240 const result = await backend.CompleteSessionComposerSubmission(...args);
241 settled.resolve(); await response.promise; return result;
242 };
243 let pending!: Promise<unknown>;
244 await act(async () => {
245 pending = sendPersistedComposer("race-tab", "first", "first", "stale-settlement-id", async () => {});
246 await settled.promise;
247 });
248 const current = await backend.GetSessionComposerState(ref);
249 await backend.SaveSessionComposerState({ ref, expectedRevision: current.revision, contentVersion: 1, contentJson: '{"text":"another window input"}' });
250 await act(async () => editor.retry());
251 await act(async () => { response.resolve(); await pending; });
252 assert.equal(editor.target!.initial.text, "another window input");
253 host.commands.CompleteSessionComposerSubmission = backend.CompleteSessionComposerSubmission;
254 });
255 } finally {
256 await act(async () => root.unmount());
257 dom.window.close();
258 }
259 });
260
260 lines Plain Text