| 1 | import { verifyRemoteSubmissionLifecycle, verifyRemoteSubmissionTabIsolation } from "./helpers/remoteSubmissionLifecycle"; |
| 2 | import React, { act } from "react"; |
| 3 | import { RemoteNavigationHarness } from "./helpers/RemoteNavigationHarness"; |
| 4 | import { installRemoteSurfaceDom } from "./helpers/remoteSurfaceDom"; |
| 5 | import type { AppBindings } from "../lib/bridge"; |
| 6 | import type { TabMeta } from "../lib/types"; |
| 7 | import type { RemoteSessionApi } from "../lib/useRemoteSession"; |
| 8 | import { installDesktopHostStub } from "./desktopHostStub"; |
| 9 | import { makeExactRemoteInteractionBindings, runLegacyRemoteCapabilityScenario, runRemoteExtensionFormScenarios } from "../test-support/remoteInteractionScenarios"; |
| 10 | import { installRemoteTranscriptFixture } from "./helpers/remoteTranscriptFixture"; |
| 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; |
| 16 | else failed += 1; |
| 17 | } |
| 18 | console.log("\nRemote session surface + hook"); |
| 19 | const dom = installRemoteSurfaceDom(); |
| 20 | |
| 21 | const tape: string[] = []; |
| 22 | let failApproval = false; |
| 23 | let failOpen = false; |
| 24 | let submitError: Error | undefined; |
| 25 | let failHydration = true; |
| 26 | let statusGoalStatus: "stopped" | "complete" = "stopped"; |
| 27 | let statusQualityFloor: "standard" | "delivery" = "standard"; |
| 28 | let statusModelLabel = "DeepSeek · Mock"; |
| 29 | let statusEffort = "high"; |
| 30 | let statusPendingPrompt = false, replayedPrompts: unknown[] = []; |
| 31 | let snapshotHistory: unknown[] = []; |
| 32 | let blockApproval = false; |
| 33 | let releaseApproval: (() => void) | undefined; |
| 34 | let blockAnswer = false, failAnswer = false; |
| 35 | let releaseAnswer: (() => void) | undefined; |
| 36 | let blockExtensionForm = false; |
| 37 | let releaseExtensionForm: (() => void) | undefined; |
| 38 | let resolveRaceSnapshot: ((value: { history: unknown[]; status: unknown }) => void) | undefined; |
| 39 | const resolveStateRaceSnapshots: Array<(value: { history: unknown[]; status: unknown }) => void> = []; |
| 40 | let rotationSnapshotCalls = 0; |
| 41 | let resolveRotationReconcile: ((value: { history: unknown[]; status: unknown }) => void) | undefined; |
| 42 | const desktopStub = installDesktopHostStub(({ main: { App: { |
| 43 | async RegisterNavigationIntent(token: string) { tape.push(`navigation:${token}`); }, |
| 44 | async RemoteTabSnapshot(tabId: string) { |
| 45 | tape.push(`snapshot:${tabId}`); |
| 46 | if (tabId === "tab-hydration-failure" && failHydration) throw new Error("history exceeds bridge limit"); |
| 47 | if (tabId === "tab-pending-model") return new Promise(() => {}); |
| 48 | if (tabId === "tab-race") return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveRaceSnapshot = resolve; }); |
| 49 | if (tabId === "tab-state-race") return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveStateRaceSnapshots.push(resolve); }); |
| 50 | if (tabId === "tab-reconcile-rotation") { |
| 51 | rotationSnapshotCalls += 1; |
| 52 | if (rotationSnapshotCalls === 2) { |
| 53 | return new Promise<{ history: unknown[]; status: unknown }>((resolve) => { resolveRotationReconcile = resolve; }); |
| 54 | } |
| 55 | return { |
| 56 | history: [{ role: "assistant", content: rotationSnapshotCalls === 1 ? "initial session" : rotationSnapshotCalls === 3 ? "fresh rotated session" : "fresh reconciled turn" }], |
| 57 | status: { running: false, label: "Rotation", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 58 | }; |
| 59 | } |
| 60 | if (tabId === "tab-tool-history") return { |
| 61 | history: [ |
| 62 | { role: "assistant", content: "", toolCalls: [{ id: "remote-tool", name: "bash", arguments: "{\"command\":\"go test ./...\"}" }] }, |
| 63 | { role: "tool", content: "remote tool output", toolCallId: "remote-tool", toolName: "bash" }, |
| 64 | ], |
| 65 | status: { running: false, label: "Tools", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 66 | }; |
| 67 | if (tabId === "tab-replay") return { |
| 68 | history: [], status: { label: "Replay", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 69 | pendingEvents: [ |
| 70 | { kind: "approval_request", approval: { id: "replayed-approval", tool: "bash", subject: "pending while inactive" } }, |
| 71 | { kind: "extension_surface", extension: { pluginId: "replayed-plugin", surfaceId: "replayed-form", kind: "form", form: { title: "Pending form", fields: [{ key: "region", label: "Region", kind: "input" }] } } }, |
| 72 | ], |
| 73 | }; |
| 74 | if (tabId === "tab-status-fallback") return { history: [] }; |
| 75 | return { |
| 76 | history: snapshotHistory, |
| 77 | checkpoints: [{ |
| 78 | turn: 3, |
| 79 | prompt: "checkpoint", |
| 80 | files: ["src/main.ts"], |
| 81 | fileCount: 2, |
| 82 | time: 1, |
| 83 | canCode: true, |
| 84 | canConversation: true, |
| 85 | }], |
| 86 | commands: [{ name: "remote-review", description: "Review remotely", kind: "custom", group: "skills" }], |
| 87 | status: { |
| 88 | label: statusModelLabel, |
| 89 | plan: true, |
| 90 | toolApprovalMode: "auto", |
| 91 | goal: "", |
| 92 | goalStatus: statusGoalStatus, |
| 93 | goalRuntime: { turnsUsed: 2, turnsLimit: 0, tokensUsed: 321, requestsUsed: 3, workDurationMs: 4000, tokensLimit: 0, noProgressTurns: 0, noProgressLimit: 0, budgetExtensions: 0 }, |
| 94 | qualityFloor: statusQualityFloor, |
| 95 | effort: { supported: true, current: statusEffort, default: "auto", levels: ["auto", "high", "max"] }, |
| 96 | used: 1200, |
| 97 | window: 64000, |
| 98 | cacheHit: 800, |
| 99 | cacheMiss: 400, |
| 100 | lastUsage: { promptTokens: 1000, completionTokens: 200, totalTokens: 1200, cacheHitTokens: 800, cacheMissTokens: 200 }, |
| 101 | balance: { available: true, display: "¥88.00" }, |
| 102 | sessionCostQuote: { original: { amount: "0.12", currency: "CNY" }, selected: { amount: "0.12", currency: "CNY" }, estimated: false, costComplete: true, displayComplete: true, complete: true }, |
| 103 | jobs: [{ id: "job-remote", kind: "bash", label: "tests", status: "running", startedAt: 1 }], |
| 104 | }, |
| 105 | }; |
| 106 | }, |
| 107 | async RemoteTabStatus(tabId: string) { |
| 108 | tape.push(`status:${tabId}`); |
| 109 | if (tabId === "tab-status-fallback") { |
| 110 | return { running: false, plan: true, toolApprovalMode: "yolo", goal: "", qualityFloor: "standard" }; |
| 111 | } |
| 112 | return { |
| 113 | running: false, pendingPrompt: statusPendingPrompt, backgroundJobs: 0, |
| 114 | label: statusModelLabel, plan: true, toolApprovalMode: "auto", goal: "", |
| 115 | goalStatus: statusGoalStatus, qualityFloor: statusQualityFloor, |
| 116 | goalRuntime: { turnsUsed: 2, turnsLimit: 0, tokensUsed: 321, requestsUsed: 3, workDurationMs: 4000, tokensLimit: 0, noProgressTurns: 0, noProgressLimit: 0, budgetExtensions: 0 }, |
| 117 | effort: { supported: true, current: statusEffort, default: "auto", levels: ["auto", "high", "max"] }, |
| 118 | }; |
| 119 | }, |
| 120 | async ReplayRemoteTabPrompts(tabId: string) { tape.push(`replay-prompts:${tabId}`); return replayedPrompts; }, |
| 121 | async SubmitRemoteTab(tabId: string, text: string) { |
| 122 | tape.push(`submit:${tabId}:${text}`); |
| 123 | if (submitError) throw submitError; |
| 124 | }, |
| 125 | async CancelRemoteTab(tabId: string) { |
| 126 | tape.push(`cancel:${tabId}`); |
| 127 | }, |
| 128 | async RewindRemoteTab(tabId: string, checkpointId: string, scope: string) { |
| 129 | tape.push(`rewind:${tabId}:${checkpointId}:${scope}`); |
| 130 | }, |
| 131 | async ForkRemoteTab(tabId: string, turn: number, name: string) { |
| 132 | tape.push(`fork:${tabId}:${turn}:${name}`); |
| 133 | }, |
| 134 | async SummarizeRemoteTab(tabId: string, turn: number, mode: string) { |
| 135 | tape.push(`summarize:${tabId}:${turn}:${mode}`); |
| 136 | }, |
| 137 | async CompactRemoteTab(tabId: string, instructions: string) { tape.push(`compact:${tabId}:${instructions}`); }, |
| 138 | async SetRemoteTabEffort(tabId: string, level: string) { |
| 139 | tape.push(`effort:${tabId}:${level}`); |
| 140 | statusEffort = level; |
| 141 | }, |
| 142 | async SetRemoteTabModel(tabId: string, ref: string) { |
| 143 | tape.push(`model:${tabId}:${ref}`); |
| 144 | statusModelLabel = `Model · ${ref}`; |
| 145 | statusEffort = "max"; |
| 146 | }, |
| 147 | async SetRemoteTabQualityFloor(tabId: string, floor: string) { |
| 148 | tape.push(`quality-floor:${tabId}:${floor}`); |
| 149 | }, |
| 150 | async PauseRemoteTabGoal(tabId: string) { |
| 151 | tape.push(`pause-goal:${tabId}`); |
| 152 | }, |
| 153 | async ResumeRemoteTabGoal(tabId: string) { |
| 154 | tape.push(`resume-goal:${tabId}`); |
| 155 | }, |
| 156 | async SteerRemoteTab(tabId: string, input: string) { |
| 157 | tape.push(`steer:${tabId}:${input}`); |
| 158 | }, |
| 159 | async CancelRemoteTabJobs(tabId: string, jobIds: string[]) { |
| 160 | tape.push(`cancel-jobs:${tabId}:${jobIds.join(",")}`); |
| 161 | }, |
| 162 | ...makeExactRemoteInteractionBindings({ tape, failApproval: () => failApproval, failAnswer: () => failAnswer, |
| 163 | waitApproval: () => blockApproval ? new Promise<void>((resolve) => { releaseApproval = resolve; }) : Promise.resolve(), |
| 164 | waitAnswer: () => blockAnswer ? new Promise<void>((resolve) => { releaseAnswer = resolve; }) : Promise.resolve(), |
| 165 | waitForm: () => blockExtensionForm ? new Promise<void>((resolve) => { releaseExtensionForm = resolve; }) : Promise.resolve(), |
| 166 | resolved: (tabId, promptId, turnId) => desktopStub.emit(`remote-tab:${tabId}:event`, { kind: "prompt_answered", itemId: promptId, turnId }) }), |
| 167 | async ApproveRemoteTab(tabId: string, callId: string, decision: string) { |
| 168 | tape.push(`approve:${tabId}:${callId}:${decision}`); |
| 169 | if (failApproval) throw new Error("tunnel write failed"); |
| 170 | if (blockApproval) await new Promise<void>((resolve) => { releaseApproval = resolve; }); |
| 171 | }, |
| 172 | async ResolveRemoteTabPlanDecision(tabId: string, callId: string, action: string, feedback: string) { |
| 173 | tape.push(`plan-decision:${tabId}:${callId}:${action}:${feedback}`); |
| 174 | }, |
| 175 | async AnswerRemoteTab(tabId: string, callId: string, answers: Array<{ QuestionID: string; Selected: string[] }>) { |
| 176 | tape.push(`answer:${tabId}:${callId}:${JSON.stringify(answers)}`); if (failAnswer) throw new Error("remote answer failed"); |
| 177 | if (blockAnswer) await new Promise<void>((resolve) => { releaseAnswer = resolve; }); |
| 178 | }, |
| 179 | async SubmitRemoteTabExtensionForm(tabId: string, pluginId: string, surfaceId: string, values: Record<string, unknown>) { |
| 180 | tape.push(`extension-form:${tabId}:${pluginId}:${surfaceId}:${JSON.stringify(values)}`); |
| 181 | }, |
| 182 | async OpenRemoteProjectTab(hostId: string, workspace: string, opts?: { newSession?: boolean }) { |
| 183 | tape.push(`open:${hostId}:${workspace}:${opts?.newSession ? "new" : ""}`); |
| 184 | if (failOpen) throw new Error("reconnect failed"); |
| 185 | return { ...remoteTab, remote: { hostId, workspace } }; |
| 186 | }, |
| 187 | async ForkTargetsRemoteTab(tabId: string) { |
| 188 | tape.push(`fork-targets:${tabId}`); |
| 189 | return { targets: [], verifiable: false }; |
| 190 | }, |
| 191 | async CreateForkRemoteTab(tabId: string, target: { turnId: string; sourceSessionId: string; boundarySequence: number }) { |
| 192 | tape.push(`fork-create:${tabId}:${target.turnId}:${target.sourceSessionId}:${target.boundarySequence}`); |
| 193 | return { opened: true, sessionId: "child-remote-1", operationId: "operation-remote-1" }; |
| 194 | }, |
| 195 | async AcknowledgeForkOperation(tabId: string, operationId: string) { tape.push(`fork-ack:${tabId}:${operationId}`); }, |
| 196 | async SetActiveTab(tabID: string) { |
| 197 | tape.push(`setActive:${tabID}`); |
| 198 | }, |
| 199 | // The pre-activation history prime reads the canonical window through the |
| 200 | // remote binding. Answer like a serve whose tab has not attached yet, so |
| 201 | // the prime stays inert here and cannot consume the RemoteTabSnapshot |
| 202 | // deferreds the hydration race and rotation scenarios count. |
| 203 | async RemoteSessionHistoryWindowForTab(tabID: string) { |
| 204 | tape.push(`window:${tabID}`); |
| 205 | throw new Error("remote tab is not attached"); |
| 206 | }, |
| 207 | } as Partial<AppBindings> as AppBindings } }).main.App); |
| 208 | |
| 209 | const __emitMockRemoteTab = (tabId: string, channel: "state" | "event", payload: unknown) => desktopStub.emit(`remote-tab:${tabId}:${channel}`, payload); |
| 210 | installRemoteTranscriptFixture(desktopStub.commands); |
| 211 | const [{ createRoot }, { RemoteSessionSurface }, { LocaleProvider }, { useRemoteSession }, { remoteRuntimeCommand }, { setTranscriptBindingIdentity }] = await Promise.all([ |
| 212 | import("react-dom/client"), |
| 213 | import("../components/RemoteSessionSurface"), |
| 214 | import("../lib/i18n"), |
| 215 | import("../lib/useRemoteSession"), |
| 216 | import("../lib/useRemoteComposerIntegration"), |
| 217 | import("../lib/canonicalTranscriptBackend"), |
| 218 | ]); |
| 219 | // Production resolves remote tabs through the controller's meta; this harness |
| 220 | // mounts the hook without a controller, so bind canonical reads to the remote |
| 221 | // bridge the way the app does for every remote tab. |
| 222 | setTranscriptBindingIdentity(() => "remote"); |
| 223 | |
| 224 | const remoteTab: TabMeta = { |
| 225 | id: "tab-remote-1", |
| 226 | scope: "project", |
| 227 | workspaceRoot: "~/app", |
| 228 | workspaceName: "app", |
| 229 | topicId: "", |
| 230 | topicTitle: "app", |
| 231 | label: "gpu-box", |
| 232 | ready: true, |
| 233 | running: false, |
| 234 | mode: "normal", |
| 235 | active: true, |
| 236 | cwd: "~/app", |
| 237 | sessionId: "remote-session-1", |
| 238 | sessionGeneration: 0, |
| 239 | interactionTargetSupported: true, |
| 240 | extensionFormInstanceSupported: true, |
| 241 | remote: { hostId: "gpu-box", workspace: "~/app" }, |
| 242 | }; |
| 243 | |
| 244 | async function flush(ticks = 4) { |
| 245 | for (let i = 0; i < ticks; i++) await Promise.resolve(); |
| 246 | await new Promise((resolve) => setTimeout(resolve, 40)); |
| 247 | } |
| 248 | |
| 249 | // The surface takes its session from the hook — the same wiring the app |
| 250 | // shell uses (the shared Transcript renders the content, the composer lives |
| 251 | // in the shell). |
| 252 | function RemoteSurfaceHarness({ tab }: { tab: TabMeta }) { |
| 253 | const session = useRemoteSession(tab.id); |
| 254 | return <RemoteNavigationHarness><RemoteSessionSurface tab={tab} session={session} /></RemoteNavigationHarness>; |
| 255 | } |
| 256 | |
| 257 | // ── Surface: shared Transcript renders reducer-driven items ── |
| 258 | const root = createRoot(document.getElementById("root")!); |
| 259 | await act(async () => { |
| 260 | root.render( |
| 261 | <LocaleProvider> |
| 262 | <RemoteSurfaceHarness tab={remoteTab} /> |
| 263 | </LocaleProvider>, |
| 264 | ); |
| 265 | }); |
| 266 | await act(async () => flush()); |
| 267 | |
| 268 | ok(document.querySelector(".remote-surface__log") === null, "no bespoke log rows — the shared Transcript owns rendering"); |
| 269 | ok(!document.querySelector(".remote-surface__composer"), "the surface renders no composer of its own"); |
| 270 | |
| 271 | await act(async () => { |
| 272 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "turn_started" }); |
| 273 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "reasoning", reasoning: "thinking hard" }); |
| 274 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "streaming answer" }); |
| 275 | await flush(); |
| 276 | }); |
| 277 | ok(document.body.textContent?.includes("streaming answer") === true, "serve text frames render through the local transcript pipeline"); |
| 278 | ok(document.body.textContent?.includes("thinking hard") === true, "reasoning renders through the local pipeline"); |
| 279 | { |
| 280 | const snapshotsBefore = tape.filter((entry) => entry.startsWith("snapshot:")).length; |
| 281 | await act(async () => { |
| 282 | __emitMockRemoteTab("tab-remote-1", "state", { state: "ready" }); |
| 283 | await flush(); |
| 284 | }); |
| 285 | ok(tape.filter((entry) => entry.startsWith("snapshot:")).length > snapshotsBefore, "a ready transition re-syncs the snapshot (session reset / reconnect path)"); |
| 286 | } |
| 287 | ok(document.body.textContent?.includes("streaming answer") === true, "same-session reconnect restores the active prefix") |
| 288 | |
| 289 | await act(async () => { |
| 290 | const statusBefore = tape.filter((entry) => entry === "status:tab-remote-1").length; |
| 291 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "turn_done" }); |
| 292 | await flush(); |
| 293 | ok(tape.filter((entry) => entry === "status:tab-remote-1").length > statusBefore, "turn_done refreshes remote goal/runtime status"); |
| 294 | }); |
| 295 | |
| 296 | await act(async () => { |
| 297 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", turnId: "turn-main", runtimeEpoch: "runtime-main", approval: { id: "call-9", tool: "bash", subject: "rm -rf /tmp/junk" } }); |
| 298 | await flush(); |
| 299 | }); |
| 300 | { |
| 301 | const dialog = document.querySelector(".remote-surface__approval"); |
| 302 | ok(Boolean(dialog), "approval card renders"); |
| 303 | ok(dialog?.textContent?.includes("rm -rf /tmp/junk") === true, "approval subject renders"); |
| 304 | ok(dialog?.textContent?.includes("Allow matching for this session") === true |
| 305 | && dialog?.textContent?.includes("Always allow matching operations") !== true, |
| 306 | "remote approval exposes only once and session scopes"); |
| 307 | await act(async () => { |
| 308 | [...dialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")].find((b) => b.textContent?.includes("Allow matching for this session"))?.click(); |
| 309 | await flush(); |
| 310 | }); |
| 311 | await act(async () => { |
| 312 | dialog?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click(); |
| 313 | await new Promise((resolve) => setTimeout(resolve, 220)); |
| 314 | }); |
| 315 | ok(tape.includes("approve:tab-remote-1:call-9:session"), "session grant forwards its approval scope"); |
| 316 | ok(!document.querySelector(".remote-surface__approval"), "approval card clears after deciding"); |
| 317 | } |
| 318 | |
| 319 | await act(async () => { |
| 320 | __emitMockRemoteTab("tab-remote-1", "event", { |
| 321 | kind: "approval_request", turnId: "turn-main", runtimeEpoch: "runtime-main", |
| 322 | approval: { id: "plan-remote", tool: "exit_plan_mode", subject: "Plan ready" }, |
| 323 | }); |
| 324 | await flush(); |
| 325 | }); |
| 326 | { |
| 327 | const planDialog = document.querySelector(".remote-surface__approval"); |
| 328 | await act(async () => { |
| 329 | [...planDialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")] |
| 330 | .find((button) => button.textContent?.includes("Revise plan"))?.click(); |
| 331 | await flush(); |
| 332 | }); |
| 333 | await act(async () => { |
| 334 | const input = planDialog?.querySelector<HTMLTextAreaElement>(".plan-revision__input"); |
| 335 | if (input) { |
| 336 | const propsKey = Object.keys(input).find((key) => key.startsWith("__reactProps")); |
| 337 | const props = propsKey ? (input as unknown as Record<string, { onChange?: (event: { target: { value: string } }) => void }>)[propsKey] : undefined; |
| 338 | props?.onChange?.({ target: { value: "cover the rollback path" } }); |
| 339 | } |
| 340 | await flush(); |
| 341 | }); |
| 342 | await act(async () => { |
| 343 | planDialog?.querySelector<HTMLButtonElement>(".plan-revision__actions .btn--primary")?.click(); |
| 344 | await new Promise((resolve) => setTimeout(resolve, 220)); |
| 345 | }); |
| 346 | ok(tape.includes("plan-decision:tab-remote-1:plan-remote:revise_plan:cover the rollback path"), |
| 347 | "remote plan revision uses the specialized decision endpoint and preserves feedback"); |
| 348 | ok(!tape.some((entry) => entry.startsWith("approve:tab-remote-1:plan-remote")), |
| 349 | "remote plan decisions never collapse into generic approval booleans"); |
| 350 | } |
| 351 | |
| 352 | await act(async () => { |
| 353 | failApproval = true; |
| 354 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "approval_request", turnId: "turn-main", runtimeEpoch: "runtime-main", approval: { id: "call-fail", tool: "bash", subject: "keep this prompt" } }); |
| 355 | await flush(); |
| 356 | }); |
| 357 | { |
| 358 | await act(async () => { |
| 359 | const failedDialog = document.querySelector(".remote-surface__approval"); |
| 360 | [...failedDialog!.querySelectorAll<HTMLButtonElement>(".prompt-action")].find((b) => b.textContent?.includes("Allow once"))?.click(); |
| 361 | await flush(); |
| 362 | }); |
| 363 | await act(async () => { |
| 364 | const failedDialog = document.querySelector(".remote-surface__approval"); |
| 365 | failedDialog?.querySelector<HTMLButtonElement>(".decision-confirm-bar__confirm")?.click(); |
| 366 | await new Promise((resolve) => setTimeout(resolve, 220)); |
| 367 | await flush(); |
| 368 | }); |
| 369 | ok(Boolean(document.querySelector(".remote-surface__approval")), "a failed approval command preserves the decision card"); |
| 370 | ok(document.body.textContent?.includes("tunnel write failed") === true, "a failed approval command surfaces an actionable error"); |
| 371 | failApproval = false; |
| 372 | } |
| 373 | |
| 374 | await act(async () => { |
| 375 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-7", questions: [{ id: "q1", prompt: "Deploy now?", options: [{ label: "yes" }, { label: "no" }] }] } }); |
| 376 | await flush(); |
| 377 | }); |
| 378 | { |
| 379 | const dialog = document.querySelector(".prompt-shelf--ask"); |
| 380 | ok(Boolean(dialog), "ask card renders"); |
| 381 | ok(dialog?.textContent?.includes("Deploy now?") === true, "ask prompt renders"); |
| 382 | await act(async () => { |
| 383 | [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "yes")?.click(); |
| 384 | await flush(); |
| 385 | }); |
| 386 | ok(!tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-7")), "selecting an option keeps the ask open until explicit submit"); |
| 387 | failAnswer = true; await act(async () => { |
| 388 | [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "Submit")?.click(); |
| 389 | await flush(); |
| 390 | }); |
| 391 | ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card"); |
| 392 | } |
| 393 | |
| 394 | await act(async () => { |
| 395 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } }); |
| 396 | await flush(); |
| 397 | }); |
| 398 | await act(async () => { |
| 399 | document.querySelector<HTMLElement>(".ask-shelf__custom-row")?.click(); |
| 400 | await flush(); |
| 401 | }); |
| 402 | await act(async () => { |
| 403 | const input = document.querySelector<HTMLInputElement>(".ask-shelf__custom"); |
| 404 | if (input) { |
| 405 | const propsKey = Object.keys(input).find((key) => key.startsWith("__reactProps")); const props = propsKey ? (input as unknown as Record<string, { onChange?: (event: { target: { value: string } }) => void }>)[propsKey] : undefined; |
| 406 | props?.onChange?.({ target: { value: "canary" } }); |
| 407 | } |
| 408 | await flush(); |
| 409 | }); |
| 410 | await act(async () => { |
| 411 | [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Submit")?.click(); |
| 412 | await flush(); |
| 413 | }); |
| 414 | ok(tape.includes('answer:tab-remote-1:ask-custom:[{"questionId":"q-custom","selected":["canary"]}]'), |
| 415 | "custom AskCard text serializes as the exact remote question selection"); |
| 416 | |
| 417 | await runRemoteExtensionFormScenarios({ emit: __emitMockRemoteTab, flush, ok, tape, |
| 418 | blockForm: (blocked) => { blockExtensionForm = blocked; }, releaseForm: () => releaseExtensionForm?.() }); |
| 419 | |
| 420 | await act(async () => { |
| 421 | __emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "retain this partial answer across disconnect" }); |
| 422 | await flush(); |
| 423 | }); |
| 424 | ok(document.querySelector("main .transcript")?.textContent?.includes("retain this partial answer across disconnect") === true, |
| 425 | "disconnect fixture has visible transcript content before connection loss"); |
| 426 | await act(async () => { |
| 427 | __emitMockRemoteTab("tab-remote-1", "state", { state: "serve_down", error: "tunnel closed" }); |
| 428 | await flush(); |
| 429 | }); |
| 430 | { |
| 431 | const warning = document.querySelector(".session-recovery[role=alert]"); |
| 432 | ok(Boolean(warning), "serve_down renders the warning state"); |
| 433 | ok(!warning?.closest("main"), "recovery controls are outside the collapsible transcript main"); |
| 434 | ok(document.querySelector("main .transcript")?.textContent?.includes("retain this partial answer across disconnect") === true, |
| 435 | "disconnect retains the already loaded transcript"); |
| 436 | await act(async () => { warning?.querySelector<HTMLButtonElement>("button[aria-controls]")?.click(); }); |
| 437 | ok(warning?.textContent?.includes("tunnel closed") === true, "serve error detail renders"); |
| 438 | await act(async () => { |
| 439 | warning?.querySelector<HTMLButtonElement>("button")?.click(); |
| 440 | await flush(); |
| 441 | }); |
| 442 | ok(tape.includes("open:gpu-box:~/app:"), "serve_down retry preserves the backend's parked session target"); |
| 443 | const reconnectNavigation = tape.findIndex((entry) => entry.startsWith("navigation:nav-")); ok(reconnectNavigation >= 0 && reconnectNavigation < tape.indexOf("open:gpu-box:~/app:"), "serve_down retry registers navigation before reopening the remote tab"); |
| 444 | failOpen = true; |
| 445 | await act(async () => { |
| 446 | warning?.querySelector<HTMLButtonElement>("button")?.click(); |
| 447 | await flush(); |
| 448 | }); |
| 449 | ok(warning?.textContent?.includes("reconnect failed") === true, "serve_down retry failures render on the surface"); |
| 450 | failOpen = false; |
| 451 | } |
| 452 | // Mid-flight disconnected events also refuse the placeholder. |
| 453 | await act(async () => { __emitMockRemoteTab("tab-remote-1", "state", { state: "disconnected" }); await flush(); }); |
| 454 | { |
| 455 | ok(!document.querySelector(".remote-surface--disconnected"), "live disconnected events do not render the placeholder"); |
| 456 | ok(document.body.textContent?.includes("retain this partial answer across disconnect") === true, "live disconnection preserves the transcript"); |
| 457 | ok(!tape.includes("setActive:tab-remote-1"), "live disconnection does not automatically revive or resubmit work"); |
| 458 | } |
| 459 | |
| 460 | await act(async () => root.unmount()); |
| 461 | |
| 462 | await runLegacyRemoteCapabilityScenario({ baseTab: remoteTab, emit: __emitMockRemoteTab, flush, ok, tape, |
| 463 | render: (targetRoot, tab) => targetRoot.render(<LocaleProvider><RemoteSurfaceHarness tab={tab} /></LocaleProvider>) }); |
| 464 | |
| 465 | let restoredShellProbe: RemoteSessionApi | undefined; |
| 466 | function RestoredShellProbe() { restoredShellProbe = useRemoteSession("tab-restored-shell", "disconnected"); return null; } |
| 467 | const restoredShellRoot = createRoot(document.getElementById("root")!); |
| 468 | await act(async () => { restoredShellRoot.render(<RestoredShellProbe />); await flush(); }); |
| 469 | ok(restoredShellProbe?.state === "ready" && restoredShellProbe.hydrated, "restored shells recover through the authoritative snapshot when the ready event is missed"); |
| 470 | ok(tape.includes("setActive:tab-restored-shell"), "restored disconnected shells trigger backend revival"); |
| 471 | await act(async () => restoredShellRoot.unmount()); |
| 472 | |
| 473 | // ── Hook: optimistic user bubble + command forwarding ── |
| 474 | let probe: RemoteSessionApi | undefined; |
| 475 | function HookProbe({ tabId = "tab-remote-2" }: { tabId?: string }) { probe = useRemoteSession(tabId); return null; } |
| 476 | const probeRoot = createRoot(document.createElement("div")); |
| 477 | await act(async () => { probeRoot.render(<LocaleProvider><HookProbe /></LocaleProvider>); await flush(); }); |
| 478 | ok(probe?.state === "ready", "a successful fenced snapshot recovers a ready event missed before listener mount"); |
| 479 | ok(probe?.composerProfile?.collaborationMode === "plan" && probe?.composerProfile?.toolApprovalMode === "workspace-write" && probe.goalRuntime?.tokensUsed === 321, |
| 480 | "snapshot status hydrates the authoritative remote composer profile"); |
| 481 | ok(probe?.effort?.current === "high" && probe?.transcript.checkpoints[0]?.turn === 3 |
| 482 | && probe?.transcript.checkpoints[0]?.fileCount === 2 && probe?.transcript.checkpoints[0]?.files.length === 1, |
| 483 | "snapshot status hydrates effort and rewind checkpoints"); |
| 484 | ok(probe?.commands.length === 1 && probe.commands[0]?.name === "remote-review", |
| 485 | "remote hydration exposes the Serve command catalog instead of local commands"); |
| 486 | ok(probe?.transcript.context.used === 1200 && probe.transcript.context.window === 64000 |
| 487 | && probe.transcript.balance?.display === "¥88.00" && probe.transcript.sessionCost === 0.12 |
| 488 | && probe.transcript.jobs[0]?.id === "job-remote" && probe.transcript.lastTurnOutputTokens === 200, |
| 489 | "snapshot status hydrates remote context, usage, balance, cost, and jobs"); |
| 490 | ok(remoteRuntimeCommand("/goal write regression tests") === undefined && remoteRuntimeCommand("/goal --strict write regression tests") === undefined, "goal-setting commands remain conversational turns"); |
| 491 | ok(remoteRuntimeCommand("/goal")?.method === "runManagementCommand" && remoteRuntimeCommand("/goal status")?.method === "runManagementCommand" |
| 492 | && remoteRuntimeCommand("/goal --strict pause")?.method === "runManagementCommand", |
| 493 | "goal status and lifecycle actions remain synchronous management commands"); |
| 494 | ok(remoteRuntimeCommand("/branch experiment")?.rehydrate === true && remoteRuntimeCommand("/switch main")?.rehydrate === true |
| 495 | && remoteRuntimeCommand("/rewind 3 conversation")?.rehydrate === true && remoteRuntimeCommand("/context")?.rehydrate !== true |
| 496 | && remoteRuntimeCommand("/compact preserve tests")?.method === "compact", |
| 497 | "session-changing management commands request authoritative rehydration"); |
| 498 | statusPendingPrompt = true; replayedPrompts = [{ kind: "approval_request", approval: { id: "recovered-prompt", tool: "bash", subject: "replayed after drop" } }]; |
| 499 | await act(async () => { |
| 500 | __emitMockRemoteTab("tab-remote-2", "event", replayedPrompts[0]); |
| 501 | await probe?.runManagementCommand("/context"); await flush(); |
| 502 | }); |
| 503 | ok(!tape.includes("replay-prompts:tab-remote-2") && probe?.transcript.approval?.id === "recovered-prompt", "Follow delivers pending prompts without legacy replay"); |
| 504 | await act(async () => { await probe?.approve("recovered-prompt", "deny"); await flush(); }); statusPendingPrompt = false; replayedPrompts = []; |
| 505 | let remoteLiveNotifications = 0; |
| 506 | const unsubscribeRemoteLive = probe?.liveStore.subscribe("tab-remote-2", () => { remoteLiveNotifications += 1; }); |
| 507 | await act(async () => { |
| 508 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_started", turnStartedAt: 1234 }); |
| 509 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "text", messageId: "remote-answer", text: "remote live ticker" }); |
| 510 | await flush(); |
| 511 | }); |
| 512 | ok(probe?.liveStore.getSnapshot("tab-remote-2")?.text === "remote live ticker" && remoteLiveNotifications > 0, |
| 513 | "remote live-store notifications drive the shared composer ticker"); |
| 514 | unsubscribeRemoteLive?.(); |
| 515 | const turnDoneGeneration = probe?.surfaceGeneration; |
| 516 | statusGoalStatus = "complete"; |
| 517 | snapshotHistory = [{ role: "user", content: "server-side prompt" }, { role: "assistant", content: "reconciled final answer" }]; |
| 518 | await act(async () => { |
| 519 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "message", messageId: "remote-answer", text: "reconciled final answer" }); |
| 520 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_done" }); |
| 521 | await flush(); |
| 522 | }); |
| 523 | ok(probe?.composerProfile?.goalStatus === "complete" && probe.surfaceGeneration === turnDoneGeneration, |
| 524 | "turn_done refreshes goal status without replacing the transcript surface"); |
| 525 | ok(probe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "reconciled final answer") === true, |
| 526 | "committed result survives turn_done without a history rebase"); |
| 527 | |
| 528 | await act(async () => { |
| 529 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-old", tool: "bash", subject: "old" } }); |
| 530 | await flush(); |
| 531 | }); |
| 532 | blockApproval = true; |
| 533 | let oldApproval: Promise<void> | undefined; |
| 534 | await act(async () => { |
| 535 | oldApproval = probe?.approve("approval-old", "allow"); |
| 536 | await flush(); |
| 537 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-next", tool: "bash", subject: "next" } }); |
| 538 | releaseApproval?.(); |
| 539 | await oldApproval; |
| 540 | await flush(); |
| 541 | }); |
| 542 | blockApproval = false; |
| 543 | ok(probe?.transcript.approval?.id === "approval-next", "an answered approval cannot clear the next prompt"); |
| 544 | |
| 545 | await act(async () => { |
| 546 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-old", questions: [] } }); |
| 547 | await flush(); |
| 548 | }); |
| 549 | blockAnswer = true; |
| 550 | let oldAnswer: Promise<void> | undefined; |
| 551 | await act(async () => { |
| 552 | oldAnswer = probe?.answer("ask-old", []); |
| 553 | await flush(); |
| 554 | __emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-next", questions: [] } }); |
| 555 | releaseAnswer?.(); |
| 556 | await oldAnswer; |
| 557 | await flush(); |
| 558 | }); |
| 559 | blockAnswer = false; |
| 560 | ok(probe?.transcript.ask?.id === "ask-next", "an answered ask cannot clear the next prompt"); |
| 561 | await act(async () => { await probe?.submit("run tests"); await flush(); }); |
| 562 | ok(Boolean(Object.values(probe?.transcript.localSubmissions ?? {}).some((submission) => submission.text === "run tests")), "submit adds the optimistic user bubble through the shared reducer"); |
| 563 | await verifyRemoteSubmissionLifecycle(() => probe, error => { submitError = error; }, tape, flush, ok); |
| 564 | await act(async () => { await probe?.runManagementCommand("/context"); await flush(); }); |
| 565 | ok(tape.includes("submit:tab-remote-2:/context") && tape.includes("status:tab-remote-2"), |
| 566 | "management commands dispatch without conversational admission and refresh status"); |
| 567 | ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/context"), |
| 568 | "management commands do not add an optimistic user turn"); |
| 569 | const beforeCompactGeneration = probe?.surfaceGeneration; |
| 570 | snapshotHistory = [{ role: "assistant", content: "compacted remote history" }]; |
| 571 | await act(async () => { await probe?.compact("preserve tests"); await flush(); }); |
| 572 | ok(tape.includes("compact:tab-remote-2:preserve tests") && probe?.surfaceGeneration === (beforeCompactGeneration ?? 0) + 1 && probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "compacted remote history"), "remote compact waits for the dedicated endpoint and rehydrates history"); |
| 573 | const beforeSwitchGeneration = probe?.surfaceGeneration; |
| 574 | snapshotHistory = [{ role: "assistant", content: "adopted switched session" }]; |
| 575 | await act(async () => { await probe?.runManagementCommand("/switch feature", true); await flush(); }); |
| 576 | ok(tape.includes("submit:tab-remote-2:/switch feature") && probe?.surfaceGeneration === (beforeSwitchGeneration ?? 0) + 1 |
| 577 | && probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "adopted switched session"), |
| 578 | "session-changing management commands replace history from an authoritative snapshot"); |
| 579 | ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/switch feature"), |
| 580 | "session-changing management commands still avoid an optimistic user turn"); |
| 581 | await act(async () => { |
| 582 | await probe?.cancelTurn(); |
| 583 | await probe?.approve("call-1", "allow"); |
| 584 | await probe?.answer("ask-1", [{ QuestionID: "q1", Selected: ["yes"] }]); |
| 585 | await probe?.rewind(3, "code"); |
| 586 | await probe?.forkTurn({ sourceSessionId: "parent-remote-1", sessionGeneration: 1, turnId: "turn-3", boundarySequence: 9, |
| 587 | turnNumber: 3, status: "committed", available: true }); |
| 588 | await probe?.rewind(3, "summ-from"); |
| 589 | await probe?.rewind(3, "summ-upto"); |
| 590 | await flush(); |
| 591 | }); |
| 592 | const metadataGeneration = probe?.surfaceGeneration; |
| 593 | statusGoalStatus = "complete"; |
| 594 | statusQualityFloor = "delivery"; // Simulate an older server that still reports its real policy. |
| 595 | await act(async () => { |
| 596 | await probe?.setModel("remote/new-model"); |
| 597 | await probe?.setEffort("high"); |
| 598 | await probe?.setQualityFloor("delivery"); |
| 599 | await probe?.pauseGoal(); |
| 600 | await probe?.resumeGoal(); |
| 601 | await probe?.steer("narrow the change"); |
| 602 | await probe?.cancelJob("job-remote"); |
| 603 | await flush(); |
| 604 | }); |
| 605 | ok(probe?.surfaceGeneration === metadataGeneration, "metadata-only remote commands preserve the transcript generation and viewport"); |
| 606 | ok(probe?.composerProfile?.goalStatus === "complete" && probe.composerProfile.qualityFloor === "delivery", |
| 607 | "status-only refresh preserves an older server's reported policy"); |
| 608 | ok(probe?.modelLabel === "Model · remote/new-model" && probe.effort?.current === "high", |
| 609 | "model switching refreshes the authoritative remote profile before the next turn"); |
| 610 | for (const want of [ |
| 611 | "submit:tab-remote-2:run tests", |
| 612 | "cancel:tab-remote-2", |
| 613 | "approve:tab-remote-2:call-1:allow", |
| 614 | 'answer:tab-remote-2:ask-1:[{"QuestionID":"q1","Selected":["yes"]}]', |
| 615 | "rewind:tab-remote-2:3:code", |
| 616 | "summarize:tab-remote-2:3:from", |
| 617 | "summarize:tab-remote-2:3:upto", |
| 618 | "model:tab-remote-2:remote/new-model", |
| 619 | "effort:tab-remote-2:high", |
| 620 | "quality-floor:tab-remote-2:delivery", |
| 621 | "pause-goal:tab-remote-2", |
| 622 | "resume-goal:tab-remote-2", |
| 623 | "steer:tab-remote-2:narrow the change", |
| 624 | "cancel-jobs:tab-remote-2:job-remote", |
| 625 | ]) { |
| 626 | ok(tape.includes(want), `command forwarded: ${want}`); |
| 627 | } |
| 628 | ok(tape.some((entry) => entry.startsWith("fork-create:tab-remote-2:turn-3:")), |
| 629 | "remote forking creates the child through the create-only endpoint"); |
| 630 | ok(!tape.some((entry) => entry.startsWith("fork:tab-remote-2")), |
| 631 | "remote forking never reaches the route that switches the parent session"); |
| 632 | await verifyRemoteSubmissionTabIsolation(() => probe, tabId => probeRoot.render(<LocaleProvider><HookProbe tabId={tabId} /></LocaleProvider>), flush, ok); |
| 633 | await act(async () => { |
| 634 | probeRoot.render(<LocaleProvider><HookProbe tabId="tab-pending-model" /></LocaleProvider>); |
| 635 | await Promise.resolve(); |
| 636 | }); |
| 637 | ok(probe?.modelLabel === "", "switching remote tabs clears the previous model label before hydration"); |
| 638 | |
| 639 | await act(async () => probeRoot.unmount()); |
| 640 | |
| 641 | let fallbackProbe: RemoteSessionApi | undefined; |
| 642 | function FallbackProbe() { |
| 643 | fallbackProbe = useRemoteSession("tab-status-fallback"); |
| 644 | return null; |
| 645 | } |
| 646 | const fallbackRoot = createRoot(document.createElement("div")); |
| 647 | await act(async () => { |
| 648 | fallbackRoot.render(<LocaleProvider><FallbackProbe /></LocaleProvider>); |
| 649 | await flush(); |
| 650 | }); |
| 651 | ok(fallbackProbe?.hydrated === true && fallbackProbe.composerProfile?.collaborationMode === "plan" |
| 652 | && fallbackProbe.composerProfile.toolApprovalMode === "workspace-write" |
| 653 | && tape.includes("status:tab-status-fallback"), |
| 654 | "missing aggregate status is fetched before the remote composer becomes ready"); |
| 655 | await act(async () => fallbackRoot.unmount()); |
| 656 | |
| 657 | await (await import("./helpers/remoteHistoryProjectionCases")).runRemoteToolHistoryCase({ ok, flush }); |
| 658 | |
| 659 | let failureProbe: RemoteSessionApi | undefined; |
| 660 | function FailureProbe() { |
| 661 | failureProbe = useRemoteSession("tab-hydration-failure", "ready"); |
| 662 | return null; |
| 663 | } |
| 664 | const failureRoot = createRoot(document.createElement("div")); |
| 665 | await act(async () => { |
| 666 | failureRoot.render(<LocaleProvider><FailureProbe /></LocaleProvider>); |
| 667 | }); |
| 668 | await act(async () => { |
| 669 | await new Promise((resolve) => setTimeout(resolve, 2200)); |
| 670 | }); |
| 671 | ok(failureProbe?.hydrated === false && failureProbe.error.includes("history exceeds bridge limit"), |
| 672 | "exhausted ready-session hydration exposes a retryable error"); |
| 673 | failHydration = false; |
| 674 | await act(async () => { |
| 675 | await failureProbe?.retryHydration(); |
| 676 | await flush(); |
| 677 | }); |
| 678 | ok(failureProbe?.hydrated === true && failureProbe.error === "", "explicit hydration retry recovers the surface"); |
| 679 | await act(async () => failureRoot.unmount()); |
| 680 | |
| 681 | // ── Hydration fence: an SSE event delivered while the snapshot is in flight |
| 682 | // is replayed after history instead of being overwritten by it. ── |
| 683 | let raceProbe: RemoteSessionApi | undefined; |
| 684 | function RaceProbe() { |
| 685 | raceProbe = useRemoteSession("tab-race"); |
| 686 | return null; |
| 687 | } |
| 688 | const raceRoot = createRoot(document.createElement("div")); |
| 689 | await act(async () => { |
| 690 | raceRoot.render(<LocaleProvider><RaceProbe /></LocaleProvider>); |
| 691 | await Promise.resolve(); |
| 692 | }); |
| 693 | await act(async () => { |
| 694 | __emitMockRemoteTab("tab-race", "event", { kind: "turn_started" }); |
| 695 | __emitMockRemoteTab("tab-race", "event", { kind: "text", text: "arrived during hydration" }); |
| 696 | resolveRaceSnapshot?.({ |
| 697 | history: [], |
| 698 | status: { running: true, label: "Race", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 699 | }); |
| 700 | await flush(); |
| 701 | }); |
| 702 | ok(raceProbe?.transcript.live.text === "arrived during hydration", "hydration replays concurrently delivered remote events"); |
| 703 | await act(async () => raceRoot.unmount()); |
| 704 | |
| 705 | let stateRaceProbe: RemoteSessionApi | undefined; |
| 706 | function StateRaceProbe() { |
| 707 | stateRaceProbe = useRemoteSession("tab-state-race"); |
| 708 | return null; |
| 709 | } |
| 710 | const stateRaceRoot = createRoot(document.createElement("div")); |
| 711 | await act(async () => { |
| 712 | stateRaceRoot.render(<LocaleProvider><StateRaceProbe /></LocaleProvider>); |
| 713 | await Promise.resolve(); |
| 714 | }); |
| 715 | await act(async () => { |
| 716 | __emitMockRemoteTab("tab-state-race", "state", { state: "reconnecting" }); |
| 717 | __emitMockRemoteTab("tab-state-race", "state", { state: "ready" }); |
| 718 | resolveStateRaceSnapshots[0]?.({ |
| 719 | history: [], |
| 720 | status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 721 | }); |
| 722 | await flush(); |
| 723 | }); |
| 724 | await act(async () => { |
| 725 | resolveStateRaceSnapshots[1]?.({ |
| 726 | history: [{ role: "assistant", content: "fresh generation" }], |
| 727 | status: { running: false, label: "Fresh", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 728 | }); |
| 729 | await flush(); |
| 730 | }); |
| 731 | ok(stateRaceProbe?.state === "ready" && stateRaceProbe.hydrated === true |
| 732 | && stateRaceProbe.modelLabel === "Fresh" |
| 733 | && stateRaceProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh generation"), |
| 734 | "a ready generation re-hydrates after discarding the stale in-flight snapshot"); |
| 735 | await act(async () => stateRaceRoot.unmount()); |
| 736 | |
| 737 | // Post-turn reconciliation can overlap a ready-to-ready /new, /clear, or |
| 738 | // resume. The old history response must not replace the newly adopted session. |
| 739 | let rotationProbe: RemoteSessionApi | undefined; |
| 740 | function RotationProbe() { rotationProbe = useRemoteSession("tab-reconcile-rotation"); return null; } |
| 741 | const rotationRoot = createRoot(document.createElement("div")); |
| 742 | await act(async () => { rotationRoot.render(<LocaleProvider><RotationProbe /></LocaleProvider>); await flush(); }); |
| 743 | await act(async () => { |
| 744 | __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" }); |
| 745 | __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" }); |
| 746 | await Promise.resolve(); |
| 747 | __emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" }); |
| 748 | await flush(); |
| 749 | }); |
| 750 | await act(async () => { |
| 751 | __emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" }); |
| 752 | await flush(); |
| 753 | }); |
| 754 | ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true, |
| 755 | "ready-to-ready rotation hydrates the adopted session while old reconciliation is pending"); |
| 756 | await act(async () => { __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" }); __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" }); await Promise.resolve(); }); |
| 757 | await act(async () => { |
| 758 | resolveRotationReconcile?.({ |
| 759 | history: [{ role: "assistant", content: "stale previous session" }], |
| 760 | status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" }, |
| 761 | }); |
| 762 | await flush(); |
| 763 | }); |
| 764 | ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true |
| 765 | && !rotationProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "stale previous session"), |
| 766 | "session generation fence rejects stale history and hands reconciliation to the new generation"); |
| 767 | await act(async () => rotationRoot.unmount()); |
| 768 | |
| 769 | await (await import("./helpers/remoteHistoryProjectionCases")).runRemotePendingPromptReplayCase({ ok, flush }); |
| 770 | await (await import("./helpers/remoteRuntimeReconciliationCases")).runRemoteRuntimeCases({ commands: desktopStub.commands as unknown as AppBindings, emitRemote: __emitMockRemoteTab, remoteTab, ok, tape, flush, setSnapshotHistory: value => { snapshotHistory = value; } }); |
| 771 | dom.window.close(); |
| 772 | process.stdout.write(`\n${passed} passed, ${failed} failed\n`); |
| 773 | if (failed > 0) process.exit(1); |
| 774 |