* feat(ui-user-questions): show answered batches as shared message bubbles
* refactor(ui-primitives): share clipboard confirmation lifecycle
* test(web): include answered question bubbles in steering goldens
* fix(ui-user-questions): keep answered turns collapsible
* test(ui-user-questions): drop the removed compactAnswer expectation
master removed ChatTurnProcessPresentation.compactAnswer in aaeb344
("fix(chat): keep the reply gap on a folded final answer"); reply spacing
now comes from the adjacent-row rules. The rebase left this assertion
behind, so the parametrized replay/live/prepend cases failed on every CI
coverage lane even though typecheck stayed green: toMatchObject accepts
the stale property.
* fix(ui-user-questions): address answer bubble review feedback
* test(web): observe file card styles after both cards mount
---------
Co-authored-by: imccyu <[email protected]>
117 lines
6.0 KiB
TypeScript
117 lines
6.0 KiB
TypeScript
/** PTC answers stay visible through real Web composition, process folding, and reload. */
|
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import type { Browser, Page } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
import { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
|
import type { ReplayEntry } from '@deepseek-ai/dsh-llm-replay'
|
|
import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types'
|
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
|
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
|
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
|
|
|
|
const question = 'Which PTC answer should remain visible?'
|
|
const args = JSON.stringify({
|
|
description: 'Ask for a retained answer',
|
|
code: `return await tools.ask_user_question(${JSON.stringify({ questions: [{
|
|
id: 'choice', question, options: [{ label: 'Blue' }, { label: 'Green' }],
|
|
}] })});`,
|
|
})
|
|
const script: ReplayEntry[] = [
|
|
{ kind: 'chunks', chunks: [
|
|
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
|
{ type: 'tool-call-delta', index: 0, id: ToolCallId('ptc-question'), name: 'run_code', argumentsDelta: args },
|
|
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('ptc-question'), name: 'run_code', arguments: args } },
|
|
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
|
] },
|
|
{ kind: 'chunks', chunks: [
|
|
{ type: 'block-start', index: 0, blockType: 'text' },
|
|
{ type: 'text-delta', index: 0, text: 'PTC answer recorded.' },
|
|
{ type: 'block-end', index: 0, block: { type: 'text', text: 'PTC answer recorded.' } },
|
|
{ type: 'finish', reason: { kind: 'stop' } },
|
|
] },
|
|
]
|
|
|
|
describe.skipIf(webSnapshotMode() === 'record')('web e2e: PTC answered question', () => {
|
|
let directory: string | undefined
|
|
let scaffold: WebScaffold | undefined
|
|
let browser: Browser | undefined
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
const events: SessionEvent[] = []
|
|
|
|
beforeAll(async () => {
|
|
directory = await mkdtemp(join(tmpdir(), 'dsh-question-ptc-'))
|
|
const replayOverride = join(directory, 'replay.override.json')
|
|
await writeFile(replayOverride, JSON.stringify(script))
|
|
scaffold = await launchWebScaffold({
|
|
agentPresets: { default: 'ptc' },
|
|
replayFixture: join(directory, 'override-only.jsonl'), replayOverride,
|
|
})
|
|
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
|
|
browser = await chromium.launch()
|
|
page = await newEnglishPage(browser)
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
|
}, 120_000)
|
|
|
|
afterAll(async () => {
|
|
const failures: unknown[] = []
|
|
await browser?.close().catch((error: unknown) => failures.push(error))
|
|
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
|
if (directory !== undefined) await rm(directory, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
|
|
if (failures.length > 0) throw new AggregateError(failures, 'PTC question cleanup failed')
|
|
})
|
|
|
|
it('records a real subcall answer once and retains its bubble when process rows fold', async () => {
|
|
const settled = scaffold!.whenTurnSettled(60_000)
|
|
const input = page.locator('[data-composer-input]').first()
|
|
await input.fill('Ask me which color to retain.')
|
|
await input.press('Enter')
|
|
const composer = page.locator('[data-question-key]')
|
|
await composer.getByRole('radio', { name: 'Blue', exact: true }).click()
|
|
await composer.getByRole('button', { name: 'Submit', exact: true }).click()
|
|
await settled
|
|
const dispatch = events.find(event => event.type === 'tool/ptc-dispatch' && event.data.name === 'ask_user_question')
|
|
expect(dispatch?.type).toBe('tool/ptc-dispatch')
|
|
if (dispatch?.type !== 'tool/ptc-dispatch') throw new Error('missing PTC question settlement')
|
|
expect(dispatch.data.isError).toBe(false)
|
|
expect(events.filter(event => event.type === 'tool/ptc-dispatch-start' && event.data.subCallId === dispatch.data.subCallId)).toHaveLength(1)
|
|
const bubble = page.locator('[data-question-reply]')
|
|
await bubble.waitFor({ state: 'visible' })
|
|
expect(await bubble.count()).toBe(1)
|
|
expect(await bubble.getAttribute('data-question-reply')).toBe(String(dispatch.data.subCallId))
|
|
expect(await bubble.getByText(question, { exact: true }).isVisible()).toBe(true)
|
|
expect(await bubble.getByText('Blue', { exact: true }).isVisible()).toBe(true)
|
|
expect(await bubble.getByText('Green', { exact: true }).count()).toBe(0)
|
|
const process = page.locator('[data-turn-process]').first()
|
|
await expect.poll(() => process.isEnabled()).toBe(true)
|
|
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false')
|
|
await process.click()
|
|
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('true')
|
|
await process.click()
|
|
await expect.poll(() => process.getAttribute('aria-expanded')).toBe('false')
|
|
expect(await bubble.isVisible()).toBe(true)
|
|
await page.reload({ waitUntil: 'load' })
|
|
await bubble.waitFor({ state: 'visible' })
|
|
expect(await bubble.count()).toBe(1)
|
|
expect(await bubble.getByText('Blue', { exact: true }).isVisible()).toBe(true)
|
|
for (const width of [390, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 })
|
|
const bounds = await bubble.evaluate((row) => {
|
|
const wrapper = row.firstElementChild!
|
|
return { width: wrapper.getBoundingClientRect().width, limit: row.getBoundingClientRect().width * 0.82,
|
|
overflow: document.documentElement.scrollWidth - window.innerWidth }
|
|
})
|
|
expect(bounds.width).toBeLessThanOrEqual(bounds.limit + 1)
|
|
expect(bounds.overflow).toBeLessThanOrEqual(1)
|
|
}
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
expect(tripwire.warnings).toEqual([])
|
|
}, 120_000)
|
|
})
|