| 1 | // Switching back to a remote session whose transcript is already resident must |
| 2 | // paint it immediately and reconcile in the background — the local session |
| 3 | // switch experience — instead of paying the network round trips behind a |
| 4 | // loading placeholder every time. |
| 5 | import React, { act } from "react"; |
| 6 | import { JSDOM } from "jsdom"; |
| 7 | import type { AppBindings } from "../lib/bridge"; |
| 8 | import type { FollowRequest, TranscriptFollowResponse } from "../generated/desktopContract.generated"; |
| 9 | import type { RemoteSessionApi } from "../lib/useRemoteSession"; |
| 10 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 11 | |
| 12 | let passed = 0, failed = 0; |
| 13 | function ok(value: boolean, label: string) { |
| 14 | process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`); |
| 15 | if (value) passed += 1; else failed += 1; |
| 16 | } |
| 17 | console.log("\nRemote session resident-cache paint"); |
| 18 | const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", { pretendToBeVisual: true, url: "http://localhost/" }); |
| 19 | (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 20 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 21 | globalThis.document = dom.window.document; |
| 22 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 23 | globalThis.localStorage = dom.window.localStorage; |
| 24 | globalThis.requestAnimationFrame = dom.window.requestAnimationFrame?.bind(dom.window) ?? ((cb: FrameRequestCallback) => setTimeout(() => cb(Date.now()), 16) as unknown as number); |
| 25 | globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame?.bind(dom.window) ?? ((handle: number) => clearTimeout(handle)); |
| 26 | |
| 27 | type FollowMode = "ok" | "deferred"; |
| 28 | const followMode = new Map<string, FollowMode>(); |
| 29 | const deferred = new Map<string, (response: TranscriptFollowResponse) => void>(); |
| 30 | const answerFor = new Map<string, string>(); |
| 31 | const statusFor = () => ({ running: false, label: "Model", plan: false, toolApprovalMode: "ask", goal: "", takenOver: false }); |
| 32 | const inline = (id: string, content: string) => ({ messageId: id, position: 0, version: 1, role: "assistant", eventSequence: 1, visibleTurn: 1, preview: "", inline: { id, role: "assistant", content } }); |
| 33 | const followResponse = (tabId: string): TranscriptFollowResponse => ({ |
| 34 | protocolVersion: 2, subscription: `sub-${tabId}`, changes: [], resetRequired: false, |
| 35 | snapshot: { |
| 36 | protocolVersion: 1, snapshotId: `cut-${tabId}`, identity: { sessionId: tabId, runtimeEpoch: "epoch", rewriteEpoch: 0, headId: "" }, |
| 37 | projectionRevision: 10, coveredThroughSeq: 4, durableSeq: 4, records: [], activeRecords: [], activeAttempts: [], |
| 38 | runtime: { status: "completed", pendingEvents: [], samplingCount: 0, toolCount: 0 }, before: 0, hasOlder: false, totalRecords: 1, totalTurns: 1, stale: false, |
| 39 | }, |
| 40 | history: { status: "ready", snapshotSequence: 4, coverageSequence: 4, generation: "gen", totalTurns: 1, hasOlder: false, hasNewer: false, messages: [inline(`answer-${tabId}`, answerFor.get(tabId) ?? "follower answer")] }, |
| 41 | }); |
| 42 | installDesktopHostStub({ |
| 43 | async RegisterNavigationIntent() {}, |
| 44 | async SetActiveTab() {}, |
| 45 | async ForkTargetsRemoteTab() { return { targets: [], verifiable: false }; }, |
| 46 | async RemoteTabStatus() { return statusFor(); }, |
| 47 | async RemoteTabMetadata() { return { status: statusFor() }; }, |
| 48 | async RemoteTabSnapshot() { return { history: [], status: statusFor() }; }, |
| 49 | async RemoteTranscriptFollowForTab(tabId: string, request: FollowRequest) { |
| 50 | if (request.close) return { protocolVersion: 2, subscription: request.subscription ?? "", changes: [], resetRequired: false }; |
| 51 | if (request.subscription) return new Promise<TranscriptFollowResponse>(() => {}); |
| 52 | if ((followMode.get(tabId) ?? "ok") === "deferred") { |
| 53 | return new Promise<TranscriptFollowResponse>((resolve) => { deferred.set(tabId, resolve); }); |
| 54 | } |
| 55 | return followResponse(tabId); |
| 56 | }, |
| 57 | async RemoteSessionHistoryWindowForTab() { return new Promise(() => {}); }, |
| 58 | } as Partial<AppBindings> as AppBindings); |
| 59 | |
| 60 | const [{ createRoot }, { useRemoteSession }, { getTranscriptStore }, { setTranscriptBindingIdentity }] = await Promise.all([ |
| 61 | import("react-dom/client"), import("../lib/useRemoteSession"), import("../lib/transcriptStore"), import("../lib/canonicalTranscriptBackend"), |
| 62 | ]); |
| 63 | setTranscriptBindingIdentity(() => "remote"); |
| 64 | |
| 65 | async function flush(ticks = 8) { |
| 66 | for (let i = 0; i < ticks; i++) await Promise.resolve(); |
| 67 | await new Promise((resolve) => setTimeout(resolve, 30)); |
| 68 | } |
| 69 | let probe: RemoteSessionApi | undefined; |
| 70 | function Probe({ tabId, sessionPath }: { tabId: string; sessionPath: string }) { |
| 71 | probe = useRemoteSession(tabId, "ready", sessionPath); |
| 72 | return null; |
| 73 | } |
| 74 | const root = createRoot(document.getElementById("root")!); |
| 75 | const mount = (tabId: string, sessionPath: string) => act(async () => { |
| 76 | root.render(<Probe tabId={tabId} sessionPath={sessionPath} />); |
| 77 | await flush(); |
| 78 | // The follower runtime is dynamically imported on first use. |
| 79 | for (let i = 0; i < 3; i++) await new Promise((resolve) => setTimeout(resolve, 40)); |
| 80 | await flush(); |
| 81 | }); |
| 82 | const texts = () => (probe?.transcript.items ?? []).flatMap((item) => item.kind === "assistant" ? [item.text] : []); |
| 83 | |
| 84 | // 1. Visit session A so its transcript becomes resident, then switch away. |
| 85 | followMode.set("tab-switch", "ok"); |
| 86 | answerFor.set("tab-switch", "session A answer"); |
| 87 | await mount("tab-switch", "session-a"); |
| 88 | // The mount effect's hydrate depends on the follower runtime's dynamic import, |
| 89 | // which settles unreliably in JSDOM (the repo's prime suite is red on HEAD for |
| 90 | // the same reason). Drive it explicitly to reach the state under test. |
| 91 | await act(async () => { await probe?.retryHydration(); await flush(); }); |
| 92 | ok(probe?.hydrated === true && texts().includes("session A answer"), "session A hydrates and becomes resident"); |
| 93 | |
| 94 | // 2. Switching to a session with no resident cut keeps the network path. |
| 95 | followMode.set("tab-switch", "deferred"); |
| 96 | await mount("tab-switch", "session-b"); |
| 97 | ok(probe?.hydrated === false && probe?.revalidating === false, "an uncached session still waits for its hydrate"); |
| 98 | |
| 99 | // 3. Switching back paints the resident cut with no network wait. |
| 100 | await mount("tab-switch", "session-a"); |
| 101 | ok(texts().includes("session A answer"), "the resident session is visible immediately on switch-back"); |
| 102 | ok(probe?.hydrated === true && probe?.revalidating === true, "the painted surface reports revalidating while the hydrate runs"); |
| 103 | ok(getTranscriptStore().peek("tab-switch", "session-a") !== undefined, "the paint reads the session-keyed resident cut"); |
| 104 | |
| 105 | // 4. The background hydrate supersedes the paint. |
| 106 | answerFor.set("tab-switch", "session A revalidated"); |
| 107 | await act(async () => { void probe?.retryHydration(); await flush(); }); |
| 108 | await act(async () => { deferred.get("tab-switch")?.(followResponse("tab-switch")); await flush(); }); |
| 109 | ok(texts().includes("session A revalidated") && !texts().includes("session A answer"), "the authoritative cut replaces the painted one"); |
| 110 | ok(probe?.revalidating === false && probe?.hydrated === true, "revalidating clears once the hydrate settles"); |
| 111 | |
| 112 | await act(async () => { root.unmount(); }); |
| 113 | dom.window.close(); |
| 114 | console.log(`\n${passed} passed, ${failed} failed`); |
| 115 | process.exit(failed === 0 ? 0 : 1); |
| 116 |