返回 DeepSeek-Reasonix
send-failed.test.ts
根目录 / desktop / frontend / src / __tests__ / send-failed.test.ts
1 // Run: tsx src/__tests__/send-failed.test.ts
2
3 import { readFileSync } from "node:fs";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import { acceptsRuntimeEventEpoch, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
7 import { continueDelivery } from "../lib/deliveryContinue";
8 import {
9 activateGoalAndSubmit,
10 activateGoalAndSubmitOnTab,
11 } from "../lib/goalSubmit";
12 import type { WireEvent } from "../lib/types";
13
14 let passed = 0;
15 let failed = 0;
16
17 function eq(a: unknown, b: unknown, label: string) {
18 if (a === b) {
19 process.stdout.write(` PASS ${label}\n`);
20 passed += 1;
21 } else {
22 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
23 failed += 1;
24 }
25 }
26
27 console.log("\nsend failure feedback");
28
29 {
30 const calls: string[] = [];
31 await activateGoalAndSubmit({
32 displayText: "List the existing notes",
33 submitText: "/ui-ux-pro-max List the existing notes",
34 structured: {
35 display: "/ui-ux-pro-max List the existing notes",
36 input: "List the existing notes",
37 invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
38 },
39 applyGoal: async (goal) => {
40 calls.push(`goal:${goal}`);
41 },
42 send: async (display, submit, structured) => {
43 calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`);
44 },
45 });
46 eq(calls.join("|"), "goal:List the existing notes|send:List the existing notes:/ui-ux-pro-max List the existing notes:ui-ux-pro-max", "initial Goal activates before structured Skill submission");
47 }
48
49 {
50 // Bridge failure must abort structured Skill submit: there is no `/goal` fallback.
51 const calls: string[] = [];
52 let threw = false;
53 try {
54 await activateGoalAndSubmit({
55 displayText: "Ship the feature",
56 submitText: "/ui-ux-pro-max Ship the feature",
57 structured: {
58 display: "/ui-ux-pro-max Ship the feature",
59 input: "Ship the feature",
60 invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
61 },
62 applyGoal: async (goal) => {
63 calls.push(`goal:${goal}`);
64 throw new Error("SetGoalForTab: tab closed");
65 },
66 send: async (display, submit, structured) => {
67 calls.push(`send:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}`);
68 },
69 });
70 } catch (error) {
71 threw = error instanceof Error && error.message === "SetGoalForTab: tab closed";
72 }
73 eq(threw, true, "Goal activation bridge failure propagates");
74 eq(calls.join("|"), "goal:Ship the feature", "failed Goal activation does not submit the structured Skill");
75 }
76
77 {
78 // Tab-scoped helper captures source tab and workbench target once; callbacks
79 // receive both even if a surrounding "active tab" concept changes mid-flight.
80 const calls: string[] = [];
81 let releaseSubmit!: () => void;
82 const submitGate = new Promise<void>((resolve) => {
83 releaseSubmit = resolve;
84 });
85 let activeTab = "tab-a";
86 const pending = activateGoalAndSubmitOnTab({
87 tabId: "tab-a",
88 displayText: "Cross-tab safe goal",
89 submitText: "/ui-ux-pro-max Cross-tab safe goal",
90 structured: {
91 display: "/ui-ux-pro-max Cross-tab safe goal",
92 input: "Cross-tab safe goal",
93 invocations: [{ name: "ui-ux-pro-max", kind: "skill", offset: 0 }],
94 },
95 sendToTab: async (tabId, goal, display, submit, structured) => {
96 await submitGate;
97 calls.push(
98 `send:${tabId}:${goal}:${display}:${submit}:${structured?.invocations[0]?.name ?? ""}:active=${activeTab}`,
99 );
100 },
101 });
102 activeTab = "tab-b";
103 calls.push("switched-to-tab-b");
104 releaseSubmit();
105 await pending;
106 eq(
107 calls.join("|"),
108 "switched-to-tab-b|send:tab-a:Cross-tab safe goal:Cross-tab safe goal:/ui-ux-pro-max Cross-tab safe goal:ui-ux-pro-max:active=tab-b",
109 "activateGoalAndSubmitOnTab keeps Goal and Skill on the captured source tab",
110 );
111 }
112
113 eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "starting", epoch: "e1" } }), false, "starting runtime cannot submit");
114 eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "lease_blocked", epoch: "e1" } }), false, "lease-blocked runtime cannot submit");
115 eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit");
116 eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit");
117 eq(normalizeTurnSubmit(" visible prompt ", " provider prompt ").submit, "provider prompt", "submit normalization trims provider input");
118 let rejectedVisibleOnlySubmit = false;
119 try {
120 normalizeTurnSubmit("visible prompt", " ");
121 } catch {
122 rejectedVisibleOnlySubmit = true;
123 }
124 eq(rejectedVisibleOnlySubmit, true, "visible display text cannot start an empty provider turn");
125 eq(acceptsRuntimeEventEpoch("e2", "e1"), false, "old runtime epoch is rejected");
126 eq(acceptsRuntimeEventEpoch("e2", "e2"), true, "current runtime epoch is accepted");
127 eq(acceptsRuntimeEventEpoch(undefined, "e1"), true, "first runtime epoch can establish the fence");
128 eq(acceptsRuntimeEventEpoch("e2", undefined), true, "legacy events remain compatible");
129
130 const sent = reducer({ ...initialState }, { type: "user", text: "hello", seq: 0 });
131 eq(sent.items.length, 1, "submit appends the user bubble immediately");
132 eq(sent.items[0].kind === "user" && sent.items[0].text, "hello", "bubble carries the submitted text");
133 eq(sent.running, true, "submit marks the turn running");
134 eq(sent.pendingUser, "hello", "submit tracks the optimistic bubble");
135
136 const hiddenSubmit = reducer({ ...initialState }, { type: "user", text: "display prompt", submitText: "hidden context\ndisplay prompt", seq: 0 });
137 eq(
138 hiddenSubmit.items[0].kind === "user" && hiddenSubmit.items[0].submitText,
139 "hidden context\ndisplay prompt",
140 "optimistic user bubble preserves submit-only context",
141 );
142
143 const confirmed = reducer(sent, { type: "event", e: { kind: "text", text: "hi" } as WireEvent });
144 eq(confirmed.items.filter((it) => it.kind === "user").length, 1, "first backend event confirms without duplicating");
145 eq(confirmed.pendingUser, undefined, "confirmation clears the pending marker");
146
147 const memoryCitationMessage = {
148 kind: "message",
149 memoryCitations: [{ kind: "memory_reference", source: "MEMORY.md", note: "reasonix workflow" }],
150 } as WireEvent;
151 const started = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent });
152 const citationOnlyFinal = reducer(started, { type: "event", e: memoryCitationMessage });
153 eq(citationOnlyFinal.items.length, 1, "memory citations alone do not leave an empty assistant bubble");
154 eq(citationOnlyFinal.items.some((it) => it.kind === "assistant"), false, "memory citations alone stay hidden from the transcript");
155 const textThenCitationFinal = reducer(reducer(started, { type: "event", e: { kind: "text", text: "done" } as WireEvent }), { type: "event", e: memoryCitationMessage });
156 const citedAssistant = textThenCitationFinal.items.find((it) => it.kind === "assistant");
157 eq(citedAssistant?.kind === "assistant" && citedAssistant.text, "done", "memory citations preserve existing assistant text");
158 eq(citedAssistant?.kind === "assistant" && citedAssistant.memoryCitations?.length, 1, "memory citations attach to real assistant content");
159
160 const failedState = reducer(sent, { type: "send_failed", error: "Send failed: bridge unavailable" });
161 const failedBubble = failedState.items.find((it) => it.kind === "user");
162 eq(failedBubble?.kind === "user" && failedBubble.failed, true, "send_failed marks the bubble failed");
163 const notice = failedState.items[failedState.items.length - 1];
164 eq(notice.kind, "notice", "send_failed appends a notice");
165 eq(notice.kind === "notice" && notice.level, "warn", "the notice is a warning");
166 eq(failedState.running, false, "send_failed stops the running indicator");
167 eq(failedState.pendingUser, undefined, "send_failed clears the pending marker");
168
169 const readinessStarted = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent });
170 const readinessState = reducer(readinessStarted, {
171 type: "event",
172 e: {
173 kind: "turn_done",
174 outcome: "final_readiness",
175 err: "final-answer readiness failed 3 times: missing verification",
176 readiness: { attempts: 3, missing: ["verification", "review"] },
177 } as WireEvent,
178 });
179 const readinessNotice = readinessState.items[readinessState.items.length - 1];
180 eq(readinessNotice.kind, "notice", "final readiness appends a notice");
181 eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity");
182 eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness uses the delivery status treatment");
183 eq(readinessNotice.kind === "notice" && readinessNotice.title, "Delivery checks are not complete", "final readiness uses localized product copy");
184 eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail");
185 eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
186 const readinessUser = readinessState.items.find((it) => it.kind === "user");
187 eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "final readiness does not mark the delivered user message as failed");
188 eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
189 eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
190
191 const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, deliveryRecovery: true });
192 const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done" } as WireEvent });
193 eq(recovered.items.some((it) => it.kind === "notice" && it.variant === "delivery"), false, "successful explicit recovery removes the stale delivery card");
194
195 const ordinaryTurnError = reducer(readinessStarted, {
196 type: "event",
197 e: { kind: "turn_done", err: "provider failed" } as WireEvent,
198 });
199 const ordinaryTurnNotice = ordinaryTurnError.items[ordinaryTurnError.items.length - 1];
200 eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.level, "warn", "ordinary turn errors remain warnings");
201 eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.text, "provider failed", "ordinary turn errors keep their diagnostic text");
202
203 const recoveryPaused = reducer(readinessStarted, {
204 type: "event",
205 e: {
206 kind: "turn_done",
207 outcome: "recovery_paused",
208 err: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction.",
209 } as WireEvent,
210 });
211 const recoveryNotice = recoveryPaused.items[recoveryPaused.items.length - 1];
212 eq(recoveryNotice.kind === "notice" && recoveryNotice.level, "info", "recovery_paused uses informational severity");
213 eq(recoveryNotice.kind === "notice" && Boolean(recoveryNotice.title), true, "recovery_paused shows a product title");
214 eq(
215 recoveryNotice.kind === "notice" && recoveryNotice.text,
216 "Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.",
217 "recovery_paused uses the localized product copy",
218 );
219 eq(
220 recoveryNotice.kind === "notice" && Boolean(recoveryNotice.detail),
221 false,
222 "recovery_paused does not repeat the backend English fallback as localized detail",
223 );
224 const recoveryUser = recoveryPaused.items.find((it) => it.kind === "user");
225 eq(recoveryUser?.kind === "user" && Boolean(recoveryUser.failed), false, "recovery_paused does not mark the user message as failed");
226 eq(recoveryPaused.running, false, "recovery_paused frees the composer");
227
228 const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0 });
229 const shellFailed = reducer(shellSent, { type: "send_failed", error: "Command failed: workspace is still starting" });
230 const shellNotice = shellFailed.items[shellFailed.items.length - 1];
231 eq(shellNotice.kind, "notice", "rejected shell command appends a visible notice");
232 eq(shellNotice.kind === "notice" && shellNotice.text.includes("workspace is still starting"), true, "shell rejection notice includes the backend error");
233
234 const lateFailure = reducer(confirmed, { type: "send_failed", error: "Send failed: late" });
235 eq(lateFailure, confirmed, "send_failed after backend confirmation is a no-op");
236
237 const beforeMcpReady = { ...initialState };
238 const mcpReady = reducer(beforeMcpReady, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
239 eq(mcpReady, beforeMcpReady, "mcp_surface_ready is accepted as a deliberate no-op");
240 const pendingMcpReady = reducer(sent, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
241 eq(pendingMcpReady, sent, "mcp_surface_ready does not confirm a pending submit");
242 const failedAfterMcpReady = reducer(pendingMcpReady, { type: "send_failed", error: "Send failed: bridge unavailable" });
243 const failedAfterMcpReadyBubble = failedAfterMcpReady.items.find((it) => it.kind === "user");
244 eq(
245 failedAfterMcpReadyBubble?.kind === "user" && failedAfterMcpReadyBubble.failed,
246 true,
247 "send_failed still marks a pending submit after mcp readiness",
248 );
249
250 const here = dirname(fileURLToPath(import.meta.url));
251 const appSource = readFileSync(resolve(here, "../App.tsx"), "utf8");
252 const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8");
253 const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8");
254 eq(typesSource.includes('"mcp_surface_ready"'), true, "TypeScript EventKind declares mcp_surface_ready");
255 eq(controllerSource.includes('e.kind === "mcp_surface_ready"'), true, "reducer handles mcp_surface_ready before optimistic confirmation");
256 eq(
257 /if \(allow\) \{\s*await applyCollaborationMode\("normal"\);\s*resolvePlanDecision\(state\.approval!\.id, "start_execution"\);/.test(appSource),
258 true,
259 "plan approval clears the remembered plan restore intent and records start execution explicitly",
260 );
261 eq(
262 /onExitPlan=\{async \(\) => \{\s*await applyCollaborationMode\("normal"\);\s*resolvePlanDecision\(state\.approval!\.id, "exit_plan"\);\s*\}\}/.test(appSource),
263 true,
264 "exit-without-executing switches to Normal before recording the explicit plan exit",
265 );
266 eq(
267 /onRevisePlan=\{\(text\) => \{[\s\S]{0,260}resolvePlanDecision\(state\.approval!\.id, "revise_plan"\);/.test(appSource),
268 true,
269 "plan revision records a distinct revise decision",
270 );
271 eq(
272 !/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource),
273 true,
274 "plan approval must not preserve stale plan restore intent",
275 );
276 eq(
277 !appSource.includes("rememberUserIntent"),
278 true,
279 "collaboration mode changes always reconcile the remembered plan restore intent",
280 );
281 eq(
282 appSource.includes("runtimeTransitionTabsRef.current.has(tabId)"),
283 true,
284 "runtime profile transitions reject rapid duplicate switches for one tab",
285 );
286 eq(
287 appSource.includes("delete pending.tokenMode") && appSource.includes("tokenMode: previous"),
288 true,
289 "failed runtime profile transitions roll back the optimistic token mode",
290 );
291 eq(
292 appSource.includes("!state.backendActivationPending &&") && appSource.includes("!runtimeTransitioning"),
293 true,
294 "runtime profile transitions keep submit behind the controller-ready gate",
295 );
296 eq(
297 appSource.includes("activateGoalAndSubmitOnTab({") &&
298 appSource.includes("tabId: sourceTabId") &&
299 appSource.includes("goal: nextGoal") &&
300 appSource.includes("collaborationMode: controllerComposerProfileCollaborationMode(composerProfile)") &&
301 appSource.includes("toolApprovalMode,"),
302 true,
303 "initial Goal activation captures the submission tab",
304 );
305 eq(
306 appSource.includes("setControllerGoalForTab(tabId, trimmed)") && appSource.includes("clearControllerGoalForTab(tabId)"),
307 true,
308 "tab-scoped Goal activation updates the matching controller",
309 );
310 eq(
311 /await \(trimmed \? setControllerGoalForTab\(tabId, trimmed\) : clearControllerGoalForTab\(tabId\)\);\s*patchActivatedGoalForTab\(tabId, trimmed\)/.test(appSource),
312 true,
313 "local Goal profile is patched only after backend activation succeeds",
314 );
315 eq(
316 controllerSource.includes("await app.SetGoalForTab(tabId, goal)") && !/SetGoalForTab\(tabId, goal\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
317 true,
318 "SetGoalForTab activation failures propagate to callers",
319 );
320 eq(
321 controllerSource.includes("await app.ClearGoalForTab(tabId)") && !/ClearGoalForTab\(tabId\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
322 true,
323 "ClearGoalForTab failures also propagate to callers",
324 );
325 eq(
326 /await continueDelivery\(\{[\s\S]{0,240}goal: state\.meta\?\.goal,[\s\S]{0,240}resumeGoal: resumeControllerGoalForTab,/.test(appSource),
327 true,
328 "delivery recovery routes through continueDelivery with the backend Goal state",
329 );
330 eq(
331 controllerSource.includes("app.SubmitInitialGoalToTab(") &&
332 appSource.includes("patchActivatedGoalForTab(sourceTabId, trimmed)"),
333 true,
334 "the first Goal turn uses the atomic target-scoped backend contract",
335 );
336
337 const unsent = reducer(sent, { type: "unsend" });
338 eq(unsent.pendingUser, undefined, "unsend clears the pending marker");
339 eq(unsent.discardTurn, true, "unsend discards the in-flight turn");
340
341 const planApprovalFirst = reducer(
342 { ...initialState },
343 { type: "event", e: { kind: "approval_request", approval: { id: "plan-1", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent },
344 );
345 const planTurnDoneAfter = reducer(planApprovalFirst, { type: "event", e: { kind: "turn_done" } as WireEvent });
346 eq(
347 planTurnDoneAfter.approval?.id,
348 "plan-1",
349 "turn_done preserves out-of-order plan approval",
350 );
351 eq(planTurnDoneAfter.running, true, "preserved plan approval keeps the tab running");
352 eq(planTurnDoneAfter.pendingPrompt, true, "preserved plan approval keeps the prompt gate active");
353
354 let replayCalls = 0;
355 replayPendingPromptsForActiveTab(undefined, () => {
356 replayCalls += 1;
357 return Promise.resolve();
358 });
359 eq(replayCalls, 0, "no active tab does not replay pending prompts");
360
361 replayPendingPromptsForActiveTab("tab-a", () => {
362 replayCalls += 1;
363 return Promise.resolve();
364 });
365 eq(replayCalls, 1, "active tab switch replays pending prompts");
366
367 replayPendingPromptsForActiveTab("tab-b", () => {
368 replayCalls += 1;
369 return Promise.reject(new Error("bridge unavailable"));
370 });
371 await new Promise((resolve) => setTimeout(resolve, 0));
372 eq(replayCalls, 2, "replay bridge failures are swallowed by the tab-switch effect");
373
374 console.log("\ndelivery recovery continuation");
375
376 interface ContinueCalls {
377 resumes: string[];
378 sends: string[];
379 }
380
381 async function runContinueDelivery(opts: {
382 goal: string | undefined;
383 resumed?: boolean;
384 ready?: boolean;
385 tabId?: string | null;
386 tabAfterResume?: string;
387 }): Promise<ContinueCalls> {
388 const calls: ContinueCalls = { resumes: [], sends: [] };
389 await continueDelivery({
390 tabId: opts.tabId === undefined ? "tab-a" : opts.tabId,
391 ready: opts.ready ?? true,
392 goal: opts.goal,
393 activeTabId: () => opts.tabAfterResume ?? "tab-a",
394 resumeGoal: (tabId) => {
395 calls.resumes.push(tabId);
396 return Promise.resolve(opts.resumed ?? true);
397 },
398 send: (tabId) => {
399 calls.sends.push(tabId);
400 return Promise.resolve();
401 },
402 });
403 return calls;
404 }
405
406 const noGoal = await runContinueDelivery({ goal: undefined });
407 eq(noGoal.resumes.length, 0, "delivery recovery without a Goal skips the resume call");
408 eq(noGoal.sends.join(","), "tab-a", "delivery recovery without a Goal submits the continuation directly");
409
410 const blankGoal = await runContinueDelivery({ goal: " " });
411 eq(blankGoal.resumes.length, 0, "delivery recovery treats a blank Goal as absent");
412 eq(blankGoal.sends.join(","), "tab-a", "delivery recovery with a blank Goal still submits the continuation");
413
414 const goalResumed = await runContinueDelivery({ goal: "ship it", resumed: true });
415 eq(goalResumed.resumes.join(","), "tab-a", "delivery recovery with a Goal resumes it first");
416 eq(goalResumed.sends.join(","), "tab-a", "delivery recovery submits after the Goal resumes");
417
418 const goalRefused = await runContinueDelivery({ goal: "ship it", resumed: false });
419 eq(goalRefused.resumes.join(","), "tab-a", "an unresumable Goal is still offered the resume");
420 eq(goalRefused.sends.length, 0, "an unresumable (completed) Goal does not submit the continuation");
421
422 const tabSwitched = await runContinueDelivery({ goal: "ship it", resumed: true, tabAfterResume: "tab-b" });
423 eq(tabSwitched.sends.length, 0, "a tab switch during resume drops the continuation");
424
425 const notReady = await runContinueDelivery({ goal: undefined, ready: false });
426 eq(notReady.sends.length, 0, "delivery recovery waits for controller readiness");
427
428 const noTab = await runContinueDelivery({ goal: undefined, tabId: null });
429 eq(noTab.sends.length, 0, "delivery recovery without an active tab is a no-op");
430
431 console.log(`\n${passed} passed, ${failed} failed`);
432 if (failed > 0) process.exit(1);
433
433 lines TYPESCRIPT