| 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 { stateOwnsInteraction } from "../lib/interactionOwnership"; |
| 9 | import { sessionIdentityStableKey } from "../lib/sessionIdentity"; |
| 10 | import { CommandCancelled } from "../lib/commandOutcome"; |
| 11 | import { resolvePromptForSession } from "../lib/exactPromptSubmit"; |
| 12 | import { sessionObservationDiagnostics } from "../lib/sessionObservationDiagnostics"; |
| 13 | |
| 14 | const dom = new JSDOM("<div id='root'></div>"); |
| 15 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 16 | const root = createRoot(document.getElementById("root")!); |
| 17 | const meta = { label: "test", ready: true, eventChannel: "test", cwd: "/workspace", |
| 18 | session: { hostId: "local", sessionId: "session-A" }, sessionGeneration: 0 }; |
| 19 | const approval = { id: "1", tool: "write_file", subject: "temporary render script", kind: "write_access", |
| 20 | turnId: "turn-A", runtimeEpoch: "runtime-A", generation: 1, permissionRevision: 1, |
| 21 | write_access: { directories: ["/tmp/render-tools"] } }; |
| 22 | const target = { tabId: "A", sessionKey: sessionIdentityStableKey(meta) }; |
| 23 | let commands!: ReturnType<typeof useSessionPromptCommands>; |
| 24 | let calls = 0; |
| 25 | let submit: () => void | Promise<void> = () => { calls++; }; |
| 26 | const ports: PromptPorts = { |
| 27 | isPromptCurrentForTab: candidate => stateOwnsInteraction({ meta, approval }, candidate), |
| 28 | approveForTab: candidate => resolvePromptForSession({ |
| 29 | ResolvePromptForSession: async received => { |
| 30 | assert.equal(received.sessionGeneration, 0, "fresh binding generation reaches the host unchanged"); |
| 31 | await submit(); |
| 32 | }, |
| 33 | ResolvePromptForTab: async () => { throw new Error("unexpected legacy route"); }, |
| 34 | PendingPromptIdentitiesForTab: async () => [], |
| 35 | }, candidate, { allow: true }), |
| 36 | resolvePlanForTab: () => submit(), resolveRecoveryForTab: () => submit(), |
| 37 | answerQuestionForTab: async () => {}, answerMCPForTab: () => {}, |
| 38 | setCollaborationModeForTab: async () => {}, clearGoalForTab: async () => {}, |
| 39 | setRemoteComposerProfile: async () => [], patchComposerProfile: () => {}, notePlanMode: () => {}, |
| 40 | drainRemoteApprovals: () => {}, rememberRevision: () => {}, |
| 41 | }; |
| 42 | function Probe({ ready, resourceKey }: { ready: boolean; resourceKey: string }) { |
| 43 | const operations = useSessionOperations({ visible: target, resources: [{ ...target, sessionKey: resourceKey }] }); |
| 44 | commands = useSessionPromptCommands({ target, session: ready ? meta.session : undefined, |
| 45 | sessionGeneration: ready ? meta.sessionGeneration : undefined, approval, |
| 46 | remote: false, goal: "", toolApprovalMode: "workspace-write", ports, operations, |
| 47 | reportError: error => { throw error; } }); |
| 48 | return null; |
| 49 | } |
| 50 | async function paint(ready = true, resourceKey = target.sessionKey) { |
| 51 | await act(async () => root.render(<Probe ready={ready} resourceKey={resourceKey} />)); |
| 52 | } |
| 53 | try { |
| 54 | await paint(false); |
| 55 | await assert.rejects(commands.handleApprovalAnswer(true, false, false), CommandCancelled, |
| 56 | "missing session binding must not masquerade as an accepted approval"); |
| 57 | assert.equal(calls, 0); |
| 58 | await paint(true, "replaced-session"); |
| 59 | await assert.rejects(commands.handleApprovalAnswer(true, false, false), CommandCancelled, |
| 60 | "a cancelled operation must release the card's submission lock"); |
| 61 | assert.equal(calls, 0, "a stale card never authorizes the replacement session"); |
| 62 | await paint(); |
| 63 | await commands.handleApprovalAnswer(true, false, false); |
| 64 | assert.equal(calls, 1, "retry after the authoritative binding arrives submits once"); |
| 65 | |
| 66 | let reject!: (error: Error) => void; |
| 67 | let entered!: () => void; |
| 68 | const started = new Promise<void>(resolve => { entered = resolve; }); |
| 69 | const pending = new Promise<void>((_resolve, fail) => { reject = fail; }); |
| 70 | submit = () => { entered(); return pending; }; |
| 71 | let settled = false; |
| 72 | const result = commands.handleApprovalAnswer(true, false, false); |
| 73 | const checked = assert.rejects(result, /transport unavailable/); |
| 74 | void result.then(() => { settled = true; }, () => { settled = true; }); |
| 75 | await started; |
| 76 | await new Promise(resolve => setTimeout(resolve, 0)); |
| 77 | assert.equal(settled, false, "the card waits for the actual approval transport outcome"); |
| 78 | reject(new Error("transport unavailable")); |
| 79 | await checked; |
| 80 | const trace = sessionObservationDiagnostics("session-id:session-A", "A"); |
| 81 | assert.ok(trace.events.some(e => e.action === "prompt-transport" && e.status === "accepted" && e.prompt?.bindingGeneration === 0)); |
| 82 | assert.ok(trace.events.some(e => e.action === "prompt-transport" && e.status === "rejected:unavailable")); |
| 83 | assert.ok(!JSON.stringify(trace).includes("transport unavailable"), "diagnostics keep closed error classes, not raw errors"); |
| 84 | assert.equal(sessionObservationDiagnostics("session-id:session-A", "B").events.length, 0, "diagnostics stay with the submitting tab"); |
| 85 | console.log("approval outcomes: missing/stale binding, safe retry and transport failure passed"); |
| 86 | } finally { |
| 87 | await act(async () => root.unmount()); |
| 88 | dom.window.close(); |
| 89 | } |
| 90 |