返回 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 // A CLI serve never binds desktop routing metadata, so its prompts carry no
395 // runtimeEpoch; the serve fences on the epoch only when it has one.
396 await act(async () => {
397 __emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
398 await flush();
399 });
400 {
401 ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
402 await act(async () => {
403 [...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
404 await flush();
405 });
406 await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
407 ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
408 }
409
410 await act(async () => {
411 __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" }] }] } });
412 await flush();
413 });
414 await act(async () => {
415 document.querySelector<HTMLElement>(".ask-shelf__custom-row")?.click();
416 await flush();
417 });
418 await act(async () => {
419 const input = document.querySelector<HTMLInputElement>(".ask-shelf__custom");
420 if (input) {
421 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;
422 props?.onChange?.({ target: { value: "canary" } });
423 }
424 await flush();
425 });
426 await act(async () => {
427 [...document.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === "Submit")?.click();
428 await flush();
429 });
430 ok(tape.includes('answer:tab-remote-1:ask-custom:[{"questionId":"q-custom","selected":["canary"]}]'),
431 "custom AskCard text serializes as the exact remote question selection");
432
433 await runRemoteExtensionFormScenarios({ emit: __emitMockRemoteTab, flush, ok, tape,
434 blockForm: (blocked) => { blockExtensionForm = blocked; }, releaseForm: () => releaseExtensionForm?.() });
435
436 await act(async () => {
437 __emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "retain this partial answer across disconnect" });
438 await flush();
439 });
440 ok(document.querySelector("main .transcript")?.textContent?.includes("retain this partial answer across disconnect") === true,
441 "disconnect fixture has visible transcript content before connection loss");
442 await act(async () => {
443 __emitMockRemoteTab("tab-remote-1", "state", { state: "serve_down", error: "tunnel closed" });
444 await flush();
445 });
446 {
447 const warning = document.querySelector(".session-recovery[role=alert]");
448 ok(Boolean(warning), "serve_down renders the warning state");
449 ok(!warning?.closest("main"), "recovery controls are outside the collapsible transcript main");
450 ok(document.querySelector("main .transcript")?.textContent?.includes("retain this partial answer across disconnect") === true,
451 "disconnect retains the already loaded transcript");
452 await act(async () => { warning?.querySelector<HTMLButtonElement>("button[aria-controls]")?.click(); });
453 ok(warning?.textContent?.includes("tunnel closed") === true, "serve error detail renders");
454 await act(async () => {
455 warning?.querySelector<HTMLButtonElement>("button")?.click();
456 await flush();
457 });
458 ok(tape.includes("open:gpu-box:~/app:"), "serve_down retry preserves the backend's parked session target");
459 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");
460 failOpen = true;
461 await act(async () => {
462 warning?.querySelector<HTMLButtonElement>("button")?.click();
463 await flush();
464 });
465 ok(warning?.textContent?.includes("reconnect failed") === true, "serve_down retry failures render on the surface");
466 failOpen = false;
467 }
468 // Mid-flight disconnected events also refuse the placeholder.
469 await act(async () => { __emitMockRemoteTab("tab-remote-1", "state", { state: "disconnected" }); await flush(); });
470 {
471 ok(!document.querySelector(".remote-surface--disconnected"), "live disconnected events do not render the placeholder");
472 ok(document.body.textContent?.includes("retain this partial answer across disconnect") === true, "live disconnection preserves the transcript");
473 ok(!tape.includes("setActive:tab-remote-1"), "live disconnection does not automatically revive or resubmit work");
474 }
475
476 await act(async () => root.unmount());
477
478 await runLegacyRemoteCapabilityScenario({ baseTab: remoteTab, emit: __emitMockRemoteTab, flush, ok, tape,
479 render: (targetRoot, tab) => targetRoot.render(<LocaleProvider><RemoteSurfaceHarness tab={tab} /></LocaleProvider>) });
480
481 let restoredShellProbe: RemoteSessionApi | undefined;
482 function RestoredShellProbe() { restoredShellProbe = useRemoteSession("tab-restored-shell", "disconnected"); return null; }
483 const restoredShellRoot = createRoot(document.getElementById("root")!);
484 await act(async () => { restoredShellRoot.render(<RestoredShellProbe />); await flush(); });
485 ok(restoredShellProbe?.state === "ready" && restoredShellProbe.hydrated, "restored shells recover through the authoritative snapshot when the ready event is missed");
486 ok(tape.includes("setActive:tab-restored-shell"), "restored disconnected shells trigger backend revival");
487 await act(async () => restoredShellRoot.unmount());
488
489 // ── Hook: optimistic user bubble + command forwarding ──
490 let probe: RemoteSessionApi | undefined;
491 function HookProbe({ tabId = "tab-remote-2" }: { tabId?: string }) { probe = useRemoteSession(tabId); return null; }
492 const probeRoot = createRoot(document.createElement("div"));
493 await act(async () => { probeRoot.render(<LocaleProvider><HookProbe /></LocaleProvider>); await flush(); });
494 ok(probe?.state === "ready", "a successful fenced snapshot recovers a ready event missed before listener mount");
495 ok(probe?.composerProfile?.collaborationMode === "plan" && probe?.composerProfile?.toolApprovalMode === "workspace-write" && probe.goalRuntime?.tokensUsed === 321,
496 "snapshot status hydrates the authoritative remote composer profile");
497 ok(probe?.effort?.current === "high" && probe?.transcript.checkpoints[0]?.turn === 3
498 && probe?.transcript.checkpoints[0]?.fileCount === 2 && probe?.transcript.checkpoints[0]?.files.length === 1,
499 "snapshot status hydrates effort and rewind checkpoints");
500 ok(probe?.commands.length === 1 && probe.commands[0]?.name === "remote-review",
501 "remote hydration exposes the Serve command catalog instead of local commands");
502 ok(probe?.transcript.context.used === 1200 && probe.transcript.context.window === 64000
503 && probe.transcript.balance?.display === "¥88.00" && probe.transcript.sessionCost === 0.12
504 && probe.transcript.jobs[0]?.id === "job-remote" && probe.transcript.lastTurnOutputTokens === 200,
505 "snapshot status hydrates remote context, usage, balance, cost, and jobs");
506 ok(remoteRuntimeCommand("/goal write regression tests") === undefined && remoteRuntimeCommand("/goal --strict write regression tests") === undefined, "goal-setting commands remain conversational turns");
507 ok(remoteRuntimeCommand("/goal")?.method === "runManagementCommand" && remoteRuntimeCommand("/goal status")?.method === "runManagementCommand"
508 && remoteRuntimeCommand("/goal --strict pause")?.method === "runManagementCommand",
509 "goal status and lifecycle actions remain synchronous management commands");
510 ok(remoteRuntimeCommand("/branch experiment")?.rehydrate === true && remoteRuntimeCommand("/switch main")?.rehydrate === true
511 && remoteRuntimeCommand("/rewind 3 conversation")?.rehydrate === true && remoteRuntimeCommand("/context")?.rehydrate !== true
512 && remoteRuntimeCommand("/compact preserve tests")?.method === "compact",
513 "session-changing management commands request authoritative rehydration");
514 statusPendingPrompt = true; replayedPrompts = [{ kind: "approval_request", approval: { id: "recovered-prompt", tool: "bash", subject: "replayed after drop" } }];
515 await act(async () => {
516 __emitMockRemoteTab("tab-remote-2", "event", replayedPrompts[0]);
517 await probe?.runManagementCommand("/context"); await flush();
518 });
519 ok(!tape.includes("replay-prompts:tab-remote-2") && probe?.transcript.approval?.id === "recovered-prompt", "Follow delivers pending prompts without legacy replay");
520 await act(async () => { await probe?.approve("recovered-prompt", "deny"); await flush(); }); statusPendingPrompt = false; replayedPrompts = [];
521 let remoteLiveNotifications = 0;
522 const unsubscribeRemoteLive = probe?.liveStore.subscribe("tab-remote-2", () => { remoteLiveNotifications += 1; });
523 await act(async () => {
524 __emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_started", turnStartedAt: 1234 });
525 __emitMockRemoteTab("tab-remote-2", "event", { kind: "text", messageId: "remote-answer", text: "remote live ticker" });
526 await flush();
527 });
528 ok(probe?.liveStore.getSnapshot("tab-remote-2")?.text === "remote live ticker" && remoteLiveNotifications > 0,
529 "remote live-store notifications drive the shared composer ticker");
530 unsubscribeRemoteLive?.();
531 const turnDoneGeneration = probe?.surfaceGeneration;
532 statusGoalStatus = "complete";
533 snapshotHistory = [{ role: "user", content: "server-side prompt" }, { role: "assistant", content: "reconciled final answer" }];
534 await act(async () => {
535 __emitMockRemoteTab("tab-remote-2", "event", { kind: "message", messageId: "remote-answer", text: "reconciled final answer" });
536 __emitMockRemoteTab("tab-remote-2", "event", { kind: "turn_done" });
537 await flush();
538 });
539 ok(probe?.composerProfile?.goalStatus === "complete" && probe.surfaceGeneration === turnDoneGeneration,
540 "turn_done refreshes goal status without replacing the transcript surface");
541 ok(probe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "reconciled final answer") === true,
542 "committed result survives turn_done without a history rebase");
543
544 await act(async () => {
545 __emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-old", tool: "bash", subject: "old" } });
546 await flush();
547 });
548 blockApproval = true;
549 let oldApproval: Promise<void> | undefined;
550 await act(async () => {
551 oldApproval = probe?.approve("approval-old", "allow");
552 await flush();
553 __emitMockRemoteTab("tab-remote-2", "event", { kind: "approval_request", approval: { id: "approval-next", tool: "bash", subject: "next" } });
554 releaseApproval?.();
555 await oldApproval;
556 await flush();
557 });
558 blockApproval = false;
559 ok(probe?.transcript.approval?.id === "approval-next", "an answered approval cannot clear the next prompt");
560
561 await act(async () => {
562 __emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-old", questions: [] } });
563 await flush();
564 });
565 blockAnswer = true;
566 let oldAnswer: Promise<void> | undefined;
567 await act(async () => {
568 oldAnswer = probe?.answer("ask-old", []);
569 await flush();
570 __emitMockRemoteTab("tab-remote-2", "event", { kind: "ask_request", ask: { id: "ask-next", questions: [] } });
571 releaseAnswer?.();
572 await oldAnswer;
573 await flush();
574 });
575 blockAnswer = false;
576 ok(probe?.transcript.ask?.id === "ask-next", "an answered ask cannot clear the next prompt");
577 await act(async () => { await probe?.submit("run tests"); await flush(); });
578 ok(Boolean(Object.values(probe?.transcript.localSubmissions ?? {}).some((submission) => submission.text === "run tests")), "submit adds the optimistic user bubble through the shared reducer");
579 await verifyRemoteSubmissionLifecycle(() => probe, error => { submitError = error; }, tape, flush, ok);
580 await act(async () => { await probe?.runManagementCommand("/context"); await flush(); });
581 ok(tape.includes("submit:tab-remote-2:/context") && tape.includes("status:tab-remote-2"),
582 "management commands dispatch without conversational admission and refresh status");
583 ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/context"),
584 "management commands do not add an optimistic user turn");
585 const beforeCompactGeneration = probe?.surfaceGeneration;
586 snapshotHistory = [{ role: "assistant", content: "compacted remote history" }];
587 await act(async () => { await probe?.compact("preserve tests"); await flush(); });
588 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");
589 const beforeSwitchGeneration = probe?.surfaceGeneration;
590 snapshotHistory = [{ role: "assistant", content: "adopted switched session" }];
591 await act(async () => { await probe?.runManagementCommand("/switch feature", true); await flush(); });
592 ok(tape.includes("submit:tab-remote-2:/switch feature") && probe?.surfaceGeneration === (beforeSwitchGeneration ?? 0) + 1
593 && probe.transcript.items.some((item) => item.kind === "assistant" && item.text === "adopted switched session"),
594 "session-changing management commands replace history from an authoritative snapshot");
595 ok(!probe?.transcript.items.some((item) => item.kind === "user" && item.text === "/switch feature"),
596 "session-changing management commands still avoid an optimistic user turn");
597 await act(async () => {
598 await probe?.cancelTurn();
599 await probe?.approve("call-1", "allow");
600 await probe?.answer("ask-1", [{ QuestionID: "q1", Selected: ["yes"] }]);
601 await probe?.rewind(3, "code");
602 await probe?.forkTurn({ sourceSessionId: "parent-remote-1", sessionGeneration: 1, turnId: "turn-3", boundarySequence: 9,
603 turnNumber: 3, status: "committed", available: true });
604 await probe?.rewind(3, "summ-from");
605 await probe?.rewind(3, "summ-upto");
606 await flush();
607 });
608 const metadataGeneration = probe?.surfaceGeneration;
609 statusGoalStatus = "complete";
610 statusQualityFloor = "delivery"; // Simulate an older server that still reports its real policy.
611 await act(async () => {
612 await probe?.setModel("remote/new-model");
613 await probe?.setEffort("high");
614 await probe?.setQualityFloor("delivery");
615 await probe?.pauseGoal();
616 await probe?.resumeGoal();
617 await probe?.steer("narrow the change");
618 await probe?.cancelJob("job-remote");
619 await flush();
620 });
621 ok(probe?.surfaceGeneration === metadataGeneration, "metadata-only remote commands preserve the transcript generation and viewport");
622 ok(probe?.composerProfile?.goalStatus === "complete" && probe.composerProfile.qualityFloor === "delivery",
623 "status-only refresh preserves an older server's reported policy");
624 ok(probe?.modelLabel === "Model · remote/new-model" && probe.effort?.current === "high",
625 "model switching refreshes the authoritative remote profile before the next turn");
626 for (const want of [
627 "submit:tab-remote-2:run tests",
628 "cancel:tab-remote-2",
629 "approve:tab-remote-2:call-1:allow",
630 'answer:tab-remote-2:ask-1:[{"QuestionID":"q1","Selected":["yes"]}]',
631 "rewind:tab-remote-2:3:code",
632 "summarize:tab-remote-2:3:from",
633 "summarize:tab-remote-2:3:upto",
634 "model:tab-remote-2:remote/new-model",
635 "effort:tab-remote-2:high",
636 "quality-floor:tab-remote-2:delivery",
637 "pause-goal:tab-remote-2",
638 "resume-goal:tab-remote-2",
639 "steer:tab-remote-2:narrow the change",
640 "cancel-jobs:tab-remote-2:job-remote",
641 ]) {
642 ok(tape.includes(want), `command forwarded: ${want}`);
643 }
644 ok(tape.some((entry) => entry.startsWith("fork-create:tab-remote-2:turn-3:")),
645 "remote forking creates the child through the create-only endpoint");
646 ok(!tape.some((entry) => entry.startsWith("fork:tab-remote-2")),
647 "remote forking never reaches the route that switches the parent session");
648 await verifyRemoteSubmissionTabIsolation(() => probe, tabId => probeRoot.render(<LocaleProvider><HookProbe tabId={tabId} /></LocaleProvider>), flush, ok);
649 await act(async () => {
650 probeRoot.render(<LocaleProvider><HookProbe tabId="tab-pending-model" /></LocaleProvider>);
651 await Promise.resolve();
652 });
653 ok(probe?.modelLabel === "", "switching remote tabs clears the previous model label before hydration");
654
655 await act(async () => probeRoot.unmount());
656
657 let fallbackProbe: RemoteSessionApi | undefined;
658 function FallbackProbe() {
659 fallbackProbe = useRemoteSession("tab-status-fallback");
660 return null;
661 }
662 const fallbackRoot = createRoot(document.createElement("div"));
663 await act(async () => {
664 fallbackRoot.render(<LocaleProvider><FallbackProbe /></LocaleProvider>);
665 await flush();
666 });
667 ok(fallbackProbe?.hydrated === true && fallbackProbe.composerProfile?.collaborationMode === "plan"
668 && fallbackProbe.composerProfile.toolApprovalMode === "workspace-write"
669 && tape.includes("status:tab-status-fallback"),
670 "missing aggregate status is fetched before the remote composer becomes ready");
671 await act(async () => fallbackRoot.unmount());
672
673 await (await import("./helpers/remoteHistoryProjectionCases")).runRemoteToolHistoryCase({ ok, flush });
674
675 let failureProbe: RemoteSessionApi | undefined;
676 function FailureProbe() {
677 failureProbe = useRemoteSession("tab-hydration-failure", "ready");
678 return null;
679 }
680 const failureRoot = createRoot(document.createElement("div"));
681 await act(async () => {
682 failureRoot.render(<LocaleProvider><FailureProbe /></LocaleProvider>);
683 });
684 await act(async () => {
685 await new Promise((resolve) => setTimeout(resolve, 2200));
686 });
687 ok(failureProbe?.hydrated === false && failureProbe.error.includes("history exceeds bridge limit"),
688 "exhausted ready-session hydration exposes a retryable error");
689 failHydration = false;
690 await act(async () => {
691 await failureProbe?.retryHydration();
692 await flush();
693 });
694 ok(failureProbe?.hydrated === true && failureProbe.error === "", "explicit hydration retry recovers the surface");
695 await act(async () => failureRoot.unmount());
696
697 // ── Hydration fence: an SSE event delivered while the snapshot is in flight
698 // is replayed after history instead of being overwritten by it. ──
699 let raceProbe: RemoteSessionApi | undefined;
700 function RaceProbe() {
701 raceProbe = useRemoteSession("tab-race");
702 return null;
703 }
704 const raceRoot = createRoot(document.createElement("div"));
705 await act(async () => {
706 raceRoot.render(<LocaleProvider><RaceProbe /></LocaleProvider>);
707 await Promise.resolve();
708 });
709 await act(async () => {
710 __emitMockRemoteTab("tab-race", "event", { kind: "turn_started" });
711 __emitMockRemoteTab("tab-race", "event", { kind: "text", text: "arrived during hydration" });
712 resolveRaceSnapshot?.({
713 history: [],
714 status: { running: true, label: "Race", plan: false, toolApprovalMode: "ask", goal: "" },
715 });
716 await flush();
717 });
718 ok(raceProbe?.transcript.live.text === "arrived during hydration", "hydration replays concurrently delivered remote events");
719 await act(async () => raceRoot.unmount());
720
721 let stateRaceProbe: RemoteSessionApi | undefined;
722 function StateRaceProbe() {
723 stateRaceProbe = useRemoteSession("tab-state-race");
724 return null;
725 }
726 const stateRaceRoot = createRoot(document.createElement("div"));
727 await act(async () => {
728 stateRaceRoot.render(<LocaleProvider><StateRaceProbe /></LocaleProvider>);
729 await Promise.resolve();
730 });
731 await act(async () => {
732 __emitMockRemoteTab("tab-state-race", "state", { state: "reconnecting" });
733 __emitMockRemoteTab("tab-state-race", "state", { state: "ready" });
734 resolveStateRaceSnapshots[0]?.({
735 history: [],
736 status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" },
737 });
738 await flush();
739 });
740 await act(async () => {
741 resolveStateRaceSnapshots[1]?.({
742 history: [{ role: "assistant", content: "fresh generation" }],
743 status: { running: false, label: "Fresh", plan: false, toolApprovalMode: "ask", goal: "" },
744 });
745 await flush();
746 });
747 ok(stateRaceProbe?.state === "ready" && stateRaceProbe.hydrated === true
748 && stateRaceProbe.modelLabel === "Fresh"
749 && stateRaceProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh generation"),
750 "a ready generation re-hydrates after discarding the stale in-flight snapshot");
751 await act(async () => stateRaceRoot.unmount());
752
753 // Post-turn reconciliation can overlap a ready-to-ready /new, /clear, or
754 // resume. The old history response must not replace the newly adopted session.
755 let rotationProbe: RemoteSessionApi | undefined;
756 function RotationProbe() { rotationProbe = useRemoteSession("tab-reconcile-rotation"); return null; }
757 const rotationRoot = createRoot(document.createElement("div"));
758 await act(async () => { rotationRoot.render(<LocaleProvider><RotationProbe /></LocaleProvider>); await flush(); });
759 await act(async () => {
760 __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" });
761 __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" });
762 await Promise.resolve();
763 __emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" });
764 await flush();
765 });
766 await act(async () => {
767 __emitMockRemoteTab("tab-reconcile-rotation", "state", { state: "ready" });
768 await flush();
769 });
770 ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true,
771 "ready-to-ready rotation hydrates the adopted session while old reconciliation is pending");
772 await act(async () => { __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_started" }); __emitMockRemoteTab("tab-reconcile-rotation", "event", { kind: "turn_done" }); await Promise.resolve(); });
773 await act(async () => {
774 resolveRotationReconcile?.({
775 history: [{ role: "assistant", content: "stale previous session" }],
776 status: { running: false, label: "Stale", plan: false, toolApprovalMode: "ask", goal: "" },
777 });
778 await flush();
779 });
780 ok(rotationProbe?.transcript.items.some((item) => item.kind === "assistant" && item.text === "fresh rotated session") === true
781 && !rotationProbe.transcript.items.some((item) => item.kind === "assistant" && item.text === "stale previous session"),
782 "session generation fence rejects stale history and hands reconciliation to the new generation");
783 await act(async () => rotationRoot.unmount());
784
785 await (await import("./helpers/remoteHistoryProjectionCases")).runRemotePendingPromptReplayCase({ ok, flush });
786 await (await import("./helpers/remoteRuntimeReconciliationCases")).runRemoteRuntimeCases({ commands: desktopStub.commands as unknown as AppBindings, emitRemote: __emitMockRemoteTab, remoteTab, ok, tape, flush, setSnapshotHistory: value => { snapshotHistory = value; } });
787 dom.window.close();
788 process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
789 if (failed > 0) process.exit(1);
790
790 lines Plain Text