import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { AgentInteractivePromptSurface } from "./AgentInteractivePromptSurface"; import { setAgentGuiI18nTestLocale } from "../../i18n/testUtils"; const labels = { approvalLead: "Waiting for your approval", fileChangeApprovalLead: "Codex requests authorization to edit files", planLead: "Ready to implement. How should permissions work?", planModes: [ { id: "acceptEdits", label: "Accept edits", description: "Auto-approve file edits" }, { id: "default", label: "Ask for approval", description: "Prompt before each tool" }, { id: "bypassPermissions", label: "Allow all", description: "Do not prompt for tools" } ], stayInPlan: "Keep planning", sendFeedback: "Send feedback and keep planning", feedbackPlaceholder: "Give feedback to refine the plan...", previousQuestion: "Back", nextQuestion: "Next", submitAnswers: "Submit answers", answerPlaceholder: "Add details for the agent...", waitingForAnswer: "Waiting for your answer...", returnToConversation: "Back to conversation", continueAnswering: "Continue answering", planImplementationLead: "Implement this plan?", planImplementationConfirm: "Implement plan", planImplementationFeedbackPlaceholder: "Adjust the plan instead...", planImplementationSend: "Send adjustments", planImplementationSkip: "Stay in Plan Mode" }; describe("AgentInteractivePromptSurface", () => { beforeEach(() => { setAgentGuiI18nTestLocale("en"); }); it("submits approval options from the shared prompt surface", () => { const onSubmit = vi.fn(); const prompt = { kind: "approval" as const, id: "approval:request-approval", agentSessionId: "session-1", turnId: "turn-1", requestId: "request-approval", callId: "request-approval", title: "Run command", status: "waiting_approval" as const, toolName: "Bash", input: { command: "pnpm test --run renderer", description: "Verify the renderer parity fixes." }, options: [ { id: "allow_once", label: "Allow once", kind: "allow_once" as const, description: "Run this tool a single time." } ], output: null, occurredAtUnixMs: 1 }; render( ); expect(screen.getByText("Waiting for your approval")).toBeTruthy(); expect(screen.getByText("pnpm test --run renderer")).toBeTruthy(); expect(screen.getByText("Verify the renderer parity fixes.")).toBeTruthy(); expect(screen.getByText("Run this tool a single time.")).toBeTruthy(); expect( screen .getByText("pnpm test --run renderer") .closest(".agent-gui-conversation__interactive-option-display") ).toBeTruthy(); expect( screen .getByText("pnpm test --run renderer") .closest(".agent-gui-conversation__interactive-option-button") ).toBeNull(); expect( screen.getByTestId("agent-interaction-request-approval") ).toHaveAttribute("data-agent-interaction-kind", "approval"); expect( screen.getByTestId("agent-approval-request-approval-approve") ).toHaveAttribute("data-agent-approval-option-id", "allow_once"); fireEvent.click( screen.getByRole("button", { name: "Yes, proceed Run this tool a single time." }) ); expect(onSubmit).toHaveBeenCalledWith({ agentSessionId: "session-1", requestId: "request-approval", optionId: "allow_once", turnId: "turn-1" }); }); it("shows the disabled reason when an approval control is hovered", async () => { const reason = "The shared Agent owner is offline"; render( ); const disabledGroup = screen.getByRole("group", { name: reason }); expect(screen.getByRole("button", { name: "Yes, proceed" })).toBeDisabled(); expect(disabledGroup.firstElementChild).toHaveClass("pointer-events-none"); fireEvent.pointerMove(disabledGroup); expect(await screen.findByRole("tooltip")).toHaveTextContent(reason); }); it("shows approval reasons and file-change paths below the title", () => { render( ); const lead = screen .getByText("Codex requests authorization to edit files") .closest(".agent-gui-conversation__interactive-prompt-lead-content"); expect(lead).toHaveTextContent("Allow these file changes?"); expect(lead).toHaveTextContent("/workspace/session"); expect(screen.getAllByText("/workspace/session")).toHaveLength(1); expect(screen.queryByText("Summary")).toBeNull(); expect(screen.queryByText("Path")).toBeNull(); expect(screen.getByText("Files")).toBeTruthy(); expect(screen.getByText("app.js, styles.css")).toBeTruthy(); }); it("does not infer an edit-files title from file details", () => { render( ); expect(screen.getByText("Waiting for your approval")).toBeTruthy(); expect( screen.queryByText("Codex requests authorization to edit files") ).toBeNull(); }); it("clears approval option loading when external submission settles", async () => { const onSubmit = vi.fn(); const prompt = { kind: "approval" as const, id: "approval:request-approval", turnId: "turn-1", requestId: "request-approval", callId: "request-approval", title: "Run command", status: "waiting_approval" as const, toolName: "Bash", input: null, options: [ { id: "allow_once", label: "Allow once", kind: "allow_once" as const, description: "Run this tool a single time." } ], output: null, occurredAtUnixMs: 1 }; const { rerender } = render( ); fireEvent.click( screen.getByRole("button", { name: "Yes, proceed Run this tool a single time." }) ); expect(screen.getByTestId("agent-interactive-option-spinner")).toBeTruthy(); rerender( ); expect(screen.getByTestId("agent-interactive-option-spinner")).toBeTruthy(); rerender( ); await waitFor(() => { expect( screen.queryByTestId("agent-interactive-option-spinner") ).toBeNull(); }); expect( screen.getByRole("button", { name: "Yes, proceed Run this tool a single time." }) ).not.toBeDisabled(); }); it("submits abort approval options with optional feedback", () => { const onSubmit = vi.fn(); setAgentGuiI18nTestLocale("zh-CN"); render( ); expect( screen.queryByPlaceholderText(labels.feedbackPlaceholder) ).toBeNull(); expect( screen.getByTestId("agent-approval-request-approval-feedback") ).toHaveAttribute("data-agent-approval-option-id", "abort"); fireEvent.click( screen.getByRole("button", { name: "拒绝,然后发送新的指令" }) ); expect(onSubmit).not.toHaveBeenCalled(); expect( screen.queryByRole("button", { name: "拒绝,然后发送新的指令" }) ).toBeNull(); expect(screen.getByPlaceholderText(labels.feedbackPlaceholder)).toBe( document.activeElement ); expect( screen .getByPlaceholderText(labels.feedbackPlaceholder) .closest(".agent-gui-conversation__interactive-feedback-composer") ?.tagName ).toBe("DIV"); expect( screen .getByRole("button", { name: labels.sendFeedback }) .closest(".agent-gui-conversation__interactive-feedback-composer") ).toBe( screen .getByPlaceholderText(labels.feedbackPlaceholder) .closest(".agent-gui-conversation__interactive-feedback-composer") ); expect( screen.getByRole("button", { name: labels.sendFeedback }) ).toBeDisabled(); expect( screen.getByTestId("agent-approval-request-approval-feedback-input") ).toBe(screen.getByPlaceholderText(labels.feedbackPlaceholder)); expect( screen.getByTestId("agent-approval-request-approval-feedback-submit") ).toBe(screen.getByRole("button", { name: labels.sendFeedback })); const feedback = screen.getByPlaceholderText(labels.feedbackPlaceholder); fireEvent.change(feedback, { target: { value: "Please split the work into smaller steps." } }); fireEvent.keyDown(feedback, { key: "Enter", code: "Enter" }); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-approval", action: "deny", optionId: "abort", payload: { denyMessage: "Please split the work into smaller steps." } }); }); it("turns Claude Code reject approval options into feedback composers", () => { const onSubmit = vi.fn(); render( ); fireEvent.click( screen.getByRole("button", { name: "No, then send new instructions" }) ); expect(onSubmit).not.toHaveBeenCalled(); expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); expect(screen.getByPlaceholderText(labels.feedbackPlaceholder)).toBe( document.activeElement ); fireEvent.change(screen.getByPlaceholderText(labels.feedbackPlaceholder), { target: { value: "Explain why this needs approval instead." } }); fireEvent.click(screen.getByRole("button", { name: labels.sendFeedback })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-approval", action: "deny", optionId: "reject", payload: { denyMessage: "Explain why this needs approval instead." } }); }); it("prefers a non-terminal reject option for feedback over abort", () => { const onSubmit = vi.fn(); render( ); expect( screen.getByRole("button", { name: "No, then send new instructions" }) ).toBeTruthy(); expect( screen.getByTestId("agent-approval-request-approval-feedback") ).toHaveAttribute("data-agent-approval-option-id", "reject"); expect( screen.getByTestId("agent-approval-request-approval-deny") ).toHaveAttribute("data-agent-approval-option-id", "abort"); expect( screen.getByRole("button", { name: "Deny and stop the current response" }) ).toBe(screen.getByTestId("agent-approval-request-approval-deny")); fireEvent.click( screen.getByRole("button", { name: "No, then send new instructions" }) ); fireEvent.change(screen.getByPlaceholderText(labels.feedbackPlaceholder), { target: { value: "Use another command" } }); fireEvent.click(screen.getByRole("button", { name: labels.sendFeedback })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-approval", action: "deny", optionId: "reject", payload: { denyMessage: "Use another command" } }); }); it("uses localized feedback labels instead of provider reject copy", () => { setAgentGuiI18nTestLocale("zh-CN"); render( ); expect( screen.getByRole("button", { name: "拒绝,然后发送新的指令" }) ).toBeTruthy(); expect(screen.queryByRole("button", { name: "Reject" })).toBeNull(); }); it("does not infer feedback behavior from localized option labels", () => { const onSubmit = vi.fn(); render( ); fireEvent.click( screen.getByRole("button", { name: "No, and tell Codex what to do differently" }) ); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-approval", optionId: "custom_choice" }); expect( screen.queryByPlaceholderText(labels.feedbackPlaceholder) ).toBeNull(); }); it("does not submit empty feedback with Enter", () => { const onSubmit = vi.fn(); render( ); fireEvent.click( screen.getByRole("button", { name: "No, then send new instructions" }) ); const feedback = screen.getByPlaceholderText(labels.feedbackPlaceholder); feedback.focus(); fireEvent.keyDown(feedback, { key: "Enter", code: "Enter" }); expect(onSubmit).not.toHaveBeenCalled(); }); it("hides approval shortcut hints and ignores shortcut submission when shortcuts are disabled", () => { const onSubmit = vi.fn(); render( ); expect(screen.queryByText("Enter")).toBeNull(); expect(screen.queryByText(/Enter$/)).toBeNull(); fireEvent.keyDown(window, { key: "Enter", code: "Enter" }); expect(onSubmit).not.toHaveBeenCalled(); }); it("renders approval lead copy without a trailing sentence period", () => { render( ); expect(screen.getByText("Codex needs your choice")).toBeTruthy(); expect(screen.queryByText("Codex needs your choice.")).toBeNull(); }); it("applies edge glow to the prompt card only when requested", () => { const prompt = { kind: "approval" as const, id: "approval:request-approval", turnId: "turn-1", requestId: "request-approval", callId: "request-approval", title: "Run command", status: "waiting_approval" as const, toolName: "Bash", input: null, options: [ { id: "allow_once", label: "Allow once", kind: "allow_once" as const, description: "" } ], output: null, occurredAtUnixMs: 1 }; const { container, rerender } = render( ); expect( container.querySelector( ".agent-gui-conversation__interactive-prompt-card" ) ).not.toHaveClass("agent-gui-edge-glow"); rerender( ); expect( container.querySelector( ".agent-gui-conversation__interactive-prompt-card" ) ).toHaveClass("agent-gui-edge-glow"); }); it("shows command details from nested approval tool calls", () => { render( ); expect( screen.getByText("touch /workspace/project/approval-check.txt") ).toBeTruthy(); expect( screen.getByText("Create a temporary approval check file.") ).toBeTruthy(); expect(screen.queryByText("requestId")).toBeNull(); }); it("clamps long command details and exposes the full command in a tooltip after a hover delay", async () => { vi.useFakeTimers(); const longCommand = [ "node - <<'NODE'", "const fs = require('fs');", "const content = `Desktop summary with many files, folders, and generated notes`;", "fs.writeFileSync('/workspace/readme.md', content.repeat(24));", "console.log('/workspace/readme.md');", "NODE" ].join(" "); try { const { container } = render( ); const commandDetail = container.querySelector( '[data-agent-interactive-command-detail="true"]' ); expect(commandDetail).toHaveTextContent(longCommand); expect(commandDetail).toHaveClass( "agent-gui-conversation__interactive-option-command-description" ); fireEvent.pointerMove(commandDetail as HTMLElement, { pointerType: "mouse" }); await act(async () => { await vi.advanceTimersByTimeAsync(999); }); expect(screen.queryByRole("tooltip")).toBeNull(); await act(async () => { await vi.advanceTimersByTimeAsync(1); }); expect(screen.getByRole("tooltip")).toHaveTextContent(longCommand); } finally { vi.useRealTimers(); } }); it("renders structured details instead of approval titles", () => { render( ); expect(screen.getByText("Command")).toBeTruthy(); expect(screen.getByText("date")).toBeTruthy(); expect(screen.queryByText(/requestId:/)).toBeNull(); expect( screen.queryByText(/4601155f-fdab-47b8-9b1a-a569e7a79df3/) ).toBeNull(); }); it("does not render approval titles without structured details", () => { render( ); expect(screen.getByText("Claude Code needs your choice")).toBeTruthy(); expect(screen.queryByText(/requestId:/)).toBeNull(); }); it("does not render approval metadata titles", () => { render( ); expect(screen.getByText("Claude Code needs your choice")).toBeTruthy(); expect(screen.queryByText(/level:/)).toBeNull(); }); it("uses structured approval details instead of repeating the prompt title", () => { render( ); expect(screen.getByText("/workspace/project/index.html")).toBeTruthy(); expect(screen.getByText("Path")).toBeTruthy(); expect(screen.getByRole("button", { name: "Yes, proceed" })).toBeTruthy(); }); it("shows explicit MCP server, tool, and argument details for approval prompts", () => { render( ); expect(screen.getByText("MCP")).toBeTruthy(); expect(screen.getByText("playwright / browser_navigate")).toBeTruthy(); expect(screen.getByText("url: file:///workspace/index.html")).toBeTruthy(); expect(screen.queryByText("Navigate to a URL")).toBeNull(); }); it("localizes known approval option labels in the active UI language", () => { setAgentGuiI18nTestLocale("zh-CN"); render( ); expect(screen.getByRole("button", { name: "允许执行" })).toBeTruthy(); expect(screen.getByRole("button", { name: "本次会话允许" })).toBeTruthy(); expect( screen.getByRole("button", { name: "允许,并且不再询问以 `curl -I https://example.com` 开头的命令" }) ).toBeTruthy(); expect( screen.getByText("允许,并且不再询问以下列内容开头的命令") ).toBeTruthy(); expect( screen .getByText("curl -I https://example.com") .closest("[data-agent-interactive-command-prefix-option='true']") ).toBeTruthy(); expect( screen.getByRole("button", { name: "始终允许 Bash(chmod +x ./bootstrap.sh)" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "允许,并绕过权限" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "允许,并使用自动模式" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "允许,并自动接受编辑" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "允许,并手动确认编辑" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "拒绝,然后发送新的指令" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "拒绝,并且不再询问" }) ).toBeTruthy(); expect( screen.getByRole("button", { name: "Custom provider option" }) ).toBeTruthy(); }); it("uses a single continue-planning action for exit-plan prompts", () => { const onSubmit = vi.fn(); const { unmount } = render( ); fireEvent.click( screen.getByRole("button", { name: "Accept edits Auto-approve file edits" }) ); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-plan", action: "allow", optionId: "acceptEdits" }); unmount(); const secondPrompt = render( ); expect( screen.getByRole("button", { name: labels.stayInPlan }) ).toBeTruthy(); expect( screen.queryByRole("button", { name: labels.sendFeedback }) ).toBeNull(); fireEvent.click(screen.getByRole("button", { name: labels.stayInPlan })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-plan-feedback", action: "deny" }); secondPrompt.unmount(); render( ); fireEvent.change(screen.getByPlaceholderText(labels.feedbackPlaceholder), { target: { value: "Please split the work into smaller steps." } }); expect( screen.queryByRole("button", { name: labels.stayInPlan }) ).toBeNull(); expect( screen.getByRole("button", { name: labels.sendFeedback }) ).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: labels.sendFeedback })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-plan-feedback-2", action: "deny", payload: { denyMessage: "Please split the work into smaller steps." } }); }); it("hides the Back button for a single-question ask-user prompt", () => { const onSubmit = vi.fn(); render( ); // Only one question, so there is nowhere to go back to. expect( screen.queryByRole("button", { name: labels.previousQuestion }) ).toBeNull(); expect( screen.getByRole("button", { name: labels.submitAnswers }) ).toBeTruthy(); }); it("returns to the conversation without submitting and restores the selected answer", () => { const onSubmit = vi.fn(); render( ); fireEvent.click(screen.getByRole("button", { name: /Small/ })); expect(screen.queryByPlaceholderText(labels.answerPlaceholder)).toBeNull(); fireEvent.click( screen.getByRole("button", { name: labels.returnToConversation }) ); expect(onSubmit).not.toHaveBeenCalled(); expect( screen.getByTestId( "agent-question-request-return-to-conversation-collapsed" ) ).toBeTruthy(); expect(screen.queryByPlaceholderText(labels.answerPlaceholder)).toBeNull(); fireEvent.click( screen.getByRole("button", { name: labels.continueAnswering }) ); expect(screen.getByRole("button", { name: /Small/ })).toHaveAttribute( "aria-pressed", "true" ); fireEvent.click(screen.getByRole("button", { name: labels.submitAnswers })); expect(onSubmit).toHaveBeenCalledWith({ agentSessionId: "session-return-to-conversation", requestId: "request-return-to-conversation", action: "submit", payload: { answers: ["Small"], answersByQuestionId: { scope: ["Small"] } }, turnId: "turn-return-to-conversation" }); }); it("fails closed when conversation return lacks an exact interaction tuple", () => { render( ); expect( screen.queryByRole("button", { name: labels.returnToConversation }) ).toBeNull(); expect(screen.getByPlaceholderText(labels.answerPlaceholder)).toBeTruthy(); }); it("fails closed when a fixed-choice prompt cannot be answered by Composer text", () => { render( ); expect( screen.queryByRole("button", { name: labels.returnToConversation }) ).toBeNull(); expect(screen.getByRole("button", { name: /Small/ })).toBeTruthy(); }); it("resets presentation state for the exact interaction when request ids repeat", () => { const onSubmit = vi.fn(); const firstPrompt = { agentSessionId: "session-1", kind: "ask-user" as const, requestId: "request-reused", title: "First question", turnId: "turn-1", questions: [ { id: "first", header: "First", question: "First turn question?", options: [], multiSelect: false } ] }; const { rerender } = render( ); fireEvent.click( screen.getByRole("button", { name: labels.returnToConversation }) ); expect( screen.getByTestId("agent-question-request-reused-collapsed") ).toBeTruthy(); rerender( ); expect( screen.queryByTestId("agent-question-request-reused-collapsed") ).toBeNull(); expect(screen.getByText("Second turn question?")).toBeTruthy(); }); it("can return to the conversation when provider interaction controls are disabled", () => { const onSubmit = vi.fn(); render( ); const returnToConversation = screen.getByRole("button", { name: labels.returnToConversation }); expect(returnToConversation).toBeEnabled(); expect(returnToConversation.closest('[aria-disabled="true"]')).toBeNull(); expect(screen.getByRole("button", { name: /Small/ })).toBeDisabled(); fireEvent.click(returnToConversation); expect(onSubmit).not.toHaveBeenCalled(); const collapsed = screen.getByTestId( "agent-question-request-disabled-return-collapsed" ); expect(collapsed).toBeTruthy(); const continueAnswering = screen.getByRole("button", { name: labels.continueAnswering }); expect(continueAnswering.closest('[aria-disabled="true"]')).toBeNull(); }); it("collects answers for ask-user prompts before submission", () => { const onSubmit = vi.fn(); render( ); const smallOption = screen.getByRole("button", { name: "Small Minimal change" }); const largeOption = screen.getByRole("button", { name: "Large Broader cleanup" }); expect( screen.queryByRole("button", { name: labels.returnToConversation }) ).toBeNull(); expect(screen.getByPlaceholderText(labels.answerPlaceholder)).toBeTruthy(); expect( screen.getByTestId("agent-question-request-ask-scope") ).toHaveAttribute("data-agent-interaction-id", "request-ask"); expect( screen.getByTestId("agent-question-request-ask-scope-option-small") ).toBe(smallOption); expect( screen.getByTestId("agent-question-request-ask-scope-option-large") ).toBe(largeOption); expect(smallOption.getAttribute("aria-pressed")).toBe("false"); fireEvent.click(smallOption); expect(smallOption.getAttribute("aria-pressed")).toBe("true"); expect(largeOption.getAttribute("aria-pressed")).toBe("false"); expect(onSubmit).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: labels.nextQuestion })); expect( screen.getByTestId("agent-question-request-ask-details-custom-answer") ).toBeTruthy(); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: "Include API docs parity." } }); expect( screen.getByTestId("agent-question-request-ask-details-submit") ).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: labels.submitAnswers })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "request-ask", action: "submit", payload: { answers: ["Small", "Include API docs parity."], answersByQuestionId: { scope: "Small", details: "Include API docs parity." } } }); expect(screen.queryByText("Questions for you")).toBeNull(); }); it("dispatches implement / skip / feedback for a plan-implementation prompt", () => { const onSubmit = vi.fn(); render( ); expect(screen.getByText(labels.planImplementationLead)).toBeTruthy(); fireEvent.click(screen.getByTestId("agent-plan-implementation-implement")); expect(onSubmit).toHaveBeenCalledWith({ requestId: "plan-turn-1", action: "implement" }); // Empty continue button skips (stays in plan mode). fireEvent.click(screen.getByTestId("agent-plan-implementation-continue")); expect(onSubmit).toHaveBeenCalledWith({ requestId: "plan-turn-1", action: "skip", payload: undefined }); // Typing then continuing sends the adjustment back as feedback. fireEvent.change(screen.getByTestId("agent-plan-implementation-feedback"), { target: { value: "focus on tests first" } }); fireEvent.click(screen.getByTestId("agent-plan-implementation-continue")); expect(onSubmit).toHaveBeenCalledWith({ requestId: "plan-turn-1", action: "feedback", payload: { text: "focus on tests first" } }); }); it("submits a single-select ask-user answer in one click when compact", () => { const onSubmit = vi.fn(); render( ); // One click submits the chosen option directly — no separate submit step, // no free-text box (that rich flow lives in the conversation). fireEvent.click(screen.getByRole("button", { name: /Health check/ })); expect(onSubmit).toHaveBeenCalledOnce(); expect(onSubmit).toHaveBeenCalledWith({ requestId: "ask-req-1", action: "submit", payload: { answers: ["Health check"], answersByQuestionId: { "plan-kind": "Health check" } } }); expect(screen.queryByPlaceholderText(labels.answerPlaceholder)).toBeNull(); expect( screen.queryByRole("button", { name: labels.submitAnswers }) ).toBeNull(); expect( screen.queryByRole("button", { name: labels.returnToConversation }) ).toBeNull(); }); it("supports selecting and removing compact multi-select answers", () => { const onSubmit = vi.fn(); render( ); const backend = screen.getByRole("button", { name: /Backend/ }); const frontend = screen.getByRole("button", { name: /Frontend/ }); const submit = screen.getByRole("button", { name: labels.submitAnswers }); expect(backend).toHaveAttribute("aria-pressed", "false"); expect(submit).toBeDisabled(); fireEvent.click(backend); fireEvent.click(frontend); fireEvent.click(backend); expect(backend).toHaveAttribute("aria-pressed", "false"); expect(frontend).toHaveAttribute("aria-pressed", "true"); fireEvent.click(submit); expect(onSubmit).toHaveBeenCalledOnce(); expect(onSubmit).toHaveBeenCalledWith({ requestId: "ask-req-multi", action: "submit", payload: { answers: ["Frontend"], answersByQuestionId: { areas: ["Frontend"] } } }); }); it("keeps compact multi-question drafts while navigating and submits only when complete", () => { const onSubmit = vi.fn(); render( ); const previous = screen.getByRole("button", { name: labels.previousQuestion }); const next = screen.getByRole("button", { name: labels.nextQuestion }); expect(previous).toBeDisabled(); expect(next).toBeDisabled(); const small = screen.getByRole("button", { name: /Small/ }); fireEvent.click(small); fireEvent.click(small); expect( screen.getByRole("button", { name: labels.nextQuestion }) ).toBeDisabled(); fireEvent.click(small); fireEvent.click(next); expect(onSubmit).not.toHaveBeenCalled(); const submit = screen.getByRole("button", { name: labels.submitAnswers }); expect(submit).toBeDisabled(); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: "Keep the compatibility path" } }); fireEvent.click( screen.getByRole("button", { name: labels.previousQuestion }) ); expect(screen.getByRole("button", { name: /Small/ })).toHaveAttribute( "aria-pressed", "true" ); fireEvent.click(screen.getByRole("button", { name: labels.nextQuestion })); expect(screen.getByPlaceholderText(labels.answerPlaceholder)).toHaveValue( "Keep the compatibility path" ); fireEvent.click(screen.getByRole("button", { name: labels.submitAnswers })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "ask-req-multi-question", action: "submit", payload: { answers: ["Small", "Keep the compatibility path"], answersByQuestionId: { scope: "Small", details: "Keep the compatibility path" } } }); }); it("hides free text for option-only ask-user questions", () => { const onSubmit = vi.fn(); render( ); expect( screen.queryByPlaceholderText(labels.answerPlaceholder) ).not.toBeInTheDocument(); const submit = screen.getByRole("button", { name: labels.submitAnswers }); expect(submit).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: /First/ })); fireEvent.click(submit); expect(onSubmit).toHaveBeenCalledWith({ requestId: "ask-option-only", action: "submit", payload: { answers: ["First"], answersByQuestionId: { "question-1": "First" } } }); }); it("trims pure-text answers and rejects whitespace-only input", () => { const onSubmit = vi.fn(); render( ); const textarea = screen.getByPlaceholderText(labels.answerPlaceholder); const submit = screen.getByRole("button", { name: labels.submitAnswers }); expect(submit).toBeDisabled(); fireEvent.change(textarea, { target: { value: " " } }); expect(submit).toBeDisabled(); fireEvent.change(textarea, { target: { value: " Include tests " } }); fireEvent.click(submit); expect(onSubmit).toHaveBeenCalledWith({ requestId: "ask-text", action: "submit", payload: { answers: ["Include tests"], answersByQuestionId: { details: "Include tests" } } }); }); it("supports prototype-like question ids as own payload keys", () => { const onSubmit = vi.fn(); render( ); const textarea = screen.getByPlaceholderText(labels.answerPlaceholder); fireEvent.change(textarea, { target: { value: "First answer" } }); fireEvent.click(screen.getByRole("button", { name: labels.nextQuestion })); fireEvent.change(textarea, { target: { value: "Second answer" } }); fireEvent.click(screen.getByRole("button", { name: labels.submitAnswers })); expect(onSubmit).toHaveBeenCalledOnce(); const payload = onSubmit.mock.calls[0]?.[0]?.payload; expect(payload?.answers).toEqual(["First answer", "Second answer"]); expect(Object.hasOwn(payload?.answersByQuestionId ?? {}, "__proto__")).toBe( true ); expect( Object.hasOwn(payload?.answersByQuestionId ?? {}, "constructor") ).toBe(true); expect(payload?.answersByQuestionId?.["__proto__"]).toBe("First answer"); expect(payload?.answersByQuestionId?.constructor).toBe("Second answer"); }); it("uses custom text instead of a single choice and appends it to multiple choices", () => { const onSubmit = vi.fn(); render( ); fireEvent.click(screen.getByRole("button", { name: /Small/ })); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: " Full workspace " } }); fireEvent.click(screen.getByRole("button", { name: labels.nextQuestion })); fireEvent.click(screen.getByRole("button", { name: /Renderer/ })); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: " Documentation " } }); fireEvent.click(screen.getByRole("button", { name: labels.submitAnswers })); expect(onSubmit).toHaveBeenCalledWith({ requestId: "ask-custom-answers", action: "submit", payload: { answers: ["Full workspace", "Renderer, Documentation"], answersByQuestionId: { scope: "Full workspace", areas: ["Renderer", "Documentation"] } } }); }); it("resets question position and drafts when requestId changes", () => { const firstPrompt = { kind: "ask-user" as const, requestId: "ask-first", title: "First request", questions: [ { id: "scope", header: "Scope", question: "Which scope?", options: [{ label: "Small", description: "Minimal" }], multiSelect: false }, { id: "details", header: "Details", question: "What else?", options: [], multiSelect: false } ] }; const { rerender } = render( ); fireEvent.click(screen.getByRole("button", { name: /Small/ })); fireEvent.click(screen.getByRole("button", { name: labels.nextQuestion })); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: "Old draft" } }); rerender( ); expect(screen.getByText("1/2")).toBeTruthy(); expect(screen.getByRole("button", { name: /Small/ })).toHaveAttribute( "aria-pressed", "false" ); expect(screen.getByPlaceholderText(labels.answerPlaceholder)).toHaveValue( "" ); expect( screen.getByRole("button", { name: labels.nextQuestion }) ).toBeDisabled(); }); it("disables answer writes and navigation while submitting", () => { const onSubmit = vi.fn(); const { rerender } = render( ); expect(screen.getByRole("button", { name: /Small/ })).toBeDisabled(); expect( screen.getByPlaceholderText(labels.answerPlaceholder) ).toBeDisabled(); expect( screen.getByRole("button", { name: labels.previousQuestion }) ).toBeDisabled(); expect( screen.getByRole("button", { name: labels.nextQuestion }) ).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: /Small/ })); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: "Blocked" } }); expect(onSubmit).not.toHaveBeenCalled(); expect(screen.getByPlaceholderText(labels.answerPlaceholder)).toHaveValue( "" ); rerender( ); expect( screen.getByRole("button", { name: labels.submitAnswers }) ).toBeDisabled(); fireEvent.click(screen.getByRole("button", { name: labels.submitAnswers })); expect(onSubmit).not.toHaveBeenCalled(); }); it("submits identical payloads from full and compact answer flows", () => { const prompt = { kind: "ask-user" as const, requestId: "ask-parity", title: "Parity", questions: [ { id: "scope", header: "Scope", question: "Which scope?", options: [{ label: "Small", description: "Minimal" }], multiSelect: false }, { id: "details", header: "Details", question: "What else?", options: [], multiSelect: false } ] }; const submitWithVariant = (variant: "full" | "compact") => { const onSubmit = vi.fn(); const view = render( ); fireEvent.click(screen.getByRole("button", { name: /Small/ })); fireEvent.click( screen.getByRole("button", { name: labels.nextQuestion }) ); fireEvent.change(screen.getByPlaceholderText(labels.answerPlaceholder), { target: { value: "Same details" } }); fireEvent.click( screen.getByRole("button", { name: labels.submitAnswers }) ); const submitted = onSubmit.mock.calls[0]?.[0]; view.unmount(); return submitted; }; expect(submitWithVariant("compact")).toEqual(submitWithVariant("full")); }); it("offers only the implement decision for a compact plan-implementation prompt", () => { const onSubmit = vi.fn(); render( ); // Compact (message-center deck): only the primary "implement" action is // shown; refining / staying in plan is deferred to the conversation. fireEvent.click(screen.getByTestId("agent-plan-implementation-implement")); expect(onSubmit).toHaveBeenCalledWith({ requestId: "plan-turn-1", action: "implement" }); expect( screen.queryByTestId("agent-plan-implementation-feedback") ).toBeNull(); expect( screen.queryByTestId("agent-plan-implementation-continue") ).toBeNull(); }); });