| 1 | // Run: tsx src/__tests__/new-session-load-race.test.tsx |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import React, { act } from "react"; |
| 5 | import { createRoot } from "react-dom/client"; |
| 6 | import { initialState, reducer, runtimeReadyForSubmit, useController, type Item } from "../lib/useController"; |
| 7 | import type { NavigationResult } from "../lib/navigationSurfaceTransition"; |
| 8 | import { historySliceFromMessages } from "./mockHistorySlice"; |
| 9 | import type { AppBindings } from "../lib/bridge"; |
| 10 | import type { BalanceInfo, CheckpointMeta, ContextInfo, EffortInfo, HistoryMessage, HistorySliceRequest, JobView, Meta, TabMeta, WireEvent } from "../lib/types"; |
| 11 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 12 | import { meta, tabMeta } from "./helpers/sessionSwitchFixtures"; |
| 13 | import { resetSessionDiagnostics, sessionPipelineDiagnostics } from "../lib/sessionDiagnostics"; |
| 14 | import { runTodoSessionSwitchScenario } from "../test-support/todoSessionSwitchScenario"; |
| 15 | |
| 16 | let passed = 0, failed = 0; |
| 17 | |
| 18 | function ok(value: boolean, label: string) { |
| 19 | if (value) { |
| 20 | process.stdout.write(` PASS ${label}\n`); |
| 21 | passed += 1; |
| 22 | } else { |
| 23 | process.stdout.write(` FAIL ${label}\n`); |
| 24 | failed += 1; |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | function eq(actual: unknown, expected: unknown, label: string) { |
| 29 | if (actual === expected) { |
| 30 | ok(true, label); |
| 31 | } else { |
| 32 | ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | function flushPromises(): Promise<void> { return new Promise((resolve) => setTimeout(resolve, 0)); } |
| 37 | |
| 38 | function deferred<T>() { |
| 39 | let resolve!: (value: T) => void; |
| 40 | let reject!: (reason?: unknown) => void; |
| 41 | const promise = new Promise<T>((res, rej) => { |
| 42 | resolve = res; |
| 43 | reject = rej; |
| 44 | }); |
| 45 | return { promise, resolve, reject }; |
| 46 | } |
| 47 | |
| 48 | async function waitFor(label: string, predicate: () => boolean) { |
| 49 | for (let attempt = 0; attempt < 20; attempt += 1) { |
| 50 | await act(async () => { |
| 51 | await flushPromises(); |
| 52 | }); |
| 53 | if (predicate()) return; |
| 54 | } |
| 55 | throw new Error(`timed out waiting for ${label}`); |
| 56 | } |
| 57 | |
| 58 | |
| 59 | console.log("\nnew session load race"); |
| 60 | |
| 61 | const resetSourceItems: Item[] = [{ kind: "user", id: "old-user", text: "old prompt" }]; |
| 62 | const resetPlaceholderItems: Item[] = [{ kind: "user", id: "placeholder-user", text: "placeholder prompt" }]; |
| 63 | const resetState = reducer( |
| 64 | { |
| 65 | ...initialState, |
| 66 | items: resetSourceItems, |
| 67 | hydrating: true, |
| 68 | hydrateReason: "open-topic", |
| 69 | hydratePlaceholderItems: resetPlaceholderItems, |
| 70 | }, |
| 71 | { type: "reset" }, |
| 72 | ); |
| 73 | eq(resetState.items.length, 0, "reset clears real transcript items"); |
| 74 | eq(resetState.hydratePlaceholderItems?.length, 1, "reset preserves hydration placeholder separately"); |
| 75 | |
| 76 | const emptyHistoryState = reducer(resetState, { type: "history", messages: [] }); |
| 77 | eq(emptyHistoryState.items.length, 0, "empty history keeps the real transcript empty"); |
| 78 | eq(emptyHistoryState.hydrateHistoryLoaded, true, "empty history marks transcript hydration loaded"); |
| 79 | eq(emptyHistoryState.hydratePlaceholderItems?.length ?? 0, 0, "empty history clears hydration placeholder items"); |
| 80 | |
| 81 | const hydrateDoneState = reducer(emptyHistoryState, { type: "hydrate_done" }); |
| 82 | eq(Boolean(hydrateDoneState.hydrateHistoryLoaded), false, "hydrate_done clears the history-loaded marker"); |
| 83 | |
| 84 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { |
| 85 | pretendToBeVisual: true, |
| 86 | url: "http://localhost/", |
| 87 | }); |
| 88 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 89 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 90 | globalThis.document = dom.window.document; |
| 91 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 92 | globalThis.Node = dom.window.Node; |
| 93 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 94 | globalThis.Event = dom.window.Event; |
| 95 | globalThis.CustomEvent = dom.window.CustomEvent; |
| 96 | globalThis.KeyboardEvent = dom.window.KeyboardEvent; |
| 97 | globalThis.MouseEvent = dom.window.MouseEvent; |
| 98 | globalThis.localStorage = dom.window.localStorage; |
| 99 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window); |
| 100 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window); |
| 101 | |
| 102 | const staleHistory = deferred<HistoryMessage[]>(); |
| 103 | const staleSessionMeta = deferred<Meta>(); |
| 104 | let newSessionCalls = 0; |
| 105 | let backendCanonicalTodos = [{ content: "Old task", status: "in_progress" }]; |
| 106 | let backendHistory: HistoryMessage[] | undefined; |
| 107 | let holdNextMeta = false; |
| 108 | let staleMetaStarted = false; |
| 109 | let backendRuntimeEpoch = "runtime-old"; |
| 110 | let backendPendingPrompt = false; |
| 111 | let promptReplayCalls = 0; |
| 112 | const resumeRPCGate = deferred<void>(); |
| 113 | const channelRPCGate = deferred<void>(); |
| 114 | const context: ContextInfo = { used: 12, window: 100, sessionTokens: 12 }; |
| 115 | const effort: EffortInfo = { supported: true, current: "auto", default: "auto", levels: ["auto"] }; |
| 116 | const balance: BalanceInfo = { available: false, display: "" }; |
| 117 | const jobs: JobView[] = []; |
| 118 | const checkpoints: CheckpointMeta[] = []; |
| 119 | |
| 120 | const appStubTable = { |
| 121 | RegisterNavigationIntent: async () => {}, |
| 122 | ListTabs: async () => { |
| 123 | return [tabMeta({ |
| 124 | runtime: { phase: "ready", epoch: backendRuntimeEpoch }, |
| 125 | running: backendPendingPrompt, |
| 126 | pendingPrompt: backendPendingPrompt, |
| 127 | cancellable: backendPendingPrompt, |
| 128 | })]; |
| 129 | }, |
| 130 | MetaForTab: async () => { |
| 131 | if (holdNextMeta) { |
| 132 | holdNextMeta = false; |
| 133 | staleMetaStarted = true; |
| 134 | return staleSessionMeta.promise; |
| 135 | } |
| 136 | return meta({ canonicalTodos: backendCanonicalTodos, runtime: { phase: "ready", epoch: backendRuntimeEpoch } }); |
| 137 | }, |
| 138 | ContextUsageForTab: async () => context, |
| 139 | EffortForTab: async () => effort, |
| 140 | BalanceForTab: async () => balance, |
| 141 | JobsForTab: async () => jobs, |
| 142 | CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 143 | HistoryForTab: async () => staleHistory.promise, |
| 144 | HistoryPageForTab: async () => { |
| 145 | const messages = await staleHistory.promise; |
| 146 | return { messages, startTurn: 0, endTurn: messages.filter((message) => message.role === "user").length, totalTurns: messages.filter((message) => message.role === "user").length, hasOlder: false }; |
| 147 | }, |
| 148 | HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => |
| 149 | historySliceFromMessages(tabID, backendHistory ?? await staleHistory.promise, req), |
| 150 | HistoryCheckpointTurnsForTab: async () => [], |
| 151 | ReplayPendingPrompts: async () => {}, |
| 152 | ReplayPendingPromptsForTab: async (tabID: string) => { |
| 153 | promptReplayCalls += 1; |
| 154 | if (tabID !== "tab-a" || !backendPendingPrompt) return; |
| 155 | desktopStub.emit("agent:event", { |
| 156 | kind: "ask_request", |
| 157 | tabId: tabID, |
| 158 | runtimeEpoch: backendRuntimeEpoch, |
| 159 | ask: { id: `replayed-${backendRuntimeEpoch}`, questions: [{ id: "choice", prompt: "Recovered after hydration", options: [] }] }, |
| 160 | }); |
| 161 | }, |
| 162 | NewSession: async () => { |
| 163 | newSessionCalls += 1; |
| 164 | backendCanonicalTodos = []; |
| 165 | }, |
| 166 | NewSessionForTab: async (tabID: string) => { |
| 167 | if (tabID !== "tab-a") throw new Error(`unexpected new-session target ${tabID}`); |
| 168 | newSessionCalls += 1; |
| 169 | backendCanonicalTodos = []; |
| 170 | backendHistory = []; |
| 171 | }, |
| 172 | ResumeTranscriptSessionForTab: async () => { |
| 173 | backendHistory = [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }]; |
| 174 | backendCanonicalTodos = [{ content: "Restored task", status: "completed" }]; |
| 175 | const oldEpoch = backendRuntimeEpoch; |
| 176 | backendRuntimeEpoch = "runtime-resumed"; |
| 177 | backendPendingPrompt = true; |
| 178 | desktopStub.emit("runtime:rebuilt", "tab-a", backendRuntimeEpoch); |
| 179 | desktopStub.emit("agent:event", { |
| 180 | kind: "ask_request", |
| 181 | tabId: "tab-a", |
| 182 | runtimeEpoch: oldEpoch, |
| 183 | ask: { id: "stale-old-epoch", questions: [{ id: "choice", prompt: "Stale", options: [] }] }, |
| 184 | }); |
| 185 | desktopStub.emit("agent:event", { |
| 186 | kind: "ask_request", |
| 187 | tabId: "tab-a", |
| 188 | runtimeEpoch: backendRuntimeEpoch, |
| 189 | ask: { id: "pre-response-resume", questions: [{ id: "choice", prompt: "Before Resume RPC returns", options: [] }] }, |
| 190 | }); |
| 191 | await resumeRPCGate.promise; |
| 192 | return { |
| 193 | messages: [{ role: "user", content: "restore" }, { role: "assistant", content: "done" }], |
| 194 | startTurn: 0, |
| 195 | endTurn: 1, |
| 196 | totalTurns: 1, |
| 197 | hasOlder: false, |
| 198 | }; |
| 199 | }, |
| 200 | OpenChannelTranscriptSessionForTab: async () => { |
| 201 | backendHistory = [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }]; |
| 202 | const oldEpoch = backendRuntimeEpoch; |
| 203 | backendRuntimeEpoch = "runtime-channel"; |
| 204 | backendPendingPrompt = true; |
| 205 | desktopStub.emit("runtime:rebuilt", "tab-a", backendRuntimeEpoch); |
| 206 | desktopStub.emit("agent:event", { |
| 207 | kind: "ask_request", |
| 208 | tabId: "tab-a", |
| 209 | runtimeEpoch: oldEpoch, |
| 210 | ask: { id: "stale-channel-old-epoch", questions: [{ id: "choice", prompt: "Stale channel", options: [] }] }, |
| 211 | }); |
| 212 | desktopStub.emit("agent:event", { |
| 213 | kind: "ask_request", |
| 214 | tabId: "tab-a", |
| 215 | runtimeEpoch: backendRuntimeEpoch, |
| 216 | ask: { id: "pre-response-channel", questions: [{ id: "choice", prompt: "Before channel RPC returns", options: [] }] }, |
| 217 | }); |
| 218 | await channelRPCGate.promise; |
| 219 | return { |
| 220 | messages: [{ role: "user", content: "channel" }, { role: "assistant", content: "waiting" }], |
| 221 | startTurn: 0, |
| 222 | endTurn: 1, |
| 223 | totalTurns: 1, |
| 224 | hasOlder: false, |
| 225 | }; |
| 226 | }, |
| 227 | } as Partial<AppBindings> as AppBindings; |
| 228 | const desktopStub = installDesktopHostStub(appStubTable); |
| 229 | |
| 230 | type Controller = ReturnType<typeof useController>; |
| 231 | let controller: Controller | undefined; |
| 232 | |
| 233 | function Probe() { |
| 234 | controller = useController(); |
| 235 | return null; |
| 236 | } |
| 237 | |
| 238 | const rootEl = document.getElementById("root"); |
| 239 | if (!rootEl) throw new Error("missing root"); |
| 240 | const root = createRoot(rootEl); |
| 241 | |
| 242 | await act(async () => { |
| 243 | root.render(<Probe />); |
| 244 | await flushPromises(); |
| 245 | }); |
| 246 | await waitFor("active tab", () => controller?.activeTabId === "tab-a"); |
| 247 | |
| 248 | await act(async () => { |
| 249 | await controller?.refreshMeta(); |
| 250 | await flushPromises(); |
| 251 | }); |
| 252 | eq(controller?.state.meta?.canonicalTodos?.[0]?.content, "Old task", "pre-reset metadata exposes the current session todo"); |
| 253 | |
| 254 | holdNextMeta = true; |
| 255 | await act(async () => { |
| 256 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a" }); |
| 257 | await flushPromises(); |
| 258 | }); |
| 259 | await waitFor("stale metadata request", () => staleMetaStarted); |
| 260 | |
| 261 | await act(async () => { |
| 262 | await controller?.newSession(); |
| 263 | await flushPromises(); |
| 264 | }); |
| 265 | eq(newSessionCalls, 1, "tab-scoped NewSession is called once"); |
| 266 | eq(controller?.state.items.length, 0, "new session clears the visible transcript"); |
| 267 | eq(controller?.state.meta?.canonicalTodos?.length, 0, "new session refresh replaces the previous session todo with an authoritative empty list"); |
| 268 | |
| 269 | await act(async () => { |
| 270 | staleSessionMeta.resolve(meta({ canonicalTodos: [{ content: "Old task", status: "in_progress" }] })); |
| 271 | await staleSessionMeta.promise; |
| 272 | await flushPromises(); |
| 273 | }); |
| 274 | eq(controller?.state.meta?.canonicalTodos?.length, 0, "metadata started before a session transition cannot restore the previous todo"); |
| 275 | |
| 276 | await act(async () => { |
| 277 | staleHistory.resolve([{ role: "user", content: "old prompt" }]); |
| 278 | await staleHistory.promise; |
| 279 | await flushPromises(); |
| 280 | }); |
| 281 | |
| 282 | eq(controller?.state.items.length, 0, "stale history load cannot repopulate a new blank session"); |
| 283 | |
| 284 | let resumeNavigation: NavigationResult<void> | undefined; |
| 285 | let resumeSurfaceSettled = false; |
| 286 | await act(async () => { |
| 287 | resumeNavigation = controller?.resumeSession("/sessions/restored.jsonl", "tab-a"); |
| 288 | void resumeNavigation?.surfaceReady.then(() => { resumeSurfaceSettled = true; }); |
| 289 | await flushPromises(); |
| 290 | }); |
| 291 | eq(resumeSurfaceSettled, false, "Resume releases navigation acquisition before target history settles"); |
| 292 | eq(controller?.state.ask?.id, undefined, "Resume waits for a consistent Follow snapshot before presenting prompts"); |
| 293 | await act(async () => { |
| 294 | resumeRPCGate.resolve(); |
| 295 | await resumeNavigation?.surfaceReady; |
| 296 | await flushPromises(); |
| 297 | }); |
| 298 | eq(resumeSurfaceSettled, true, "Resume surfaceReady resolves after authoritative history and reconciliation"); |
| 299 | eq(controller?.state.meta?.canonicalTodos?.[0]?.status, "completed", "resuming a session refreshes its authoritative canonical todo state"); |
| 300 | eq(controller?.state.ask?.id, "replayed-runtime-resumed", "Resume RPC ask emitted before return is restored after reset/history hydration"); |
| 301 | ok(promptReplayCalls > 0, "Resume completion performs a tab-scoped pending-prompt replay"); |
| 302 | |
| 303 | backendPendingPrompt = false; |
| 304 | await act(async () => { |
| 305 | desktopStub.emit("agent:event", { kind: "turn_done", tabId: "tab-a", runtimeEpoch: backendRuntimeEpoch }); |
| 306 | await flushPromises(); |
| 307 | }); |
| 308 | const replayCallsBeforeChannelOpen = promptReplayCalls; |
| 309 | let channelNavigation: NavigationResult<void> | undefined; |
| 310 | await act(async () => { |
| 311 | channelNavigation = controller?.openChannelSession("/sessions/channel.jsonl", "tab-a"); |
| 312 | await flushPromises(); |
| 313 | }); |
| 314 | eq(controller?.state.ask?.id, undefined, "channel-open waits for a consistent Follow snapshot before presenting prompts"); |
| 315 | await act(async () => { |
| 316 | channelRPCGate.resolve(); |
| 317 | await channelNavigation?.surfaceReady; |
| 318 | await flushPromises(); |
| 319 | }); |
| 320 | eq(controller?.state.ask?.id, "replayed-runtime-channel", "channel-open ask emitted before return is restored after reset/history hydration"); |
| 321 | ok(promptReplayCalls > replayCallsBeforeChannelOpen, "channel-open completion performs a tab-scoped pending-prompt replay"); |
| 322 | |
| 323 | await act(async () => { |
| 324 | root.unmount(); |
| 325 | }); |
| 326 | |
| 327 | // Reusing a blank tab must invalidate the old hydration request. The backend |
| 328 | // may return the same tab id, so the request sequence (not the tab id) is the |
| 329 | // session boundary that prevents orphaned tool cards from coming back. |
| 330 | const reusedOldHistory = deferred<{ |
| 331 | messages: HistoryMessage[]; |
| 332 | startTurn: number; |
| 333 | endTurn: number; |
| 334 | totalTurns: number; |
| 335 | hasOlder: boolean; |
| 336 | }>(); |
| 337 | const reusedHistoryCalls: string[] = []; |
| 338 | const reusedTab = tabMeta({ id: "tab-reused", sessionPath: "/sessions/old.jsonl" }); |
| 339 | const reusedTabPage = { |
| 340 | messages: [ |
| 341 | { role: "assistant", content: "", toolCalls: [{ id: "old-call", name: "bash", arguments: "pwd" }] }, |
| 342 | { role: "tool", toolCallId: "old-call", toolName: "bash", content: "/old" }, |
| 343 | ] as HistoryMessage[], |
| 344 | startTurn: 0, |
| 345 | endTurn: 0, |
| 346 | totalTurns: 0, |
| 347 | hasOlder: false, |
| 348 | }; |
| 349 | const reusedEmptyPage = { messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }; |
| 350 | desktopStub.replaceCommands({ |
| 351 | RegisterNavigationIntent: async () => {}, |
| 352 | ListTabs: async () => [reusedTab], |
| 353 | MetaForTab: async () => meta({ sessionPath: "/sessions/new.jsonl" }), |
| 354 | ContextUsageForTab: async () => context, |
| 355 | EffortForTab: async () => effort, |
| 356 | BalanceForTab: async () => balance, |
| 357 | JobsForTab: async () => jobs, |
| 358 | CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 359 | HistoryPageForTab: async () => { |
| 360 | reusedHistoryCalls.push("history"); |
| 361 | return reusedHistoryCalls.length === 1 ? reusedOldHistory.promise : reusedEmptyPage; |
| 362 | }, |
| 363 | HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => { |
| 364 | reusedHistoryCalls.push("history"); |
| 365 | const page = reusedHistoryCalls.length === 1 ? await reusedOldHistory.promise : reusedEmptyPage; |
| 366 | return historySliceFromMessages(tabID, page.messages, req); |
| 367 | }, |
| 368 | HistoryCheckpointTurnsForTab: async () => [], |
| 369 | ReplayPendingPrompts: async () => {}, |
| 370 | EnsureBlankTab: async () => ({ ...reusedTab, sessionPath: "/sessions/new.jsonl", active: true }), |
| 371 | } as Partial<AppBindings> as AppBindings); |
| 372 | |
| 373 | controller = undefined; |
| 374 | const reuseRoot = createRoot(rootEl); |
| 375 | await act(async () => { |
| 376 | reuseRoot.render(<Probe />); |
| 377 | await flushPromises(); |
| 378 | }); |
| 379 | await waitFor("reused tab startup history", () => reusedHistoryCalls.length === 1); |
| 380 | |
| 381 | await act(async () => { |
| 382 | await controller?.ensureBlankTab("project", "/repo"); |
| 383 | await flushPromises(); |
| 384 | }); |
| 385 | eq(reusedHistoryCalls.length, 2, "reusing a blank tab forces a fresh history request"); |
| 386 | eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "fresh blank-tab hydration has no old tool card"); |
| 387 | |
| 388 | await act(async () => { |
| 389 | reusedOldHistory.resolve(reusedTabPage); |
| 390 | await reusedOldHistory.promise; |
| 391 | await flushPromises(); |
| 392 | }); |
| 393 | eq(controller?.state.items.some((item) => item.kind === "tool" && item.id === "old-call"), false, "late old-session history cannot restore an orphaned tool card"); |
| 394 | |
| 395 | await act(async () => { |
| 396 | reuseRoot.unmount(); |
| 397 | }); |
| 398 | |
| 399 | // A tab-bar click can overtake EnsureBlankTab while its backend call is still |
| 400 | // in flight. Its intent must invalidate the older completion immediately, and |
| 401 | // the stale backend activation must be repaired after it eventually returns. |
| 402 | const queuedBlank = deferred<TabMeta>(); |
| 403 | const raceTabA = tabMeta({ id: "race-a", active: true, sessionPath: "/sessions/race-a.jsonl" }); |
| 404 | const raceTabB = tabMeta({ id: "race-b", active: false, sessionPath: "/sessions/race-b.jsonl" }); |
| 405 | const raceBlank = tabMeta({ id: "race-blank", active: false, sessionPath: "/sessions/race-blank.jsonl" }); |
| 406 | let raceBackendActiveId = raceTabA.id; |
| 407 | const raceHistoryCalls: string[] = []; |
| 408 | const raceSetActiveCalls: string[] = []; |
| 409 | desktopStub.replaceCommands({ |
| 410 | RegisterNavigationIntent: async () => {}, |
| 411 | ListTabs: async () => [raceTabA, raceTabB, raceBlank].map((tab) => ({ ...tab, active: tab.id === raceBackendActiveId })), |
| 412 | MetaForTab: async (tabID: string) => meta({ sessionPath: `/sessions/${tabID}.jsonl` }), |
| 413 | ContextUsageForTab: async () => context, |
| 414 | EffortForTab: async () => effort, |
| 415 | BalanceForTab: async () => balance, |
| 416 | JobsForTab: async () => jobs, |
| 417 | CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 418 | HistoryPageForTab: async (tabID: string) => { |
| 419 | raceHistoryCalls.push(tabID); |
| 420 | return reusedEmptyPage; |
| 421 | }, |
| 422 | HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => { |
| 423 | raceHistoryCalls.push(tabID); |
| 424 | return historySliceFromMessages(tabID, reusedEmptyPage.messages, req); |
| 425 | }, |
| 426 | HistoryCheckpointTurnsForTab: async () => [], |
| 427 | ReplayPendingPrompts: async () => {}, |
| 428 | EnsureBlankTab: async () => { |
| 429 | const tab = await queuedBlank.promise; |
| 430 | raceBackendActiveId = tab.id; |
| 431 | return tab; |
| 432 | }, |
| 433 | SetActiveTab: async (tabID: string) => { |
| 434 | raceSetActiveCalls.push(tabID); |
| 435 | raceBackendActiveId = tabID; |
| 436 | }, |
| 437 | } as Partial<AppBindings> as AppBindings); |
| 438 | |
| 439 | controller = undefined; |
| 440 | const queuedRaceRoot = createRoot(rootEl); |
| 441 | await act(async () => { |
| 442 | queuedRaceRoot.render(<Probe />); |
| 443 | await flushPromises(); |
| 444 | }); |
| 445 | await waitFor("queued blank race startup", () => controller?.activeTabId === raceTabA.id); |
| 446 | |
| 447 | let pendingBlank: Promise<TabMeta> | undefined; |
| 448 | await act(async () => { |
| 449 | pendingBlank = controller?.ensureBlankTab("project", "/repo"); |
| 450 | await flushPromises(); |
| 451 | }); |
| 452 | const tabClickIntent = controller?.noteNavigationIntent(); |
| 453 | if (tabClickIntent === undefined) throw new Error("missing queued tab intent"); |
| 454 | |
| 455 | let pendingTabSwitch: Promise<TabMeta[] | undefined> | undefined; |
| 456 | await act(async () => { |
| 457 | pendingTabSwitch = controller?.switchTab(raceTabB.id, raceTabB, tabClickIntent); |
| 458 | await pendingTabSwitch; |
| 459 | await flushPromises(); |
| 460 | }); |
| 461 | eq(controller?.activeTabId, raceTabB.id, "queued tab click becomes visible before the older blank completion"); |
| 462 | eq(raceBackendActiveId, raceTabB.id, "queued tab click becomes backend-active before the older blank completion"); |
| 463 | |
| 464 | await act(async () => { |
| 465 | queuedBlank.resolve({ ...raceBlank, active: true }); |
| 466 | await pendingBlank; |
| 467 | await flushPromises(); |
| 468 | }); |
| 469 | eq(controller?.activeTabId, raceTabB.id, "late blank completion cannot replace the newer visible tab"); |
| 470 | eq(raceHistoryCalls.includes(raceBlank.id), false, "stale blank completion does not hydrate the abandoned tab"); |
| 471 | eq(raceBackendActiveId, raceTabB.id, "late blank completion reasserts the newer backend-active tab"); |
| 472 | eq(raceSetActiveCalls.join(","), `${raceTabB.id},${raceTabB.id}`, "stale blank completion repairs backend focus exactly once"); |
| 473 | |
| 474 | await act(async () => { |
| 475 | queuedRaceRoot.unmount(); |
| 476 | }); |
| 477 | |
| 478 | const guardedStartupTabs = deferred<TabMeta[]>(); |
| 479 | const staleProjectA = "/repo/project-a"; |
| 480 | const targetProjectB = "/repo/project-b"; |
| 481 | const ensureBlankSurfaceCalls: Array<{ scope: string; workspaceRoot: string }> = []; |
| 482 | desktopStub.replaceCommands({ |
| 483 | RegisterNavigationIntent: async () => {}, |
| 484 | ListTabs: async () => guardedStartupTabs.promise, |
| 485 | MetaForTab: async (tabID: string) => tabID === "tab-new" |
| 486 | ? meta({ cwd: targetProjectB, workspaceRoot: targetProjectB, workspaceName: "project-b", workspacePath: targetProjectB }) |
| 487 | : meta({ cwd: staleProjectA, workspaceRoot: staleProjectA, workspaceName: "project-a", workspacePath: staleProjectA }), |
| 488 | ContextUsageForTab: async () => context, |
| 489 | EffortForTab: async () => effort, |
| 490 | BalanceForTab: async () => balance, |
| 491 | JobsForTab: async () => jobs, |
| 492 | CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 493 | HistoryForTab: async () => [], |
| 494 | HistoryPageForTab: async () => ({ messages: [], startTurn: 0, endTurn: 0, totalTurns: 0, hasOlder: false }), |
| 495 | HistoryCheckpointTurnsForTab: async () => [], |
| 496 | ReplayPendingPrompts: async () => {}, |
| 497 | EnsureBlankSurface: async (scope: string, workspaceRoot: string) => { |
| 498 | ensureBlankSurfaceCalls.push({ scope, workspaceRoot }); |
| 499 | return tabMeta({ |
| 500 | id: "tab-new", |
| 501 | topicId: "topic-new", |
| 502 | topicTitle: "New session", |
| 503 | workspaceRoot: targetProjectB, |
| 504 | workspaceName: "project-b", |
| 505 | workspacePath: targetProjectB, |
| 506 | cwd: targetProjectB, |
| 507 | }); |
| 508 | }, |
| 509 | } as Partial<AppBindings> as AppBindings); |
| 510 | |
| 511 | controller = undefined; |
| 512 | const guardRoot = createRoot(rootEl); |
| 513 | |
| 514 | await act(async () => { |
| 515 | guardRoot.render(<Probe />); |
| 516 | await flushPromises(); |
| 517 | }); |
| 518 | |
| 519 | await act(async () => { |
| 520 | await controller?.ensureBlankSurface("project", targetProjectB); |
| 521 | await flushPromises(); |
| 522 | }); |
| 523 | |
| 524 | eq(ensureBlankSurfaceCalls.length, 1, "EnsureBlankSurface is called once"); |
| 525 | eq(ensureBlankSurfaceCalls[0]?.workspaceRoot, targetProjectB, "EnsureBlankSurface keeps the requested project root"); |
| 526 | eq(controller?.activeTabId, "tab-new", "blank surface becomes active before startup sync resolves"); |
| 527 | eq(controller?.state.meta?.workspaceRoot, targetProjectB, "blank surface exposes the new project root"); |
| 528 | |
| 529 | await act(async () => { |
| 530 | guardedStartupTabs.resolve([tabMeta({ |
| 531 | id: "tab-old", |
| 532 | topicId: "topic-old", |
| 533 | topicTitle: "Old session", |
| 534 | workspaceRoot: staleProjectA, |
| 535 | workspaceName: "project-a", |
| 536 | workspacePath: staleProjectA, |
| 537 | cwd: staleProjectA, |
| 538 | })]); |
| 539 | await guardedStartupTabs.promise; |
| 540 | await flushPromises(); |
| 541 | }); |
| 542 | |
| 543 | eq(controller?.activeTabId, "tab-new", "guarded startup sync cannot restore an older active tab"); |
| 544 | eq(controller?.state.meta?.workspaceRoot, targetProjectB, "guarded startup sync cannot restore the old project root"); |
| 545 | |
| 546 | await act(async () => { |
| 547 | guardRoot.unmount(); |
| 548 | }); |
| 549 | |
| 550 | // Exercise the production hook's modern entry points: none may install an |
| 551 | // independently fetched legacy prefix or leave the new runtime paused forever. |
| 552 | let modernEpoch = "modern-start"; |
| 553 | let modernPath = "/sessions/modern-start.jsonl"; |
| 554 | let modernSnapshots = 0; |
| 555 | let legacyReads = 0; |
| 556 | let modernAdoptions = 0; |
| 557 | const slowModernGate = deferred<void>(); |
| 558 | const modernPhases = { resolveMs: 1, loadMs: 2, rebindMs: 3, historyMs: 0, totalMs: 6, loadedMessages: 2, loadedBytes: 100, historyEntries: 0, durableReads: 1, outcome: "ok" }; |
| 559 | const modernReplace = (name: string) => { |
| 560 | modernEpoch = name; |
| 561 | modernPath = `/sessions/${name}.jsonl`; |
| 562 | desktopStub.emit("runtime:rebuilt", "tab-a", modernEpoch); |
| 563 | }; |
| 564 | const legacyRead = async () => { legacyReads++; throw new Error("modern hydration read legacy history"); }; |
| 565 | desktopStub.replaceCommands({ |
| 566 | RegisterNavigationIntent: async () => {}, |
| 567 | ListTabs: async () => [tabMeta({ runtime: { phase: "ready", epoch: modernEpoch } })], |
| 568 | MetaForTab: async () => meta({ sessionPath: modernPath, runtime: { phase: "ready", epoch: modernEpoch } }), |
| 569 | ContextUsageForTab: async () => context, |
| 570 | EffortForTab: async () => effort, |
| 571 | BalanceForTab: async () => balance, |
| 572 | JobsForTab: async () => jobs, |
| 573 | CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 574 | HistoryCheckpointTurnsForTab: async () => [], |
| 575 | HistoryForTab: legacyRead, HistoryPageForTab: legacyRead, HistorySliceForTab: legacyRead, |
| 576 | ResumeSessionPageForTab: legacyRead, OpenChannelSessionPageForTab: legacyRead, |
| 577 | ReplayPendingPrompts: async () => {}, |
| 578 | ReplayPendingPromptsForTab: async () => {}, |
| 579 | SessionHistoryWindowForTab: async () => { |
| 580 | modernSnapshots++; |
| 581 | return { status: "ready", snapshotSequence: 0, coverageSequence: 0, generation: modernEpoch, messages: [], totalTurns: 0, hasOlder: false, hasNewer: false }; |
| 582 | }, |
| 583 | NewSessionForTab: async () => modernReplace("modern-new"), |
| 584 | ClearSessionForTab: async () => { modernReplace("modern-clear"); return { sessionPath: modernPath, sessionGeneration: 2 }; }, |
| 585 | ResumeTranscriptSessionForTab: async (_tab: string, path: string) => { |
| 586 | modernAdoptions++; modernReplace(path.includes("slow") ? "modern-slow" : "modern-resume"); |
| 587 | if (path.includes("slow")) await slowModernGate.promise; |
| 588 | return { ...modernPhases, totalMs: path.includes("slow") ? 500 : modernPhases.totalMs }; |
| 589 | }, |
| 590 | OpenChannelTranscriptSessionForTab: async () => { modernAdoptions++; modernReplace("modern-channel"); return modernPhases; }, |
| 591 | } as Partial<AppBindings>); |
| 592 | controller = undefined; |
| 593 | const modernRoot = createRoot(rootEl); |
| 594 | await act(async () => { modernRoot.render(<Probe />); await flushPromises(); }); |
| 595 | await waitFor("modern startup snapshot", () => controller?.state.transcriptProtocol === 2); |
| 596 | const verifyModernSuffix = async (label: string) => { |
| 597 | await act(async () => { |
| 598 | desktopStub.emit("agent:event", { kind: "user_message", tabId: "tab-a", runtimeEpoch: modernEpoch, |
| 599 | sessionId: modernEpoch, seq: 1, messageId: `${modernEpoch}-user`, text: label }); |
| 600 | desktopStub.emit("agent:event", { kind: "text", tabId: "tab-a", runtimeEpoch: modernEpoch, |
| 601 | sessionId: modernEpoch, seq: 2, messageId: `${modernEpoch}-assistant`, text: "suffix" }); |
| 602 | await flushPromises(); |
| 603 | }); |
| 604 | eq(controller?.state.items.filter(item => item.kind === "user").length + (controller?.state.localSubmissionOrder.length ?? 0), 0, `${label} backend identity event cannot fabricate a durable row or local echo`); |
| 605 | eq(controller?.state.live?.text, "suffix", `${label} accepts the ordered live suffix`); |
| 606 | }; |
| 607 | await verifyModernSuffix("startup"); |
| 608 | await act(async () => { await controller?.newSession(); await flushPromises(); }); |
| 609 | eq(controller?.state.items.length, 0, "modern new session installs its empty cut"); |
| 610 | await verifyModernSuffix("new"); |
| 611 | await act(async () => { await controller?.clearSession(); await flushPromises(); }); |
| 612 | eq(controller?.state.items.length, 0, "modern clear installs its empty cut"); |
| 613 | await verifyModernSuffix("clear"); |
| 614 | await act(async () => { await controller?.resumeSession("/sessions/modern-resume.jsonl", "tab-a")?.surfaceReady; await flushPromises(); }); |
| 615 | eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern resume records snapshot installation"); |
| 616 | eq(sessionPipelineDiagnostics().duplicateLoadCount, 0, "modern resume receives backend load evidence"); |
| 617 | ok(typeof sessionPipelineDiagnostics().resumeSnapshotMs === "number", "modern resume measures snapshot time separately"); |
| 618 | await verifyModernSuffix("resume"); |
| 619 | await act(async () => { await controller?.openChannelSession("/sessions/modern-channel.jsonl", "tab-a")?.surfaceReady; await flushPromises(); }); |
| 620 | eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern channel records snapshot installation"); |
| 621 | await verifyModernSuffix("channel"); |
| 622 | eq(modernAdoptions, 2, "resume and channel use adoption without a legacy history payload"); |
| 623 | eq(modernSnapshots, 5, "each modern entry point obtains one authoritative cut"); |
| 624 | eq(legacyReads, 0, "modern entry points never read legacy history"); |
| 625 | let staleModern: NavigationResult<void> | undefined; |
| 626 | await act(async () => { |
| 627 | staleModern = controller?.resumeSession("/sessions/slow.jsonl", "tab-a"); |
| 628 | await flushPromises(); |
| 629 | }); |
| 630 | eq(sessionPipelineDiagnostics().duplicateLoadCount, null, "pending switch does not reuse previous evidence"); |
| 631 | await act(async () => { await controller?.resumeSession("/sessions/fast.jsonl", "tab-a")?.surfaceReady; await flushPromises(); }); |
| 632 | await act(async () => { slowModernGate.resolve(); await staleModern?.surfaceReady; await flushPromises(); }); |
| 633 | eq(sessionPipelineDiagnostics().resumeSwitch?.totalMs, modernPhases.totalMs, "stale modern adoption cannot overwrite committed diagnostics"); |
| 634 | eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "modern race retains authoritative snapshot evidence"); |
| 635 | |
| 636 | desktopStub.commands.TranscriptFollowForTab = async () => { throw new Error("configured model is unavailable before controller startup"); }; |
| 637 | desktopStub.commands.HistorySliceForTab = async (tabID: string, req: HistorySliceRequest) => { legacyReads++; return historySliceFromMessages(tabID, [{ role: "user", content: "recovered without controller" }], req); }; |
| 638 | await act(async () => { |
| 639 | await controller?.retrySessionHistory("tab-a"); |
| 640 | await flushPromises(); |
| 641 | }); |
| 642 | ok(!(controller?.state.items.some((item) => item.kind === "user" && item.text === "recovered without controller") ?? false), |
| 643 | "failed Follow never falls back to a different protocol"); |
| 644 | eq(legacyReads, 0, "snapshot failure performs no compatibility history read"); |
| 645 | ok(Boolean(controller?.state.hydrateError), "failed synchronization remains visible"); |
| 646 | await act(async () => { modernRoot.unmount(); }); |
| 647 | // ── session switch: one history commit, composer bound to the new runtime ──── |
| 648 | // The switch shows the restored transcript as soon as its page lands, but the |
| 649 | // tab is only submittable once the runtime reconcile confirms which session the |
| 650 | // controller now owns. A superseded switch must not paint over the newer one. |
| 651 | const switchMetaGate = deferred<Meta>(); |
| 652 | const slowSwitchGate = deferred<void>(); |
| 653 | let switchMetaHeld = false; |
| 654 | let switchMetaPath = "/sessions/one.jsonl"; |
| 655 | let switchResumeCalls = 0; |
| 656 | let switchHistoryPageCalls = 0; |
| 657 | const switchTab = tabMeta({ id: "tab-switch", sessionPath: "/sessions/one.jsonl" }); |
| 658 | const switchPage = (text: string, durableReads = 1) => ({ |
| 659 | messages: [{ role: "user", content: text } as HistoryMessage], |
| 660 | startTurn: 0, |
| 661 | endTurn: 1, |
| 662 | totalTurns: 1, |
| 663 | hasOlder: false, |
| 664 | switch: { |
| 665 | resolveMs: 0, loadMs: 1, rebindMs: 2, historyMs: 1, totalMs: 4, |
| 666 | loadedMessages: 1, loadedBytes: 64, historyEntries: 1, durableReads, outcome: "ok", |
| 667 | }, |
| 668 | }); |
| 669 | desktopStub.replaceCommands({ |
| 670 | RegisterNavigationIntent: async () => {}, |
| 671 | ListTabs: async () => [switchTab], |
| 672 | SessionOpenForTab: undefined, // Exercise the older host's resume-page contract. |
| 673 | MetaForTab: async () => { |
| 674 | if (switchMetaHeld) return switchMetaGate.promise; |
| 675 | return meta({ sessionPath: switchMetaPath }); |
| 676 | }, |
| 677 | ContextUsageForTab: async () => context, |
| 678 | EffortForTab: async () => effort, |
| 679 | BalanceForTab: async () => balance, |
| 680 | JobsForTab: async () => jobs, |
| 681 | CheckpointsForTab: async () => checkpoints, ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 682 | HistoryPageForTab: async () => { |
| 683 | switchHistoryPageCalls += 1; |
| 684 | return switchPage("full-history-refetch"); |
| 685 | }, |
| 686 | HistorySliceForTab: async (tabID: string, req: HistorySliceRequest) => historySliceFromMessages(tabID, switchMetaPath ? [{ role: "user", content: switchMetaPath }] : [], req), |
| 687 | HistoryCheckpointTurnsForTab: async () => [], |
| 688 | ReplayPendingPrompts: async () => {}, |
| 689 | ReplayPendingPromptsForTab: async () => {}, |
| 690 | ResumeTranscriptSessionForTab: async (_tabID: string, path: string) => { |
| 691 | switchResumeCalls += 1; |
| 692 | if (path.includes("slow")) await slowSwitchGate.promise; |
| 693 | switchMetaPath = path; |
| 694 | const page = switchPage(path); |
| 695 | page.switch.totalMs = path.includes("slow") ? 500 : 4; |
| 696 | return page.switch; |
| 697 | }, |
| 698 | }); |
| 699 | |
| 700 | resetSessionDiagnostics(); |
| 701 | const switchRoot = createRoot(document.createElement("div")); |
| 702 | await act(async () => { |
| 703 | switchRoot.render(<Probe />); |
| 704 | await flushPromises(); |
| 705 | }); |
| 706 | await waitFor("switch tab active", () => controller?.activeTabId === "tab-switch"); |
| 707 | |
| 708 | switchMetaHeld = true; |
| 709 | let switchNav: NavigationResult<void> | undefined; |
| 710 | await act(async () => { |
| 711 | switchNav = controller?.resumeSession("/sessions/two.jsonl", "tab-switch"); |
| 712 | await flushPromises(); |
| 713 | }); |
| 714 | await waitFor("switched transcript", () => (controller?.state.items.length ?? 0) > 0); |
| 715 | eq(controller?.state.items[0]?.text, "/sessions/two.jsonl", "switch commits the restored transcript before ancillary work finishes"); |
| 716 | eq(runtimeReadyForSubmit(controller?.state.meta), false, "composer stays disabled until the switched runtime is reconciled"); |
| 717 | |
| 718 | await act(async () => { |
| 719 | switchMetaHeld = false; |
| 720 | switchMetaPath = "/sessions/two.jsonl"; |
| 721 | switchMetaGate.resolve(meta({ sessionPath: switchMetaPath })); |
| 722 | await switchNav?.surfaceReady; |
| 723 | await flushPromises(); |
| 724 | }); |
| 725 | eq(runtimeReadyForSubmit(controller?.state.meta), true, "composer re-enables once the switched runtime is reconciled"); |
| 726 | eq(switchResumeCalls, 1, "a switch issues exactly one resume page request"); |
| 727 | eq(switchHistoryPageCalls, 0, "a switch does not refetch the full history page from the frontend"); |
| 728 | eq(sessionPipelineDiagnostics().duplicateLoadCount, 0, "switch reports no duplicate durable load"); |
| 729 | eq(sessionPipelineDiagnostics().resumeHistory?.source, "transcript-v2", "the switch's first screen is attributed to Follow"); |
| 730 | |
| 731 | let slowNav: NavigationResult<void> | undefined; |
| 732 | let fastNav: NavigationResult<void> | undefined; |
| 733 | await act(async () => { |
| 734 | slowNav = controller?.resumeSession("/sessions/slow.jsonl", "tab-switch"); |
| 735 | await flushPromises(); |
| 736 | }); |
| 737 | await act(async () => { |
| 738 | fastNav = controller?.resumeSession("/sessions/fast.jsonl", "tab-switch"); |
| 739 | await fastNav?.surfaceReady; |
| 740 | await flushPromises(); |
| 741 | }); |
| 742 | await act(async () => { |
| 743 | slowSwitchGate.resolve(); |
| 744 | await slowNav?.surfaceReady; |
| 745 | await flushPromises(); |
| 746 | }); |
| 747 | eq(controller?.state.items[0]?.text, "/sessions/fast.jsonl", "a superseded switch cannot paint over the newer transcript"); |
| 748 | eq(sessionPipelineDiagnostics().resumeSwitch?.totalMs, 4, "superseded response cannot overwrite current switch diagnostics"); |
| 749 | |
| 750 | await runTodoSessionSwitchScenario({ controller: () => controller, desktopStub, currentSessionPath: () => switchMetaPath, meta, equal: eq }); |
| 751 | |
| 752 | await act(async () => { |
| 753 | switchRoot.unmount(); |
| 754 | }); |
| 755 | // Navigation admission: an old MetaForTab completion races the new ready=false. |
| 756 | { |
| 757 | const oldMeta = deferred<Meta>(); |
| 758 | const navigationGate = deferred<void>(); |
| 759 | let holdMeta = false; |
| 760 | let holdNavigation = false; |
| 761 | let path = "/sessions/source.jsonl"; |
| 762 | const submissions: string[] = []; |
| 763 | desktopStub.replaceCommands({ |
| 764 | RegisterNavigationIntent: async () => { if (holdNavigation) await navigationGate.promise; }, |
| 765 | ListTabs: async () => [tabMeta({ id: "meta-race", sessionPath: path })], |
| 766 | SessionOpenForTab: undefined, |
| 767 | MetaForTab: async () => holdMeta ? oldMeta.promise : meta({ sessionPath: path }), |
| 768 | ContextUsageForTab: async () => context, EffortForTab: async () => effort, |
| 769 | BalanceForTab: async () => balance, JobsForTab: async () => jobs, |
| 770 | CheckpointsForTab: async () => checkpoints, HistoryCheckpointTurnsForTab: async () => [], ForkTargetsForTab: async () => ({ targets: [], verifiable: false }), |
| 771 | HistorySliceForTab: async (id: string, req: HistorySliceRequest) => historySliceFromMessages(id, [], req), |
| 772 | ReplayPendingPrompts: async () => {}, ReplayPendingPromptsForTab: async () => {}, |
| 773 | ResumeTranscriptSessionForTab: async (_id: string, target: string) => { path = target; return switchPage(target).switch; }, |
| 774 | StartTurnForTab: async () => { submissions.push(path); return { turnId: "wrong-source" }; }, |
| 775 | }); |
| 776 | const raceRoot = createRoot(document.createElement("div")); |
| 777 | await act(async () => { raceRoot.render(<Probe />); await flushPromises(); }); |
| 778 | await waitFor("meta race active", () => controller?.activeTabId === "meta-race" && runtimeReadyForSubmit(controller?.state.meta)); |
| 779 | holdMeta = true; |
| 780 | let oldRefresh: Promise<void> | undefined; |
| 781 | await act(async () => { oldRefresh = controller?.refreshMeta(); await flushPromises(); }); |
| 782 | holdNavigation = true; |
| 783 | let navigation: NavigationResult<void> | undefined; |
| 784 | await act(async () => { navigation = controller?.resumeSession("/sessions/target.jsonl", "meta-race"); await flushPromises(); }); |
| 785 | eq(runtimeReadyForSubmit(controller?.state.meta), false, "switch initially closes admission"); |
| 786 | await act(async () => { |
| 787 | holdMeta = false; |
| 788 | oldMeta.resolve(meta({ sessionPath: "/sessions/source.jsonl" })); |
| 789 | await oldRefresh; |
| 790 | await flushPromises(); |
| 791 | }); |
| 792 | eq(runtimeReadyForSubmit(controller?.state.meta), false, "old metadata must not reopen admission during navigation registration"); |
| 793 | await act(async () => { await controller?.sendToTab("meta-race", "raced submission").catch(() => {}); await flushPromises(); }); |
| 794 | eq(submissions.length, 0, "pending switch must not send into its source controller"); |
| 795 | await act(async () => { holdNavigation = false; navigationGate.resolve(); await navigation?.surfaceReady; await flushPromises(); raceRoot.unmount(); }); |
| 796 | } |
| 797 | dom.window.close(); |
| 798 | |
| 799 | console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`); |
| 800 | if (failed > 0) process.exit(1); |
| 801 |