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)