diff --git a/apps/desktop/e2e/batch-clarify.spec.ts b/apps/desktop/e2e/batch-clarify.spec.ts index a3280642d5..062a022b59 100644 --- a/apps/desktop/e2e/batch-clarify.spec.ts +++ b/apps/desktop/e2e/batch-clarify.spec.ts @@ -15,7 +15,12 @@ import { expect, test } from './test' import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' -import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from '../../../tests-js/scripts/mock-server' +import { + BATCH_CLARIFY_QUESTIONS, + BATCH_CLARIFY_TRIGGER, + SINGLE_BATCH_CLARIFY_QUESTIONS, + SINGLE_BATCH_CLARIFY_TRIGGER +} from '../../../tests-js/scripts/mock-server' let fixture: MockBackendFixture | null = null @@ -82,4 +87,40 @@ test.describe('batch clarify card', () => { // And still no duplicate live card lingering after settle. await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0) }) + + // #98645: since the questions[]-only schema (#95907) a single question is a + // one-entry batch on both the tool args and the gateway wire. The card must + // mount and answer exactly like the 2+ case — a blank or spinner-only + // section that eats the 10-minute tool timeout is the reported failure. + test('renders and answers a one-entry batch like the 2+ case', async () => { + const page = fixture!.page + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + + await composer.click() + await composer.type(SINGLE_BATCH_CLARIFY_TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + const batchCard = page.locator('form[data-clarify-batch]') + await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 }) + + await expect(batchCard).toHaveCount(1) + await expect(batchCard).toHaveAttribute('data-clarify-batch', '1') + + await expect(batchCard.getByText(SINGLE_BATCH_CLARIFY_QUESTIONS[0]!.question)).toHaveCount(1) + + const confirmButton = batchCard.locator('button[type="submit"]') + await expect(confirmButton).toBeDisabled() + + await batchCard.getByRole('button', { name: /Espresso/ }).click() + await expect(confirmButton).toBeEnabled() + await confirmButton.click() + + const settled = page.locator('[data-clarify-settled]') + await settled.waitFor({ state: 'visible', timeout: 30_000 }) + await expect(settled.getByText(SINGLE_BATCH_CLARIFY_QUESTIONS[0]!.question)).toBeVisible() + await expect(settled.getByText('Espresso', { exact: true })).toBeVisible() + + await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0) + }) }) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index c5b8ed4486..b6d46bb00a 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -933,6 +933,107 @@ describe('ClarifyTool batch card', () => { expect(request).not.toHaveBeenCalled() }) + // ─── Single-entry batch (one-question questions[]) ──────────────────────── + // #95907 made `questions[]` the only advertised shape, so a single question + // now arrives as a one-entry batch on BOTH sides: tool args carry + // `questions:[{question, choices}]` and the gateway wire carries + // `questions:[{qid, question, choices}]` with no top-level question. The + // batch card must mount for the one-entry case exactly as it does for 2+. + + function singleBatchArgs(): { questions: { question: string; choices: string[] }[] } { + return { + questions: [ + { + choices: ['Local Markdown under .scratch/', 'GitHub Issues', 'Linear', 'GitLab Issues'], + question: 'Which issue tracker should this repository use?' + } + ] + } + } + + function liveSingleBatchProps(): ToolCallMessagePartProps { + const args = singleBatchArgs() + + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result: undefined, + resume: vi.fn(), + status: { type: 'running' }, + toolCallId: 'clarify-single-batch', + toolName: 'clarify', + type: 'tool-call' + } + } + + function singleBatchRequest() { + return { + choices: null, + multiSelect: false, + question: '', + questions: [ + { + choices: ['Local Markdown under .scratch/', 'GitHub Issues', 'Linear', 'GitLab Issues'], + multiSelect: false, + qid: 'q0', + question: 'Which issue tracker should this repository use?' + } + ], + requestId: 'request-single-batch', + sessionId: 'session-1' + } + } + + it('renders a one-entry batch as the batch card, not a blank/spinner single card', () => { + const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] }) + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + setClarifyRequest(singleBatchRequest()) + renderClarify() + + expect(screen.getByText('Which issue tracker should this repository use?')).toBeTruthy() + expect(screen.getByRole('button', { name: /GitHub Issues/ })).toBeTruthy() + expect(document.querySelector('form[data-clarify-batch]')?.getAttribute('data-clarify-batch')).toBe('1') + }) + + it('mounts the batch card once the wire request lands after the tool row (#98645 timing)', async () => { + const request = vi.fn().mockResolvedValue({ ok: true, remaining: [] }) + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + + // Tool row mounts FIRST with the model's args; the gateway wire (with the + // qid the renderer needs) arrives a beat later — same ordering as + // tool.start → clarify.request in a live session. + const { rerender } = renderClarify() + + await act(async () => { + setClarifyRequest(singleBatchRequest()) + }) + rerender(clarifyTree()) + + await waitFor(() => { + expect(screen.getByText('Which issue tracker should this repository use?')).toBeTruthy() + }) + expect(screen.getByRole('button', { name: /GitHub Issues/ })).toBeTruthy() + + // And the single pick answers with the qid-keyed lock. + fireEvent.click(screen.getByRole('button', { name: /GitHub Issues/ })) + fireEvent.click(screen.getByRole('button', { name: /Confirm and continue/ })) + + await waitFor(() => { + expect(request).toHaveBeenCalledWith('clarify.lock', { + answer: 'GitHub Issues', + question_id: 'q0', + request_id: 'request-single-batch' + }) + }) + }) + it('renders the settled batch with all questions and answers', () => { renderClarify( + message?.role === 'tool' && JSON.stringify(message).includes('single-batch'), + ) + + if (!hasOwnToolResult) { + if (stream) { + streamScriptedTurn(res, model, SINGLE_BATCH_CLARIFY_TURN) + } else { + nonStreamingScriptedTurn(res, model, SINGLE_BATCH_CLARIFY_TURN) + } + + return + } + } + if (includesBlockingClarifyTrigger(parsed.messages)) { if (stream) { streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)