| 1 | // A transient inbox-target fence (session switching, reconnecting, route |
| 2 | // adoption) clears by itself. The composer must wait it out and deliver the |
| 3 | // message instead of surfacing "the message was not sent" for a window the |
| 4 | // user cannot act on. |
| 5 | import assert from "node:assert/strict"; |
| 6 | import { act } from "react"; |
| 7 | import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness"; |
| 8 | import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore"; |
| 9 | import { acceptRuntimeState } from "../lib/runtimeStateReducer"; |
| 10 | import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup"; |
| 11 | import { formatInboxError } from "../lib/inboxError"; |
| 12 | const TRANSIENT_INBOX_TARGET_CODE = "inbox_target_transient"; |
| 13 | const TRANSIENT_GUIDANCE_RETRY_DELAYS_MS = [150, 350, 500, 500]; |
| 14 | |
| 15 | const pendingSession = followupSessionKey("session-a"); |
| 16 | const DEAD_END_COPY = "Refresh the session"; |
| 17 | |
| 18 | const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0)); |
| 19 | const settle = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)); |
| 20 | |
| 21 | async function paste(text: string) { |
| 22 | await act(async () => { |
| 23 | const event = new window.Event("paste", { bubbles: true, cancelable: true }); |
| 24 | Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } }); |
| 25 | document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event); |
| 26 | await flush(); |
| 27 | }); |
| 28 | } |
| 29 | async function send() { |
| 30 | await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); }); |
| 31 | } |
| 32 | function transientError() { |
| 33 | return Object.assign(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), { code: "business", data: { transient: true } }); |
| 34 | } |
| 35 | function state(scenario: number, phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection { |
| 36 | return { epoch: `transient-test-${scenario}`, revision, topics: [], sessions: [{ |
| 37 | tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1, |
| 38 | open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision, |
| 39 | phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3, turnStartedAt: 0, |
| 40 | pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" }, |
| 41 | }] }; |
| 42 | } |
| 43 | const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true }; |
| 44 | const receipt = { itemId: "item-1", disposition: "queued_followup", position: 0, paused: false }; |
| 45 | |
| 46 | // The classifier and the copy table must recognize the bridge's transient mark. |
| 47 | { |
| 48 | assert.equal(formatInboxError(new Error(`reasonix_error:${TRANSIENT_INBOX_TARGET_CODE}`), "en").includes("switching"), true); |
| 49 | } |
| 50 | |
| 51 | // 1. A capture-phase fence is waited out: the send completes normally. |
| 52 | { |
| 53 | installDom(); |
| 54 | let captures = 0, posts = 0; |
| 55 | installBridgeApp({ |
| 56 | CaptureInboxTarget: async () => { captures++; if (captures <= 2) throw transientError(); return target; }, |
| 57 | EnqueueInboxFollowupForTarget: async () => { posts++; return receipt; }, |
| 58 | InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }), |
| 59 | }); |
| 60 | const SCENARIO = 1; |
| 61 | await renderComposer({ inboxSessionPath: "session-a" }); |
| 62 | await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); }); |
| 63 | await paste("held during switch"); |
| 64 | await send(); |
| 65 | await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[1] + 80); }); |
| 66 | assert.equal(captures, 3, "capture retried until the tab settled"); |
| 67 | assert.equal(posts, 1, "the settled send enqueued exactly once"); |
| 68 | assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "no dead-end copy for a self-clearing window"); |
| 69 | } |
| 70 | |
| 71 | // 2. An enqueue-phase fence holds the request and auto-delivers on retry. |
| 72 | { |
| 73 | installDom(); |
| 74 | let posts = 0; |
| 75 | installBridgeApp({ |
| 76 | CaptureInboxTarget: async () => target, |
| 77 | EnqueueInboxFollowupForTarget: async () => { posts++; if (posts === 1) throw transientError(); return receipt; }, |
| 78 | InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }), |
| 79 | }); |
| 80 | const SCENARIO = 2; |
| 81 | await renderComposer({ inboxSessionPath: "session-a" }); |
| 82 | await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); }); |
| 83 | await paste("retried until queued"); |
| 84 | await send(); |
| 85 | await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS[0] + 80); }); |
| 86 | assert.equal(posts, 2, "the held request retried the enqueue"); |
| 87 | assert.equal(pendingFollowups.get(pendingSession), undefined, "a delivered request clears its pending identity"); |
| 88 | assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "delivery never surfaced the dead-end copy"); |
| 89 | } |
| 90 | |
| 91 | // 3. Outlasting the bound keeps the request visible for later reconciliation |
| 92 | // instead of telling the user to refresh. |
| 93 | { |
| 94 | installDom(); |
| 95 | let posts = 0; |
| 96 | installBridgeApp({ |
| 97 | CaptureInboxTarget: async () => target, |
| 98 | EnqueueInboxFollowupForTarget: async () => { posts++; throw transientError(); }, |
| 99 | InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }), |
| 100 | }); |
| 101 | const SCENARIO = 3; |
| 102 | await renderComposer({ inboxSessionPath: "session-a" }); |
| 103 | await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); }); |
| 104 | await paste("switching forever"); |
| 105 | await send(); |
| 106 | await act(async () => { await settle(TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.reduce((sum, ms) => sum + ms, 0) + 200); }); |
| 107 | assert.equal(posts, 1 + TRANSIENT_GUIDANCE_RETRY_DELAYS_MS.length, "the retry bound is finite"); |
| 108 | assert.ok(pendingFollowups.get(pendingSession), "an undelivered request stays pending for reconciliation"); |
| 109 | assert.equal(document.body.textContent?.includes(DEAD_END_COPY), false, "exhaustion still avoids the dead-end copy"); |
| 110 | // The pending store is module-global: retire this scenario's leftover so the |
| 111 | // next one starts from a clean session identity. |
| 112 | pendingFollowups.clear(pendingSession, pendingFollowups.get(pendingSession)); |
| 113 | } |
| 114 | |
| 115 | // 4. Permanent refusals keep the historical behavior (clear + report). |
| 116 | { |
| 117 | installDom(); |
| 118 | installBridgeApp({ |
| 119 | CaptureInboxTarget: async () => target, |
| 120 | EnqueueInboxFollowupForTarget: async () => { throw new Error("reasonix_error:inbox_item_too_large"); }, |
| 121 | InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }), |
| 122 | }); |
| 123 | const SCENARIO = 4; |
| 124 | await renderComposer({ inboxSessionPath: "session-a" }); |
| 125 | await act(async () => { acceptRuntimeState(runtimeStateStore, state(SCENARIO, "finishing", 1), true); await flush(); }); |
| 126 | await paste("permanently refused"); |
| 127 | await send(); |
| 128 | await act(async () => { await settle(60); }); |
| 129 | assert.equal(pendingFollowups.get(pendingSession), undefined, "permanent refusals drop the pending identity"); |
| 130 | assert.equal(document.body.textContent?.includes("too large"), true, "permanent refusals stay visible to the user"); |
| 131 | } |
| 132 | |
| 133 | console.log("composer transient guidance: retries a self-clearing fence and keeps the message visible"); |
| 134 | // Mounted roots leave timers behind; exit once the scenarios above ran. |
| 135 | process.exit(0); |
| 136 |