返回 DeepSeek-Reasonix
composer-inbox-recovery.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-inbox-recovery.test.tsx
1 // Run: tsx src/__tests__/composer-inbox-recovery.test.tsx
2
3 import { act } from "react";
4 import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
5 import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
6 import { acceptRuntimeState } from "../lib/runtimeStateReducer";
7
8 let passed = 0;
9 let failed = 0;
10
11 function ok(value: boolean, label: string) {
12 if (value) {
13 process.stdout.write(` PASS ${label}\n`);
14 passed += 1;
15 } else {
16 process.stdout.write(` FAIL ${label}\n`);
17 failed += 1;
18 }
19 }
20
21 function flushTimers(): Promise<void> {
22 return new Promise((resolve) => setTimeout(resolve, 0));
23 }
24
25 async function waitFor(label: string, check: () => boolean) {
26 for (let attempt = 0; attempt < 30; attempt += 1) {
27 if (check()) return;
28 await act(async () => { await flushTimers(); });
29 }
30 ok(false, label);
31 }
32
33 function recoveredSnapshot(count = 3) {
34 const items = Array.from({ length: count }, (_, index) => ({
35 id: `recovered-${index + 1}`,
36 intent: "followup",
37 state: "uncertain",
38 preview: `Recovered instruction ${index + 1}`,
39 byteSize: 128,
40 position: index + 1,
41 }));
42 return {
43 revision: 1,
44 paused: true,
45 recovered: true,
46 recoveredCount: count,
47 items,
48 itemsCount: items.length,
49 bytes: items.length * 128,
50 maxItems: 64,
51 maxBytes: 64 * 1024 * 1024,
52 };
53 }
54
55 console.log("\ncomposer inbox recovery");
56
57 {
58 const dom = installDom();
59 let snapshot = recoveredSnapshot();
60 const pauseCalls: Array<{ tabId: string; paused: boolean }> = [];
61 installBridgeApp({
62 InboxSnapshot: async () => snapshot,
63 SetInboxPaused: async (tabId: string, paused: boolean) => {
64 pauseCalls.push({ tabId, paused });
65 if (!paused) snapshot = { ...snapshot, paused: false, recovered: false };
66 },
67 });
68 const { root } = await renderComposer();
69
70 await waitFor("recovery banner rendered", () => document.querySelector(".composer-inbox-recovery") !== null);
71 ok(document.querySelector(".composer-inbox-recovery")?.textContent?.includes("Recovered 3 pending instructions") === true, "banner reports recovered count");
72 ok(document.querySelectorAll(".composer-guidance-item").length === 2, "queue starts with bounded preview");
73
74 const review = document.querySelector(".composer-inbox-recovery .btn") as HTMLButtonElement;
75 await act(async () => { review.click(); await flushTimers(); });
76 ok(document.querySelectorAll(".composer-guidance-item").length === 3, "review action expands the recovered queue");
77
78 const buttons = Array.from(document.querySelectorAll(".composer-inbox-recovery .btn")) as HTMLButtonElement[];
79 await act(async () => { buttons[2].click(); await flushTimers(); });
80 ok(pauseCalls.at(-1)?.paused === true, "keep paused confirms the server-side pause");
81 ok(document.querySelector(".composer-inbox-recovery") !== null, "keep paused preserves the resume path");
82 ok(buttons[2].textContent === "Paused" && buttons[2].disabled, "confirmed pause is visible and idempotent");
83
84 await act(async () => { buttons[1].click(); await flushTimers(); });
85 ok(pauseCalls.at(-1)?.paused === false, "continue resumes the durable inbox");
86 await waitFor("recovery banner cleared after resume", () => document.querySelector(".composer-inbox-recovery") === null);
87 ok(document.querySelector(".composer-inbox-recovery") === null, "successful resume clears the recovery banner");
88
89 await act(async () => { root.unmount(); });
90 dom.window.close();
91 }
92
93 {
94 const dom = installDom();
95 let resolveTabA!: (value: ReturnType<typeof recoveredSnapshot>) => void;
96 const tabAPromise = new Promise<ReturnType<typeof recoveredSnapshot>>((resolve) => { resolveTabA = resolve; });
97 installBridgeApp({
98 InboxSnapshot: async (tabId: string) => tabId === "tab-a"
99 ? tabAPromise
100 : { ...recoveredSnapshot(0), paused: false, recovered: false },
101 SetInboxPaused: async () => {},
102 });
103 const { root, rerender } = await renderComposer();
104 await rerender({ tabId: "tab-b", sessionKey: "session-b" });
105 resolveTabA(recoveredSnapshot(2));
106 await act(async () => { await flushTimers(); });
107 ok(document.querySelector(".composer-inbox-recovery") === null, "stale snapshot cannot show recovery controls on another session");
108
109 await act(async () => { root.unmount(); });
110 dom.window.close();
111 }
112
113 {
114 const dom = installDom("zh-CN");
115 const snapshot = {
116 revision: 3,
117 paused: false,
118 recovered: false,
119 recoveredCount: 0,
120 items: [{
121 id: "queued-before-pause",
122 intent: "followup",
123 state: "queued",
124 preview: "引导当前回合",
125 byteSize: 64,
126 position: 1,
127 }],
128 itemsCount: 1,
129 bytes: 64,
130 maxItems: 64,
131 maxBytes: 64 * 1024 * 1024,
132 };
133 installBridgeApp({
134 InboxSnapshot: async () => snapshot,
135 SteerInboxItem: async () => { throw new Error("reasonix_error:inbox_paused"); },
136 });
137 const { root } = await renderComposer({ running: true });
138
139 await waitFor("queued guidance rendered for localization check", () => document.querySelector(".composer-guidance-item__guide") !== null);
140 const guide = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement;
141 await act(async () => { guide.click(); await flushTimers(); });
142 await waitFor("localized paused toast rendered", () => document.querySelector(".toast__text")?.textContent === "收件箱已暂停");
143 ok(document.querySelector(".toast__text")?.textContent === "收件箱已暂停", "coded paused error renders in the active Chinese locale");
144
145 await act(async () => { root.unmount(); });
146 dom.window.close();
147 }
148
149 {
150 const dom = installDom();
151 let snapshot = {
152 revision: 2,
153 paused: true,
154 recovered: false,
155 recoveredCount: 0,
156 items: [{
157 id: "paused-queued-1",
158 intent: "followup",
159 state: "queued",
160 preview: "Guide the active turn",
161 byteSize: 64,
162 position: 1,
163 }],
164 itemsCount: 1,
165 bytes: 64,
166 maxItems: 64,
167 maxBytes: 64 * 1024 * 1024,
168 };
169 const pauseCalls: boolean[] = [];
170 installBridgeApp({
171 InboxSnapshot: async () => snapshot,
172 SetInboxPaused: async (_tabId: string, paused: boolean) => {
173 pauseCalls.push(paused);
174 if (!paused) snapshot = { ...snapshot, paused: false };
175 },
176 });
177 const { root } = await renderComposer({ running: true }, true);
178
179 await waitFor("ordinary pause banner rendered", () => document.querySelector(".composer-inbox-recovery") !== null);
180 const pauseBanner = document.querySelector(".composer-inbox-recovery");
181 ok(pauseBanner?.textContent?.includes("Inbox is paused") === true, "non-recovered pause exposes an actionable pause banner");
182 const guide = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement | null;
183 ok(guide?.disabled === true, "paused inbox disables guide admission instead of surfacing a backend error");
184
185 const buttons = Array.from(document.querySelectorAll(".composer-inbox-recovery .btn")) as HTMLButtonElement[];
186 if (buttons[1]) {
187 await act(async () => { buttons[1].click(); await flushTimers(); });
188 ok(pauseCalls.at(-1) === false, "continue resumes a non-recovered paused inbox");
189 await waitFor("ordinary pause banner cleared after resume", () => document.querySelector(".composer-inbox-recovery") === null);
190 }
191
192 await act(async () => { root.unmount(); });
193 dom.window.close();
194 }
195
196 {
197 const dom = installDom();
198 let snapshot = {
199 revision: 4,
200 paused: false,
201 recovered: false,
202 recoveredCount: 0,
203 items: [
204 {
205 id: "idle-queued-1",
206 intent: "followup",
207 state: "queued",
208 preview: "Send the next instruction",
209 byteSize: 64,
210 position: 1,
211 },
212 {
213 id: "idle-queued-2",
214 intent: "followup",
215 state: "queued",
216 preview: "Wait behind the first instruction",
217 byteSize: 64,
218 position: 2,
219 },
220 ],
221 itemsCount: 2,
222 bytes: 128,
223 maxItems: 64,
224 maxBytes: 64 * 1024 * 1024,
225 };
226 const pauseCalls: Array<{ tabId: string; paused: boolean }> = [];
227 let steerCalls = 0;
228 installBridgeApp({
229 InboxSnapshot: async () => snapshot,
230 SetInboxPaused: async (tabId: string, paused: boolean) => {
231 pauseCalls.push({ tabId, paused });
232 snapshot = { ...snapshot, items: [], itemsCount: 0, bytes: 0 };
233 },
234 SteerInboxItem: async () => { steerCalls += 1; },
235 });
236 const { root } = await renderComposer({ running: false });
237
238 await waitFor("idle durable guidance rendered", () => document.querySelectorAll(".composer-guidance-item__guide").length === 2);
239 const guides = Array.from(document.querySelectorAll(".composer-guidance-item__guide")) as HTMLButtonElement[];
240 ok(!guides[0].disabled && guides[0].textContent === "Send", "idle FIFO head exposes an explicit send fallback");
241 ok(guides[0].getAttribute("aria-label") === "Send this guidance to the transcript", "idle send fallback has an accessible label");
242 ok(guides[1].disabled, "idle non-head guidance remains disabled to preserve FIFO order");
243 ok(guides[1].getAttribute("aria-label") === "Waiting for earlier queued guidance", "idle non-head guidance explains the FIFO gate");
244 await act(async () => { guides[0].click(); await flushTimers(); });
245 ok(pauseCalls.length === 1 && pauseCalls[0].tabId === "tab-a" && pauseCalls[0].paused === false, "idle send re-kicks the Controller drain without changing pause state");
246 ok(steerCalls === 0, "idle send never attempts active-turn steering");
247 await waitFor("idle durable guidance clears after dispatch kick", () => document.querySelector(".composer-guidance-item") === null);
248
249 await act(async () => { root.unmount(); });
250 dom.window.close();
251 }
252
253 {
254 const dom = installDom();
255 let steerCalls = 0;
256 let deleteCalls = 0;
257 installBridgeApp({
258 InboxSnapshot: async () => ({
259 revision: 4,
260 paused: false,
261 recovered: false,
262 recoveredCount: 0,
263 items: [{
264 id: "active-steer",
265 intent: "steer",
266 state: "steer_accepted",
267 preview: "Already admitted guidance",
268 byteSize: 64,
269 position: 1,
270 }],
271 itemsCount: 1,
272 bytes: 64,
273 maxItems: 64,
274 maxBytes: 64 * 1024 * 1024,
275 }),
276 SteerInboxItem: async () => { steerCalls += 1; },
277 DeleteInboxItem: async () => { deleteCalls += 1; },
278 });
279 const { root } = await renderComposer({ running: true });
280 await waitFor("active steer rendered", () => document.querySelector(".composer-guidance-item__guide") !== null);
281 const guide = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement;
282 const actions = document.querySelectorAll(".composer-guidance-item__action");
283 const dismiss = actions[actions.length - 1] as HTMLButtonElement;
284 ok(guide.disabled && dismiss.disabled, "active steer disables send and delete actions");
285 ok(guide.getAttribute("aria-label") === "Guidance is already being applied", "active steer explains why actions are disabled");
286 await act(async () => { guide.click(); dismiss.click(); await flushTimers(); });
287 ok(steerCalls === 0 && deleteCalls === 0, "active steer never sends invalid backend operations");
288
289 await act(async () => { root.unmount(); });
290 dom.window.close();
291 }
292 {
293 const dom = installDom();
294 installBridgeApp({
295 InboxSnapshot: async () => ({
296 revision: 5,
297 paused: false,
298 recovered: false,
299 recoveredCount: 0,
300 items: [
301 {
302 id: "consumed-steer",
303 intent: "steer",
304 state: "steer_consumed",
305 preview: "Already shown in the transcript",
306 byteSize: 64,
307 position: 1,
308 },
309 {
310 id: "still-queued",
311 intent: "followup",
312 state: "queued",
313 preview: "Still waiting to run",
314 byteSize: 64,
315 position: 2,
316 },
317 ],
318 itemsCount: 2,
319 bytes: 128,
320 maxItems: 64,
321 maxBytes: 64 * 1024 * 1024,
322 }),
323 });
324 const { root } = await renderComposer({ running: true });
325
326 await waitFor("pending guidance rendered without consumed steer", () => document.querySelectorAll(".composer-guidance-item").length === 1);
327 ok(document.querySelector(".composer-guidance-item")?.textContent?.includes("Still waiting to run") === true, "consumed steer is excluded from the pending guidance shelf");
328 ok(document.body.textContent?.includes("Already shown in the transcript") === false, "consumed steer cannot reappear after inbox refresh");
329
330 await act(async () => { root.unmount(); });
331 dom.window.close();
332 }
333 {
334 const dom = installDom();
335 let retryCalls = 0;
336 let steerCalls = 0;
337 const snapshot = {
338 revision: 5,
339 paused: false,
340 recovered: true,
341 recoveredCount: 1,
342 items: [{
343 id: "uncertain-guidance",
344 intent: "followup",
345 state: "uncertain",
346 preview: "Retry recovered guidance",
347 byteSize: 64,
348 position: 1,
349 }],
350 itemsCount: 1,
351 bytes: 64,
352 maxItems: 64,
353 maxBytes: 64 * 1024 * 1024,
354 };
355 installBridgeApp({
356 InboxSnapshot: async () => snapshot,
357 RetryInboxItem: async () => { retryCalls += 1; },
358 SteerInboxItem: async () => { steerCalls += 1; },
359 });
360 const { root } = await renderComposer({ running: false });
361
362 await waitFor("uncertain guidance rendered", () => document.querySelector(".composer-guidance-item__guide") !== null);
363 const retry = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement;
364 ok(!retry.disabled && retry.textContent === "Retry", "uncertain idle guidance exposes an explicit retry action");
365 ok(retry.getAttribute("aria-label") === "Retry this guidance", "retry action has a state-aware accessible label");
366 await act(async () => { retry.click(); await flushTimers(); });
367 ok(retryCalls === 1 && steerCalls === 0, "idle retry requeues through the Controller without an invalid steer");
368
369 await act(async () => { root.unmount(); });
370 dom.window.close();
371 }
372
373 {
374 const dom = installDom();
375 let retryCalls = 0;
376 let steerCalls = 0;
377 installBridgeApp({
378 InboxSnapshot: async () => ({
379 revision: 6,
380 paused: false,
381 recovered: false,
382 recoveredCount: 0,
383 items: [{
384 id: "blocked-guidance",
385 intent: "steer",
386 state: "blocked",
387 preview: "Retry blocked guidance",
388 byteSize: 64,
389 position: 1,
390 }],
391 itemsCount: 1,
392 bytes: 64,
393 maxItems: 64,
394 maxBytes: 64 * 1024 * 1024,
395 }),
396 RetryInboxItem: async () => { retryCalls += 1; },
397 SteerInboxItem: async () => {
398 steerCalls += 1;
399 return { disposition: "queued_followup" };
400 },
401 });
402 const { root } = await renderComposer({ running: true });
403
404 await waitFor("blocked guidance rendered", () => document.querySelector(".composer-guidance-item__guide") !== null);
405 const retry = document.querySelector(".composer-guidance-item__guide") as HTMLButtonElement;
406 await act(async () => { retry.click(); await flushTimers(); });
407 ok(retryCalls === 1 && steerCalls === 1, "busy retry requeues before attempting active-turn admission");
408
409 await act(async () => { root.unmount(); });
410 dom.window.close();
411 }
412
413 {
414 const dom = installDom();
415 installBridgeApp({
416 InboxSnapshot: async () => ({
417 revision: 7,
418 paused: true,
419 recovered: false,
420 recoveredCount: 0,
421 items: [{
422 id: "processing-guidance",
423 intent: "steer",
424 state: "steer_accepted",
425 preview: "Already being applied",
426 byteSize: 64,
427 position: 1,
428 }],
429 itemsCount: 1,
430 bytes: 64,
431 maxItems: 64,
432 maxBytes: 64 * 1024 * 1024,
433 }),
434 });
435 const { root } = await renderComposer({ running: true });
436 await waitFor("in-flight paused item rendered", () => document.querySelector(".composer-guidance-item") !== null);
437 ok(document.querySelector(".composer-inbox-recovery") === null, "pause banner hides while guidance is already being applied");
438 await act(async () => { root.unmount(); });
439 dom.window.close();
440 }
441
442 {
443 const dom = installDom();
444 let updated = "";
445 installBridgeApp({
446 InboxSnapshot: async () => ({
447 revision: 8,
448 paused: false,
449 recovered: false,
450 recoveredCount: 0,
451 items: [{
452 id: "editable-guidance",
453 intent: "followup",
454 state: "queued",
455 preview: "Original queued text",
456 byteSize: 64,
457 position: 1,
458 }],
459 itemsCount: 1,
460 bytes: 64,
461 maxItems: 64,
462 maxBytes: 64 * 1024 * 1024,
463 }),
464 UpdateInboxItem: async (_tabId: string, _id: string, display: string) => {
465 updated = display;
466 },
467 });
468 const { root } = await renderComposer({ running: false });
469 await waitFor("editable guidance rendered", () => document.querySelector("[aria-label=\"Edit this queued guidance\"]") !== null);
470 const edit = document.querySelector("[aria-label=\"Edit this queued guidance\"]") as HTMLButtonElement;
471 await act(async () => { edit.click(); await flushTimers(); });
472 const editor = document.querySelector(".composer-guidance-item__editor") as HTMLInputElement;
473 ok(editor?.value === "Original queued text", "edit mode loads the queued text");
474 const save = document.querySelector("[aria-label=\"Save guidance edits\"]") as HTMLButtonElement;
475 await act(async () => { save.click(); await flushTimers(); });
476 ok(updated === "Original queued text", "edit saves through UpdateInboxItem");
477 await act(async () => { root.unmount(); });
478 dom.window.close();
479 }
480
481 {
482 const dom = installDom();
483 installBridgeApp({
484 InboxSnapshot: async () => ({
485 revision: 9,
486 paused: true,
487 recovered: false,
488 recoveredCount: 0,
489 items: [{
490 id: "empty-preview",
491 intent: "followup",
492 state: "queued",
493 preview: "",
494 byteSize: 64,
495 position: 1,
496 }],
497 itemsCount: 1,
498 bytes: 64,
499 maxItems: 64,
500 maxBytes: 64 * 1024 * 1024,
501 }),
502 ReadInboxItem: async () => ({ id: "empty-preview", displayText: "Loaded body", rawText: "Loaded body", submitText: "Loaded body" }),
503 });
504 const { root } = await renderComposer({ running: false });
505 await waitFor("empty preview hydrated", () => document.querySelector(".composer-guidance-item__text")?.textContent === "Loaded body");
506 ok(document.querySelector(".composer-guidance-item__text")?.textContent === "Loaded body", "review path hydrates an empty snapshot preview");
507 await act(async () => { root.unmount(); });
508 dom.window.close();
509 }
510
511 {
512 const dom = installDom();
513 const snapshots: Record<string, ReturnType<typeof recoveredSnapshot>> = {
514 "/sessions/a.jsonl": recoveredSnapshot(12),
515 "/sessions/b.jsonl": { ...recoveredSnapshot(0), paused: false, recovered: false, recoveredCount: 0, items: [], itemsCount: 0, bytes: 0 },
516 };
517 installBridgeApp({
518 InboxSnapshot: async () => {
519 const path = currentPath;
520 return { ...snapshots[path], sessionPath: path };
521 },
522 });
523 let currentPath = "/sessions/a.jsonl";
524 const { root, rerender } = await renderComposer({
525 tabId: "tab-shared",
526 sessionKey: "session-topic\0project\0/repo\0topic-a",
527 inboxSessionPath: currentPath,
528 });
529 await waitFor("session A recovered queue rendered", () => document.querySelectorAll(".composer-guidance-item").length === 2);
530 ok(document.querySelector(".composer-inbox-recovery")?.textContent?.includes("Recovered 12 pending instructions") === true, "session A shows its own recovered inbox");
531
532 currentPath = "/sessions/b.jsonl";
533 await rerender({ inboxSessionPath: currentPath });
534 await waitFor("session B does not inherit session A inbox", () => document.querySelector(".composer-guidance-item") === null);
535 ok(document.querySelector(".composer-inbox-recovery") === null, "sibling session does not inherit the previous recovered banner");
536 ok(document.querySelector(".composer-guidance-item") === null, "sibling session does not inherit the previous queued guidance");
537
538 await act(async () => { root.unmount(); });
539 dom.window.close();
540 }
541
542 {
543 const dom = installDom();
544 let resolveStale!: (value: ReturnType<typeof recoveredSnapshot> & { sessionPath: string }) => void;
545 const stalePromise = new Promise<ReturnType<typeof recoveredSnapshot> & { sessionPath: string }>((resolve) => { resolveStale = resolve; });
546 installBridgeApp({
547 InboxSnapshot: async (_tabId: string) => currentPath === "/sessions/a.jsonl"
548 ? stalePromise
549 : { ...recoveredSnapshot(0), paused: false, recovered: false, recoveredCount: 0, items: [], itemsCount: 0, bytes: 0, sessionPath: currentPath },
550 });
551 let currentPath = "/sessions/a.jsonl";
552 const { root, rerender } = await renderComposer({
553 tabId: "tab-shared",
554 sessionKey: "session-topic\0project\0/repo\0topic-a",
555 inboxSessionPath: currentPath,
556 });
557 currentPath = "/sessions/b.jsonl";
558 await rerender({ inboxSessionPath: currentPath });
559 resolveStale({ ...recoveredSnapshot(12), sessionPath: "/sessions/a.jsonl" });
560 await act(async () => { await flushTimers(); });
561 ok(document.querySelector(".composer-inbox-recovery") === null, "stale recovered snapshot cannot land on another session");
562 ok(document.querySelector(".composer-guidance-item") === null, "stale queued items cannot land on another session");
563
564 await act(async () => { root.unmount(); });
565 dom.window.close();
566 }
567
568 {
569 const dom = installDom();
570 const longText = "x".repeat(240);
571 let deletedID = "";
572 installBridgeApp({
573 InboxSnapshot: async () => ({
574 revision: 8,
575 paused: false,
576 recovered: false,
577 items: [
578 { id: "same-first", intent: "steer", state: "queued", preview: longText.slice(0, 120), source: "desktop", byteSize: 240, position: 1 },
579 { id: "same-second", intent: "steer", state: "queued", preview: longText.slice(0, 120), source: "desktop", byteSize: 240, position: 2 },
580 ],
581 itemsCount: 2,
582 bytes: 480,
583 maxItems: 64,
584 maxBytes: 64 * 1024 * 1024,
585 }),
586 DeleteInboxItem: async (_tabId: string, id: string) => { deletedID = id; },
587 });
588 const { root, rerender } = await renderComposer({ running: true });
589 await waitFor("duplicate long guidance rendered", () => document.querySelectorAll(".composer-guidance-item").length === 2);
590 await rerender({ guidanceConsumedKey: "steer-event-1", guidanceConsumedItemId: "same-second", guidanceConsumedText: longText });
591 await waitFor("item id removes one duplicate", () => document.querySelectorAll(".composer-guidance-item").length === 1);
592 const duplicateActions = document.querySelectorAll(".composer-guidance-item__action");
593 const dismiss = duplicateActions[duplicateActions.length - 1] as HTMLButtonElement;
594 await act(async () => { dismiss.click(); await flushTimers(); });
595 ok(deletedID === "same-first", "consumed steer uses item ID for long duplicate text");
596 await act(async () => { root.unmount(); });
597 dom.window.close();
598 }
599
600 {
601 const dom = installDom();
602 installBridgeApp({
603 InboxSnapshot: async () => ({
604 revision: 9,
605 paused: true,
606 recovered: true,
607 recoveredCount: 3,
608 items: [
609 { id: "already-running", intent: "followup", state: "running", preview: "running", byteSize: 32, position: 1 },
610 { id: "already-consumed", intent: "steer", state: "steer_consumed", preview: "consumed", byteSize: 32, position: 2 },
611 { id: "still-pending", intent: "followup", state: "uncertain", preview: "pending", byteSize: 32, position: 3 },
612 ],
613 itemsCount: 3,
614 bytes: 96,
615 maxItems: 64,
616 maxBytes: 64 * 1024 * 1024,
617 }),
618 });
619 const { root } = await renderComposer({ running: false });
620 await waitFor("delivered states filtered", () => document.querySelectorAll(".composer-guidance-item").length === 1);
621 ok(document.body.textContent?.includes("running") === false && document.body.textContent?.includes("consumed") === false, "running and consumed items stay out of the pending queue");
622 ok(document.querySelector(".composer-inbox-recovery")?.textContent?.includes("Recovered 1 pending instruction") === true, "recovery count reflects visible pending items");
623 await act(async () => { root.unmount(); });
624 dom.window.close();
625 }
626
627 {
628 const dom = installDom();
629 installBridgeApp({
630 InboxSnapshot: async () => ({
631 revision: 10,
632 paused: false,
633 recovered: false,
634 items: [
635 { id: "withdrawn", intent: "followup", state: "queued", preview: "restore me", source: "desktop", byteSize: 32, position: 1 },
636 { id: "delivered", intent: "steer", state: "queued", preview: "do not restore me", source: "desktop", byteSize: 32, position: 2 },
637 ],
638 itemsCount: 2,
639 bytes: 64,
640 maxItems: 64,
641 maxBytes: 64 * 1024 * 1024,
642 }),
643 });
644 const { root } = await renderComposer({
645 running: true,
646 onCancel: async () => ({ discardedItemIds: ["withdrawn"] }),
647 });
648 await waitFor("mixed cancel queue rendered", () => document.querySelectorAll(".composer-guidance-item").length === 2);
649 const stop = document.querySelector(".composer__btn--stop") as HTMLButtonElement;
650 await act(async () => { stop.click(); await flushTimers(); });
651 const textarea = document.querySelector("textarea") as HTMLTextAreaElement;
652 ok(textarea.value.includes("restore me") && !textarea.value.includes("do not restore me"), "stop restores only backend-confirmed withdrawn messages");
653 ok(document.querySelector(".composer-guidance-item")?.textContent?.includes("do not restore me") === true, "non-withdrawn durable message remains backend-owned");
654 await act(async () => { root.unmount(); });
655 dom.window.close();
656 }
657
658 {
659 const dom = installDom();
660 installBridgeApp({
661 InboxSnapshot: async () => ({
662 revision: 11,
663 paused: false,
664 recovered: false,
665 items: [{ id: "readonly", intent: "followup", state: "queued", preview: "readonly item", source: "desktop", byteSize: 32, position: 1 }],
666 itemsCount: 1,
667 bytes: 32,
668 maxItems: 64,
669 maxBytes: 64 * 1024 * 1024,
670 }),
671 });
672 const { root } = await renderComposer({ running: true, readOnly: true });
673 await waitFor("readonly item rendered", () => document.querySelector(".composer-guidance-item__action") !== null);
674 ok((document.querySelector(".composer-guidance-item__action") as HTMLButtonElement).disabled, "read-only queue disables delete");
675 await act(async () => { root.unmount(); });
676 dom.window.close();
677 }
678
679 {
680 const dom = installDom("zh-CN");
681 const calls: { display: string; submit: string; id: string }[] = [];
682 const lookups: string[] = [];
683 let rejectEnqueue = true, cancelled = 0, direct = 0;
684 installBridgeApp({
685 InboxSnapshot: async () => ({ ...recoveredSnapshot(0), paused: false, recovered: false }),
686 CaptureInboxTarget: async () => ({ tabId: "tab-a", sessionPath: "session-a", generation: 1, selection: 0, remote: false }),
687 EnqueueInboxFollowupForTarget: async (_target: unknown, display: string, submit: string, _invocations: unknown, id: string) => {
688 calls.push({ display, submit, id });
689 if (rejectEnqueue) throw new Error("transport outcome unknown");
690 return { itemId: "durable-followup", disposition: "queued_followup", position: 1, paused: false };
691 },
692 LookupInboxFollowupForTarget: async (_target: unknown, id: string) => {
693 lookups.push(id);
694 return { itemId: "durable-followup", disposition: "idempotent_hit", position: 0, paused: false };
695 },
696 });
697 const snapshot: RuntimeProjection = { epoch: "finishing-test", revision: 1, topics: [], sessions: [{
698 tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
699 open: true, remote: false, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller-test", revision: 1,
700 phase: "finishing", running: true, turnId: "turn", turnStatus: "completed", turnEventSeq: 3,
701 pendingPrompt: false, cancelRequested: false, cancellable: false, backgroundJobs: 0, activity: "" },
702 }] };
703 acceptRuntimeState(runtimeStateStore, snapshot, true);
704 const { root } = await renderComposer({ running: false, inboxSessionPath: "session-a", onSend: () => { direct++; }, onCancel: () => { cancelled++; } });
705 ok(Boolean(document.querySelector(".composer-run-strip")?.textContent?.includes("正在收尾")), "finishing is visibly announced from shared state after TurnDone");
706 ok(!document.querySelector(".composer-card--running") && !document.querySelector(".composer-run-strip__dot"), "finishing does not animate as model execution");
707 ok(!document.querySelector(".composer__btn--stop"), "finishing hides current-turn stop");
708 const input = document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement;
709 await act(async () => {
710 const paste = new window.Event("paste", { bubbles: true, cancelable: true });
711 Object.defineProperty(paste, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => "follow up after cleanup" } });
712 input.dispatchEvent(paste);
713 await flushTimers();
714 });
715 await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flushTimers(); });
716 ok(calls.length === 1 && input.value.includes("follow up"), "uncertain queue failure retains draft and makes one request");
717 rejectEnqueue = false;
718 await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flushTimers(); });
719 ok(calls.length === 1 && calls[0].id !== "" && calls[0].id === lookups[0], "explicit retry only looks up the original idempotency key");
720 ok(calls[0]?.submit === "follow up after cleanup" && input.value === "", "durable queue receipt clears the complete submitted draft");
721 ok(direct === 0 && cancelled === 0, "finishing neither directly submits nor cancels");
722 await act(async () => { root.unmount(); });
723 dom.window.close();
724 }
725
726 if (failed > 0) {
727 process.stderr.write(`\n${failed} failed, ${passed} passed\n`);
728 process.exit(1);
729 }
730 process.stdout.write(`\n${passed} passed\n`);
731
731 lines Plain Text