| 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 | |
| 6 | import { useSessionDraftSurface } from "../app-runtime/useSessionDraftSurface"; |
| 7 | import type { PersistentComposerDraft } from "../components/Composer"; |
| 8 | import type { SessionDraftSubmissionRequest, SessionDraftView } from "../generated/desktopContract.generated"; |
| 9 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 10 | |
| 11 | function deferred<T>() { |
| 12 | let resolve!: (value: T) => void; |
| 13 | const promise = new Promise<T>((next) => { resolve = next; }); |
| 14 | return { promise, resolve }; |
| 15 | } |
| 16 | |
| 17 | const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" }); |
| 18 | Object.assign(globalThis, { |
| 19 | window: dom.window, |
| 20 | document: dom.window.document, |
| 21 | IS_REACT_ACT_ENVIRONMENT: true, |
| 22 | requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(() => callback(0), 0), |
| 23 | cancelAnimationFrame: (id: number) => clearTimeout(id), |
| 24 | }); |
| 25 | dom.window.requestAnimationFrame = globalThis.requestAnimationFrame; |
| 26 | dom.window.cancelAnimationFrame = globalThis.cancelAnimationFrame; |
| 27 | |
| 28 | const settings = { |
| 29 | model: "fixture/model", |
| 30 | modelSource: "default", |
| 31 | effort: "high", |
| 32 | mode: "normal", |
| 33 | collaborationMode: "normal", |
| 34 | toolApprovalMode: "ask", |
| 35 | disabledMcp: {}, |
| 36 | mcpOrder: [], |
| 37 | }; |
| 38 | const makeDraft = (id: string, root: string): SessionDraftView => ({ |
| 39 | id, |
| 40 | workspaceId: `workspace-${id}`, |
| 41 | scope: "project", |
| 42 | workspaceRoot: root, |
| 43 | revision: 1, |
| 44 | contentJson: "{}", |
| 45 | settings: structuredClone(settings), |
| 46 | status: "active", |
| 47 | updatedAt: 1, |
| 48 | }); |
| 49 | const drafts = new Map<string, SessionDraftView>([ |
| 50 | ["draft-a", makeDraft("draft-a", "/workspace/a")], |
| 51 | ["draft-b", makeDraft("draft-b", "/workspace/b")], |
| 52 | ["draft-c", makeDraft("draft-c", "/workspace/c")], |
| 53 | ]); |
| 54 | const firstSaveStarted = deferred<void>(); |
| 55 | const releaseFirstSave = deferred<void>(); |
| 56 | const openC = deferred<SessionDraftView>(); |
| 57 | const savePayloads: Array<{ id: string; text: string }> = []; |
| 58 | const submissions: SessionDraftSubmissionRequest[] = []; |
| 59 | let saveCount = 0; |
| 60 | let loseNextSaveAcknowledgement = false; |
| 61 | let submissionPolls = 0; |
| 62 | let operationCount = 0; |
| 63 | let saveDefaultModel: string | undefined; |
| 64 | const contextResponses: Array<Promise<SessionDraftView>> = []; |
| 65 | |
| 66 | const stub = installDesktopHostStub({ |
| 67 | ListSessionDraftSummaries: async () => [], |
| 68 | OpenSessionDraftForTarget: async (_scope: string, root: string) => { |
| 69 | if (root.endsWith("/c")) return openC.promise; |
| 70 | return structuredClone(root.endsWith("/a") ? drafts.get("draft-a") : drafts.get("draft-b")); |
| 71 | }, |
| 72 | GetDraftContext: async (id: string) => ({ draft: await (contextResponses.shift() ?? Promise.resolve(structuredClone(drafts.get(id)!))), commands: [], servers: [] }), |
| 73 | GetSessionDraft: async (id: string) => structuredClone(drafts.get(id)!), |
| 74 | SetSessionDraftRestoreTarget: async () => {}, |
| 75 | DismissSessionDraft: async () => {}, |
| 76 | RestoreSessionDraft: async () => null, |
| 77 | ListTabs: async () => [{ id: "formal" }], |
| 78 | AttachmentDataURLForTarget: async () => "", |
| 79 | SaveSessionDraft: async (request: { draftId: string; revision: number; contentJson: string; settings: typeof settings }) => { |
| 80 | saveCount++; |
| 81 | const current = drafts.get(request.draftId)!; |
| 82 | savePayloads.push({ id: request.draftId, text: JSON.parse(request.contentJson).text ?? "" }); |
| 83 | if (saveCount === 1) { |
| 84 | firstSaveStarted.resolve(); |
| 85 | await releaseFirstSave.promise; |
| 86 | } |
| 87 | if (request.revision !== current.revision) { |
| 88 | return { draft: structuredClone(current), conflict: true, outcome: "conflict" }; |
| 89 | } |
| 90 | const next = { ...current, revision: current.revision + 1, contentJson: request.contentJson, settings: structuredClone(request.settings) }; |
| 91 | if (next.settings.modelSource === "default" && saveDefaultModel) next.settings.model = saveDefaultModel; |
| 92 | drafts.set(request.draftId, next); |
| 93 | if (loseNextSaveAcknowledgement) { |
| 94 | loseNextSaveAcknowledgement = false; |
| 95 | throw new Error("save acknowledgement lost"); |
| 96 | } |
| 97 | return { draft: structuredClone(next), conflict: false, outcome: "saved" }; |
| 98 | }, |
| 99 | BeginDraftSubmission: async (request: SessionDraftSubmissionRequest) => { |
| 100 | submissions.push(structuredClone(request)); |
| 101 | operationCount++; |
| 102 | return { |
| 103 | operationId: `operation-${operationCount}`, |
| 104 | draftId: request.draftId, |
| 105 | phase: "starting", |
| 106 | submissionId: "submission-goal", |
| 107 | session: { hostId: "local", sessionId: "session-goal" }, |
| 108 | updatedAt: 2, |
| 109 | }; |
| 110 | }, |
| 111 | GetDraftSubmission: async () => { |
| 112 | submissionPolls++; |
| 113 | return { |
| 114 | operationId: `operation-${operationCount}`, |
| 115 | draftId: "draft-a", |
| 116 | phase: "accepted", |
| 117 | submissionId: "submission-goal", |
| 118 | session: { hostId: "local", sessionId: "session-goal" }, |
| 119 | updatedAt: 3, |
| 120 | }; |
| 121 | }, |
| 122 | }); |
| 123 | |
| 124 | let owner!: ReturnType<typeof useSessionDraftSurface>; |
| 125 | function Probe() { |
| 126 | owner = useSessionDraftSurface({ onAccepted: () => {}, onChanged: () => {} }); |
| 127 | return null; |
| 128 | } |
| 129 | |
| 130 | const content = (text: string): PersistentComposerDraft => ({ |
| 131 | text, |
| 132 | invocations: [], |
| 133 | attachments: [], |
| 134 | workspaceRefs: [], |
| 135 | pastedBlocks: [], |
| 136 | openPastedLabels: [], |
| 137 | sessionRefs: [], |
| 138 | selectedTextRefs: [], |
| 139 | }); |
| 140 | |
| 141 | const root = createRoot(document.getElementById("root")!); |
| 142 | try { |
| 143 | await act(async () => { root.render(<Probe />); }); |
| 144 | await act(async () => { await owner.open("project", "/workspace/a"); }); |
| 145 | act(() => owner.updateContent(content("version one"))); |
| 146 | let flush!: Promise<SessionDraftView | null>; |
| 147 | act(() => { flush = owner.flush(); }); |
| 148 | await firstSaveStarted.promise; |
| 149 | act(() => owner.updateContent(content("version two"))); |
| 150 | await act(async () => { await owner.open("project", "/workspace/b"); }); |
| 151 | assert.equal(owner.surface?.draft.id, "draft-b", "navigation is independent from A's active save"); |
| 152 | releaseFirstSave.resolve(); |
| 153 | await act(async () => { await flush; }); |
| 154 | assert.deepEqual(savePayloads.slice(0, 2), [ |
| 155 | { id: "draft-a", text: "version one" }, |
| 156 | { id: "draft-a", text: "version two" }, |
| 157 | ], "an edit made during save is persisted by the same draft's next CAS iteration"); |
| 158 | |
| 159 | await act(async () => { await owner.open("project", "/workspace/a"); }); |
| 160 | assert.equal(owner.surface?.content.text, "version two", "returning to A keeps the newest confirmed content"); |
| 161 | |
| 162 | loseNextSaveAcknowledgement = true; |
| 163 | act(() => owner.updateContent(content("acknowledgement recovered"))); |
| 164 | await act(async () => { await owner.flush(); }); |
| 165 | assert.equal(owner.surface?.saveState, "saved", "read-back confirms a save whose acknowledgement was lost"); |
| 166 | assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).text, "acknowledgement recovered"); |
| 167 | |
| 168 | const sourceDraftId = owner.surface!.draft.id; |
| 169 | const sourceGeneration = owner.surface!.generation; |
| 170 | const attachmentFinished = deferred<void>(); |
| 171 | const attachmentTask = attachmentFinished.promise.then(() => owner.updateContentFor(sourceDraftId, sourceGeneration, { |
| 172 | ...content("acknowledgement recovered"), |
| 173 | attachments: [{ path: ".reasonix/attachments/background.txt", name: "background.txt", mime: "text/plain" }], |
| 174 | })); |
| 175 | act(() => { owner.trackTask(sourceDraftId, sourceGeneration, attachmentTask); }); |
| 176 | await act(async () => { await owner.open("project", "/workspace/b"); }); |
| 177 | await act(async () => { |
| 178 | attachmentFinished.resolve(); |
| 179 | await attachmentTask; |
| 180 | }); |
| 181 | await act(async () => { await owner.flushDraft(sourceDraftId); }); |
| 182 | assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).attachments[0]?.path, ".reasonix/attachments/background.txt", |
| 183 | "an attachment completed in the background is persisted to its captured draft"); |
| 184 | |
| 185 | await act(async () => { await owner.open("project", "/workspace/a"); }); |
| 186 | act(() => owner.updateContent(content("local conflict value"))); |
| 187 | const firstRemote = drafts.get("draft-a")!; |
| 188 | drafts.set("draft-a", { ...firstRemote, revision: firstRemote.revision + 1, contentJson: JSON.stringify(content("remote revision two")) }); |
| 189 | await act(async () => { await owner.flush(); }); |
| 190 | assert.equal(owner.surface?.saveState, "conflict"); |
| 191 | const secondRemote = drafts.get("draft-a")!; |
| 192 | drafts.set("draft-a", { ...secondRemote, revision: secondRemote.revision + 1, contentJson: JSON.stringify(content("remote revision three")) }); |
| 193 | await act(async () => { await owner.keepLocalConflict(); }); |
| 194 | assert.equal(owner.surface?.saveState, "conflict", "keep-local uses CAS and cannot overwrite a newer third-party revision"); |
| 195 | assert.equal(JSON.parse(drafts.get("draft-a")!.contentJson).text, "remote revision three"); |
| 196 | act(() => owner.useSavedConflict()); |
| 197 | assert.equal(owner.surface?.content.text, "remote revision three", "the latest conflict snapshot remains available to resolve explicitly"); |
| 198 | |
| 199 | let staleOpen!: Promise<void>; |
| 200 | act(() => { staleOpen = owner.open("project", "/workspace/c"); }); |
| 201 | await act(async () => { await owner.dismiss(); }); |
| 202 | openC.resolve(structuredClone(drafts.get("draft-c")!)); |
| 203 | await act(async () => { await staleOpen; }); |
| 204 | assert.equal(owner.surface, null, "a pending draft open cannot reinstall after formal navigation dismisses it"); |
| 205 | |
| 206 | await act(async () => { await owner.open("project", "/workspace/a"); }); |
| 207 | const inherited = drafts.get("draft-a")!; |
| 208 | drafts.set("draft-a", { |
| 209 | ...inherited, |
| 210 | settings: { ...inherited.settings, model: "fixture/default-next", modelSource: "default" }, |
| 211 | }); |
| 212 | await act(async () => { |
| 213 | window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed")); |
| 214 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 215 | }); |
| 216 | assert.equal(owner.surface?.settings.model, "fixture/default-next", |
| 217 | "an unpinned draft follows the current settings default when the model catalog changes"); |
| 218 | |
| 219 | const olderRefresh = deferred<SessionDraftView>(); |
| 220 | const newerRefresh = deferred<SessionDraftView>(); |
| 221 | // Only A inherits a default in this scenario, so each event issues one read. |
| 222 | await act(async () => { await owner.open("project", "/workspace/b"); }); |
| 223 | act(() => owner.updateSettings({ model: "fixture/pinned-b", modelSource: "explicit" })); |
| 224 | await act(async () => { await owner.flush(); await owner.open("project", "/workspace/a"); }); |
| 225 | contextResponses.push(olderRefresh.promise, newerRefresh.promise); |
| 226 | act(() => { |
| 227 | window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed")); |
| 228 | window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed")); |
| 229 | }); |
| 230 | const refreshed = structuredClone(drafts.get("draft-a")!); |
| 231 | await act(async () => { |
| 232 | newerRefresh.resolve({ ...refreshed, settings: { ...refreshed.settings, model: "fixture/newest" } }); |
| 233 | }); |
| 234 | await act(async () => { |
| 235 | olderRefresh.resolve({ ...refreshed, settings: { ...refreshed.settings, model: "fixture/older" } }); |
| 236 | }); |
| 237 | assert.equal(owner.surface?.settings.model, "fixture/newest", "a late catalog response cannot replace the latest default"); |
| 238 | |
| 239 | const delayedRefresh = deferred<SessionDraftView>(); |
| 240 | contextResponses.push(delayedRefresh.promise); |
| 241 | act(() => window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed"))); |
| 242 | const reopened = { ...refreshed, settings: { ...refreshed.settings, model: "fixture/reopened" } }; |
| 243 | drafts.set("draft-a", reopened); |
| 244 | await act(async () => { await owner.open("project", "/workspace/a"); }); |
| 245 | await act(async () => { delayedRefresh.resolve(refreshed); }); |
| 246 | assert.equal(owner.surface?.settings.model, "fixture/reopened", "a refresh predating reopen cannot overwrite its newer context"); |
| 247 | |
| 248 | saveDefaultModel = "fixture/changed-during-save"; |
| 249 | loseNextSaveAcknowledgement = true; |
| 250 | act(() => owner.updateContent(content("default changed while saving"))); |
| 251 | await act(async () => { await owner.flush(); }); |
| 252 | assert.equal(owner.surface?.saveState, "saved", "live-default resolution must not create a false lost-acknowledgement conflict"); |
| 253 | assert.equal(owner.surface?.settings.model, saveDefaultModel, "save reconciliation publishes the latest effective model"); |
| 254 | |
| 255 | // A default may change after capture but before the required save completes. |
| 256 | act(() => owner.updateContent(content("inherited first turn"))); |
| 257 | saveDefaultModel = "fixture/changed-before-send"; |
| 258 | await act(async () => { await owner.submit("inherited first turn"); }); |
| 259 | assert.equal(submissions.length, 1, "an inherited mirror change cannot block first submission"); |
| 260 | assert.equal(submissions[0]?.settings.modelSource, "default"); |
| 261 | submissions.length = 0; |
| 262 | submissionPolls = 0; |
| 263 | saveDefaultModel = undefined; |
| 264 | await act(async () => { await owner.open("project", "/workspace/a"); }); |
| 265 | act(() => owner.updateSettings({ model: "fixture/pinned", modelSource: "explicit" })); |
| 266 | await act(async () => { |
| 267 | window.dispatchEvent(new dom.window.Event("reasonix:model-catalog-changed")); |
| 268 | await new Promise((resolve) => setTimeout(resolve, 0)); |
| 269 | }); |
| 270 | assert.equal(owner.surface?.settings.model, "fixture/pinned", |
| 271 | "an explicit draft model remains pinned across later catalog refreshes"); |
| 272 | act(() => { |
| 273 | owner.updateSettings({ collaborationMode: "goal", goal: "" }); |
| 274 | owner.updateContent(content("goal text")); |
| 275 | }); |
| 276 | await act(async () => { await owner.submit("goal text", "task bytes"); }); |
| 277 | assert.equal(submissions.length, 1); |
| 278 | assert.equal(submissions[0]?.draftId, "draft-a"); |
| 279 | assert.equal(submissions[0]?.goal, "goal text", "initial Goal has a non-empty objective"); |
| 280 | assert.equal(submissions[0]?.input, "/goal task bytes", "ordinary initial Goal preserves the established provider-visible prefix"); |
| 281 | assert.equal(submissions[0]?.snapshotVersion, 5, "the operation uses the live-default-aware snapshot contract"); |
| 282 | assert.equal(submissions[0]?.settings.model, "fixture/pinned", "the operation carries its frozen model settings"); |
| 283 | assert.equal(submissions[0]?.settings.modelSource, "explicit", "the operation preserves explicit model provenance"); |
| 284 | assert.equal(submissionPolls, 1, "a recoverable operation phase keeps polling the original OperationID"); |
| 285 | |
| 286 | await act(async () => { root.unmount(); }); |
| 287 | console.log("session draft store races: save iteration, navigation fencing and initial Goal passed"); |
| 288 | } finally { |
| 289 | stub.uninstall(); |
| 290 | dom.window.close(); |
| 291 | } |
| 292 |