返回 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, historyMessagesToItems, initialState, normalizeTurnSubmit, reducer, replayPendingPromptsForActiveTab, runtimeReadyForSubmit } from "../lib/useController";
7 import { continueDelivery } from "../lib/deliveryContinue";
8 import type { WireEvent } from "../lib/types";
9 import { submitPlanDecision, type SessionActionPorts } from "../app-runtime/sessionActionOwner";
10 import { createSessionSurfaceFence } from "../app-runtime/sessionTarget";
11 import { sessionIdentityStableKey } from "../lib/sessionIdentity";
12
13 let passed = 0;
14 let failed = 0;
15
16 function eq(a: unknown, b: unknown, label: string) {
17 if (a === b) {
18 process.stdout.write(` PASS ${label}\n`);
19 passed += 1;
20 } else {
21 process.stdout.write(` FAIL ${label}: expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}\n`);
22 failed += 1;
23 }
24 }
25
26 console.log("\nsend failure feedback");
27
28 // The initial Goal + structured Skill scenarios formerly exercised the
29 // goalSubmit.ts shim. That wrapper is deleted; the same contracts are covered on
30 // the real chain by session-submission-lifecycle.test.tsx (atomic payload and
31 // activation ordering at the submission owner) and goal-activation-tab-routing
32 // .test.tsx (source-tab capture and fail-closed propagation at the controller).
33 eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "starting", epoch: "e1" } }), false, "starting runtime cannot submit");
34 eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "lease_blocked", epoch: "e1" } }), false, "lease-blocked runtime cannot submit");
35 eq(runtimeReadyForSubmit({ label: "", ready: false, eventChannel: "", cwd: "", runtime: { phase: "failed", epoch: "e1" } }), false, "failed runtime cannot submit");
36 eq(runtimeReadyForSubmit({ label: "", ready: true, eventChannel: "", cwd: "", runtime: { phase: "ready", epoch: "e1" } }), true, "ready runtime can submit");
37 eq(normalizeTurnSubmit(" visible prompt ", " provider prompt ").submit, "provider prompt", "submit normalization trims provider input");
38 const managementPending = reducer(reducer(initialState, {
39 type: "user", text: "/context", seq: 0, submissionId: "management-1",
40 }), { type: "management_confirmed", submissionId: "management-1" });
41 eq(managementPending.items.some((item) => item.kind === "user" && item.text === "/context"), false, "handled management commands do not remain as conversation turns");
42 eq(managementPending.running, false, "handled management commands release the composer");
43 let rejectedVisibleOnlySubmit = false;
44 try {
45 normalizeTurnSubmit("visible prompt", " ");
46 } catch {
47 rejectedVisibleOnlySubmit = true;
48 }
49 eq(rejectedVisibleOnlySubmit, true, "visible display text cannot start an empty provider turn");
50 eq(acceptsRuntimeEventEpoch("e2", "e1"), false, "old runtime epoch is rejected");
51 eq(acceptsRuntimeEventEpoch("e2", "e2"), true, "current runtime epoch is accepted");
52 eq(acceptsRuntimeEventEpoch(undefined, "e1"), true, "first runtime epoch can establish the fence");
53 eq(acceptsRuntimeEventEpoch("e2", undefined), true, "legacy events remain compatible");
54
55 const sent = reducer({ ...initialState }, { type: "user", text: "hello", seq: 0, submissionId: "send-0" });
56 eq(sent.items.length, 0, "submit keeps the optimistic bubble out of durable transcript items");
57 eq(sent.localSubmissions["send-0"]?.text, "hello", "local bubble carries the submitted text");
58 eq(sent.running, true, "submit marks the turn running");
59 eq(sent.pendingUser, "hello", "submit tracks the optimistic bubble");
60
61 const hiddenSubmit = reducer({ ...initialState }, { type: "user", text: "display prompt", submitText: "hidden context\ndisplay prompt", seq: 0, submissionId: "hidden-0" });
62 eq(
63 hiddenSubmit.localSubmissions["hidden-0"]?.submitText,
64 "hidden context\ndisplay prompt",
65 "optimistic user bubble preserves submit-only context",
66 );
67
68 const confirmed = reducer(sent, { type: "event", e: { kind: "turn_done", submissionId: "send-0" } as WireEvent });
69 eq(confirmed.localSubmissions["send-0"]?.status, "accepted", "matching TurnDone confirms the local bubble without duplicating");
70 eq(confirmed.pendingUser, undefined, "matching submission id clears the pending marker");
71
72 const memoryCitationMessage = {
73 kind: "message",
74 memoryCitations: [{ kind: "memory_reference", source: "MEMORY.md", note: "reasonix workflow" }],
75 } as WireEvent;
76 const started = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent });
77 const citationOnlyFinal = reducer(started, { type: "event", e: memoryCitationMessage });
78 eq(citationOnlyFinal.items.length, 0, "memory citations alone do not add durable transcript rows");
79 eq(citationOnlyFinal.localSubmissionOrder.length, 1, "memory citations leave the local user echo visible");
80 eq(citationOnlyFinal.items.some((it) => it.kind === "assistant"), false, "memory citations alone stay hidden from the transcript");
81 const textThenCitationFinal = reducer(reducer(started, { type: "event", e: { kind: "text", text: "done" } as WireEvent }), { type: "event", e: memoryCitationMessage });
82 const citedAssistant = textThenCitationFinal.items.find((it) => it.kind === "assistant");
83 eq(citedAssistant?.kind === "assistant" && citedAssistant.text, "done", "memory citations preserve existing assistant text");
84 eq(citedAssistant?.kind === "assistant" && citedAssistant.memoryCitations?.length, 1, "memory citations attach to real assistant content");
85
86 const failedState = reducer(sent, { type: "send_failed", submissionId: "send-0", error: "Send failed: bridge unavailable" });
87 const failedBubble = failedState.localSubmissions["send-0"];
88 eq(failedBubble?.status, "failed", "send_failed marks the bubble failed");
89 const notice = failedState.items[failedState.items.length - 1];
90 eq(notice.kind, "notice", "send_failed appends a notice");
91 eq(notice.kind === "notice" && notice.level, "warn", "the notice is a warning");
92 eq(failedState.running, false, "send_failed stops the running indicator");
93 eq(failedState.pendingUser, undefined, "send_failed clears the pending marker");
94
95 const promptMeta = { label: "", ready: true, eventChannel: "agent:event", cwd: "", session: { hostId: "local", sessionId: "session-a" }, sessionGeneration: 1 };
96 const askTarget = (promptId: string, turnId: string) => ({ tabId: "tab-a", sessionKey: sessionIdentityStableKey(promptMeta), hostId: "local", sessionId: "session-a", sessionGeneration: 1,
97 promptId, turnId, kind: "ask" as const, instanceKey: `${turnId}:${promptId}` });
98 const waitingAsk = reducer({ ...initialState, meta: promptMeta }, {
99 type: "event",
100 e: {
101 kind: "ask_request",
102 turnId: "turn-existing",
103 ask: { id: "ask-existing", questions: [{ id: "q1", prompt: "Choose", options: [{ label: "A" }] }] },
104 } as WireEvent,
105 });
106 const collidingSubmit = reducer(waitingAsk, { type: "user", text: "continue", seq: waitingAsk.seq, submissionId: "send-collision" });
107 const rejectedCollision = reducer(collidingSubmit, {
108 type: "turn_submit_rejected",
109 submissionId: "send-collision",
110 error: "Send failed: turn already running",
111 });
112 eq(rejectedCollision.localSubmissions["send-collision"]?.status, "failed", "rejected admission marks the exact optimistic bubble failed");
113 eq(rejectedCollision.running, true, "rejected admission stays conservatively running until reconciliation");
114 eq(rejectedCollision.pendingPrompt, true, "rejected admission restores the visible Ask gate");
115 eq(rejectedCollision.ask?.id, "ask-existing", "rejected admission preserves the pending Ask");
116
117 const reconciledCollision = reducer(rejectedCollision, {
118 type: "backend_status",
119 running: true,
120 pendingPrompt: true,
121 backgroundJobs: 0,
122 cancelRequested: false,
123 cancellable: true,
124 turnId: "turn-existing",
125 });
126 eq(reconciledCollision.activeTurnId, "turn-existing", "authoritative active snapshot restores the existing turn id");
127 eq(reconciledCollision.running, true, "authoritative active snapshot keeps the composer blocked");
128
129 const reconciledIdle = reducer(rejectedCollision, {
130 type: "backend_status",
131 running: false,
132 pendingPrompt: false,
133 backgroundJobs: 0,
134 cancelRequested: false,
135 cancellable: false,
136 });
137 eq(reconciledIdle.running, false, "authoritative idle snapshot releases the rejected submit gate");
138 eq(reconciledIdle.ask, undefined, "authoritative idle snapshot clears a stale Ask");
139
140 const answeredAsk = reducer(waitingAsk, { type: "ask_submit_succeeded", target: askTarget("ask-existing", "turn-existing"), epoch: waitingAsk.promptEpoch });
141 eq(answeredAsk.ask, undefined, "successful Ask submission clears the matching prompt");
142 eq(answeredAsk.resolvedPromptId, "ask-existing", "successful Ask submission tombstones the matching prompt id");
143 const nextAsk = reducer(waitingAsk, {
144 type: "event",
145 e: { kind: "ask_request", turnId: "turn-existing", ask: { id: "ask-next", questions: [] } } as WireEvent,
146 });
147 const lateAskSuccess = reducer(nextAsk, { type: "ask_submit_succeeded", target: askTarget("ask-existing", "turn-existing"), epoch: nextAsk.promptEpoch });
148 eq(lateAskSuccess.ask?.id, "ask-next", "late Ask success cannot clear a newer prompt");
149 const rebuiltAsk = reducer(reducer(waitingAsk, { type: "controller_rebuilt" }), {
150 type: "event",
151 e: { kind: "ask_request", turnId: "turn-new", ask: { id: "ask-existing", questions: [] } } as WireEvent,
152 });
153 const oldEpochSuccess = reducer(rebuiltAsk, { type: "ask_submit_succeeded", target: askTarget("ask-existing", "turn-existing"), epoch: waitingAsk.promptEpoch });
154 eq(oldEpochSuccess.ask?.id, "ask-existing", "old prompt epoch cannot clear an id reused by a rebuilt controller");
155
156 const readinessStarted = reducer(sent, { type: "event", e: { kind: "turn_started" } as WireEvent });
157 const readinessState = reducer(readinessStarted, {
158 type: "event",
159 e: {
160 kind: "turn_done",
161 outcome: "final_readiness",
162 err: "final-answer readiness failed 3 times: missing verification",
163 submissionId: "send-0",
164 readiness: { attempts: 3, missing: ["verification", "review"] },
165 } as WireEvent,
166 });
167 const readinessNotice = readinessState.items[readinessState.items.length - 1];
168 eq(readinessNotice.kind, "notice", "final readiness appends a notice");
169 eq(readinessNotice.kind === "notice" && readinessNotice.level, "info", "final readiness uses informational severity");
170 eq(readinessNotice.kind === "notice" && readinessNotice.variant, "delivery", "final readiness keeps the compatibility notice variant");
171 eq(readinessNotice.kind === "notice" && readinessNotice.title, "Checks are not complete", "final readiness uses the generic recovery title");
172 eq(
173 readinessNotice.kind === "notice" && readinessNotice.text,
174 "The response was generated, but required verification or review is still incomplete.",
175 "final readiness explains the recovery boundary",
176 );
177 eq(readinessNotice.kind === "notice" && readinessNotice.detail, "Still needed: verification, change review", "structured requirements produce localized detail");
178 eq(readinessNotice.kind === "notice" && readinessNotice.action, "continue_delivery", "final readiness offers a recovery action");
179 const readinessUser = readinessState.items.find((it) => it.kind === "user");
180 eq(readinessUser?.kind === "user" && Boolean(readinessUser.failed), false, "final readiness does not mark the delivered user message as failed");
181 eq(readinessState.running, false, "an unclicked continue-check action does not keep the turn running");
182 eq(readinessState.pendingPrompt, false, "an unclicked continue-check action does not create a pending prompt");
183
184 const reloadedReadiness = historyMessagesToItems([{
185 role: "notice",
186 content: "Task status needs one more check; continue the remaining work.",
187 code: "final_readiness",
188 level: "info",
189 pending: true,
190 readiness: { attempts: 1, missing: ["verification"] },
191 }], "h").items[0];
192 eq(reloadedReadiness.kind === "notice" && reloadedReadiness.action, "continue_delivery", "reloaded readiness metadata restores the explicit action");
193 eq(reloadedReadiness.kind === "notice" && reloadedReadiness.detail, "Still needed: verification", "reloaded readiness metadata restores structured detail");
194
195 const recovering = reducer(readinessState, { type: "user", text: "Continue checks", seq: readinessState.seq, submissionId: "recovery-submit", deliveryRecovery: true });
196 const recovered = reducer(recovering, { type: "event", e: { kind: "turn_done", submissionId: "recovery-submit" } as WireEvent });
197 eq(recovered.items.some((it) => it.kind === "notice" && it.variant === "delivery"), false, "successful explicit recovery removes the stale delivery card");
198
199 const ordinaryTurnError = reducer(readinessStarted, {
200 type: "event",
201 e: { kind: "turn_done", err: "provider failed", submissionId: "send-0" } as WireEvent,
202 });
203 const ordinaryTurnNotice = ordinaryTurnError.items[ordinaryTurnError.items.length - 1];
204 eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.level, "warn", "ordinary turn errors remain warnings");
205 eq(ordinaryTurnNotice.kind === "notice" && ordinaryTurnNotice.text, "provider failed", "ordinary turn errors keep their diagnostic text");
206
207 const recoveryPaused = reducer(readinessStarted, {
208 type: "event",
209 e: {
210 kind: "turn_done",
211 submissionId: "send-0",
212 outcome: "recovery_paused",
213 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.",
214 } as WireEvent,
215 });
216 const recoveryNotice = recoveryPaused.items[recoveryPaused.items.length - 1];
217 eq(recoveryNotice.kind === "notice" && recoveryNotice.level, "info", "recovery_paused uses informational severity");
218 eq(recoveryNotice.kind === "notice" && Boolean(recoveryNotice.title), true, "recovery_paused shows a product title");
219 eq(
220 recoveryNotice.kind === "notice" && recoveryNotice.text,
221 "Reasonix stopped repeated attempts and kept completed work. Send “Continue” to start a fresh attempt, or add instructions to change direction.",
222 "recovery_paused uses the localized product copy",
223 );
224 eq(
225 recoveryNotice.kind === "notice" && Boolean(recoveryNotice.detail),
226 false,
227 "recovery_paused does not repeat the backend English fallback as localized detail",
228 );
229 const recoveryUser = recoveryPaused.items.find((it) => it.kind === "user");
230 eq(recoveryUser?.kind === "user" && Boolean(recoveryUser.failed), false, "recovery_paused does not mark the user message as failed");
231 eq(recoveryPaused.running, false, "recovery_paused frees the composer");
232
233 const completionUncertain = reducer(readinessStarted, {
234 type: "event",
235 e: {
236 kind: "turn_done",
237 submissionId: "send-0",
238 outcome: "completion_uncertain",
239 err: "Completion could not be confirmed. Reasonix kept the current result and all completed work.",
240 } as WireEvent,
241 });
242 const uncertainNotice = completionUncertain.items[completionUncertain.items.length - 1];
243 eq(uncertainNotice.kind === "notice" && uncertainNotice.level, "info", "completion_uncertain uses informational severity, not a send failure");
244 eq(uncertainNotice.kind === "notice" && Boolean(uncertainNotice.title), true, "completion_uncertain shows a product title");
245 eq(
246 uncertainNotice.kind === "notice" && uncertainNotice.text,
247 "The result could not be confirmed as complete. The current answer and all completed work are kept. Send “继续 / continue” to resume, or restate what should change.",
248 "completion_uncertain uses the localized product copy",
249 );
250 const uncertainUser = completionUncertain.items.find((it) => it.kind === "user");
251 eq(uncertainUser?.kind === "user" && Boolean(uncertainUser.failed), false, "completion_uncertain does not mark the user message as failed");
252 eq(completionUncertain.running, false, "completion_uncertain frees the composer");
253
254 const shellSent = reducer({ ...initialState }, { type: "user", text: "!ls", seq: 0, submissionId: "shell-0" });
255 const shellFailed = reducer(shellSent, { type: "send_failed", submissionId: "shell-0", error: "Command failed: workspace is still starting" });
256 const shellNotice = shellFailed.items[shellFailed.items.length - 1];
257 eq(shellNotice.kind, "notice", "rejected shell command appends a visible notice");
258 eq(shellNotice.kind === "notice" && shellNotice.text.includes("workspace is still starting"), true, "shell rejection notice includes the backend error");
259
260 const lateFailure = reducer(confirmed, { type: "send_failed", submissionId: "send-0", error: "Send failed: late" });
261 eq(lateFailure, confirmed, "send_failed after backend confirmation is a no-op");
262 eq(lateFailure.items, confirmed.items, "late send_failed leaves the confirmed transcript untouched");
263
264 const beforeMcpReady = { ...initialState };
265 const mcpReady = reducer(beforeMcpReady, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
266 eq(mcpReady, beforeMcpReady, "mcp_surface_ready is accepted as a deliberate no-op");
267 const pendingMcpReady = reducer(sent, { type: "event", e: { kind: "mcp_surface_ready" } as WireEvent });
268 eq(pendingMcpReady, sent, "mcp_surface_ready does not confirm a pending submit");
269 const failedAfterMcpReady = reducer(pendingMcpReady, { type: "send_failed", submissionId: "send-0", error: "Send failed: bridge unavailable" });
270 eq(failedAfterMcpReady.localSubmissions["send-0"]?.status, "failed", "send_failed still marks a pending submit after mcp readiness");
271
272 const here = dirname(fileURLToPath(import.meta.url));
273 const appSource = readFileSync(resolve(here, "../AppRuntime.tsx"), "utf8");
274 const sessionCompositionSource = readFileSync(resolve(here, "../app-runtime/useAppSessionComposition.ts"), "utf8");
275 const typesSource = readFileSync(resolve(here, "../lib/types.ts"), "utf8");
276 const controllerSource = readFileSync(resolve(here, "../lib/useController.ts"), "utf8") + readFileSync(resolve(here, "../lib/useGoalControllerActions.ts"), "utf8");
277 eq(typesSource.includes('"mcp_surface_ready"'), true, "TypeScript EventKind declares mcp_surface_ready");
278 eq(controllerSource.includes('e.kind === "mcp_surface_ready"'), true, "reducer handles mcp_surface_ready before optimistic confirmation");
279 {
280 const calls: string[] = [];
281 const ports: SessionActionPorts = {
282 approveForTab: () => undefined,
283 resolvePlanForTab: (target, action) => { calls.push(`resolve:${target.tabId}:${target.promptId}:${action}`); },
284 resolveRecoveryForTab: () => undefined,
285 answerQuestionForTab: async () => undefined,
286 answerMCPForTab: () => undefined,
287 setCollaborationModeForTab: async (tabId, mode) => { calls.push(`mode:${tabId}:${mode}`); },
288 clearGoalForTab: async (tabId) => { calls.push(`goal-clear:${tabId}`); },
289 setRemoteComposerProfile: async () => [],
290 patchComposerProfile: (tabId, mode) => calls.push(`profile:${tabId}:${mode}`),
291 notePlanMode: (tabId, enabled) => calls.push(`plan:${tabId}:${enabled}`),
292 drainRemoteApprovals: () => undefined,
293 };
294 const target = { tabId: "tab-source", sessionKey: "session-source:1", hostId: "local", sessionId: "session-source", sessionGeneration: 1,
295 promptId: "approval-7", kind: "plan" as const, instanceKey: "plan-source:approval-7" };
296 await submitPlanDecision(target, {
297 action: "start_execution", leavePlanMode: true, remote: false, goal: "", toolApprovalMode: "ask",
298 }, ports, { checkpoint() {}, ownsUI: () => true });
299 eq(
300 calls.join("|"),
301 "mode:tab-source:normal|plan:tab-source:false|profile:tab-source:normal|resolve:tab-source:approval-7:start_execution",
302 "plan approval clears source plan mode before recording start execution",
303 );
304
305 calls.length = 0;
306 await submitPlanDecision(target, {
307 action: "exit_plan", leavePlanMode: true, remote: false, goal: "", toolApprovalMode: "ask",
308 }, ports, { checkpoint() {}, ownsUI: () => true });
309 eq(calls[calls.length - 1], "resolve:tab-source:approval-7:exit_plan", "exit-without-executing records the explicit source-bound plan exit last");
310
311 calls.length = 0;
312 await submitPlanDecision(target, {
313 action: "revise_plan", leavePlanMode: false, remote: false, goal: "", toolApprovalMode: "ask",
314 }, ports, { checkpoint() {}, ownsUI: () => true });
315 eq(calls.join("|"), "resolve:tab-source:approval-7:revise_plan", "plan revision records only the source-bound revise decision");
316 }
317 eq(
318 !/exit_plan_mode[\s\S]{0,240}rememberUserIntent:\s*false/.test(appSource),
319 true,
320 "plan approval must not preserve stale plan restore intent",
321 );
322 eq(
323 !appSource.includes("rememberUserIntent"),
324 true,
325 "collaboration mode changes always reconcile the remembered plan restore intent",
326 );
327 eq(
328 !appSource.includes("runtimeTransitionTabsRef") && !appSource.includes("pending.tokenMode"),
329 true,
330 "execution-mode switch state is gone from the app shell",
331 );
332 eq(
333 sessionCompositionSource.includes("!state.backendActivationPending &&") && sessionCompositionSource.includes("!runtimeTransitioning"),
334 true,
335 "composer submit stays behind the controller-ready gate",
336 );
337 // session-submission-lifecycle.test.tsx mounts the production submission owner
338 // and adapter: explicit targets, failure-before-patch, pause/resume, and exact
339 // structured/unstructured first-Goal bytes replace the old App source locations.
340 eq(
341 controllerSource.includes("await app.SetGoalForTab(tabId, goal)") && !/SetGoalForTab\(tabId, goal\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
342 true,
343 "SetGoalForTab activation failures propagate to callers",
344 );
345 eq(
346 controllerSource.includes("await app.ClearGoalForTab(tabId)") && !/ClearGoalForTab\(tabId\)\.catch\(\(\) => \{\}\)/.test(controllerSource),
347 true,
348 "ClearGoalForTab failures also propagate to callers",
349 );
350 // goal-activation-tab-routing.test.tsx retains real Controller/bridge coverage
351 // for the atomic target-scoped first Goal contract.
352
353 const unsent = reducer(sent, { type: "unsend" });
354 eq(unsent.pendingUser, undefined, "unsend clears the pending marker");
355 eq(unsent.discardTurn, true, "unsend discards the in-flight turn");
356
357 const planApprovalFirst = reducer(
358 { ...initialState },
359 { type: "event", e: { kind: "approval_request", approval: { id: "plan-1", tool: "exit_plan_mode", subject: "Approve plan" } } as WireEvent },
360 );
361 const planTurnDoneAfter = reducer(planApprovalFirst, { type: "event", e: { kind: "turn_done" } as WireEvent });
362 eq(
363 planTurnDoneAfter.approval?.id,
364 "plan-1",
365 "turn_done preserves out-of-order plan approval",
366 );
367 eq(planTurnDoneAfter.running, true, "preserved plan approval keeps the tab running");
368 eq(planTurnDoneAfter.pendingPrompt, true, "preserved plan approval keeps the prompt gate active");
369
370 let replayCalls = 0;
371 replayPendingPromptsForActiveTab(undefined, () => {
372 replayCalls += 1;
373 return Promise.resolve();
374 });
375 eq(replayCalls, 0, "no active tab does not replay pending prompts");
376
377 replayPendingPromptsForActiveTab("tab-a", () => {
378 replayCalls += 1;
379 return Promise.resolve();
380 });
381 eq(replayCalls, 1, "active tab switch replays pending prompts");
382
383 replayPendingPromptsForActiveTab("tab-b", () => {
384 replayCalls += 1;
385 return Promise.reject(new Error("bridge unavailable"));
386 });
387 await new Promise((resolve) => setTimeout(resolve, 0));
388 eq(replayCalls, 2, "replay bridge failures are swallowed by the tab-switch effect");
389
390 console.log("\ndelivery recovery continuation");
391
392 interface ContinueCalls {
393 resumes: string[];
394 sends: string[];
395 }
396
397 async function runContinueDelivery(opts: {
398 goal: string | undefined;
399 resumed?: boolean;
400 ready?: boolean;
401 tabId?: string | null;
402 tabAfterResume?: string;
403 }): Promise<ContinueCalls> {
404 const calls: ContinueCalls = { resumes: [], sends: [] };
405 await continueDelivery({
406 tabId: opts.tabId === undefined ? "tab-a" : opts.tabId,
407 ready: opts.ready ?? true,
408 goal: opts.goal,
409 activeTabId: () => opts.tabAfterResume ?? "tab-a",
410 resumeGoal: (tabId) => {
411 calls.resumes.push(tabId);
412 return Promise.resolve(opts.resumed ?? true);
413 },
414 send: (tabId) => {
415 calls.sends.push(tabId);
416 return Promise.resolve();
417 },
418 });
419 return calls;
420 }
421
422 const noGoal = await runContinueDelivery({ goal: undefined });
423 eq(noGoal.resumes.length, 0, "delivery recovery without a Goal skips the resume call");
424 eq(noGoal.sends.join(","), "tab-a", "delivery recovery without a Goal submits the continuation directly");
425
426 {
427 const fence = createSessionSurfaceFence();
428 const ownership = fence.commit("tab-a", "session-a:1")!;
429 let releaseResume!: () => void;
430 const resumeGate = new Promise<void>((resolve) => { releaseResume = resolve; });
431 const sends: string[] = [];
432 const pending = continueDelivery({
433 tabId: "tab-a",
434 ready: true,
435 goal: "ship",
436 uiOwnership: ownership,
437 ownsUI: fence.ownsUnknown,
438 resumeGoal: async () => { await resumeGate; return true; },
439 send: async (tabId) => { sends.push(tabId); },
440 });
441 fence.commit("tab-b", "session-b:1");
442 fence.commit("tab-a", "session-a:1");
443 releaseResume();
444 await pending;
445 eq(sends.length, 0, "delivery recovery cannot reacquire UI ownership after A → B → A");
446 }
447
448 const blankGoal = await runContinueDelivery({ goal: " " });
449 eq(blankGoal.resumes.length, 0, "delivery recovery treats a blank Goal as absent");
450 eq(blankGoal.sends.join(","), "tab-a", "delivery recovery with a blank Goal still submits the continuation");
451
452 const goalResumed = await runContinueDelivery({ goal: "ship it", resumed: true });
453 eq(goalResumed.resumes.join(","), "tab-a", "delivery recovery with a Goal resumes it first");
454 eq(goalResumed.sends.join(","), "tab-a", "delivery recovery submits after the Goal resumes");
455
456 const goalRefused = await runContinueDelivery({ goal: "ship it", resumed: false });
457 eq(goalRefused.resumes.join(","), "tab-a", "an unresumable Goal is still offered the resume");
458 eq(goalRefused.sends.length, 0, "an unresumable (completed) Goal does not submit the continuation");
459
460 const tabSwitched = await runContinueDelivery({ goal: "ship it", resumed: true, tabAfterResume: "tab-b" });
461 eq(tabSwitched.sends.length, 0, "a tab switch during resume drops the continuation");
462
463 const notReady = await runContinueDelivery({ goal: undefined, ready: false });
464 eq(notReady.sends.length, 0, "delivery recovery waits for controller readiness");
465
466 const noTab = await runContinueDelivery({ goal: undefined, tabId: null });
467 eq(noTab.sends.length, 0, "delivery recovery without an active tab is a no-op");
468
469 console.log(`\n${passed} passed, ${failed} failed`);
470 if (failed > 0) process.exit(1);
471
471 lines TYPESCRIPT