返回 DeepSeek-Reasonix
remote-session-surface.test.tsx
根目录 / desktop / frontend / src / __tests__ / remote-session-surface.test.tsx
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
774 lines Plain Text