| 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 { installDesktopHostStub } from "./desktopHostStub"; |
| 6 | import { makeSessionUIMock } from "../lib/sessionUIMock"; |
| 7 | import type { ManualSessionCreationView, ManualSessionCreationRequest } from "../generated/desktopContract.generated"; |
| 8 | |
| 9 | import { useSessionNavigationCommands, type SessionNavigationCommandsInput } from "../app-runtime/useSessionNavigationCommands"; |
| 10 | |
| 11 | function deferred() { |
| 12 | let resolve!: () => void; |
| 13 | const promise = new Promise<void>((done) => { resolve = done; }); |
| 14 | return { promise, resolve }; |
| 15 | } |
| 16 | |
| 17 | const dom = new JSDOM("<div id='root'></div>"); |
| 18 | Object.assign(globalThis, { |
| 19 | window: dom.window, |
| 20 | document: dom.window.document, |
| 21 | IS_REACT_ACT_ENVIRONMENT: true, |
| 22 | }); |
| 23 | |
| 24 | let intent = 0; |
| 25 | let commands!: ReturnType<typeof useSessionNavigationCommands>; |
| 26 | const creationStarted = deferred(); |
| 27 | const finishCreation = deferred(); |
| 28 | let blockCreation = false; |
| 29 | const created: string[] = []; |
| 30 | const legacyCalls: string[] = []; |
| 31 | const begun: number[] = []; |
| 32 | const notices: string[] = []; |
| 33 | const retired = (name: string) => async () => { legacyCalls.push(name); throw new Error("retired draft database is unreadable"); }; |
| 34 | const stub = installDesktopHostStub({ |
| 35 | ...makeSessionUIMock(async (_scope,_root,id) => { |
| 36 | created.push(id); |
| 37 | if (blockCreation) { creationStarted.resolve(); await finishCreation.promise; } |
| 38 | }), |
| 39 | ListSessionDraftSummaries: retired("list"), OpenSessionDraftForTarget: retired("open"), |
| 40 | DismissSessionDraft: retired("dismiss"), SetSessionDraftRestoreTarget: retired("restore"), |
| 41 | }); |
| 42 | const enqueued: Array<{ request: unknown; intent: number }> = []; |
| 43 | |
| 44 | function Probe() { |
| 45 | commands = useSessionNavigationCommands({ |
| 46 | activeTab: { id: "fixture", scope: "project", workspaceRoot: "/workspace" }, |
| 47 | showToast: message => { notices.push(message); }, |
| 48 | closeTransientOverlays: () => {}, |
| 49 | clearImDetail: () => {}, |
| 50 | prepareBlankWorkspace: () => {}, |
| 51 | navigation: { |
| 52 | enqueueNavigation: async () => {}, |
| 53 | enqueueNavigationWithIntent: async (request, navigationIntent) => { |
| 54 | enqueued.push({ request, intent: navigationIntent }); |
| 55 | }, |
| 56 | openRemoteProject: async () => ({ status: "cancelled", reason: "superseded" }), |
| 57 | }, |
| 58 | noteNavigationIntent: () => ++intent, |
| 59 | beginNavigationSurface: seq => { begun.push(seq); }, |
| 60 | settleNavigationSurface: () => {}, |
| 61 | isNavigationIntentCurrent: (candidate) => candidate === intent, |
| 62 | markProjectChanged: () => {}, |
| 63 | refreshTabMetas: async () => {}, |
| 64 | refreshHistoryView: () => {}, |
| 65 | enterConversation: () => {}, |
| 66 | pickWorkspace: async () => "", |
| 67 | switchWorkspace: async () => {}, |
| 68 | ports: { |
| 69 | openTaskSessionForTab: async () => ({ ok: false }), |
| 70 | listSessionsForTab: async () => [], |
| 71 | }, |
| 72 | } as SessionNavigationCommandsInput); |
| 73 | return null; |
| 74 | } |
| 75 | |
| 76 | const root = createRoot(document.getElementById("root")!); |
| 77 | try { |
| 78 | await act(async () => { root.render(<Probe />); }); |
| 79 | await act(async () => { await commands.handleOpenTopic("project", "/workspace", "existing-topic", "session-id:existing-session"); }); |
| 80 | assert.deepEqual(enqueued.at(-1)?.request, { |
| 81 | kind: "canonical-session", ref: { hostId: "local", sessionId: "existing-session" }, |
| 82 | }, "sidebar session keeps its exact local identity on the canonical session path"); |
| 83 | enqueued.length = 0; |
| 84 | intent = 0; |
| 85 | await act(async () => { |
| 86 | await commands.openBlankSession("global", "/ignored/global/root"); |
| 87 | await commands.openBlankSession("project", "/workspace"); |
| 88 | await commands.handleNewTab(); |
| 89 | }); |
| 90 | assert.deepEqual(legacyCalls, [], "manual new never touches retired input, even if its database is unreadable"); |
| 91 | assert.equal(new Set(created).size, 3, "each click has its own formal identity"); |
| 92 | assert.equal(enqueued.length,3); |
| 93 | enqueued.length=0; |
| 94 | blockCreation = true; |
| 95 | |
| 96 | let stale!: Promise<void>; |
| 97 | act(() => { |
| 98 | stale = commands.openBlankSession("project", "/workspace"); |
| 99 | }); |
| 100 | await act(async () => { await creationStarted.promise; }); |
| 101 | await act(async () => { await commands.openCanonicalSession({ hostId: "local", sessionId: "session-b" }); }); |
| 102 | finishCreation.resolve(); |
| 103 | await act(async () => { await stale; }); |
| 104 | assert.equal(enqueued.length, 1); |
| 105 | assert.equal(enqueued[0]?.intent, 5, "late formal creation cannot override newer navigation"); |
| 106 | assert.deepEqual(enqueued[0]?.request, { |
| 107 | kind: "canonical-session", |
| 108 | ref: { hostId: "local", sessionId: "session-b" }, |
| 109 | }); |
| 110 | assert.deepEqual(legacyCalls, [], "navigation never waits for or writes old drafts"); |
| 111 | |
| 112 | enqueued.length = 0; |
| 113 | const pollStarted = deferred(); |
| 114 | let complete!: (view: ManualSessionCreationView) => void; |
| 115 | const pending = new Promise<ManualSessionCreationView>(resolve => { complete = resolve; }); |
| 116 | let starting!: ManualSessionCreationView; |
| 117 | stub.commands.BeginManualSessionCreation = async (request: ManualSessionCreationRequest) => { |
| 118 | starting = { operationId: request.operationId, workspaceId: "canonical", scope: "project", workspaceRoot: "/workspace", |
| 119 | ref: { hostId: "local", sessionId: "independent-new-session" }, topicId: "topic", phase: "starting", surfaceReady: true, |
| 120 | settings: { model: "stub", mode: "normal", toolApprovalMode: "default", disabledMcp: {}, mcpOrder: [] } }; |
| 121 | return starting; |
| 122 | }; |
| 123 | stub.commands.GetManualSessionCreation = async (operationId: string) => { |
| 124 | assert.equal(operationId, starting.operationId, "observation retains the original creation identity"); |
| 125 | pollStarted.resolve(); return pending; |
| 126 | }; |
| 127 | let creating!: Promise<void>; |
| 128 | act(() => { creating = commands.handleNewTab(); }); |
| 129 | assert.equal(begun.at(-1), 6, "new click fences the old composer before the first asynchronous result"); |
| 130 | await act(async () => { await pollStarted.promise; }); |
| 131 | assert.deepEqual(enqueued[0]?.request, { kind: "canonical-session", ref: starting.ref }, "new input surface opens before runtime readiness"); |
| 132 | await act(async () => { await commands.openCanonicalSession({ hostId: "local", sessionId: "session-c" }); }); |
| 133 | complete({ ...starting, phase: "ready" }); |
| 134 | await act(async () => { await creating; }); |
| 135 | assert.equal(enqueued.length, 2, "runtime completion never reselects the new session after the user leaves"); |
| 136 | assert.deepEqual(enqueued.at(-1)?.request, { kind: "canonical-session", ref: { hostId: "local", sessionId: "session-c" } }); |
| 137 | assert.deepEqual(notices, []); |
| 138 | |
| 139 | const failedRequests: string[] = []; |
| 140 | stub.commands.BeginManualSessionCreation = async (request: ManualSessionCreationRequest) => { |
| 141 | failedRequests.push(request.operationId); |
| 142 | throw new Error("transport unavailable"); |
| 143 | }; |
| 144 | stub.commands.GetManualSessionCreation = async () => { throw new Error("still unavailable"); }; |
| 145 | await act(async () => { await commands.handleNewTab(); }); |
| 146 | assert.equal(commands.manualCreation?.failed, true, "unacknowledged creation has an inline retry state"); |
| 147 | assert.equal(commands.manualCreation?.pending, false); |
| 148 | await act(async () => { await commands.retryCreation(); }); |
| 149 | assert.equal(failedRequests.length, 2); |
| 150 | assert.equal(failedRequests[0], failedRequests[1], "retrying before acknowledgement does not allocate a new operation"); |
| 151 | assert.deepEqual(notices, [], "inline failure is not duplicated in a toast"); |
| 152 | |
| 153 | await act(async () => { root.unmount(); }); |
| 154 | console.log("session navigation: retired drafts are untouched; late formal creation preserves the newest selection"); |
| 155 | } finally { |
| 156 | dom.window.close(); |
| 157 | } |
| 158 |