| 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 { useSessionDraftSurface } from "../app-runtime/useSessionDraftSurface"; |
| 6 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 7 | |
| 8 | const dom = new JSDOM("<body></body>", { url: "http://localhost" }); |
| 9 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true, |
| 10 | requestAnimationFrame: (fn: FrameRequestCallback) => setTimeout(() => fn(0), 0), cancelAnimationFrame: clearTimeout }); |
| 11 | dom.window.requestAnimationFrame = globalThis.requestAnimationFrame; |
| 12 | dom.window.cancelAnimationFrame = globalThis.cancelAnimationFrame; |
| 13 | function deferred<T>() { let resolve!: (v: T) => void; const promise = new Promise<T>(r => { resolve = r; }); return { promise, resolve }; } |
| 14 | const content = (text: string) => ({ text, invocations: [], attachments: [], workspaceRefs: [], pastedBlocks: [], openPastedLabels: [], sessionRefs: [], selectedTextRefs: [] }); |
| 15 | const settings = { model: "fixture/old", effort: "high", mode: "normal", collaborationMode: "normal", toolApprovalMode: "ask", disabledMcp: {}, mcpOrder: [] }; |
| 16 | async function fixture(overrides: Record<string, unknown> = {}) { |
| 17 | const records = new Map(["a", "b"].map(id => [id, { id, workspaceId: `ws-${id}`, scope: "project", workspaceRoot: `/tmp/${id}`, revision: 1, contentJson: JSON.stringify(content(`text-${id}`)), settings: structuredClone(settings), status: "active", updatedAt: 1 }])); |
| 18 | const deleted: string[] = []; |
| 19 | const stub = installDesktopHostStub({ |
| 20 | ListSessionDraftSummaries: async () => [], |
| 21 | OpenSessionDraftForTarget: async (_: string, path: string) => structuredClone(records.get(path.split("/").pop()!)), |
| 22 | GetDraftContext: async (id: string) => ({ draft: structuredClone(records.get(id)), commands: [], servers: [] }), |
| 23 | GetSessionDraft: async (id: string) => structuredClone(records.get(id)), |
| 24 | SetSessionDraftRestoreTarget: async () => {}, DismissSessionDraft: async () => {}, |
| 25 | SaveSessionDraft: async (req: { draftId: string; revision: number; contentJson: string; settings: typeof settings }) => { |
| 26 | const old = records.get(req.draftId)!; |
| 27 | if (req.revision !== old.revision) return { draft: structuredClone(old), conflict: true, outcome: "conflict" }; |
| 28 | const saved = { ...old, contentJson: req.contentJson, settings: req.settings, revision: old.revision + 1 }; |
| 29 | records.set(req.draftId, saved); |
| 30 | return { draft: structuredClone(saved), conflict: false, outcome: "saved" }; |
| 31 | }, |
| 32 | DiscardSessionDraft: async (id: string) => { deleted.push(id); }, |
| 33 | ...overrides, |
| 34 | }); |
| 35 | let owner!: ReturnType<typeof useSessionDraftSurface>; |
| 36 | function Probe() { owner = useSessionDraftSurface({ onAccepted: () => {}, onChanged: () => {} }); return null; } |
| 37 | const node = document.createElement("div"); document.body.append(node); |
| 38 | const root = createRoot(node); |
| 39 | await act(async () => { root.render(<Probe />); }); |
| 40 | await act(async () => { await owner.open("project", "/tmp/a"); }); |
| 41 | return { get owner() { return owner; }, records, deleted, |
| 42 | close: async () => { await act(async () => { root.unmount(); }); stub.uninstall(); node.remove(); } }; |
| 43 | } |
| 44 | |
| 45 | try { |
| 46 | for (const field of ["attachments", "workspaceRefs", "invocations", "pastedBlocks", "sessionRefs", "selectedTextRefs"]) { |
| 47 | let confirmations = 0; |
| 48 | const f = await fixture({ ConfirmAction: async () => { confirmations++; return false; } }); |
| 49 | const payload = JSON.parse(JSON.stringify(content(""))); |
| 50 | payload[field] = [{ id: "saved-content", name: "keep", path: "/tmp/keep", text: "keep" }]; |
| 51 | act(() => f.owner.updateContent(payload)); |
| 52 | await act(async () => { await f.owner.confirmDiscard({ title: "Discard", message: "Discard", detail: "Cannot restore", confirmLabel: "Discard", cancelLabel: "Cancel" }); }); |
| 53 | assert.equal(confirmations, 1, `${field} without body text requires confirmation`); |
| 54 | assert.deepEqual(f.deleted, [], `${field} survives cancellation`); |
| 55 | await f.close(); |
| 56 | } |
| 57 | { |
| 58 | let confirmations = 0; |
| 59 | const f = await fixture({ ConfirmAction: async () => { confirmations++; return false; } }); |
| 60 | const labels = { title: "Discard", message: "Discard", detail: "Cannot restore", confirmLabel: "Discard", cancelLabel: "Cancel" }; |
| 61 | await act(async () => { await f.owner.confirmDiscard(labels); }); |
| 62 | assert.deepEqual(f.deleted, [], "cancel keeps the saved draft"); |
| 63 | assert.equal(f.owner.surface!.content.text, "text-a"); |
| 64 | act(() => f.owner.updateContent(content(""))); |
| 65 | await act(async () => { await f.owner.confirmDiscard(labels); }); |
| 66 | assert.equal(confirmations, 1, "an empty draft needs no confirmation"); |
| 67 | assert.deepEqual(f.deleted, ["a"]); |
| 68 | assert.equal(f.owner.surface, null); |
| 69 | await f.close(); |
| 70 | } |
| 71 | { |
| 72 | const f = await fixture({ DiscardSessionDraft: async () => { throw new Error("write failed"); } }); |
| 73 | await act(async () => { await assert.rejects(f.owner.discard(), /write failed/); }); |
| 74 | assert.equal(f.owner.surface!.content.text, "text-a", "failed discard retains content"); |
| 75 | assert.equal(f.owner.surface!.discarding, false, "failed discard unlocks retry"); |
| 76 | await f.close(); |
| 77 | } |
| 78 | { |
| 79 | const gate = deferred<void>(); |
| 80 | let calls = 0; |
| 81 | const f = await fixture({ DiscardSessionDraft: () => { calls++; return gate.promise; } }); |
| 82 | let first!: Promise<void>; |
| 83 | act(() => { first = f.owner.discard(); }); |
| 84 | await act(async () => { await f.owner.discard(); }); |
| 85 | assert.equal(calls, 1, "duplicate clicks share the in-flight deletion"); |
| 86 | assert.equal(f.owner.surface!.discarding, true); |
| 87 | await act(async () => { gate.resolve(); await first; }); |
| 88 | await f.close(); |
| 89 | } |
| 90 | { |
| 91 | const confirmation = deferred<boolean>(); |
| 92 | const f = await fixture({ ConfirmAction: () => confirmation.promise }); |
| 93 | let pending!: Promise<void>; |
| 94 | act(() => { pending = f.owner.confirmDiscard({ title: "", message: "", detail: "", confirmLabel: "", cancelLabel: "" }); }); |
| 95 | act(() => f.owner.updateContent(content("new edit after confirmation opened"))); |
| 96 | await act(async () => { confirmation.resolve(true); await assert.rejects(pending, /draft changed/); }); |
| 97 | assert.deepEqual(f.deleted, [], "confirmation cannot discard unseen edits"); |
| 98 | assert.equal(f.owner.surface!.content.text, "new edit after confirmation opened"); |
| 99 | await f.close(); |
| 100 | } |
| 101 | { |
| 102 | const f = await fixture(); |
| 103 | act(() => { f.owner.captureSubmission("a", 1); }); |
| 104 | await act(async () => { await assert.rejects(f.owner.discard(), /Cancel the submission/); }); |
| 105 | assert.deepEqual(f.deleted, [], "submission owns the draft until cancellation is acknowledged"); |
| 106 | await f.close(); |
| 107 | } |
| 108 | { |
| 109 | const f = await fixture(); |
| 110 | await act(async () => { await f.owner.open("project", "/tmp/b"); }); |
| 111 | act(() => { f.owner.reportTaskError("a", 1, "attachment failed in A"); }); |
| 112 | assert.equal(f.owner.surface!.taskError, undefined, "background errors do not appear on B"); |
| 113 | await act(async () => { await f.owner.open("project", "/tmp/a"); }); |
| 114 | assert.equal(f.owner.surface!.taskError, "attachment failed in A"); |
| 115 | act(() => { f.owner.reportTaskError("a", 0, "stale error"); }); |
| 116 | assert.equal(f.owner.surface!.taskError, "attachment failed in A", "old generations cannot replace task errors"); |
| 117 | await f.close(); |
| 118 | } |
| 119 | { |
| 120 | const requests: { requestId: string }[] = []; |
| 121 | const f = await fixture({ |
| 122 | GetSessionDraftState: async () => ({ operation: undefined }), |
| 123 | BeginDraftSubmission: async (request: { requestId: string }) => { |
| 124 | requests.push(request); |
| 125 | if (requests.length === 1) throw new Error("connection lost before the first transaction completed"); |
| 126 | return { operationId: "same-op", requestId: request.requestId, draftId: "a", phase: "accepted", revision: 2, submissionId: "same-submit", updatedAt: 1, session: { hostId: "local", sessionId: "same-session" } }; |
| 127 | }, |
| 128 | }); |
| 129 | let capture: ReturnType<typeof f.owner.captureSubmission>; |
| 130 | act(() => { capture = f.owner.captureSubmission("a", 1); }); |
| 131 | await act(async () => { await f.owner.submit("text-a", "text-a", undefined, undefined, capture); }); |
| 132 | assert.equal(requests.length, 2); |
| 133 | assert.equal(requests[0].requestId, requests[1].requestId, "absence during a lost Begin retries the original preparation identity"); |
| 134 | assert.equal(f.owner.surface, null); |
| 135 | await f.close(); |
| 136 | } |
| 137 | { |
| 138 | const operation = { operationId: "original-op", draftId: "a", phase: "resume_required", revision: 7, submissionId: "original-submit", canResume: true, canEdit: false, canCancel: true, canDiscard: false, updatedAt: 1, session: { hostId: "local", sessionId: "original-session" } }; |
| 139 | const calls: unknown[] = []; |
| 140 | const f = await fixture({ |
| 141 | GetDraftContext: async () => ({ draft: { id: "a", workspaceId: "ws-a", scope: "project", workspaceRoot: "/tmp/a", revision: 1, contentJson: JSON.stringify(content("text-a")), settings, status: "active", updatedAt: 1 }, operation, commands: [], servers: [] }), |
| 142 | ResumeDraftSubmission: async (...args: unknown[]) => { calls.push(args); return { ...operation, phase: "accepted", revision: 8 }; }, |
| 143 | }); |
| 144 | assert.equal(f.owner.surface!.operation!.operationId, "original-op"); |
| 145 | assert.equal(f.owner.captureSubmission("a", 1), null, "restored operation cannot accidentally prepare a new submission"); |
| 146 | await act(async () => { await f.owner.resumeSubmission(); }); |
| 147 | assert.deepEqual(calls, [["original-op", 7]], "explicit continuation uses the original operation and expected version"); |
| 148 | await f.close(); |
| 149 | } |
| 150 | { |
| 151 | const operation = { operationId: "failed-runtime", draftId: "a", phase: "runtime_failed", revision: 7, submissionId: "original-submit", canResume: true, canEdit: false, canCancel: true, canDiscard: false, updatedAt: 1 }; |
| 152 | const calls: unknown[] = []; |
| 153 | const retry = deferred<typeof operation>(); |
| 154 | const f = await fixture({ |
| 155 | GetDraftContext: async () => ({ draft: { id: "a", workspaceId: "ws-a", scope: "project", workspaceRoot: "/tmp/a", revision: 1, contentJson: JSON.stringify(content("keep my draft")), settings, status: "active", updatedAt: 1 }, operation, commands: [], servers: [] }), |
| 156 | ResumeDraftSubmission: async (...args: unknown[]) => { |
| 157 | calls.push(args); |
| 158 | if (calls.length === 1) throw new Error("create session runtime: path unavailable"); |
| 159 | return retry.promise; |
| 160 | }, |
| 161 | BeginDraftSubmission: async () => { throw new Error("retry must reuse the original operation"); }, |
| 162 | }); |
| 163 | await act(async () => { await f.owner.resumeSubmission(); }); |
| 164 | assert.equal(f.owner.surface!.saveState, "saved", "runtime failure is not a persistence failure"); |
| 165 | assert.equal(f.owner.surface!.error, undefined); |
| 166 | assert.equal(f.owner.surface!.submissionError, "create session runtime: path unavailable"); |
| 167 | assert.equal(f.owner.surface!.content.text, "keep my draft"); |
| 168 | let pending!: Promise<void>; |
| 169 | await act(async () => { |
| 170 | pending = f.owner.resumeSubmission(); |
| 171 | await f.owner.resumeSubmission(); |
| 172 | }); |
| 173 | assert.equal(f.owner.surface!.resumingSubmission, true); |
| 174 | assert.deepEqual(calls, [["failed-runtime", 7], ["failed-runtime", 7]], "overlapping retries share one in-flight resume"); |
| 175 | await act(async () => { retry.resolve({ ...operation, phase: "runtime_failed", revision: 8 }); await pending; }); |
| 176 | assert.equal(f.owner.surface!.submissionError, undefined, "successful resume RPC clears its previous error"); |
| 177 | assert.equal(f.owner.surface!.resumingSubmission, false); |
| 178 | assert.equal(f.owner.surface!.content.text, "keep my draft"); |
| 179 | await f.close(); |
| 180 | } |
| 181 | { |
| 182 | const timers: (() => void)[] = []; |
| 183 | const originalTimer = window.setTimeout; |
| 184 | window.setTimeout = ((callback: () => void, delay?: number) => delay && delay >= 250 ? (timers.push(callback), timers.length) : originalTimer(callback, delay)) as typeof window.setTimeout; |
| 185 | const operation = { operationId: "unknown-op", draftId: "a", phase: "dispatching_shell", revision: 4, submissionId: "shell-submit", canResume: false, canEdit: false, canCancel: true, canDiscard: false, updatedAt: 1, session: { hostId: "local", sessionId: "shell-session" } }; |
| 186 | const f = await fixture({ |
| 187 | GetDraftContext: async () => ({ draft: { id: "a", workspaceId: "ws-a", scope: "project", workspaceRoot: "/tmp/a", revision: 1, contentJson: JSON.stringify(content("text-a")), settings, status: "active", updatedAt: 1 }, operation, commands: [], servers: [] }), |
| 188 | CancelDraftSubmission: async () => ({ ...operation, phase: "accepted", revision: 6 }), |
| 189 | GetDraftSubmission: async () => ({ ...operation, revision: 5 }), |
| 190 | }); |
| 191 | assert.ok(timers.length, "shell dispatch keeps an application-level verification loop"); |
| 192 | await act(async () => { await f.owner.cancelSubmission(); }); |
| 193 | assert.equal(f.owner.surface, null, "accepted cancellation converts immediately without waiting for the poll timer"); |
| 194 | await act(async () => { timers.shift()?.(); }); |
| 195 | assert.equal(f.owner.surface, null, "an older poll cannot resurrect the accepted draft"); |
| 196 | await f.close(); |
| 197 | window.setTimeout = originalTimer; |
| 198 | } |
| 199 | { |
| 200 | const f = await fixture(); |
| 201 | act(() => { f.owner.captureSubmission("a", 1); }); |
| 202 | act(() => { f.owner.updateSettings({ model: "fixture/new" }); f.owner.updateContent(content("new text")); }); |
| 203 | assert.equal(f.owner.surface!.preparingSubmission, true, "capture synchronously locks the owner before context expansion"); |
| 204 | assert.equal(f.owner.surface!.settings.model, "fixture/old"); |
| 205 | assert.equal(f.owner.surface!.content.text, "text-a"); |
| 206 | await f.close(); |
| 207 | } |
| 208 | { |
| 209 | const confirmation = deferred<boolean>(); |
| 210 | const started = deferred<void>(); |
| 211 | const f = await fixture({ ConfirmAction: () => { started.resolve(); return confirmation.promise; } }); |
| 212 | let pending!: Promise<void>; |
| 213 | act(() => { pending = f.owner.confirmDiscard({ title: "", message: "", detail: "", confirmLabel: "", cancelLabel: "" }); }); |
| 214 | await started.promise; |
| 215 | await act(async () => { await f.owner.open("project", "/tmp/b"); }); |
| 216 | await act(async () => { confirmation.resolve(true); await pending; }); |
| 217 | assert.deepEqual(f.deleted, ["a"], "confirmation remains bound to A"); |
| 218 | assert.equal(f.owner.surface!.draft.id, "b"); |
| 219 | await f.close(); |
| 220 | } |
| 221 | { |
| 222 | const finished = deferred<void>(); |
| 223 | const f = await fixture({ DiscardSessionDraft: () => finished.promise }); |
| 224 | let pending!: Promise<void>; |
| 225 | act(() => { pending = f.owner.discard(); }); |
| 226 | await act(async () => { await f.owner.open("project", "/tmp/b"); }); |
| 227 | await act(async () => { finished.resolve(); await pending; }); |
| 228 | assert.equal(f.owner.surface!.draft.id, "b", "late discard cannot dismiss B"); |
| 229 | await f.close(); |
| 230 | } |
| 231 | { |
| 232 | const task = deferred<void>(); |
| 233 | const f = await fixture(); |
| 234 | let tracked!: Promise<void>; |
| 235 | act(() => { tracked = f.owner.trackTask("a", 1, task.promise); f.owner.updateContent(content("local")); }); |
| 236 | const saved = f.records.get("a")!; |
| 237 | f.records.set("a", { ...saved, revision: 2, contentJson: JSON.stringify(content("remote")) }); |
| 238 | await act(async () => { await f.owner.flush(); }); |
| 239 | assert.equal(f.owner.surface!.saveState, "conflict"); |
| 240 | act(() => { f.owner.useSavedConflict(); }); |
| 241 | await act(async () => { task.resolve(); await tracked; }); |
| 242 | assert.equal(f.owner.surface!.pendingTasks, 0, "old generation still releases its task registration"); |
| 243 | await act(async () => { await window.__reasonixFlushSessionDraft!(); }); |
| 244 | await f.close(); |
| 245 | } |
| 246 | console.log("draft ownership: synchronous capture, target-bound discard and task settlement passed"); |
| 247 | } finally { dom.window.close(); } |
| 248 |