mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
The Gateway and the carrier each compiled both halves under one tsconfig, so the Host aggregate built their browser faces — including the face that owns `ctx.remote`, the most likely future consumer of a generated `/remote` contribution. Both packages now expose a host and a client face, and each aggregate references only its own; three modules the halves share appear in both file lists, as api/remotes already does. The two apps/web specs in the Host aggregate restate the conversation engine's Context key format instead of importing the Client runtime for it. A drift makes the key miss its rendered node, so the assertion fails loudly. The Host aggregate now reaches one Client project, the carrier's host face, which the Gateway's own dispatch face needs; no generated contribution is reachable from it.
291 lines
14 KiB
TypeScript
291 lines
14 KiB
TypeScript
// Long-history Chat behavior contract that stays valid under a 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 { conversationContextKey, 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<void> {
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready
|
|
await new Promise<void>(resolve => requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => { resolve() })
|
|
}))
|
|
})
|
|
}
|
|
|
|
async function openSeed(page: Page): Promise<void> {
|
|
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<void> {
|
|
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<T extends SessionEvent['type']>(
|
|
events: readonly SessionEvent[],
|
|
type: T,
|
|
marker: string,
|
|
): Extract<SessionEvent, { type: T }> {
|
|
const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
|
|
candidate.type === type && carries(candidate, marker)
|
|
))
|
|
if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
|
|
return event
|
|
}
|
|
|
|
function messageKey(event: SessionEvent<'user/message'>): string {
|
|
return conversationContextKey('input-message', String(event.data.id))
|
|
}
|
|
|
|
function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
|
return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
|
|
}
|
|
|
|
function turnTailKey(turn: number): string {
|
|
return conversationContextKey('turn-tail', String(turn))
|
|
}
|
|
|
|
describe('web e2e: long Chat interaction contract', () => {
|
|
let browser: Browser
|
|
let page: Page
|
|
let replayDir: string
|
|
let scaffold: WebScaffold
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
|
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 turn/end event`)
|
|
const expectedUserText = textContent(branchUserEvent.data.content)
|
|
|
|
await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
|
|
const toolUserKey = messageKey(toolUserEvent)
|
|
const toolAssistantKey = assistantKey(toolAssistantEvent)
|
|
const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
|
|
const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
|
|
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-step')
|
|
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 = [
|
|
toolUserKey,
|
|
conversationContextKey('tool-call', TARGET_CALL_1),
|
|
conversationContextKey('tool-call', TARGET_CALL_2),
|
|
toolAssistantKey,
|
|
]
|
|
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 toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
|
|
element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
|
|
))))
|
|
expect(toolKinds).toEqual(['tool-call', 'tool-call'])
|
|
|
|
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 })
|
|
|
|
const branchUserKey = messageKey(branchUserEvent)
|
|
const branchAssistantKey = assistantKey(branchAssistantEvent)
|
|
await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
|
|
const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
|
|
const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
|
|
const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
|
|
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 turnTailRow.hover()
|
|
await turnTailRow.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)
|
|
})
|