返回 DeepSeek-Reasonix
composer-followup-confirmation.test.tsx
根目录 / desktop / frontend / src / __tests__ / composer-followup-confirmation.test.tsx
1 import assert from "node:assert/strict";
2 import { act } from "react";
3 import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
4 import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
5 import { acceptRuntimeState } from "../lib/runtimeStateReducer";
6 import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup";
7 import { composerDraftKeyForTab } from "../lib/composerDraftKey";
8
9 const pendingSession = followupSessionKey("session-a");
10
11 const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
12 async function paste(text: string) {
13 await act(async () => {
14 const event = new window.Event("paste", { bubbles: true, cancelable: true });
15 Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
16 document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
17 await flush();
18 });
19 }
20 async function send() {
21 await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
22 }
23 function state(phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection {
24 return { epoch: "confirmation-test", revision, topics: [], sessions: [{
25 tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
26 open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision,
27 phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3,
28 pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
29 }] };
30 }
31
32 for (const phase of ["idle", "executing", "finishing"] as const) {
33 const dom = installDom();
34 let posts = 0, direct = 0, steers = 0, queries = 0;
35 const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true };
36 let key = "", confirmed = false;
37 installBridgeApp({
38 CaptureInboxTarget: async () => target,
39 EnqueueInboxFollowupForTarget: async (captured: InboxTarget, _display: string, _submit: string, _invocations: unknown, id: string) => {
40 posts++; key = id; assert.deepEqual(captured, target); throw new Error("reply and first receipt lookup lost");
41 },
42 LookupInboxFollowupForTarget: async (captured: InboxTarget, id: string) => {
43 queries++; assert.deepEqual(captured, target); assert.equal(id, key);
44 if (!confirmed) throw new Error("receipt unavailable");
45 return { itemId: "already-consumed", disposition: "idempotent_hit", position: 0, paused: false };
46 },
47 InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
48 });
49 runtimeStateStore.commit(state("finishing", 1));
50 let view = await renderComposer({ inboxSessionPath: "session-a", onSend: () => { direct++; }, onSteer: async () => { steers++; } });
51 await paste("original follow-up");
52 await send();
53 assert.equal(posts, 1);
54 assert.equal(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
55 await act(async () => { acceptRuntimeState(runtimeStateStore, state(phase, 2), true); await flush(); });
56 await send();
57 assert.equal(queries, 1);
58 assert.equal(posts, 1);
59 assert.equal(direct + steers, 0);
60 assert.ok(pendingFollowups.get(pendingSession));
61
62 await view.rerender({ tabId: "tab-b", sessionKey: "session-b", inboxSessionPath: "session-b" });
63 assert.notEqual(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
64 await view.rerender({ tabId: "tab-a", sessionKey: "session-a", inboxSessionPath: "session-a" });
65 if (phase === "executing") {
66 await act(async () => { view.root.unmount(); });
67 view = await renderComposer({ inboxSessionPath: "session-a", onSend: () => { direct++; }, onSteer: async () => { steers++; } });
68 assert.equal(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
69 assert.match(document.querySelector('[role="status"]')?.textContent ?? "", /original follow-up/, "the original request survives remount");
70 }
71 await paste("later draft edit");
72 confirmed = true;
73 await send();
74 assert.equal(posts, 1);
75 assert.equal(queries, 2);
76 assert.equal(direct + steers, 0);
77 assert.equal(pendingFollowups.get(pendingSession), undefined);
78 assert.match((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, /later draft edit/);
79 assert.equal(document.querySelector(".composer-guidance-item"), null, "completed receipt must not resurrect a queued row");
80 await act(async () => { view.root.unmount(); });
81 dom.window.close();
82 }
83
84 {
85 const dom = installDom();
86 let posts = 0;
87 installBridgeApp({ EnqueueInboxFollowup: async () => { posts++; throw new Error("legacy transport outcome unknown"); } });
88 runtimeStateStore.commit(state("finishing", 1));
89 const view = await renderComposer();
90 await paste("legacy follow-up");
91 await send();
92 await act(async () => { runtimeStateStore.commit(state("idle", 2)); });
93 await send();
94 assert.equal(posts, 1, "missing receipt capability never permits another POST");
95 const pending = pendingFollowups.get(pendingSession);
96 assert.ok(pending);
97 await view.rerender({ inboxHostId: "different-host", inboxWorkspace: "/repo" });
98 assert.notEqual(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result", "host identity isolates otherwise identical draft keys");
99 await act(async () => { pendingFollowups.clear(pendingSession, pending); view.root.unmount(); });
100 dom.window.close();
101 }
102
103 {
104 const dom = installDom();
105 let posts = 0;
106 installBridgeApp({ EnqueueInboxFollowup: async () => { posts++; throw new Error("reasonix_error:inbox_not_submitted"); } });
107 runtimeStateStore.commit(state("finishing", 1));
108 const view = await renderComposer();
109 await paste("rejected follow-up");
110 await send();
111 assert.equal(pendingFollowups.get(pendingSession), undefined, "a definite pre-execution rejection releases the request");
112 assert.match((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, /rejected follow-up/);
113 await send();
114 assert.equal(posts, 2, "a known unexecuted request remains retryable");
115 await act(async () => { view.root.unmount(); });
116 dom.window.close();
117 }
118 console.log("PASS: uncertain follow-up stays bound across phases, selection, edits, and remounts");
119
120 {
121 const dom = installDom();
122 const tab = { id: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a" };
123 const sessionKey = composerDraftKeyForTab(tab);
124 assert.equal(sessionKey, composerDraftKeyForTab({ ...tab, sessionPath: "session-b" }));
125 let posts = 0, direct = 0, reads = 0;
126 let release!: () => void;
127 installBridgeApp({
128 CaptureInboxTarget: async () => ({ tabId: "tab-a", sessionPath: "session-a", generation: 1, selection: 0, remote: false }),
129 EnqueueInboxFollowupForTarget: async () => { posts++; throw new Error("reply lost"); },
130 LookupInboxFollowupForTarget: async (target: InboxTarget) => {
131 reads++; assert.equal(target.sessionPath, "session-a");
132 await new Promise<void>(resolve => { release = resolve; });
133 return { itemId: "original", disposition: "idempotent_hit", position: 0, paused: false };
134 },
135 });
136 runtimeStateStore.commit(state("finishing", 1));
137 const view = await renderComposer({ sessionKey, onSend: () => { direct++; } });
138 await paste("original task"); await send();
139 await act(async () => { runtimeStateStore.commit({ ...state("idle", 2), sessions: state("idle", 2).sessions.map(s => ({ ...s, sessionPath: "session-b" })) }); });
140 await view.rerender({ inboxSessionPath: "session-b" });
141 assert.notEqual(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
142 await send();
143 assert.equal(direct, 1); assert.equal(reads, 0); assert.equal(posts, 1);
144 await view.rerender({ inboxSessionPath: "session-a" });
145 await send();
146 assert.equal(reads, 1);
147 await view.rerender({ inboxSessionPath: "session-b" });
148 await paste("new draft for B");
149 await act(async () => { release(); await flush(); });
150 assert.match((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, /new draft for B/);
151 assert.equal(pendingFollowups.get(pendingSession), undefined);
152 await act(async () => { view.root.unmount(); }); dom.window.close();
153 }
154
155 {
156 const dom = installDom();
157 let posts = 0;
158 installBridgeApp({ EnqueueInboxFollowup: async () => { posts++; } });
159 runtimeStateStore.commit(state("finishing", 1));
160 const view = await renderComposer({ inboxSessionPath: undefined });
161 await paste("path not bound"); await send();
162 assert.equal(posts, 0, "unknown durable identity cannot fall back to a topic key");
163 await act(async () => { view.root.unmount(); }); dom.window.close();
164 }
165 assert.notEqual(followupSessionKey("/same", "host", "/one"), followupSessionKey("/same", "host", "/two"));
166 console.log("PASS: same-topic sessions, late receipts, and unbound paths preserve request ownership");
167
167 lines Plain Text