chat-long-interactions.e2e.ts 14 KB

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