chat-long-interactions.e2e.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. // Long-history Chat behavior contract for a future virtualized renderer. Wheel
  2. // input only navigates to the semantic target; assertions pin content identity
  3. // and interaction routing rather than scroll geometry or mounted row counts.
  4. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import type { Browser, Page } from 'playwright'
  8. import { chromium } from 'playwright'
  9. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  10. import type { StreamChunk } from '@deepseek-ai/dsh-llm'
  11. import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  12. import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
  13. import { createChatScrollFixture } from './chat-scroll-fixture.ts'
  14. import {
  15. launchWebScaffold,
  16. seedSession,
  17. watchConsole,
  18. webSnapshotMode,
  19. type WebScaffold,
  20. } from './scaffold.ts'
  21. import { newEnglishPage, saveFailureShot } from './support.ts'
  22. const MODE = webSnapshotMode()
  23. const SESSION_ID = 'chat-long-interactions-e2e'
  24. const FIXTURE_TURNS = 88
  25. const TOOL_TURN = FIXTURE_TURNS
  26. const BRANCH_TURN = 80
  27. const TARGET_CALL_1 = 'chat-scroll-088-1'
  28. const TARGET_CALL_2 = 'chat-scroll-088-2'
  29. const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
  30. const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
  31. const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
  32. const FIXTURE = createChatScrollFixture({
  33. markerPrefix: 'INTERACTION',
  34. title: 'CHAT_INTERACTION long semantic identity session',
  35. turns: FIXTURE_TURNS,
  36. })
  37. function continuationChunks(): StreamChunk[] {
  38. const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
  39. return [
  40. { type: 'block-start', index: 0, blockType: 'text' },
  41. { type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
  42. { type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
  43. { type: 'block-end', index: 0, block: { type: 'text', text: response } },
  44. { type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
  45. { type: 'finish', reason: { kind: 'stop' } },
  46. ]
  47. }
  48. function replayEntry(chunks: StreamChunk[]): ReplayEntry {
  49. return { kind: 'chunks', chunks }
  50. }
  51. function carries(event: SessionEvent, marker: string): boolean {
  52. return JSON.stringify(event).includes(marker)
  53. }
  54. function textContent(content: readonly unknown[]): string {
  55. return content.flatMap((block) => {
  56. if (typeof block !== 'object' || block === null) return []
  57. const candidate = block as { type?: unknown; text?: unknown }
  58. return candidate.type === 'text' && typeof candidate.text === 'string'
  59. ? [candidate.text]
  60. : []
  61. }).join('')
  62. }
  63. async function nextPaint(page: Page): Promise<void> {
  64. await page.evaluate(async () => {
  65. await document.fonts.ready
  66. await new Promise<void>(resolve => requestAnimationFrame(() => {
  67. requestAnimationFrame(() => { resolve() })
  68. }))
  69. })
  70. }
  71. async function openSeed(page: Page): Promise<void> {
  72. await page.getByText(/^\d+ sessions?$/, { exact: true }).waitFor({ timeout: 30_000 })
  73. const search = page.getByRole('textbox', { name: 'Search name, keywords...', exact: true })
  74. await search.fill(FIXTURE.markers.user(1))
  75. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  76. await results.first().waitFor({ timeout: 60_000 })
  77. const resultCount = await results.count()
  78. if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
  79. await results.click()
  80. await results.click()
  81. await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
  82. .last().waitFor({ timeout: 30_000 })
  83. await nextPaint(page)
  84. }
  85. async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
  86. const scrollport = page.locator('[data-conversation-scroll]')
  87. const box = await scrollport.boundingBox()
  88. if (box === null) throw new Error('conversation scrollport has no layout box')
  89. await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
  90. for (let attempt = 0; attempt < 20; attempt += 1) {
  91. if (await page.locator(selector).count() > 0) return
  92. await page.mouse.wheel(0, deltaY)
  93. await nextPaint(page)
  94. }
  95. throw new Error(`semantic Chat target did not mount: ${selector}`)
  96. }
  97. function requiredEvent<T extends SessionEvent['type']>(
  98. events: readonly SessionEvent[],
  99. type: T,
  100. marker: string,
  101. ): Extract<SessionEvent, { type: T }> {
  102. const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
  103. candidate.type === type && carries(candidate, marker)
  104. ))
  105. if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
  106. return event
  107. }
  108. describe('web e2e: long Chat interaction contract', () => {
  109. let browser: Browser
  110. let page: Page
  111. let replayDir: string
  112. let scaffold: WebScaffold
  113. let tripwire: ReturnType<typeof watchConsole>
  114. beforeAll(async () => {
  115. replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
  116. const replayOverride = join(replayDir, 'replay.override.json')
  117. const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
  118. await writeFile(replayOverride, JSON.stringify(replay))
  119. scaffold = await launchWebScaffold({
  120. replayFixture: join(replayDir, 'override-only.jsonl'),
  121. replayOverride,
  122. replayContextWindow: 10_000_000,
  123. paceMs: 18,
  124. })
  125. await seedSession(scaffold, FIXTURE.log, SESSION_ID)
  126. browser = await chromium.launch()
  127. page = await newEnglishPage(browser, 900)
  128. tripwire = watchConsole(page)
  129. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  130. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  131. await openSeed(page)
  132. }, 120_000)
  133. afterAll(async () => {
  134. const failures: unknown[] = []
  135. await browser?.close().catch((error: unknown) => failures.push(error))
  136. await scaffold?.close().catch((error: unknown) => failures.push(error))
  137. if (replayDir !== undefined) {
  138. await rm(replayDir, { recursive: true, force: true })
  139. .catch((error: unknown) => failures.push(error))
  140. }
  141. if (failures.length === 1) throw failures[0]
  142. if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
  143. })
  144. it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
  145. onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
  146. const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
  147. if (source === undefined) throw new Error('seeded long-history agent is not attached')
  148. const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
  149. const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
  150. const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
  151. const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
  152. const toolUserEvent = requiredEvent(source.session.events, 'user/message', toolUserMarker)
  153. const toolAssistantEvent = requiredEvent(source.session.events, 'assistant/message', toolAssistantMarker)
  154. const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
  155. const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
  156. const branchUserEvent = requiredEvent(source.session.events, 'user/message', branchUserMarker)
  157. const branchAssistantEvent = requiredEvent(source.session.events, 'assistant/message', branchAssistantMarker)
  158. const boundary = source.session.events.find((event): event is SessionEvent<'turn/end'> => (
  159. event.type === 'turn/end' && event.data.turn === BRANCH_TURN
  160. ))
  161. if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no completed boundary`)
  162. const expectedUserText = textContent(branchUserEvent.data.content)
  163. await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
  164. const toolUserRow = page.locator(`[data-chat-anchor-key="node:${String(toolUserEvent.seq)}"]`)
  165. const toolAssistantRow = page.locator(`[data-chat-anchor-key="node:${String(toolAssistantEvent.seq)}"]`)
  166. const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
  167. const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
  168. await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
  169. await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
  170. expect(await call1.count()).toBe(1)
  171. expect(await call2.count()).toBe(1)
  172. expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
  173. expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
  174. expect(await toolUserRow.textContent()).toContain(toolUserMarker)
  175. expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
  176. expect(await call1.textContent()).toContain(toolMarker1)
  177. expect(await call2.textContent()).toContain(toolMarker2)
  178. const expectedOrder = [
  179. `node:${String(toolUserEvent.seq)}`,
  180. `call:${TARGET_CALL_1}`,
  181. `call:${TARGET_CALL_2}`,
  182. `node:${String(toolAssistantEvent.seq)}`,
  183. ]
  184. const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
  185. rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
  186. .filter((key): key is string => key !== undefined && keys.includes(key))
  187. ), expectedOrder)
  188. expect(actualOrder).toEqual(expectedOrder)
  189. const groupKeys = await Promise.all([call1, call2].map(row => row.evaluate(element => (
  190. element.closest<HTMLElement>('[data-chat-flow-kind="tool-group"]')?.dataset.chatFlowKey ?? null
  191. ))))
  192. expect(groupKeys[0]).not.toBeNull()
  193. expect(groupKeys[1]).toBe(groupKeys[0])
  194. const summary1 = call1.locator('[data-sample="bash"]')
  195. const summary2 = call2.locator('[data-sample="bash"]')
  196. expect(await summary1.getAttribute('aria-expanded')).toBe('false')
  197. expect(await summary2.getAttribute('aria-expanded')).toBe('false')
  198. await summary2.focus()
  199. await summary2.press('Enter')
  200. await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
  201. expect(await summary1.getAttribute('aria-expanded')).toBe('false')
  202. await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
  203. await wheelUntilMounted(page, `[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`, -1_100)
  204. const userRow = page.locator(`[data-chat-anchor-key="node:${String(branchUserEvent.seq)}"]`)
  205. const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(branchAssistantEvent.seq)}"]`)
  206. expect(await userRow.textContent()).toContain(branchUserMarker)
  207. expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
  208. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
  209. await userRow.hover()
  210. await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
  211. await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
  212. .toBe(expectedUserText)
  213. await assistantRow.hover()
  214. await assistantRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
  215. await expect.poll(
  216. () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
  217. { timeout: 15_000 },
  218. ).toBeDefined()
  219. const child = scaffold.ctx.agents.list()
  220. .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
  221. if (child === undefined) throw new Error('message branch did not create a child session')
  222. expect(child.session.header.seedLength).toBe(boundary.seq + 1)
  223. expect(child.session.events.some(event => carries(event, branchAssistantMarker))).toBe(true)
  224. expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
  225. expect(child.session.events.some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
  226. const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
  227. .getByRole('button').last()
  228. await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
  229. .toBe(`${FIXTURE.title} (1)`)
  230. await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  231. const settled = scaffold.whenTurnSettled(60_000)
  232. const composer = page.locator('textarea:enabled').last()
  233. await composer.fill(CONTINUE_PROMPT)
  234. await page.getByRole('button', { name: 'Send message', exact: true }).click()
  235. await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  236. expect(await settled).toBe(child.session.id)
  237. await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  238. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  239. expect(await composer.inputValue()).toBe('')
  240. expect(await composer.isEnabled()).toBe(true)
  241. expect(source.session.events.some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
  242. expect(child.session.events.filter(event => (
  243. event.type === 'user/message' && carries(event, CONTINUE_PROMPT)
  244. ))).toHaveLength(1)
  245. const lastTurnEnd = child.session.events.findLast((event): event is SessionEvent<'turn/end'> => (
  246. event.type === 'turn/end'
  247. ))
  248. expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
  249. expect(tripwire.pageErrors).toEqual([])
  250. expect(tripwire.warnings).toEqual([])
  251. }, 180_000)
  252. })