diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 54545f52..12f4398d 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -116,6 +116,42 @@ export interface SSEInstallationUpdateAvailableEvent { } } +export interface SSEQuestionAskedEvent { + type: 'question.asked' + properties: { + id: string + sessionID: string + directory?: string + questions: Array<{ + question: string + header: string + options: Array<{ label: string; description: string }> + multiple?: boolean + custom?: boolean + }> + tool?: { + messageID: string + callID: string + } + } +} + +export interface SSEQuestionRepliedEvent { + type: 'question.replied' + properties: { + sessionID: string + requestID: string + } +} + +export interface SSEQuestionRejectedEvent { + type: 'question.rejected' + properties: { + sessionID: string + requestID: string + } +} + export interface SSESessionIdleEvent { type: 'session.idle' properties: { @@ -155,6 +191,9 @@ export type SSEEvent = | SSEPermissionRepliedEvent | SSEInstallationUpdatedEvent | SSEInstallationUpdateAvailableEvent + | SSEQuestionAskedEvent + | SSEQuestionRepliedEvent + | SSEQuestionRejectedEvent export type ContentPart = | { type: 'text', content: string } diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx new file mode 100644 index 00000000..6dc55360 --- /dev/null +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -0,0 +1,357 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { ToolCallPart } from './ToolCallPart' + +const mockRespondToQuestion = vi.fn() +const mockRejectQuestion = vi.fn() +const mockGetPermissionForCallID = vi.fn(() => undefined) + +vi.mock('@/hooks/useSettings', () => ({ + useSettings: vi.fn(() => ({ + preferences: { expandToolCalls: false }, + })), +})) + +vi.mock('@/stores/userBashStore', () => ({ + useUserBash: vi.fn(() => ({ + userBashCommands: new Set(), + })), +})) + +vi.mock('@/contexts/PermissionContext', () => ({ + usePermissionContext: vi.fn(() => ({ + getPermissionForCallID: mockGetPermissionForCallID, + })), +})) + +vi.mock('@/contexts/QuestionContext', () => ({ + useQuestionContext: vi.fn(() => ({ + pendingQuestions: mockPendingQuestions, + respondToQuestion: mockRespondToQuestion, + rejectQuestion: mockRejectQuestion, + })), +})) + +vi.mock('./FileToolRender', () => ({ + getToolSpecificRender: vi.fn(() => null), +})) + +vi.mock('@/lib/fileReferences', () => ({ + detectFileReferences: vi.fn(() => []), +})) + +let mockPendingQuestions: Array<{ id: string; tool?: { callID: string; messageID: string }; sessionID: string; directory?: string; questions: Array<{ question: string; header: string; options: Array<{ label: string; description?: string }>; multiple?: boolean }> }> = [] + +function makeQuestionPart(overrides: Record = {}) { + return { + id: 'part-1', + type: 'tool' as const, + tool: 'question', + callID: 'call-1', + sessionID: 'sess-1', + state: { + status: 'running' as const, + input: { + questions: [ + { + question: 'Install Rust?', + header: 'Dependencies', + options: [ + { label: 'Yes, install Rust', description: 'Installs via rustup' }, + { label: 'No, skip', description: 'Skip installation' }, + ], + multiple: false, + custom: false, + }, + ], + }, + }, + ...overrides, + } +} + +function makeMatchingQuestion() { + return { + id: 'q-1', + sessionID: 'sess-1', + directory: '/test/dir', + tool: { callID: 'call-1', messageID: 'msg-1' }, + questions: [ + { + question: 'Install Rust?', + header: 'Dependencies', + options: [ + { label: 'Yes, install Rust', description: 'Installs via rustup' }, + { label: 'No, skip', description: 'Skip installation' }, + ], + multiple: false, + }, + ], + } +} + +describe('ToolCallPart - Question Tool', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingQuestions = [] + }) + + it('should render question UI for running question tool', () => { + mockPendingQuestions = [makeMatchingQuestion()] + render() + + expect(screen.getByText('Question awaiting your answer')).toBeInTheDocument() + expect(screen.getByText('Dependencies')).toBeInTheDocument() + expect(screen.getByText('Install Rust?')).toBeInTheDocument() + }) + + it('should render all options as buttons', () => { + mockPendingQuestions = [makeMatchingQuestion()] + render() + + expect(screen.getByText('Yes, install Rust')).toBeInTheDocument() + expect(screen.getByText('No, skip')).toBeInTheDocument() + expect(screen.getByText('Installs via rustup')).toBeInTheDocument() + }) + + it('should show Submit and Skip buttons when matching question exists', () => { + mockPendingQuestions = [makeMatchingQuestion()] + render() + + expect(screen.getByText('Submit')).toBeInTheDocument() + expect(screen.getByText('Skip')).toBeInTheDocument() + }) + + it('should show Loading when no matching question found', () => { + mockPendingQuestions = [] + render() + + expect(screen.getByText('Loading...')).toBeInTheDocument() + expect(screen.queryByText('Submit')).not.toBeInTheDocument() + expect(screen.queryByText('Skip')).not.toBeInTheDocument() + }) + + it('should disable option buttons when no matching question', () => { + mockPendingQuestions = [] + render() + + const buttons = screen.getAllByRole('button') + buttons.forEach((btn) => { + expect(btn).toBeDisabled() + }) + }) + + it('should select an option on click', async () => { + mockPendingQuestions = [makeMatchingQuestion()] + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + + const optionButton = screen.getByText('Yes, install Rust').closest('button')! + expect(optionButton.className).toContain('border-blue-500') + }) + + it('should deselect option in single-select mode when clicking different option', async () => { + mockPendingQuestions = [makeMatchingQuestion()] + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('No, skip')) + + const yesButton = screen.getByText('Yes, install Rust').closest('button')! + const noButton = screen.getByText('No, skip').closest('button')! + expect(yesButton.className).toContain('border-border') + expect(noButton.className).toContain('bg-blue-500/10') + }) + + it('should submit selected answer', async () => { + mockPendingQuestions = [makeMatchingQuestion()] + mockRespondToQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('Submit')) + + expect(mockRespondToQuestion).toHaveBeenCalledWith('q-1', [['Yes, install Rust']]) + }) + + it('should show error when submitting without selection', async () => { + mockPendingQuestions = [makeMatchingQuestion()] + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Submit')) + + expect(screen.getByText('Please select at least one option')).toBeInTheDocument() + expect(mockRespondToQuestion).not.toHaveBeenCalled() + }) + + it('should reject question when Skip is clicked', async () => { + mockPendingQuestions = [makeMatchingQuestion()] + mockRejectQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Skip')) + + expect(mockRejectQuestion).toHaveBeenCalledWith('q-1') + }) + + it('should show error when submit fails', async () => { + mockPendingQuestions = [makeMatchingQuestion()] + mockRespondToQuestion.mockRejectedValueOnce(new Error('Network error')) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('Submit')) + + expect(screen.getByText('Network error')).toBeInTheDocument() + }) + + it('should render completed question state', () => { + const part = makeQuestionPart({ + state: { + status: 'completed', + input: { questions: [] }, + output: 'Selected: Yes, install Rust', + }, + }) + + render() + + expect(screen.getByText('Question answered')).toBeInTheDocument() + expect(screen.getByText('Selected: Yes, install Rust')).toBeInTheDocument() + }) + + it('should render error question state', () => { + const part = makeQuestionPart({ + state: { + status: 'error', + input: { questions: [] }, + error: 'User rejected', + }, + }) + + render() + + expect(screen.getByText('Question rejected')).toBeInTheDocument() + expect(screen.getByText('User rejected')).toBeInTheDocument() + }) + + it('should support multiple selection when multiple=true', async () => { + const multiQuestion = makeMatchingQuestion() + multiQuestion.questions[0].multiple = true + + const part = makeQuestionPart({ + state: { + status: 'running', + input: { + questions: [ + { + ...multiQuestion.questions[0], + multiple: true, + }, + ], + }, + }, + }) + + mockPendingQuestions = [multiQuestion] + mockRespondToQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('No, skip')) + + const yesButton = screen.getByText('Yes, install Rust').closest('button')! + const noButton = screen.getByText('No, skip').closest('button')! + expect(yesButton.className).toContain('border-blue-500') + expect(noButton.className).toContain('border-blue-500') + + await user.click(screen.getByText('Submit')) + + expect(mockRespondToQuestion).toHaveBeenCalledWith('q-1', [['Yes, install Rust', 'No, skip']]) + }) + + it('should toggle off an option in multi-select mode', async () => { + const multiQuestion = makeMatchingQuestion() + multiQuestion.questions[0].multiple = true + + const part = makeQuestionPart({ + state: { + status: 'running', + input: { + questions: [ + { + ...multiQuestion.questions[0], + multiple: true, + }, + ], + }, + }, + }) + + mockPendingQuestions = [multiQuestion] + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + const yesButton = screen.getByText('Yes, install Rust').closest('button')! + expect(yesButton.className).toContain('border-blue-500') + + await user.click(screen.getByText('Yes, install Rust')) + expect(yesButton.className).toContain('border-border') + }) +}) + +describe('ToolCallPart - Non-question tools', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPendingQuestions = [] + }) + + it('should render bash tool with command preview', () => { + const part = { + id: 'part-2', + type: 'tool' as const, + tool: 'bash', + callID: 'call-2', + sessionID: 'sess-1', + state: { + status: 'completed' as const, + input: { command: 'echo hello' }, + output: 'hello', + time: { start: 1000, end: 2000 }, + }, + } + + render() + + expect(screen.getByText('bash')).toBeInTheDocument() + expect(screen.getByText('echo hello')).toBeInTheDocument() + }) + + it('should show pending status for pending tool', () => { + const part = { + id: 'part-3', + type: 'tool' as const, + tool: 'read', + callID: 'call-3', + sessionID: 'sess-1', + state: { + status: 'pending' as const, + }, + } + + render() + + expect(screen.getByText('read')).toBeInTheDocument() + expect(screen.getByText('(pending)')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/message/ToolCallPart.tsx b/frontend/src/components/message/ToolCallPart.tsx index f2c61a6a..8372533d 100644 --- a/frontend/src/components/message/ToolCallPart.tsx +++ b/frontend/src/components/message/ToolCallPart.tsx @@ -1,10 +1,11 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import type { components } from '@/api/opencode-types' import { useSettings } from '@/hooks/useSettings' import { useUserBash } from '@/stores/userBashStore' import { usePermissionContext } from '@/contexts/PermissionContext' +import { useQuestionContext } from '@/contexts/QuestionContext' import { detectFileReferences } from '@/lib/fileReferences' -import { ExternalLink, Loader2, HelpCircle } from 'lucide-react' +import { ExternalLink, Loader2, HelpCircle, Send, SkipForward, CheckCircle2 } from 'lucide-react' import { CopyButton } from '@/components/ui/copy-button' import { TodoListDisplay } from './TodoListDisplay' import { getToolSpecificRender } from './FileToolRender' @@ -73,6 +74,7 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal const { preferences } = useSettings() const { userBashCommands } = useUserBash() const { getPermissionForCallID } = usePermissionContext() + const { pendingQuestions, respondToQuestion, rejectQuestion } = useQuestionContext() const outputRef = useRef(null) const isUserBashCommand = part.tool === 'bash' && part.state.status === 'completed' && @@ -85,6 +87,74 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal const isWaitingPermission = part.state.status === 'running' && !!pendingPermission const isQuestionTool = part.tool === 'question' + const [selectedOptions, setSelectedOptions] = useState>(new Map()) + const [isSubmitting, setIsSubmitting] = useState(false) + const [submitError, setSubmitError] = useState(null) + + const matchingQuestion = useMemo(() => { + if (!isQuestionTool || part.state.status !== 'running') return null + return pendingQuestions.find((q) => q.tool?.callID === part.callID) ?? null + }, [isQuestionTool, part.state.status, part.callID, pendingQuestions]) + + const handleOptionClick = useCallback((questionIndex: number, label: string, multiple: boolean) => { + setSelectedOptions((prev) => { + const current = prev.get(questionIndex) ?? [] + if (multiple) { + if (current.includes(label)) { + return new Map(prev).set(questionIndex, current.filter((l) => l !== label)) + } + return new Map(prev).set(questionIndex, [...current, label]) + } + return new Map(prev).set(questionIndex, [label]) + }) + }, []) + + const handleQuestionSubmit = useCallback(async () => { + if (!matchingQuestion) return + + const input = part.state.input as { questions?: Array<{ question: string; header: string; options?: Array<{ label: string; description?: string }>; multiple?: boolean }> } | undefined + const questions = input?.questions || [] + + setIsSubmitting(true) + setSubmitError(null) + + try { + const answers: string[][] = questions.map((_, idx) => { + return selectedOptions.get(idx) ?? [] + }) + + const hasAnyAnswer = answers.some((a) => a.length > 0) + if (!hasAnyAnswer) { + setSubmitError('Please select at least one option') + setIsSubmitting(false) + return + } + + await respondToQuestion(matchingQuestion.id, answers) + setSelectedOptions(new Map()) + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to submit answer') + } finally { + setIsSubmitting(false) + } + }, [matchingQuestion, part.state.input, selectedOptions, respondToQuestion]) + + const handleQuestionReject = useCallback(async () => { + if (!matchingQuestion) return + + setIsSubmitting(true) + setSubmitError(null) + + try { + await rejectQuestion(matchingQuestion.id) + setSelectedOptions(new Map()) + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'Failed to skip question') + } finally { + setIsSubmitting(false) + } + }, [matchingQuestion, rejectQuestion]) + useEffect(() => { if (part.tool === 'bash' && expanded && outputRef.current) { outputRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) @@ -229,16 +299,20 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal } if (isQuestionTool) { - const input = part.state.input as { questions?: Array<{ question: string; header: string; options?: Array<{ label: string; description?: string }> }> } | undefined + const input = part.state.input as { questions?: Array<{ question: string; header: string; options?: Array<{ label: string; description?: string }>; multiple?: boolean; custom?: boolean }> } | undefined const questions = input?.questions || [] if (part.state.status === 'running') { + const canAnswer = !!matchingQuestion + return (
Question awaiting your answer - Click the dialog to respond + {!canAnswer && ( + Loading... + )}
{questions.map((q, idx) => ( @@ -246,17 +320,75 @@ export function ToolCallPart({ part, onFileClick, onChildSessionClick }: ToolCal
{q.header}
{q.question}
{q.options && q.options.length > 0 && ( -
- {q.options.map((opt, optIdx) => ( - - {opt.label} - - ))} +
+ {q.options.map((opt, optIdx) => { + const isSelected = (selectedOptions.get(idx) ?? []).includes(opt.label) + return ( + + ) + })}
)}
))} + {submitError && ( +
{submitError}
+ )}
+ {canAnswer && ( +
+ + +
+ )}
) } diff --git a/frontend/src/components/question/QuestionDialog.test.tsx b/frontend/src/components/question/QuestionDialog.test.tsx new file mode 100644 index 00000000..aad6ab5b --- /dev/null +++ b/frontend/src/components/question/QuestionDialog.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QuestionDialog } from './QuestionDialog' + +const mockRespondToQuestion = vi.fn() +const mockRejectQuestion = vi.fn() +const mockDismissDialog = vi.fn() + +vi.mock('@/contexts/QuestionContext', () => ({ + useQuestionContext: vi.fn(() => mockContextValue), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick, disabled, variant, ...props }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; variant?: string; [key: string]: unknown }) => ( + + ), +})) + +vi.mock('@/components/ui/checkbox', () => ({ + Checkbox: ({ checked, onCheckedChange, ...props }: { checked?: boolean; onCheckedChange?: () => void; [key: string]: unknown }) => ( + + ), +})) + +vi.mock('@/components/ui/input', () => ({ + Input: ({ value, onChange, placeholder, ...props }: { value?: string; onChange?: (e: React.ChangeEvent) => void; placeholder?: string; [key: string]: unknown }) => ( + + ), +})) + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( + + ), +})) + +function makeQuestion(overrides: Record = {}) { + return { + id: 'q-1', + sessionID: 'sess-1', + directory: '/test/dir', + questions: [ + { + question: 'Install Rust?', + header: 'Dependencies', + options: [ + { label: 'Yes, install Rust', description: 'Installs via rustup' }, + { label: 'No, skip', description: 'Skip installation' }, + ], + multiple: false, + custom: true, + }, + ], + tool: { messageID: 'msg-1', callID: 'call-1' }, + ...overrides, + } +} + +let mockContextValue: Record + +function setMockContext(overrides: Record = {}) { + mockContextValue = { + currentQuestion: makeQuestion(), + pendingQuestions: [makeQuestion()], + respondToQuestion: mockRespondToQuestion, + rejectQuestion: mockRejectQuestion, + addQuestion: vi.fn(), + removeQuestion: vi.fn(), + dismissDialog: mockDismissDialog, + isDialogDismissed: false, + ...overrides, + } +} + +describe('QuestionDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + setMockContext() + }) + + it('should not render when there is no current question', () => { + setMockContext({ currentQuestion: null, pendingQuestions: [] }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should not render when dialog is dismissed', () => { + setMockContext({ isDialogDismissed: true }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render question header and text', () => { + render() + expect(screen.getByText('Dependencies')).toBeInTheDocument() + expect(screen.getByText('Install Rust?')).toBeInTheDocument() + }) + + it('should render all options', () => { + render() + expect(screen.getByText('Yes, install Rust')).toBeInTheDocument() + expect(screen.getByText('No, skip')).toBeInTheDocument() + expect(screen.getByText('Installs via rustup')).toBeInTheDocument() + expect(screen.getByText('Skip installation')).toBeInTheDocument() + }) + + it('should render Submit and Skip buttons', () => { + render() + expect(screen.getByText('Submit')).toBeInTheDocument() + expect(screen.getByText('Skip')).toBeInTheDocument() + }) + + it('should call dismissDialog when X button is clicked', async () => { + const user = userEvent.setup() + render() + + const dismissButton = screen.getByTitle('Dismiss dialog (answer inline instead)') + await user.click(dismissButton) + + expect(mockDismissDialog).toHaveBeenCalledTimes(1) + expect(mockRejectQuestion).not.toHaveBeenCalled() + }) + + it('should call rejectQuestion when Skip is clicked', async () => { + mockRejectQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Skip')) + + expect(mockRejectQuestion).toHaveBeenCalledWith('q-1') + }) + + it('should show error when submitting without selection', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Submit')) + + expect(screen.getByText('Please select at least one option or provide a custom answer')).toBeInTheDocument() + expect(mockRespondToQuestion).not.toHaveBeenCalled() + }) + + it('should select an option and submit', async () => { + mockRespondToQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('Submit')) + + expect(mockRespondToQuestion).toHaveBeenCalledWith('q-1', [['Yes, install Rust']]) + }) + + it('should show custom answer input when custom is not false', () => { + render() + expect(screen.getByPlaceholderText('Type your answer...')).toBeInTheDocument() + }) + + it('should not show custom answer input when custom is false', () => { + setMockContext({ + currentQuestion: makeQuestion({ + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [{ label: 'A', description: 'Option A' }], + custom: false, + }, + ], + }), + pendingQuestions: [makeQuestion()], + }) + render() + expect(screen.queryByPlaceholderText('Type your answer...')).not.toBeInTheDocument() + }) + + it('should show pending count badge when multiple questions exist', () => { + setMockContext({ + pendingQuestions: [makeQuestion(), makeQuestion({ id: 'q-2' })], + }) + render() + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + + it('should show error when API fails during submit', async () => { + mockRespondToQuestion.mockRejectedValueOnce(new Error('Network error')) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('Submit')) + + expect(screen.getByText('Network error')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/question/QuestionDialog.tsx b/frontend/src/components/question/QuestionDialog.tsx index 26b361fd..a586ba90 100644 --- a/frontend/src/components/question/QuestionDialog.tsx +++ b/frontend/src/components/question/QuestionDialog.tsx @@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label' import { HelpCircle, X, Send, Loader2 } from 'lucide-react' export function QuestionDialog() { - const { currentQuestion, respondToQuestion, rejectQuestion, pendingQuestions } = useQuestionContext() + const { currentQuestion, respondToQuestion, rejectQuestion, pendingQuestions, dismissDialog, isDialogDismissed } = useQuestionContext() const [selectedAnswers, setSelectedAnswers] = useState>(new Map()) const [customAnswers, setCustomAnswers] = useState>(new Map()) const [isSubmitting, setIsSubmitting] = useState(false) @@ -81,7 +81,7 @@ export function QuestionDialog() { } }, [currentQuestion, rejectQuestion]) - if (!currentQuestion) { + if (!currentQuestion || isDialogDismissed) { return null } @@ -98,7 +98,7 @@ export function QuestionDialog() { )} - diff --git a/frontend/src/contexts/QuestionContext.test.tsx b/frontend/src/contexts/QuestionContext.test.tsx new file mode 100644 index 00000000..d3f50c10 --- /dev/null +++ b/frontend/src/contexts/QuestionContext.test.tsx @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import type { ReactNode } from 'react' +import { QuestionProvider, useQuestionContext, questionEvents } from './QuestionContext' + +vi.mock('@/config', () => ({ + API_BASE_URL: 'http://localhost:5001', +})) + +const mockFetch = vi.fn() +globalThis.fetch = mockFetch + +function createWrapper() { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function makeQuestion(overrides: Record = {}) { + return { + id: 'q-1', + sessionID: 'sess-1', + directory: '/test/dir', + questions: [ + { + question: 'Install Rust?', + header: 'Dependencies', + options: [ + { label: 'Yes, install Rust', description: 'Installs via rustup' }, + { label: 'No, skip', description: 'Skip installation' }, + ], + multiple: false, + custom: false, + }, + ], + tool: { messageID: 'msg-1', callID: 'call-1' }, + ...overrides, + } +} + +describe('QuestionContext', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockResolvedValue({ ok: true, json: async () => [] }) + questionEvents.listeners.clear() + }) + + afterEach(() => { + questionEvents.listeners.clear() + }) + + it('should throw when used outside provider', () => { + expect(() => { + renderHook(() => useQuestionContext()) + }).toThrow('useQuestionContext must be used within QuestionProvider') + }) + + it('should start with no pending questions', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + expect(result.current.pendingQuestions).toEqual([]) + expect(result.current.currentQuestion).toBeNull() + expect(result.current.isDialogDismissed).toBe(false) + }) + + it('should add a question via addQuestion', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + expect(result.current.pendingQuestions).toHaveLength(1) + expect(result.current.currentQuestion?.id).toBe('q-1') + }) + + it('should not add duplicate questions', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + result.current.addQuestion(makeQuestion()) + }) + + expect(result.current.pendingQuestions).toHaveLength(1) + }) + + it('should remove a question via removeQuestion', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + expect(result.current.pendingQuestions).toHaveLength(1) + + act(() => { + result.current.removeQuestion('q-1') + }) + expect(result.current.pendingQuestions).toHaveLength(0) + expect(result.current.currentQuestion).toBeNull() + }) + + it('should not re-add a question that was already answered', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + act(() => { + result.current.removeQuestion('q-1') + }) + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + expect(result.current.pendingQuestions).toHaveLength(0) + }) + + it('should respond to a question via API', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => 'ok' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + await act(async () => { + await result.current.respondToQuestion('q-1', [['Yes, install Rust']]) + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5001/api/opencode/question/q-1/reply?directory=%2Ftest%2Fdir', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: [['Yes, install Rust']] }), + }) + ) + expect(result.current.pendingQuestions).toHaveLength(0) + }) + + it('should throw when responding to a nonexistent question', async () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + await expect( + act(async () => { + await result.current.respondToQuestion('nonexistent', [['Yes']]) + }) + ).rejects.toThrow('Question not found') + }) + + it('should throw when API returns error on respond', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: false, text: async () => 'Server error' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + await expect( + act(async () => { + await result.current.respondToQuestion('q-1', [['Yes, install Rust']]) + }) + ).rejects.toThrow('Failed to respond to question: Server error') + }) + + it('should reject a question via API', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => 'ok' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + await act(async () => { + await result.current.rejectQuestion('q-1') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5001/api/opencode/question/q-1/reject?directory=%2Ftest%2Fdir', + expect.objectContaining({ method: 'POST' }) + ) + expect(result.current.pendingQuestions).toHaveLength(0) + }) + + it('should dismiss dialog without rejecting the question', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + act(() => { + result.current.dismissDialog() + }) + + expect(result.current.isDialogDismissed).toBe(true) + expect(result.current.pendingQuestions).toHaveLength(1) + expect(result.current.currentQuestion?.id).toBe('q-1') + }) + + it('should reset isDialogDismissed when a new question is added', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + act(() => { + result.current.dismissDialog() + }) + expect(result.current.isDialogDismissed).toBe(true) + + act(() => { + result.current.addQuestion(makeQuestion({ id: 'q-2' })) + }) + expect(result.current.isDialogDismissed).toBe(false) + }) + + it('should queue multiple questions and expose first as currentQuestion', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion({ id: 'q-1' })) + result.current.addQuestion(makeQuestion({ id: 'q-2' })) + }) + + expect(result.current.pendingQuestions).toHaveLength(2) + expect(result.current.currentQuestion?.id).toBe('q-1') + }) + + it('should use sessionID as directory fallback when directory is missing', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => 'ok' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion({ directory: undefined })) + }) + + await act(async () => { + await result.current.respondToQuestion('q-1', [['Yes, install Rust']]) + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5001/api/opencode/question/q-1/reply?directory=sess-1', + expect.anything() + ) + }) +}) + +describe('questionEvents', () => { + afterEach(() => { + questionEvents.listeners.clear() + }) + + it('should emit to subscribers', () => { + const listener = vi.fn() + questionEvents.subscribe(listener) + + const q = makeQuestion() + questionEvents.emit(q) + + expect(listener).toHaveBeenCalledWith(q) + }) + + it('should unsubscribe correctly', () => { + const listener = vi.fn() + const unsub = questionEvents.subscribe(listener) + unsub() + + questionEvents.emit(makeQuestion()) + + expect(listener).not.toHaveBeenCalled() + }) + + it('should support multiple subscribers', () => { + const listener1 = vi.fn() + const listener2 = vi.fn() + questionEvents.subscribe(listener1) + questionEvents.subscribe(listener2) + + questionEvents.emit(makeQuestion()) + + expect(listener1).toHaveBeenCalled() + expect(listener2).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/contexts/QuestionContext.tsx b/frontend/src/contexts/QuestionContext.tsx index 26932f34..a143a98f 100644 --- a/frontend/src/contexts/QuestionContext.tsx +++ b/frontend/src/contexts/QuestionContext.tsx @@ -33,6 +33,8 @@ interface QuestionContextValue { rejectQuestion: (requestId: string) => Promise addQuestion: (question: QuestionRequest) => void removeQuestion: (requestId: string) => void + dismissDialog: () => void + isDialogDismissed: boolean } const QuestionContext = createContext(null) @@ -40,6 +42,7 @@ const QuestionContext = createContext(null) export function QuestionProvider({ children }: { children: React.ReactNode }) { const [pendingQuestions, setPendingQuestions] = useState([]) const answeredRef = useRef>(new Set()) + const [isDialogDismissed, setIsDialogDismissed] = useState(false) const currentQuestion = useMemo(() => { return pendingQuestions[0] ?? null @@ -55,6 +58,7 @@ export function QuestionProvider({ children }: { children: React.ReactNode }) { } return [...prev, question] }) + setIsDialogDismissed(false) }, []) const removeQuestion = useCallback((requestId: string) => { @@ -103,6 +107,10 @@ export function QuestionProvider({ children }: { children: React.ReactNode }) { removeQuestion(requestId) }, [pendingQuestions, removeQuestion]) + const dismissDialog = useCallback(() => { + setIsDialogDismissed(true) + }, []) + useEffect(() => { const fetchPendingQuestions = async () => { try { @@ -136,8 +144,10 @@ export function QuestionProvider({ children }: { children: React.ReactNode }) { rejectQuestion, addQuestion, removeQuestion, + dismissDialog, + isDialogDismissed, }), - [currentQuestion, pendingQuestions, respondToQuestion, rejectQuestion, addQuestion, removeQuestion] + [currentQuestion, pendingQuestions, respondToQuestion, rejectQuestion, addQuestion, removeQuestion, dismissDialog, isDialogDismissed] ) return {children} diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 5a915f42..3daf2712 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useOpenCodeClient } from './useOpenCode' import type { SSEEvent, MessageListResponse } from '@/api/types' import { permissionEvents } from './usePermissionRequests' +import { questionEvents } from '@/contexts/QuestionContext' import { showToast } from '@/lib/toast' import { settingsApi } from '@/api/settings' import { useSessionStatus } from '@/stores/sessionStatusStore' @@ -355,6 +356,12 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string) } break + case 'question.asked': + if ('id' in event.properties && 'sessionID' in event.properties && 'questions' in event.properties) { + questionEvents.emit(event.properties as Parameters[0]) + } + break + default: break }