Files
hermes-agent/apps/desktop/e2e/batch-clarify.spec.ts
liuhao1024 00c8e5ca48 test(desktop): cover the one-entry batch clarify shape end to end
Since the questions[]-only schema (#95907) every single question is a
one-entry batch on both the tool args and the gateway wire, yet no test
exercised that shape (called out in #98645). Lock the behavior down at
both layers:

- unit: a one-entry batch renders the batch card (not a blank/spinner
  single card) both with the wire already parked and when the request
  lands after the tool row, and answers with the qid-keyed lock
- e2e: a SINGLE_BATCH trigger drives the real chain (composer -> gateway
  -> agent -> clarify tool -> clarify.request -> renderer) through mount,
  pick, confirm, and settle for questions.length === 1

The e2e mock's trigger routing also learns to scope its has-tool-result
guard to the answering turn's own question text: the existing any-tool-
result check would false-positive once a second scripted clarify shares
the conversation history.

Adapted to main: the mock server now lives in tests-js/scripts, and a batch
confirm answers with clarify.lock.
2026-09-26 18:00:36 -05:00

127 lines
5.3 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/tool-parts.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,
SINGLE_BATCH_CLARIFY_QUESTIONS,
SINGLE_BATCH_CLARIFY_TRIGGER
} from '../../../tests-js/scripts/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).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)
})
// #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)
})
})