// Long-history Chat behavior contract for a future virtualized renderer. Wheel // input only navigates to the semantic target; assertions pin content identity // and interaction routing rather than scroll geometry or mounted row counts. 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, onTestFailed } from 'vitest' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' const MODE = webSnapshotMode() const SESSION_ID = 'chat-long-interactions-e2e' const FIXTURE_TURNS = 88 const TOOL_TURN = FIXTURE_TURNS const BRANCH_TURN = 80 const TARGET_CALL_1 = 'chat-scroll-088-1' const TARGET_CALL_2 = 'chat-scroll-088-2' const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.' const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST' const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE' const FIXTURE = createChatScrollFixture({ markerPrefix: 'INTERACTION', title: 'CHAT_INTERACTION long semantic identity session', turns: FIXTURE_TURNS, }) function continuationChunks(): StreamChunk[] { const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.` return [ { type: 'block-start', index: 0, blockType: 'text' }, { type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` }, { type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` }, { type: 'block-end', index: 0, block: { type: 'text', text: response } }, { type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } }, { type: 'finish', reason: { kind: 'stop' } }, ] } function replayEntry(chunks: StreamChunk[]): ReplayEntry { return { kind: 'chunks', chunks } } function carries(event: SessionEvent, marker: string): boolean { return JSON.stringify(event).includes(marker) } function textContent(content: readonly unknown[]): string { return content.flatMap((block) => { if (typeof block !== 'object' || block === null) return [] const candidate = block as { type?: unknown; text?: unknown } return candidate.type === 'text' && typeof candidate.text === 'string' ? [candidate.text] : [] }).join('') } async function nextPaint(page: Page): Promise { await page.evaluate(async () => { await document.fonts.ready await new Promise(resolve => requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })) }) } async function openSeed(page: Page): Promise { await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 }) const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true }) await search.fill(FIXTURE.markers.user(1)) const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') await results.first().waitFor({ timeout: 60_000 }) const resultCount = await results.count() if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`) await results.click() await results.click() await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }) .last().waitFor({ timeout: 30_000 }) await nextPaint(page) } async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise { const scrollport = page.locator('[data-conversation-scroll]') const box = await scrollport.boundingBox() if (box === null) throw new Error('conversation scrollport has no layout box') await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3)) for (let attempt = 0; attempt < 20; attempt += 1) { if (await page.locator(selector).count() > 0) return await page.mouse.wheel(0, deltaY) await nextPaint(page) } throw new Error(`semantic Chat target did not mount: ${selector}`) } function requiredEvent( events: readonly SessionEvent[], type: T, marker: string, ): Extract { const event = events.find((candidate): candidate is Extract => ( candidate.type === type && carries(candidate, marker) )) if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`) return event } describe('web e2e: long Chat interaction contract', () => { let browser: Browser let page: Page let replayDir: string let scaffold: WebScaffold let tripwire: ReturnType beforeAll(async () => { replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-')) const replayOverride = join(replayDir, 'replay.override.json') const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())] await writeFile(replayOverride, JSON.stringify(replay)) scaffold = await launchWebScaffold({ replayFixture: join(replayDir, 'override-only.jsonl'), replayOverride, replayContextWindow: 10_000_000, paceMs: 18, }) await seedSession(scaffold, FIXTURE.log, SESSION_ID) browser = await chromium.launch() page = await newEnglishPage(browser, 900) tripwire = watchConsole(page) await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await openSeed(page) }, 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 (replayDir !== undefined) { await rm(replayDir, { recursive: true, force: true }) .catch((error: unknown) => failures.push(error)) } if (failures.length === 1) throw failures[0] if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed') }) it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions')) const source = scaffold.ctx.agents.get(SessionId(SESSION_ID)) if (source === undefined) throw new Error('seeded long-history agent is not attached') const toolUserMarker = FIXTURE.markers.user(TOOL_TURN) const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN) const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1) const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2) const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker) const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker) const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN) const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN) const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker) const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker) const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' && event.data.turn === BRANCH_TURN )) if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`) const expectedUserText = textContent(branchUserEvent.data.content) await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100) const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`) const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`) const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`) const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`) await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1) expect(await call1.count()).toBe(1) expect(await call2.count()).toBe(1) expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user') expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant') expect(await toolUserRow.textContent()).toContain(toolUserMarker) expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker) expect(await call1.textContent()).toContain(toolMarker1) expect(await call2.textContent()).toContain(toolMarker2) const expectedOrder = [ `node:${String(toolUserEvent.seq)}`, `call:${TARGET_CALL_1}`, `call:${TARGET_CALL_2}`, `node:${String(toolAssistantEvent.seq)}`, ] const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => ( rows.map(row => (row as HTMLElement).dataset.chatAnchorKey) .filter((key): key is string => key !== undefined && keys.includes(key)) ), expectedOrder) expect(actualOrder).toEqual(expectedOrder) const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => ( element.closest('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null )))) expect(groupKeys[0]).not.toBeNull() expect(groupKeys[1]).toBe(groupKeys[0]) const summary1 = call1.locator('[data-sample="bash"]') const summary2 = call2.locator('[data-sample="bash"]') expect(await summary1.getAttribute('aria-expanded')).toBe('false') expect(await summary2.getAttribute('aria-expanded')).toBe('false') await summary2.focus() await summary2.press('Enter') await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true') expect(await summary1.getAttribute('aria-expanded')).toBe('false') await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 }) await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100) const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`) const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`) expect(await userRow.textContent()).toContain(branchUserMarker) expect(await assistantRow.textContent()).toContain(branchAssistantMarker) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) await userRow.hover() await userRow.getByRole('button', { name: 'Copy', exact: true }).click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 }) .toBe(expectedUserText) await assistantRow.hover() await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click() await expect.poll( () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)), { timeout: 15_000 }, ).toBeDefined() const child = scaffold.ctx.agents.list() .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)) if (child === undefined) throw new Error('message branch did not create a child session') expect(child.session.header.seedLength).toBe(boundary.seq + 1) expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true) expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false) expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false) const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' }) .getByRole('button').last() await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 }) .toBe(`${FIXTURE.title} (1)`) await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 }) const settled = scaffold.whenTurnSettled(60_000) const composer = page.locator('textarea:enabled').last() await composer.fill(CONTINUE_PROMPT) await page.getByRole('button', { name: 'Send message', exact: true }).click() await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1) expect(await settled).toBe(child.session.id) await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 }) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0) expect(await composer.inputValue()).toBe('') expect(await composer.isEnabled()).toBe(true) expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false) expect(child.session.events.filter(event => ( event.type === 'user/message' && carries(event, CONTINUE_PROMPT) ))).toHaveLength(1) const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => ( event.type === 'turn/end' )) expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' }) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 180_000) })