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.
127 lines
5.3 KiB
TypeScript
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)
|
|
})
|
|
})
|