| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { useSessionOperations } from "../app-runtime/useSessionOperations"; |
| 6 | import { useSessionPromptCommands } from "../app-runtime/useSessionPromptCommands"; |
| 7 | import type { PromptPorts } from "../app-runtime/sessionPromptExecutor"; |
| 8 | import { CommandCancelled } from "../lib/commandOutcome"; |
| 9 | |
| 10 | const dom = new JSDOM("<div id='root'></div>"); |
| 11 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 12 | const root = createRoot(document.getElementById("root")!); |
| 13 | function deferred() { let resolve!: () => void; const promise = new Promise<void>(done => { resolve = done; }); return { promise, resolve }; } |
| 14 | let entered = deferred(); |
| 15 | let gate = deferred(); |
| 16 | let prompt = "approval-A"; |
| 17 | const calls: string[] = []; |
| 18 | const ports: PromptPorts = { |
| 19 | isPromptCurrentForTab: target => target.tabId === "A" && target.promptId === prompt, |
| 20 | approveForTab: target => { calls.push(`approve:${target.tabId}`); }, |
| 21 | resolvePlanForTab: target => { calls.push(`resolve:${target.tabId}:${target.promptId}`); }, |
| 22 | resolveRecoveryForTab: target => { calls.push(`recover:${target.tabId}`); }, |
| 23 | answerQuestionForTab: async target => { calls.push(`question:${target.tabId}`); }, |
| 24 | answerMCPForTab: target => { calls.push(`mcp:${target.tabId}`); }, |
| 25 | setCollaborationModeForTab: async tab => { calls.push(`mode:${tab}`); }, |
| 26 | clearGoalForTab: async tab => { calls.push(`clear:${tab}`); entered.resolve(); await gate.promise; }, |
| 27 | setRemoteComposerProfile: async tab => { calls.push(`remote:${tab}`); entered.resolve(); await gate.promise; return [prompt]; }, |
| 28 | patchComposerProfile: tab => { calls.push(`patch:${tab}`); }, |
| 29 | notePlanMode: tab => { calls.push(`remember:${tab}`); }, |
| 30 | drainRemoteApprovals: tab => { calls.push(`drain:${tab}`); }, |
| 31 | rememberRevision: tab => { calls.push(`revision:${tab}`); }, |
| 32 | }; |
| 33 | let commands!: ReturnType<typeof useSessionPromptCommands>; |
| 34 | function Probe({ tab, generation = "1", remote = false }: { tab: string; generation?: string; remote?: boolean }) { |
| 35 | const target = { tabId: tab, sessionKey: tab + generation }; |
| 36 | const operations = useSessionOperations({ visible: target, resources: ["A", "B"].map(tabId => ({ tabId, sessionKey: tabId + generation })) }); |
| 37 | commands = useSessionPromptCommands({ target, session: { hostId: "local", sessionId: `session-${tab}` }, sessionGeneration: Number(generation), approval: { id: prompt, tool: "exit_plan_mode", subject: "plan", turnId: `turn-${tab}`, runtimeEpoch: `runtime-${tab}` }, |
| 38 | remote, goal: "fixture", toolApprovalMode: "ask", ports, operations, reportError: error => { throw error; } }); |
| 39 | return null; |
| 40 | } |
| 41 | async function paint(tab: string, generation = "1", remote = false) { |
| 42 | await act(async () => root.render(<Probe tab={tab} generation={generation} remote={remote} />)); |
| 43 | } |
| 44 | function reset() { calls.length = 0; entered = deferred(); gate = deferred(); prompt = "approval-A"; } |
| 45 | try { |
| 46 | await paint("A"); |
| 47 | let pending = commands.handleApprovalAnswer(true, false, false); |
| 48 | await entered.promise; |
| 49 | await paint("B"); |
| 50 | gate.resolve(); await pending; |
| 51 | assert.deepEqual(calls, ["clear:A", "mode:A", "remember:A", "patch:A", "resolve:A:approval-A"]); |
| 52 | |
| 53 | reset(); await paint("A"); |
| 54 | pending = commands.handleExitPlan(); await entered.promise; |
| 55 | prompt = "replacement"; |
| 56 | gate.resolve(); await assert.rejects(pending, CommandCancelled); |
| 57 | assert.deepEqual(calls, ["clear:A"], "replacement prompt revokes the entire continuation, including mode changes"); |
| 58 | |
| 59 | reset(); await paint("A"); |
| 60 | pending = commands.handleExitPlan(); await entered.promise; |
| 61 | await paint("A", "2"); gate.resolve(); await assert.rejects(pending, CommandCancelled); |
| 62 | assert.deepEqual(calls, ["clear:A"], "same tab with a different session cannot resolve an old approval"); |
| 63 | |
| 64 | reset(); await paint("A", "1", true); |
| 65 | pending = commands.handleExitPlan(); await entered.promise; |
| 66 | await paint("B", "1", true); await paint("A", "1", true); |
| 67 | gate.resolve(); await pending; |
| 68 | assert.deepEqual(calls, ["remote:A", "remember:A", "patch:A", "resolve:A:approval-A"], "ABA never drains the new surface's approvals"); |
| 69 | |
| 70 | reset(); await paint("A"); |
| 71 | pending = commands.handleExitPlan(); await entered.promise; |
| 72 | await commands.handleApprovalAnswer(false, false, false); |
| 73 | gate.resolve(); await assert.rejects(pending, CommandCancelled); |
| 74 | assert.deepEqual(calls, ["clear:A", "resolve:A:approval-A"], "new decision supersedes the older mode/approval chain"); |
| 75 | |
| 76 | reset(); await paint("A"); |
| 77 | pending = commands.handleExitPlan(); await entered.promise; |
| 78 | await act(async () => root.unmount()); |
| 79 | gate.resolve(); await assert.rejects(pending, CommandCancelled); |
| 80 | await assert.rejects(commands.handleRecoveryAnswer("stop"), CommandCancelled); |
| 81 | assert.deepEqual(calls, ["clear:A"], "unmount revokes the stable entry and every in-flight continuation"); |
| 82 | console.log("session prompts: source, prompt identity, replacement, ABA, supersession and disposal passed"); |
| 83 | } finally { dom.window.close(); } |
| 84 |