返回 DeepSeek-Reasonix
decision-surface.test.tsx
根目录 / desktop / frontend / src / __tests__ / decision-surface.test.tsx
1 // Run: tsx src/__tests__/decision-surface.test.tsx
2 //
3 // Decision surfaces: ordinary approvals stay select-then-confirm while Plan
4 // and Auto boundary cards use immediate buttons; no double submit.
5
6 import { readFileSync } from "node:fs";
7 import { JSDOM } from "jsdom";
8 import React from "react";
9 import { act } from "react";
10 import { createRoot } from "react-dom/client";
11 import gsap from "gsap";
12 import { ApprovalModal } from "../components/ApprovalModal";
13 import { ClearContextCard } from "../components/ClearContextCard";
14 import { RuntimeDecisionCard } from "../components/RuntimeDecisionCard";
15 import { LocaleProvider } from "../lib/i18n";
16 import {
17 DECISION_SURFACE_MOCK_TRIGGERS,
18 LONG_DECISION_OPTIONS_MOCK_TRIGGER,
19 decisionSurfaceMockFromInput,
20 isLongDecisionOptionsMockInput,
21 } from "../lib/decisionSurfaceMock";
22 import type { WireApproval } from "../lib/types";
23
24 const styles = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
25
26 let passed = 0;
27 let failed = 0;
28
29 type GsapToOptions = { onComplete?: () => void };
30 const gsapForTests = (typeof gsap.to === "function" ? gsap : (gsap as unknown as { default?: typeof gsap }).default) as unknown as {
31 to?: (target: unknown, vars: GsapToOptions) => unknown;
32 };
33 if (typeof gsapForTests.to === "function") {
34 gsapForTests.to = (_target: unknown, vars: GsapToOptions) => {
35 vars.onComplete?.();
36 return {};
37 };
38 }
39
40 function ok(value: boolean, label: string) {
41 if (value) {
42 process.stdout.write(` PASS ${label}\n`);
43 passed += 1;
44 } else {
45 process.stdout.write(` FAIL ${label}\n`);
46 failed += 1;
47 }
48 }
49
50 function eq(actual: unknown, expected: unknown, label: string) {
51 if (actual === expected) ok(true, label);
52 else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
53 }
54
55 function flushTimers(ms = 0): Promise<void> {
56 return new Promise((resolve) => setTimeout(resolve, ms));
57 }
58
59 function installDom(language = "en-US", descriptionOverflows = true) {
60 const dom = new JSDOM("<!doctype html><html><head></head><body><div id=\"root\"></div></body></html>", {
61 pretendToBeVisual: true,
62 url: "http://localhost/",
63 });
64 (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
65 globalThis.window = dom.window as unknown as Window & typeof globalThis;
66 globalThis.document = dom.window.document;
67 Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
68 Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
69 globalThis.Node = dom.window.Node;
70 globalThis.Element = dom.window.Element;
71 globalThis.HTMLElement = dom.window.HTMLElement;
72 globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
73 globalThis.Event = dom.window.Event;
74 globalThis.KeyboardEvent = dom.window.KeyboardEvent;
75 globalThis.MouseEvent = dom.window.MouseEvent;
76 globalThis.localStorage = dom.window.localStorage;
77 globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
78 globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
79 Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
80 Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
81 Object.defineProperty(dom.window.HTMLElement.prototype, "clientHeight", {
82 configurable: true,
83 get() {
84 return this.classList.contains("prompt-action__desc") ? 42 : 0;
85 },
86 });
87 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollHeight", {
88 configurable: true,
89 get() {
90 return this.classList.contains("prompt-action__desc")
91 ? (descriptionOverflows ? 84 : 42)
92 : 0;
93 },
94 });
95 Object.defineProperty(dom.window.HTMLElement.prototype, "scrollIntoView", {
96 configurable: true,
97 value() {
98 this.setAttribute("data-scrolled-into-view", "true");
99 },
100 });
101 const style = document.createElement("style");
102 style.textContent = styles;
103 document.head.appendChild(style);
104 return dom;
105 }
106
107 console.log("\ndecision surface");
108
109 eq(Object.keys(DECISION_SURFACE_MOCK_TRIGGERS).length, 7, "QA stress scenes do not change the seven product decision surfaces");
110 for (const [kind, trigger] of Object.entries(DECISION_SURFACE_MOCK_TRIGGERS)) {
111 eq(decisionSurfaceMockFromInput(trigger), kind, `${kind} has a distinct canonical browser mock trigger`);
112 }
113 eq(decisionSurfaceMockFromInput("mock 工作区冲突"), "workspace_conflict", "Chinese mock phrases remain convenient for visual QA");
114 eq(decisionSurfaceMockFromInput("/approve-preview"), "tool_approval", "legacy approval preview trigger remains compatible");
115 eq(isLongDecisionOptionsMockInput(LONG_DECISION_OPTIONS_MOCK_TRIGGER), true, "long-option QA has a canonical browser trigger");
116 eq(isLongDecisionOptionsMockInput("mock 长文案选项"), true, "long-option QA has a convenient Chinese trigger");
117 eq(decisionSurfaceMockFromInput(LONG_DECISION_OPTIONS_MOCK_TRIGGER), null, "long-option QA is not counted as an eighth product surface");
118
119 // Plan exposes start, revise, and leave-without-executing as direct buttons so
120 // declining the current plan never traps the user in Plan mode.
121 {
122 const dom = installDom();
123 const root = createRoot(document.getElementById("root")!);
124 const answers: Array<[boolean, boolean, boolean]> = [];
125 const revisions: string[] = [];
126 let exits = 0;
127 const approval: WireApproval = {
128 id: "plan-1",
129 tool: "exit_plan_mode",
130 subject: "Plan ready",
131 };
132
133 await act(async () => {
134 root.render(
135 <LocaleProvider>
136 <ApprovalModal
137 approval={approval}
138 onAnswer={(a, s, p) => answers.push([a, s, p])}
139 onRevisePlan={(text) => revisions.push(text)}
140 onExitPlan={() => { exits += 1; }}
141 onStop={() => undefined}
142 />
143 </LocaleProvider>,
144 );
145 await flushTimers();
146 });
147
148 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
149 eq(actions.length, 3, "Plan has start, revise, and exit-without-executing actions");
150 eq(document.querySelector(".prompt-shelf__actions")?.getAttribute("role"), "group", "Plan actions use button-group semantics");
151 ok(actions.every((action) => action.getAttribute("role") === "button"), "Plan actions are announced as buttons");
152 ok(!document.querySelector(".decision-confirm-bar__confirm"), "Plan has no redundant confirm button");
153 ok(actions[2].textContent?.includes("Exit without executing"), "Plan card exposes a clear non-executing exit");
154 ok(!document.body.textContent?.includes("Stop task"), "Plan card relies on the global Stop control");
155 const planDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
156 if (!planDescriptionToggle) throw new Error("Plan description disclosure did not render");
157 await act(async () => {
158 planDescriptionToggle.click();
159 await flushTimers();
160 });
161 eq(answers.length, 0, "expanding a Plan description never starts execution");
162 eq(planDescriptionToggle.getAttribute("aria-expanded"), "true", "Plan disclosure announces its expanded state");
163
164 await act(async () => {
165 actions[1].click();
166 await flushTimers();
167 });
168 ok(document.querySelector(".plan-revision__input") != null, "Revise opens the inline editor in one click");
169 eq(answers.length, 0, "Opening revision does not start execution");
170
171 await act(async () => {
172 actions[2].click();
173 actions[2].click();
174 await flushTimers(220);
175 });
176 eq(exits, 1, "Exit without executing runs once and ignores a double click");
177 eq(answers.length, 0, "Exit without executing never approves plan execution");
178 eq(revisions.length, 0, "Exit without executing does not submit a plan revision");
179
180 await act(async () => {
181 root.unmount();
182 });
183 dom.window.close();
184 }
185
186 {
187 const dom = installDom();
188 const root = createRoot(document.getElementById("root")!);
189 let exits = 0;
190
191 await act(async () => {
192 root.render(
193 <LocaleProvider>
194 <ApprovalModal
195 approval={{ id: "plan-exit-key", tool: "exit_plan_mode", subject: "Plan ready" }}
196 onAnswer={() => undefined}
197 onExitPlan={() => { exits += 1; }}
198 onStop={() => undefined}
199 />
200 </LocaleProvider>,
201 );
202 await flushTimers();
203 });
204
205 await act(async () => {
206 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
207 await flushTimers(220);
208 });
209 eq(exits, 1, "number key 3 exits Plan without execution");
210
211 await act(async () => {
212 root.unmount();
213 });
214 dom.window.close();
215 }
216
217 {
218 const dom = installDom();
219 const root = createRoot(document.getElementById("root")!);
220 const answers: Array<[boolean, boolean, boolean]> = [];
221
222 await act(async () => {
223 root.render(
224 <LocaleProvider>
225 <ApprovalModal
226 approval={{ id: "plan-start", tool: "exit_plan_mode", subject: "Plan ready" }}
227 onAnswer={(a, s, p) => answers.push([a, s, p])}
228 onStop={() => undefined}
229 />
230 </LocaleProvider>,
231 );
232 await flushTimers();
233 });
234
235 const start = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")]
236 .find((action) => action.textContent?.includes("Start execution")) as HTMLButtonElement;
237 await act(async () => {
238 start.click();
239 start.click();
240 await flushTimers(220);
241 });
242 eq(answers.length, 1, "Plan starts with one click and ignores double submit");
243 eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "Plan start approves execution once");
244
245 await act(async () => {
246 root.unmount();
247 });
248 dom.window.close();
249 }
250
251 // Tool approval: click only selects; confirm submits once; double-confirm ignored.
252 {
253 const dom = installDom();
254 const root = createRoot(document.getElementById("root")!);
255 const answers: Array<[boolean, boolean, boolean]> = [];
256 const approval: WireApproval = {
257 id: "bash-1",
258 tool: "bash",
259 subject: "ls -la",
260 };
261
262 await act(async () => {
263 root.render(
264 <LocaleProvider>
265 <ApprovalModal
266 approval={approval}
267 onAnswer={(a, s, p) => answers.push([a, s, p])}
268 onStop={() => undefined}
269 />
270 </LocaleProvider>,
271 );
272 await flushTimers();
273 });
274
275 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
276 eq(actions.length, 4, "ordinary tool approval has four options");
277 ok(actions[0].classList.contains("prompt-action--selected"), "default selection is allow once");
278 ok(Boolean(actions[0].title), "tool approval keeps the complete option description in a desktop tooltip");
279 const toolDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
280 if (!toolDescriptionToggle) throw new Error("tool approval description disclosure did not render");
281 const toolContent = document.querySelector(".prompt-shelf__content") as HTMLElement | null;
282 const toolFooter = document.querySelector(".prompt-shelf__footer") as HTMLElement | null;
283 if (!toolContent || !toolFooter) throw new Error("tool approval scroll layout did not render");
284 eq(window.getComputedStyle(toolContent).overflow, "auto", "tool approval uses the shared decision scroller");
285 eq(toolContent.contains(toolFooter), false, "tool approval confirm footer stays visible outside the scroller");
286 await act(async () => {
287 toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
288 key: "Enter",
289 bubbles: true,
290 cancelable: true,
291 }));
292 await flushTimers();
293 });
294 eq(answers.length, 0, "Enter on tool disclosure never approves the default permission");
295 eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands tool approval copy");
296 await act(async () => {
297 toolDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
298 key: "Enter",
299 bubbles: true,
300 cancelable: true,
301 }));
302 await flushTimers();
303 });
304 eq(toolDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses tool approval copy again");
305 await act(async () => {
306 toolDescriptionToggle.click();
307 await flushTimers();
308 });
309 eq(answers.length, 0, "expanding tool approval copy never submits the selected permission");
310 eq(toolDescriptionToggle.getAttribute("aria-expanded"), "true", "tool approval disclosure announces its expanded state");
311
312 await act(async () => {
313 actions[3].click();
314 await flushTimers();
315 });
316 eq(answers.length, 0, "clicking deny only selects");
317 ok(actions[3].classList.contains("prompt-action--selected"), "deny becomes selected");
318
319 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
320 await act(async () => {
321 confirm.click();
322 confirm.click();
323 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
324 await flushTimers(220);
325 });
326 eq(answers.length, 1, "double click/enter submits only once");
327 eq(JSON.stringify(answers[0]), JSON.stringify([false, false, false]), "deny maps to (false,false,false)");
328
329 await act(async () => {
330 root.unmount();
331 });
332 dom.window.close();
333 }
334
335 // Auto reuses the decision shelf with one-click continue or revise. Task
336 // cancellation stays on the ordinary Stop control instead of becoming a third
337 // recovery-specific branch. Details stay collapsed; no select-then-confirm.
338 {
339 const dom = installDom();
340 const root = createRoot(document.getElementById("root")!);
341 const decisions: Array<{ action: string; feedback?: string }> = [];
342 const approval: WireApproval = {
343 id: "guard-1",
344 tool: "bash",
345 subject: "git push origin feature",
346 kind: "recovery",
347 recovery: {
348 next_action: "git push origin feature",
349 change_kind: "risk",
350 can_grant_task: true,
351 task_grant_scope: "git push origin → feature",
352 },
353 };
354
355 await act(async () => {
356 root.render(
357 <LocaleProvider>
358 <ApprovalModal
359 approval={approval}
360 onAnswer={() => undefined}
361 onResolveRecovery={(action, feedback) => decisions.push({ action, feedback })}
362 onStop={() => undefined}
363 />
364 </LocaleProvider>,
365 );
366 await flushTimers();
367 });
368
369 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
370 eq(actions.length, 2, "Auto recovery has continue and try-another actions");
371 eq(document.querySelector(".prompt-shelf__actions")?.getAttribute("role"), "group", "Auto boundary actions use button-group semantics");
372 ok(actions.every((action) => action.getAttribute("role") === "button"), "Auto boundary actions are announced as buttons");
373 ok(!actions.some((action) => action.textContent?.includes("Stop task")), "Auto recovery does not add a third Stop decision");
374 ok(!document.body.textContent?.includes("Stop task"), "Auto boundary card relies on the global Stop control");
375 ok(!document.querySelector(".decision-confirm-bar__confirm"), "Auto recovery has no select-then-confirm bar");
376 ok(document.body.textContent?.includes("Action needs confirmation"), "Auto boundary uses plain confirmation copy");
377 ok(!document.body.textContent?.includes("Auto needs"), "Auto boundary hides the internal mechanism name");
378 ok(!document.body.textContent?.includes("checkpoint"), "UI hides internal checkpoint terms");
379 ok(!document.body.textContent?.includes("same_strategy"), "UI hides internal reviewer terms");
380 ok(actions[0].textContent?.includes("Try another approach (recommended)"), "safer action is first and explicitly recommended");
381 ok(actions[0].classList.contains("prompt-action--selected"), "recommended recovery action has primary emphasis");
382 ok(actions[1].textContent?.includes("Continue once"), "one-shot override remains available as the secondary action");
383 ok(document.querySelector(".recovery-summary"), "Auto boundary shows one concise summary by default");
384 ok(document.body.textContent?.includes("may affect an external system"), "summary explains the user-visible risk");
385 eq(document.body.textContent?.split("git push origin feature").length, 2, "pending action is shown once by default");
386 ok(!document.querySelector(".recovery-details"), "details stay collapsed by default");
387 const guidanceTrigger = document.querySelector(".recovery-guidance-trigger") as HTMLButtonElement;
388 ok(guidanceTrigger, "custom requirements stay available as a quiet progressive-disclosure link");
389 ok(guidanceTrigger.textContent?.includes("Tell Auto"), "guidance link uses plain user-facing copy");
390 ok(!document.querySelector(".recovery-guidance__input"), "custom requirements editor stays collapsed by default");
391 eq(actions.length, 2, "custom requirements do not become a third decision card");
392 const recoveryDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
393 if (!recoveryDescriptionToggle) throw new Error("recovery description disclosure did not render");
394 const recoveryCard = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__card") as HTMLElement | null;
395 const recoveryContent = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__content") as HTMLElement | null;
396 const recoveryActions = document.querySelector(".prompt-shelf--recovery-approval .prompt-shelf__actions") as HTMLElement | null;
397 if (!recoveryCard || !recoveryContent || !recoveryActions) throw new Error("recovery height bounds did not render");
398 eq(window.getComputedStyle(recoveryCard).maxHeight, "min(62vh, 560px)", "recovery card stays bounded by the viewport");
399 eq(window.getComputedStyle(recoveryCard).overflow, "hidden", "recovery card clips only at its shared scroll boundary");
400 eq(window.getComputedStyle(recoveryContent).overflow, "auto", "recovery content uses one internal scroller");
401 eq(window.getComputedStyle(recoveryActions).maxHeight, "none", "recovery options avoid a nested height cap");
402 eq(window.getComputedStyle(recoveryActions).overflow, "visible", "recovery option text remains available in the shared scroller");
403 await act(async () => {
404 recoveryDescriptionToggle.click();
405 await flushTimers();
406 });
407 eq(decisions.length, 0, "expanding recovery details never resolves the pending action");
408 const taskGrant = document.querySelector(".recovery-task-grant input") as HTMLInputElement;
409 ok(taskGrant, "bounded recovery offers a current-task semantic grant");
410 ok(!taskGrant.checked, "task grant is opt-in");
411 ok(document.querySelector(".recovery-continue-option .recovery-task-grant"), "task grant is grouped with Continue");
412 ok(document.body.textContent?.includes("git push origin → feature"), "task grant shows the exact host-classified scope");
413
414 await act(async () => {
415 actions[1].click();
416 actions[1].click();
417 await flushTimers(220);
418 });
419 eq(decisions.length, 1, "double click submits only once");
420 eq(decisions[0]?.action, "continue", "continue resolves only the waiting action");
421
422 await act(async () => {
423 root.unmount();
424 });
425 dom.window.close();
426 }
427
428 // Specific requirements are one-shot guidance for finding another approach.
429 // They never inherit a checked task grant or become approval to execute.
430 {
431 const dom = installDom();
432 const root = createRoot(document.getElementById("root")!);
433 const decisions: Array<{ action: string; feedback?: string }> = [];
434 const approval: WireApproval = {
435 id: "guard-guidance",
436 tool: "bash",
437 subject: "git push origin feature",
438 kind: "recovery",
439 recovery: {
440 next_action: "git push origin feature",
441 change_kind: "risk",
442 can_grant_task: true,
443 },
444 };
445
446 await act(async () => {
447 root.render(
448 <LocaleProvider>
449 <ApprovalModal
450 approval={approval}
451 onAnswer={() => undefined}
452 onResolveRecovery={(action, feedback) => decisions.push({ action, feedback })}
453 onStop={() => undefined}
454 />
455 </LocaleProvider>,
456 );
457 await flushTimers();
458 });
459
460 const openGuidance = async () => {
461 await act(async () => {
462 (document.querySelector(".recovery-guidance-trigger") as HTMLButtonElement).click();
463 await flushTimers();
464 });
465 };
466 ok(
467 document.body.textContent?.includes("Only the same operation type and target boundary are reused"),
468 "older recovery events without a display scope retain the generic safety explanation",
469 );
470 await openGuidance();
471
472 let input = document.querySelector(".recovery-guidance__input") as HTMLTextAreaElement;
473 ok(input != null, "guidance link expands an inline text area");
474 eq(input.maxLength, 1000, "guidance input exposes the same 1000-character client limit as submission");
475 ok(input.placeholder.includes("only edit the current file"), "placeholder demonstrates a concrete constraint");
476 ok(input === document.activeElement, "expanded guidance receives focus");
477 ok((document.querySelector(".recovery-guidance__actions .btn--primary") as HTMLButtonElement).disabled, "empty guidance cannot submit");
478 eq(document.querySelectorAll(".prompt-shelf__actions .prompt-action").length, 2, "expanded guidance preserves the two primary decisions");
479
480 await act(async () => {
481 input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
482 await flushTimers(25);
483 });
484 ok(!document.querySelector(".recovery-guidance__input"), "Escape collapses custom guidance");
485 ok(document.querySelector(".recovery-guidance-trigger") === document.activeElement, "Escape restores focus to the guidance link");
486 eq(decisions.length, 0, "collapsing guidance does not answer the confirmation");
487
488 await act(async () => {
489 (document.querySelector(".recovery-task-grant input") as HTMLInputElement).click();
490 await flushTimers();
491 });
492 await openGuidance();
493 ok(!document.querySelector(".recovery-task-grant"), "guidance hides and clears the unrelated Continue task grant");
494 input = document.querySelector(".recovery-guidance__input") as HTMLTextAreaElement;
495 const feedback = "Only edit the current file; do not push.";
496 await act(async () => {
497 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, "value")?.set;
498 setter?.call(input, ` ${feedback} `);
499 input.dispatchEvent(new dom.window.InputEvent("input", { bubbles: true, inputType: "insertText", data: feedback }));
500 input.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
501 input.dispatchEvent(new dom.window.KeyboardEvent("keyup", { key: ".", bubbles: true }));
502 await flushTimers();
503 });
504 ok(!(document.querySelector(".recovery-guidance__actions .btn--primary") as HTMLButtonElement).disabled, "non-empty guidance enables submission");
505
506 await act(async () => {
507 input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }));
508 await flushTimers(220);
509 });
510 eq(decisions.length, 1, "Ctrl+Enter submits custom requirements once");
511 eq(decisions[0]?.action, "revise", "custom requirements always choose another approach");
512 eq(decisions[0]?.feedback, feedback, "custom requirements are trimmed and forwarded exactly once");
513
514 await act(async () => root.unmount());
515 dom.window.close();
516 }
517
518 // The optional semantic grant is explicit and maps to a distinct backend
519 // action; it is never inferred from a raw command match.
520 {
521 const dom = installDom();
522 const root = createRoot(document.getElementById("root")!);
523 const decisions: Array<{ action: string; feedback?: string }> = [];
524 const approval: WireApproval = {
525 id: "guard-task-grant",
526 tool: "bash",
527 subject: "git push origin feature",
528 kind: "recovery",
529 recovery: {
530 next_action: "git push origin feature",
531 change_kind: "risk",
532 can_grant_task: true,
533 task_grant_scope: "git push origin → feature",
534 },
535 };
536
537 await act(async () => {
538 root.render(
539 <LocaleProvider>
540 <ApprovalModal
541 approval={approval}
542 onAnswer={() => undefined}
543 onResolveRecovery={(action, feedback) => decisions.push({ action, feedback })}
544 onStop={() => undefined}
545 />
546 </LocaleProvider>,
547 );
548 await flushTimers();
549 });
550
551 const taskGrant = document.querySelector(".recovery-task-grant input") as HTMLInputElement;
552 const continueButton = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")]
553 .find((action) => action.textContent?.includes("Continue once")) as HTMLButtonElement;
554 await act(async () => {
555 taskGrant.click();
556 await flushTimers();
557 });
558 ok(continueButton.textContent?.includes("Continue and remember for this task"), "checked grant updates the action label before consent");
559 ok(!continueButton.textContent?.includes("Continue once"), "checked grant no longer looks like a one-shot action");
560 await act(async () => {
561 continueButton.click();
562 await flushTimers(220);
563 });
564 eq(decisions[0]?.action, "continue_task", "checked semantic grant uses the task-scoped recovery action");
565
566 await act(async () => root.unmount());
567 dom.window.close();
568 }
569
570 // A reviewer-confirmed scope/strategy transition is presented as a plan-level
571 // choice rather than another low-level tool warning.
572 {
573 const dom = installDom();
574 const root = createRoot(document.getElementById("root")!);
575 const decisions: Array<{ action: string; feedback?: string }> = [];
576 const approval: WireApproval = {
577 id: "guard-2",
578 tool: "todo_write",
579 subject: "Update the active execution plan",
580 kind: "recovery",
581 recovery: {
582 next_action: "Update the active execution plan",
583 change_kind: "scope",
584 change_rationale: "Publishing the migration changes the product scope.",
585 plan_before: "1. Keep the public API [in_progress]\n2. Update the implementation [pending]",
586 plan_after: "1. Replace the public API [in_progress]\n2. Update the implementation [pending]\n3. Update the migration guide [pending]",
587 },
588 };
589
590 await act(async () => {
591 root.render(
592 <LocaleProvider>
593 <ApprovalModal
594 approval={approval}
595 onAnswer={() => undefined}
596 onResolveRecovery={(action, feedback) => decisions.push({ action, feedback })}
597 onStop={() => undefined}
598 />
599 </LocaleProvider>,
600 );
601 await flushTimers();
602 });
603
604 let actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
605 ok(document.body.textContent?.includes("The execution plan needs your decision"), "material scope change uses a neutral plan-level title");
606 ok(document.body.textContent?.includes("Scope choice"), "plan card names the user-owned decision class");
607 ok(document.body.textContent?.includes("Removed from the previous plan"), "plan card identifies removed steps");
608 ok(document.body.textContent?.includes("Keep the public API"), "plan card shows the removed step");
609 ok(document.body.textContent?.includes("Added to the new plan"), "plan card identifies added steps");
610 ok(document.body.textContent?.includes("Replace the public API"), "plan card shows the replacement step");
611 ok(document.body.textContent?.includes("Update the migration guide"), "plan card shows newly added scope");
612 ok(!document.body.textContent?.includes("[in_progress]"), "plan delta omits progress-only status noise");
613 ok(actions[0].textContent?.includes("Adopt the new plan and continue"), "plan adoption remains explicit");
614 ok(actions[1].textContent?.includes("Do not adopt; tell Auto how to adjust"), "plan rejection opens a guided revision path");
615 ok(!actions[0].classList.contains("prompt-action--selected"), "plan adoption is not visually preselected");
616 ok(!actions[1].classList.contains("prompt-action--selected"), "plan adjustment is not visually preselected");
617 ok(!document.querySelector(".recovery-task-grant"), "unbounded scope change does not offer a task grant");
618 const detailsButton = document.querySelector(".prompt-shelf__header-button") as HTMLButtonElement;
619 ok(detailsButton.textContent?.includes("Technical details"), "technical diagnostics are available on demand");
620 await act(async () => {
621 detailsButton.click();
622 await flushTimers();
623 });
624 ok(document.querySelector(".recovery-details"), "technical details expand on request");
625 ok(document.querySelector(".recovery-details .recovery-detail-row"), "expanded diagnostics use restrained detail rows");
626 ok(!document.querySelector(".recovery-details .approval-reason"), "expanded diagnostics do not render an alert wall");
627 await act(async () => {
628 actions[1].click();
629 await flushTimers();
630 });
631 eq(decisions.length, 0, "opening plan adjustment does not reject or adopt the proposal");
632 const guidance = document.querySelector(".recovery-guidance__input") as HTMLTextAreaElement;
633 ok(guidance === document.activeElement, "plan adjustment opens and focuses the guidance field");
634 const feedback = "Keep the public API and update only the migration guide.";
635 await act(async () => {
636 const setter = Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, "value")?.set;
637 setter?.call(guidance, feedback);
638 guidance.dispatchEvent(new dom.window.InputEvent("input", { bubbles: true, inputType: "insertText", data: feedback }));
639 guidance.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
640 guidance.dispatchEvent(new dom.window.KeyboardEvent("keyup", { key: ".", bubbles: true }));
641 await flushTimers();
642 });
643 const submitGuidance = document.querySelector(".recovery-guidance__actions .btn--primary") as HTMLButtonElement;
644 ok(submitGuidance.textContent?.includes("Submit adjustment guidance"), "plan guidance uses an explicit submit action");
645 ok(!submitGuidance.disabled, "non-empty plan guidance enables submission");
646 await act(async () => {
647 submitGuidance.click();
648 await flushTimers(220);
649 });
650 eq(decisions[0]?.action, "revise", "submitted plan guidance rejects the proposed transition");
651 eq(decisions[0]?.feedback, feedback, "plan adjustment forwards the user's exact guidance");
652
653 await act(async () => {
654 root.render(
655 <LocaleProvider>
656 <ApprovalModal
657 approval={{ ...approval, id: "guard-3" }}
658 onAnswer={() => undefined}
659 onResolveRecovery={(action, nextFeedback) => decisions.push({ action, feedback: nextFeedback })}
660 onStop={() => undefined}
661 />
662 </LocaleProvider>,
663 );
664 await flushTimers();
665 });
666 actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
667 await act(async () => {
668 actions[0].click();
669 await flushTimers(220);
670 });
671 eq(decisions[1]?.action, "continue", "adopting the plan approves the waiting transition once");
672
673 await act(async () => {
674 root.unmount();
675 });
676 dom.window.close();
677 }
678
679 // A fresh decision for a dynamic tool is one-shot.
680 {
681 const dom = installDom();
682 const root = createRoot(document.getElementById("root")!);
683 const answers: Array<[boolean, boolean, boolean]> = [];
684 const approval: WireApproval = {
685 id: "dynamic-danger-1",
686 tool: "extension__wipe",
687 subject: "Dynamic tool declares destructive side effects",
688 reason: "Review the target and arguments before allowing this call.",
689 fresh: true,
690 };
691
692 await act(async () => {
693 root.render(
694 <LocaleProvider>
695 <ApprovalModal
696 approval={approval}
697 onAnswer={(a, s, p) => answers.push([a, s, p])}
698 onStop={() => undefined}
699 />
700 </LocaleProvider>,
701 );
702 await flushTimers();
703 });
704
705 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
706 eq(actions.length, 2, "fresh dynamic-tool decision only offers allow once and deny");
707 ok(!document.body.textContent?.includes("Always allow"), "fresh dynamic-tool decision hides remembered grants");
708
709 const confirm = document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement;
710 await act(async () => {
711 confirm.click();
712 await flushTimers(220);
713 });
714 eq(JSON.stringify(answers[0]), JSON.stringify([true, false, false]), "fresh dynamic-tool decision is one-shot");
715
716 await act(async () => {
717 root.unmount();
718 });
719 dom.window.close();
720 }
721
722 // Runtime decisions expose direct actions, so their displayed number keys must
723 // invoke the same callbacks while preserving normal typing behavior.
724 {
725 const dom = installDom();
726 const root = createRoot(document.getElementById("root")!);
727 const selected: string[] = [];
728 let cancelled = 0;
729
730 await act(async () => {
731 root.render(
732 <LocaleProvider>
733 <RuntimeDecisionCard
734 id="runtime-shortcuts"
735 title="Choose runtime action"
736 badge="Runtime"
737 meta="Select an action"
738 actions={["1", "2", "3"].map((key) => ({
739 key,
740 label: `Action ${key}`,
741 description: `Run action ${key} across a deliberately long runtime boundary that must wrap without overlapping the next decision row.`,
742 danger: key === "3",
743 onClick: () => selected.push(key),
744 }))}
745 onCancel={() => { cancelled += 1; }}
746 />
747 </LocaleProvider>,
748 );
749 await flushTimers();
750 });
751
752 const runtimeActions = document.querySelector(".prompt-shelf__actions") as HTMLElement | null;
753 const runtimeAction = document.querySelector(".prompt-action") as HTMLElement | null;
754 const runtimeActionKey = runtimeAction?.querySelector(".prompt-action__key") as HTMLElement | null;
755 const runtimeActionLabel = runtimeAction?.querySelector(".prompt-action__label") as HTMLElement | null;
756 const runtimeActionDescription = runtimeAction?.querySelector(".prompt-action__desc") as HTMLElement | null;
757 if (!runtimeActions || !runtimeAction || !runtimeActionKey || !runtimeActionLabel || !runtimeActionDescription) {
758 throw new Error("runtime decision layout did not render");
759 }
760
761 const runtimeActionsStyle = window.getComputedStyle(runtimeActions);
762 eq(runtimeActionsStyle.gridAutoRows, "max-content", "decision row wrappers accommodate external details");
763 eq(runtimeActionsStyle.alignContent, "start", "all decision rows stay aligned at the top of the scroll region");
764
765 const runtimeActionStyle = window.getComputedStyle(runtimeAction);
766 eq(runtimeActionStyle.height, "38px", "shared decision rows keep a stable desktop height");
767 eq(runtimeActionStyle.minHeight, "38px", "shared decision rows retain a compact click target");
768 eq(runtimeActionStyle.alignItems, "center", "single-line decision copy stays vertically centered with the option key");
769 eq(window.getComputedStyle(runtimeActionKey).marginTop, "0px", "decision keys do not carry a top offset");
770 eq(window.getComputedStyle(runtimeActionLabel).fontWeight, "620", "decision labels keep a clear visual hierarchy");
771
772 const runtimeDescriptionStyle = window.getComputedStyle(runtimeActionDescription);
773 eq(runtimeDescriptionStyle.whiteSpace, "nowrap", "shared decision descriptions stay on one summary line");
774 eq(runtimeDescriptionStyle.display, "block", "supplementary runtime copy uses ordinary single-line flow");
775 eq(runtimeDescriptionStyle.overflow, "hidden", "collapsed runtime summaries stay inside their row");
776 eq(runtimeDescriptionStyle.textOverflow, "ellipsis", "long runtime summaries end with an ellipsis");
777 eq(runtimeDescriptionStyle.lineHeight, "1.4", "runtime summaries keep readable density");
778 const runtimeDescriptionToggle = document.querySelector(".prompt-action-row .prompt-action__description-toggle") as HTMLButtonElement | null;
779 if (!runtimeDescriptionToggle) throw new Error("runtime description disclosure did not render");
780 eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "false", "runtime description starts collapsed");
781 await act(async () => {
782 runtimeDescriptionToggle.click();
783 await flushTimers();
784 });
785 eq(selected.length, 0, "expanding runtime details never triggers the decision action");
786 eq(runtimeDescriptionToggle.getAttribute("aria-expanded"), "true", "runtime description expansion is announced");
787 eq(window.getComputedStyle(runtimeAction).alignItems, "center", "expanded details keep the runtime row vertically centered");
788 eq(window.getComputedStyle(runtimeActionDescription).overflow, "hidden", "expanded details do not alter the runtime row summary");
789 const runtimeDetail = runtimeAction.closest(".prompt-action-row")?.querySelector(".prompt-description-detail") as HTMLElement | null;
790 if (!runtimeDetail) throw new Error("runtime detail region did not render");
791 eq(runtimeDetail.hidden, false, "runtime full copy opens in a separate detail region");
792 eq(runtimeDetail.textContent?.includes("deliberately long runtime boundary"), true, "runtime detail region reveals the full copy");
793 const runtimeActionButtons = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLElement[];
794 const dangerousRuntimeDescription = runtimeActionButtons[2]?.querySelector(".prompt-action__desc") as HTMLElement | null;
795 if (!dangerousRuntimeDescription) throw new Error("dangerous runtime description did not render");
796 const dangerousRuntimeDetail = runtimeActionButtons[2]
797 ?.closest(".prompt-action-row")
798 ?.querySelector(".prompt-description-detail") as HTMLElement | null;
799 if (!dangerousRuntimeDetail) throw new Error("dangerous runtime detail did not render");
800 eq(window.getComputedStyle(dangerousRuntimeDescription).overflow, "hidden", "dangerous runtime summary keeps the shared stable row");
801 eq(dangerousRuntimeDetail.hidden, false, "truncated dangerous runtime consequences open automatically outside the row");
802
803 await act(async () => {
804 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
805 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
806 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "3", bubbles: true }));
807 await flushTimers();
808 });
809 eq(selected.join(","), "1,2,3", "runtime decision number keys invoke their displayed actions");
810
811 await act(async () => {
812 root.render(
813 <LocaleProvider>
814 <RuntimeDecisionCard
815 id="runtime-shortcuts"
816 title="Choose runtime action"
817 badge="Runtime"
818 meta="Select an action"
819 actions={[{
820 key: "2", label: "Unavailable action", description: "Cannot run yet",
821 disabled: true, onClick: () => selected.push("disabled"),
822 }]}
823 onCancel={() => { cancelled += 1; }}
824 />
825 </LocaleProvider>,
826 );
827 await flushTimers();
828 });
829 await act(async () => {
830 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "2", bubbles: true }));
831 await flushTimers();
832 });
833 eq(selected.join(","), "1,2,3", "runtime decision shortcuts do not invoke disabled actions");
834
835 const input = document.createElement("input");
836 document.body.append(input);
837 await act(async () => {
838 input.dispatchEvent(new window.KeyboardEvent("keydown", { key: "1", bubbles: true }));
839 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
840 await flushTimers();
841 });
842 eq(selected.join(","), "1,2,3", "runtime decision shortcuts ignore editable fields");
843 eq(cancelled, 1, "Escape cancels the runtime decision");
844
845 await act(async () => {
846 root.unmount();
847 });
848 dom.window.close();
849 }
850
851 // A complete danger summary is already the full safety explanation. It must
852 // not be duplicated below the stable action row merely because it is dangerous.
853 {
854 const dom = installDom("en-US", false);
855 const root = createRoot(document.getElementById("root")!);
856
857 await act(async () => {
858 root.render(
859 <LocaleProvider>
860 <RuntimeDecisionCard
861 id="runtime-danger-complete"
862 title="Stop jobs"
863 badge="2 running"
864 meta="Stopping is required"
865 actions={[{
866 key: "1",
867 label: "Stop jobs and switch",
868 description: "Wait for the processes to exit, then switch modes.",
869 danger: true,
870 onClick: () => {},
871 }]}
872 onCancel={() => {}}
873 />
874 </LocaleProvider>,
875 );
876 await flushTimers();
877 });
878
879 eq(document.querySelectorAll(".prompt-action").length, 1, "complete dangerous runtime summary renders one action row");
880 eq(document.querySelector(".prompt-description-detail"), null, "complete dangerous runtime summary is not repeated below the row");
881 eq(document.querySelector(".prompt-action__description-toggle"), null, "complete dangerous runtime summary needs no redundant disclosure");
882
883 await act(async () => {
884 root.unmount();
885 });
886 dom.window.close();
887 }
888
889 // Clear context: default cancel; clear requires explicit confirm; Escape cancels.
890 {
891 const dom = installDom();
892 const root = createRoot(document.getElementById("root")!);
893 let cancelled = 0;
894 let confirmed = 0;
895
896 await act(async () => {
897 root.render(
898 <LocaleProvider>
899 <ClearContextCard onCancel={() => { cancelled += 1; }} onConfirm={() => { confirmed += 1; }} />
900 </LocaleProvider>,
901 );
902 await flushTimers();
903 });
904
905 const actions = [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
906 ok(actions[0].classList.contains("prompt-action--selected"), "clear context defaults to cancel");
907 eq(
908 document.querySelector(".prompt-shelf__meta")?.textContent,
909 "This deletes the current transcript from local history and keeps only the system prompt.",
910 "clear context combines the consequence into one header summary",
911 );
912 ok(document.querySelector(".prompt-shelf__body") == null, "clear context does not repeat the consequence in a second body row");
913 const clearDescriptionToggle = document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle") as HTMLButtonElement | null;
914 if (!clearDescriptionToggle) throw new Error("clear-context description disclosure did not render");
915 await act(async () => {
916 clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
917 key: "Enter",
918 bubbles: true,
919 cancelable: true,
920 }));
921 await flushTimers();
922 });
923 eq(cancelled, 0, "Enter on clear-context disclosure does not trigger the safe default");
924 eq(confirmed, 0, "Enter on clear-context disclosure never clears the conversation");
925 eq(clearDescriptionToggle.getAttribute("aria-expanded"), "true", "Enter expands clear-context copy");
926 await act(async () => {
927 clearDescriptionToggle.dispatchEvent(new window.KeyboardEvent("keydown", {
928 key: "Enter",
929 bubbles: true,
930 cancelable: true,
931 }));
932 await flushTimers();
933 });
934 eq(clearDescriptionToggle.getAttribute("aria-expanded"), "false", "Enter collapses clear-context copy again");
935 await act(async () => {
936 clearDescriptionToggle.click();
937 await flushTimers();
938 });
939 eq(cancelled, 0, "expanding clear-context copy does not trigger the safe default");
940 eq(confirmed, 0, "expanding clear-context copy does not clear anything");
941
942 await act(async () => {
943 actions[1].click();
944 await flushTimers();
945 });
946 eq(confirmed, 0, "clicking clear only selects");
947 ok(actions[1].classList.contains("prompt-action--selected"), "clear option becomes selected");
948 const dangerousClearDetail = document.querySelector(".prompt-shelf__footnote .prompt-description-detail") as HTMLElement | null;
949 if (!dangerousClearDetail) throw new Error("dangerous clear-context detail did not render");
950 eq(dangerousClearDetail.hidden, false, "truncated destructive consequence opens automatically after selection");
951 eq(document.querySelector(".prompt-shelf__footnote .prompt-action__description-toggle"), null, "auto-open destructive consequence has no redundant toggle");
952
953 await act(async () => {
954 (document.querySelector(".decision-confirm-bar__confirm") as HTMLButtonElement).click();
955 await flushTimers();
956 });
957 eq(confirmed, 1, "confirm runs clear once");
958
959 await act(async () => {
960 root.unmount();
961 });
962 dom.window.close();
963 }
964
965 {
966 const dom = installDom();
967 const root = createRoot(document.getElementById("root")!);
968 let cancelled = 0;
969
970 await act(async () => {
971 root.render(
972 <LocaleProvider>
973 <ClearContextCard onCancel={() => { cancelled += 1; }} onConfirm={() => undefined} />
974 </LocaleProvider>,
975 );
976 await flushTimers();
977 });
978
979 await act(async () => {
980 document.dispatchEvent(new window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
981 await flushTimers();
982 });
983 eq(cancelled, 1, "Escape cancels clear context immediately");
984
985 await act(async () => {
986 root.unmount();
987 });
988 dom.window.close();
989 }
990
991 // Composer decision host stays in the tree while visually hidden.
992 {
993 const dom = installDom();
994 const root = createRoot(document.getElementById("root")!);
995 await act(async () => {
996 root.render(
997 <div>
998 <div className="composer-decision-host composer-decision-host--hidden" hidden inert aria-hidden="true">
999 <textarea id="composer-input" defaultValue="draft text" />
1000 </div>
1001 </div>,
1002 );
1003 await flushTimers();
1004 });
1005
1006 const host = document.querySelector(".composer-decision-host") as HTMLElement;
1007 const input = document.getElementById("composer-input") as HTMLTextAreaElement;
1008 ok(host != null, "composer decision host remains mounted");
1009 ok(host.hasAttribute("hidden"), "host is hidden during decision");
1010 ok(host.hasAttribute("inert") || (host as HTMLElement & { inert?: boolean }).inert === true, "host is inert during decision");
1011 eq(input.value, "draft text", "draft value survives while host is hidden");
1012 eq(host.getAttribute("aria-hidden"), "true", "host is aria-hidden during decision");
1013
1014 await act(async () => {
1015 root.unmount();
1016 });
1017 dom.window.close();
1018 }
1019
1020 // New approval id resets selection and submitting state.
1021 {
1022 const dom = installDom();
1023 const root = createRoot(document.getElementById("root")!);
1024 const answers: Array<[boolean, boolean, boolean]> = [];
1025 let approval: WireApproval = { id: "a1", tool: "bash", subject: "echo 1" };
1026
1027 const paint = async (next: WireApproval) => {
1028 approval = next;
1029 await act(async () => {
1030 root.render(
1031 <LocaleProvider>
1032 <ApprovalModal
1033 approval={approval}
1034 onAnswer={(a, s, p) => answers.push([a, s, p])}
1035 onStop={() => undefined}
1036 />
1037 </LocaleProvider>,
1038 );
1039 await flushTimers();
1040 });
1041 };
1042
1043 await paint(approval);
1044 const actions = () => [...document.querySelectorAll(".prompt-shelf__actions .prompt-action")] as HTMLButtonElement[];
1045 await act(async () => {
1046 actions()[3].click();
1047 await flushTimers();
1048 });
1049 ok(actions()[3].classList.contains("prompt-action--selected"), "deny selected on first prompt");
1050
1051 await paint({ id: "a2", tool: "bash", subject: "echo 2" });
1052 ok(actions()[0].classList.contains("prompt-action--selected"), "new prompt id resets selection to allow once");
1053 eq(answers.length, 0, "selection reset does not submit");
1054
1055 await act(async () => {
1056 root.unmount();
1057 });
1058 dom.window.close();
1059 }
1060
1061 console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
1062 if (failed > 0) process.exit(1);
1063
1063 lines Plain Text