| 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 |