返回 DeepSeek-Reasonix
composer-transient-guidance.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-transient-guidance.test.tsx
1 // A transient inbox-target fence (session switching, reconnecting, route
2 // adoption) clears by itself. The composer must wait it out and deliver the
3 // message instead of surfacing "the message was not sent" for a window the
4 // user cannot act on.
5 import assert from "node:assert/strict";
6 import { act } from "react";
7 import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
8 import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
9 import { acceptRuntimeState } from "../lib/runtimeStateReducer";
10 import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup";
11 import { formatInboxError } from "../lib/inboxError";
12 const TRANSIENT_INBOX_TARGET_CODE = "inbox_target_transient";
13 const TRANSIENT_GUIDANCE_RETRY_DELAYS_MS = [150, 350, 500, 500];
14
15 const pendingSession = followupSessionKey("session-a");
16 const DEAD_END_COPY = "Refresh the session";
17
18 const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
19 const settle = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
20
21 async function paste(text: string) {
22 await act(async () => {
23 const event = new window.Event("paste", { bubbles: true, cancelable: true });
24 Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
25 document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
26 await flush();
27 });
28 }
29 async function send() {
30 await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
31 }
32 function transientError() {
33 return Object.assign(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), { code: "business", data: { transient: true } });
34 }
35 function state(scenario: number, phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection {
36 return { epoch: `transient-test-${scenario}`, revision, topics: [], sessions: [{
37 tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
38 open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision,
39 phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3, turnStartedAt: 0,
40 pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
41 }] };
42 }
43 const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true };
44 const receipt = { itemId: "item-1", disposition: "queued_followup", position: 0, paused: false };
45
46 // The classifier and the copy table must recognize the bridge's transient mark.
47 {
48 assert.equal(formatInboxError(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), "en").includes("switching"), true);
49 }
50
51 // 1. A capture-phase fence is waited out: the send completes normally.
52 {
53 installDom();
54 let captures = 0, posts = 0;
55 installBridgeApp({
56 CaptureInboxTarget: async () => { captures++; if (captures <= 2) throw transientError(); return target; },
57 EnqueueInboxFollowupForTarget: async () => { posts++; return receipt; },
58 InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
59 });
60 const SCENARIO = 1;
61 await renderComposer({ inboxSessionPath: "session-a" });
62 await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
63 await paste("held during switch");
64 await send();
65 await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[1] + 80); });
66 assert.equal(captures, 3, "capture retried until the tab settled");
67 assert.equal(posts, 1, "the settled send enqueued exactly once");
68 assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "no dead-end copy for a self-clearing window");
69 }
70
71 // 2. An enqueue-phase fence holds the request and auto-delivers on retry.
72 {
73 installDom();
74 let posts = 0;
75 installBridgeApp({
76 CaptureInboxTarget: async () => target,
77 EnqueueInboxFollowupForTarget: async () => { posts++; if (posts === 1) throw transientError(); return receipt; },
78 InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
79 });
80 const SCENARIO = 2;
81 await renderComposer({ inboxSessionPath: "session-a" });
82 await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
83 await paste("retried until queued");
84 await send();
85 await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + 80); });
86 assert.equal(posts, 2, "the held request retried the enqueue");
87 assert.equal(pendingFollowups.get(pendingSession), undefined, "a delivered request clears its pending identity");
88 assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "delivery never surfaced the dead-end copy");
89 }
90
91 // 3. Outlasting the bound keeps the request visible for later reconciliation
92 // instead of telling the user to refresh.
93 {
94 installDom();
95 let posts = 0;
96 installBridgeApp({
97 CaptureInboxTarget: async () => target,
98 EnqueueInboxFollowupForTarget: async () => { posts++; throw transientError(); },
99 InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
100 });
101 const SCENARIO = 3;
102 await renderComposer({ inboxSessionPath: "session-a" });
103 await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
104 await paste("switching forever");
105 await send();
106 await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + 200); });
107 assert.equal(posts, 1 + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.length, "the retry bound is finite");
108 assert.ok(pendingFollowups.get(pendingSession), "an undelivered request stays pending for reconciliation");
109 assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "exhaustion still avoids the dead-end copy");
110 // The pending store is module-global: retire this scenario's leftover so the
111 // next one starts from a clean session identity.
112 pendingFollowups.clear(pendingSession, pendingFollowups.get(pendingSession));
113 }
114
115 // 4. Permanent refusals keep the historical behavior (clear + report).
116 {
117 installDom();
118 installBridgeApp({
119 CaptureInboxTarget: async () => target,
120 EnqueueInboxFollowupForTarget: async () => { throw new Error("reasonix_error:inbox_item_too_large"); },
121 InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
122 });
123 const SCENARIO = 4;
124 await renderComposer({ inboxSessionPath: "session-a" });
125 await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); });
126 await paste("permanently refused");
127 await send();
128 await act(async () => { await settle(60); });
129 assert.equal(pendingFollowups.get(pendingSession), undefined, "permanent refusals drop the pending identity");
130 assert.equal(document.body.textContent?.includes("too large"), true, "permanent refusals stay visible to the user");
131 }
132
133 console.log("composer transient guidance: retries a self-clearing fence and keeps the message visible");
134 // Mounted roots leave timers behind; exit once the scenarios above ran.
135 process.exit(0);
136
136 lines Plain Text