diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.client.test.tsx new file mode 100644 index 000000000..d1ea811df --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.client.test.tsx @@ -0,0 +1,86 @@ +import { render, screen } from '@testing-library/react'; + +const { useSandboxClientMock, useSandboxPendingUserInputRequestsMock } = + vi.hoisted(() => ({ + useSandboxClientMock: vi.fn(), + useSandboxPendingUserInputRequestsMock: vi.fn(), + })); + +vi.mock('./hooks', async () => { + const actual = await vi.importActual('./hooks'); + + return { + ...actual, + useSandboxClient: useSandboxClientMock, + useSandboxPendingUserInputRequests: useSandboxPendingUserInputRequestsMock, + }; +}); + +vi.mock('@/trpc/client', () => ({ + useTRPCClient: () => ({}), +})); + +import type { PendingTaskUserInputRequest } from './hooks'; +import { + PendingUserInputRequestPanel, + PendingUserInputRequestStateProvider, +} from './PendingUserInputRequestPanel'; + +const pendingRequest: PendingTaskUserInputRequest = { + requestId: 'rui:panel-test', + sessionId: 'session-test', + turnId: 'turn-test', + callId: 'call-test', + status: 'pending', + ts: 0, + questions: [ + { + id: 'integration', + header: 'INTEGRATION', + question: 'Which integration should I inspect first?', + isOther: true, + isSecret: false, + options: [ + { label: 'Telegram', description: 'Inspect Telegram first.' }, + { label: 'Teams', description: 'Inspect Teams first.' }, + ], + }, + ], +}; + +function renderPanel() { + return render( + + + , + ); +} + +describe('PendingUserInputRequestPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + useSandboxClientMock.mockReturnValue({ commands: {} }); + }); + + it('renders a live pending question even while the reported task phase is running', () => { + // A queued/steered follow-up can flip the reported phase back to + // running while the harness turn is still blocked on the question; + // the panel must stay answerable as long as the request is pending. + useSandboxPendingUserInputRequestsMock.mockReturnValue([pendingRequest]); + + renderPanel(); + + expect( + screen.getByText('Which integration should I inspect first?'), + ).toBeInTheDocument(); + expect(screen.getByText('Telegram')).toBeInTheDocument(); + }); + + it('renders nothing when there are no pending requests', () => { + useSandboxPendingUserInputRequestsMock.mockReturnValue([]); + + const { container } = renderPanel(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.tsx index abfebfa16..800071b89 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/PendingUserInputRequestPanel.tsx @@ -17,11 +17,7 @@ import { toast } from 'sonner'; import { useTRPCClient } from '@/trpc/client'; import type { PendingTaskUserInputRequest } from './hooks'; -import { - useSandboxClient, - useSandboxPendingUserInputRequests, - useSandboxTaskPhase, -} from './hooks'; +import { useSandboxClient, useSandboxPendingUserInputRequests } from './hooks'; import { buildAnswersForRequest, OTHER_VALUE, @@ -96,7 +92,6 @@ export function PendingUserInputRequestStateProvider({ }) { const client = useSandboxClient(); const trpcClient = useTRPCClient(); - const taskPhase = useSandboxTaskPhase(); const requests = useSandboxPendingUserInputRequests(); const [drafts, setDrafts] = useState({}); @@ -154,14 +149,18 @@ export function PendingUserInputRequestStateProvider({ ); }, [requests]); + // Visibility tracks the live pending requests themselves, not the task + // phase: a queued/steered follow-up can flip the reported phase back to + // running while the harness turn is still blocked inside the question + // tool, and phase-gating here made the question unanswerable in that + // state. The runtime clears pending requests on answer, cancel, and + // turn teardown, so the list is self-correcting. const visibleRequests = useMemo( () => - taskPhase === 'waiting_for_user_input' - ? requests.filter( - (request) => resolvedRequestIds[request.requestId] !== true, - ) - : [], - [requests, resolvedRequestIds, taskPhase], + requests.filter( + (request) => resolvedRequestIds[request.requestId] !== true, + ), + [requests, resolvedRequestIds], ); const requestEntries = useMemo( diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx index dede26724..f83292ce4 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/prompt-input/PromptInput.tsx @@ -371,8 +371,10 @@ export const PromptInput = forwardRef( async (message: PromptInputMessage) => { const text = message.text.trim(); const hasAttachments = (message.files?.length ?? 0) > 0; + // Keyed off the live pending request rather than the task phase: + // the phase can report running while the turn is still blocked on + // the question, and a message here must answer it, not steer. const shouldAnswerPendingFreeText = - taskPhase === 'waiting_for_user_input' && Boolean(pendingUserInputState?.activeFreeTextRequest) && !hasAttachments; @@ -453,7 +455,6 @@ export const PromptInput = forwardRef( pendingUserInputState, sending, handlePromptChange, - taskPhase, scrollToBottom, handleMessageSent, cloudJob,