Files
hermes-agent/apps/desktop/e2e/batch-clarify.spec.ts
T
ethernet 25c6516607 feat(desktop): single confirm for the batch clarify card
The batch card previously locked each answer with its own Continue
press. Now picks and typed answers stage locally, and one Confirm and
continue button (enabled when every question has an answer) submits
the whole batch. Staged answers stay editable until that confirm.

The wire protocol is unchanged. The confirm sends the per-question
locks in sequence, because the last lock resolves the blocked tool and
each earlier lock must already be accepted when it lands. Replayed
locked answers from a reconnect pre-stage their questions so restored
progress stays visible. The TUI and CLI keep incremental per-question
locks, so a timeout there still returns partial answers.
2026-08-18 21:28:53 -04:00

87 lines
3.6 KiB
TypeScript

/**
* E2E batch clarify test — the multi-question clarify card must mount ONCE.
*
* Regression coverage for the duplicated-card bug: `tool.start` carries the
* model's tool_call_id while `clarify.request` carries a gateway-generated
* request_id. A batch payload has no top-level `question`, so the two rows
* only merge when the correlation key comes from the question list
* (`batchClarifyMatchValue` in lib/chat-messages.ts). Before that fix this
* exact flow rendered two identical interactive cards.
*
* The flow runs the real chain: composer → gateway → agent → clarify tool →
* clarify.request event → renderer, against the mock inference server.
*/
import { expect, test } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from './mock-server'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test.describe('batch clarify card', () => {
test('renders exactly one card and completes via per-question locks', 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(BATCH_CLARIFY_TRIGGER, { delay: 20 })
await page.keyboard.press('Enter')
// The live batch form marks itself with data-clarify-batch=<count>.
const batchCard = page.locator('form[data-clarify-batch]')
await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 })
// THE regression assertion: one card, not two.
await expect(batchCard).toHaveCount(1)
await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length))
// Both questions render inside the single card.
for (const entry of BATCH_CLARIFY_QUESTIONS) {
await expect(batchCard.getByText(entry.question)).toHaveCount(1)
}
// Each question text also appears exactly once in the whole transcript —
// catches a duplicate that mounts outside a form[data-clarify-batch].
for (const entry of BATCH_CLARIFY_QUESTIONS) {
await expect(page.getByText(entry.question)).toHaveCount(1)
}
// Answer both questions: stage picks locally (no server traffic yet).
const confirmButton = batchCard.locator('button[type="submit"]')
await expect(confirmButton).toContainText('Confirm and continue')
await expect(confirmButton).toBeDisabled()
await batchCard.getByRole('button', { name: /Coffee/ }).click()
await expect(confirmButton).toBeDisabled()
await batchCard.getByRole('button', { name: /Morning/ }).click()
await expect(confirmButton).toBeEnabled()
// ONE confirm submits the whole batch.
await confirmButton.click()
// The settled card lists both questions with their locked answers.
const settled = page.locator('[data-clarify-settled]')
await settled.waitFor({ state: 'visible', timeout: 30_000 })
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible()
await expect(settled.getByText('Coffee', { exact: true })).toBeVisible()
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible()
await expect(settled.getByText('Morning', { exact: true })).toBeVisible()
// And still no duplicate live card lingering after settle.
await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0)
})
})