chat-long-interactions.e2e.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 { createChatScrollFixture } from './chat-scroll-fixture.ts'
  15. import {
  16. launchWebScaffold,
  17. seedSession,
  18. watchConsole,
  19. webSnapshotMode,
  20. type WebScaffold,
  21. } from './scaffold.ts'
  22. import { conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts'
  23. const MODE = webSnapshotMode()
  24. const SESSION_ID = 'chat-long-interactions-e2e'
  25. const FIXTURE_TURNS = 88
  26. const TOOL_TURN = FIXTURE_TURNS
  27. const BRANCH_TURN = 80
  28. const TARGET_CALL_1 = 'chat-scroll-088-1'
  29. const TARGET_CALL_2 = 'chat-scroll-088-2'
  30. const CONTINUE_PROMPT = 'CHAT_INTERACTION_CONTINUE Continue from this exact branch point.'
  31. const CONTINUE_FIRST = 'CHAT_INTERACTION_CONTINUE_FIRST'
  32. const CONTINUE_DONE = 'CHAT_INTERACTION_CONTINUE_DONE'
  33. const FIXTURE = createChatScrollFixture({
  34. markerPrefix: 'INTERACTION',
  35. title: 'CHAT_INTERACTION long semantic identity session',
  36. turns: FIXTURE_TURNS,
  37. })
  38. function continuationChunks(): StreamChunk[] {
  39. const response = `${CONTINUE_FIRST} The fork retained the intended prefix. ${CONTINUE_DONE}.`
  40. return [
  41. { type: 'block-start', index: 0, blockType: 'text' },
  42. { type: 'text-delta', index: 0, text: `${CONTINUE_FIRST} ` },
  43. { type: 'text-delta', index: 0, text: `The fork retained the intended prefix. ${CONTINUE_DONE}.` },
  44. { type: 'block-end', index: 0, block: { type: 'text', text: response } },
  45. { type: 'usage', usage: { inputTokens: 512, outputTokens: 32 } },
  46. { type: 'finish', reason: { kind: 'stop' } },
  47. ]
  48. }
  49. function replayEntry(chunks: StreamChunk[]): ReplayEntry {
  50. return { kind: 'chunks', chunks }
  51. }
  52. function carries(event: SessionEvent, marker: string): boolean {
  53. return JSON.stringify(event).includes(marker)
  54. }
  55. function textContent(content: readonly unknown[]): string {
  56. return content.flatMap((block) => {
  57. if (typeof block !== 'object' || block === null) return []
  58. const candidate = block as { type?: unknown; text?: unknown }
  59. return candidate.type === 'text' && typeof candidate.text === 'string'
  60. ? [candidate.text]
  61. : []
  62. }).join('')
  63. }
  64. async function nextPaint(page: Page): Promise<void> {
  65. await page.evaluate(async () => {
  66. await document.fonts.ready
  67. await new Promise<void>(resolve => requestAnimationFrame(() => {
  68. requestAnimationFrame(() => { resolve() })
  69. }))
  70. })
  71. }
  72. async function openSeed(page: Page): Promise<void> {
  73. // The compact layout dropped group session counts; the seeded baseline is
  74. // the Ungrouped bucket once cold summaries load.
  75. await page.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 })
  76. // Search collapsed into a header action; expand it before filling.
  77. const searchButton = page.getByRole('button', { name: 'Search sessions' })
  78. if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
  79. const search = page.getByRole('textbox', { name: 'Search sessions...', exact: true })
  80. await search.fill(FIXTURE.markers.user(1))
  81. const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
  82. await results.first().waitFor({ timeout: 60_000 })
  83. const resultCount = await results.count()
  84. if (resultCount !== 1) throw new Error(`expected one seeded search result, received ${String(resultCount)}`)
  85. await results.click()
  86. await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false })
  87. .last().waitFor({ timeout: 30_000 })
  88. await nextPaint(page)
  89. }
  90. async function wheelUntilMounted(page: Page, selector: string, deltaY: number): Promise<void> {
  91. const scrollport = page.locator('[data-conversation-scroll]')
  92. const box = await scrollport.boundingBox()
  93. if (box === null) throw new Error('conversation scrollport has no layout box')
  94. await page.mouse.move(box.x + box.width / 2, box.y + Math.min(140, box.height / 3))
  95. for (let attempt = 0; attempt < 20; attempt += 1) {
  96. if (await page.locator(selector).count() > 0) return
  97. await page.mouse.wheel(0, deltaY)
  98. await nextPaint(page)
  99. }
  100. throw new Error(`semantic Chat target did not mount: ${selector}`)
  101. }
  102. function requiredEvent<T extends SessionEvent['type']>(
  103. events: readonly SessionEvent[],
  104. type: T,
  105. marker: string,
  106. ): Extract<SessionEvent, { type: T }> {
  107. const event = events.find((candidate): candidate is Extract<SessionEvent, { type: T }> => (
  108. candidate.type === type && carries(candidate, marker)
  109. ))
  110. if (event === undefined) throw new Error(`${type} carrying ${marker} is absent`)
  111. return event
  112. }
  113. function messageKey(event: SessionEvent<'user/message'>): string {
  114. return conversationContextKey('input-message', String(event.data.id))
  115. }
  116. function assistantKey(event: SessionEvent<'assistant/message'>): string {
  117. return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
  118. }
  119. function turnTailKey(turn: number): string {
  120. return conversationContextKey('turn-tail', String(turn))
  121. }
  122. describe('web e2e: long Chat interaction contract', () => {
  123. let browser: Browser
  124. let page: Page
  125. let replayDir: string
  126. let scaffold: WebScaffold
  127. let tripwire: ReturnType<typeof watchConsole>
  128. beforeAll(async () => {
  129. replayDir = await mkdtemp(join(tmpdir(), 'dsh-chat-interaction-replay-'))
  130. const replayOverride = join(replayDir, 'replay.override.json')
  131. const replay: ReplayOverrideDoc = [replayEntry(continuationChunks())]
  132. await writeFile(replayOverride, JSON.stringify(replay))
  133. scaffold = await launchWebScaffold({
  134. replayFixture: join(replayDir, 'override-only.jsonl'),
  135. replayOverride,
  136. replayContextWindow: 10_000_000,
  137. paceMs: 18,
  138. })
  139. await seedSession(scaffold, FIXTURE.log, SESSION_ID)
  140. browser = await chromium.launch()
  141. page = await newEnglishPage(browser, 900)
  142. tripwire = watchConsole(page)
  143. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  144. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  145. await openSeed(page)
  146. }, 120_000)
  147. afterAll(async () => {
  148. const failures: unknown[] = []
  149. await browser?.close().catch((error: unknown) => failures.push(error))
  150. await scaffold?.close().catch((error: unknown) => failures.push(error))
  151. if (replayDir !== undefined) {
  152. await rm(replayDir, { recursive: true, force: true })
  153. .catch((error: unknown) => failures.push(error))
  154. }
  155. if (failures.length === 1) throw failures[0]
  156. if (failures.length > 1) throw new AggregateError(failures, 'long Chat interaction cleanup failed')
  157. })
  158. it.skipIf(MODE === 'record')('keeps heterogeneous rows and their actions bound to exact semantic identities', async () => {
  159. onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-long-interactions'))
  160. await expect.poll(
  161. () => scaffold.ctx.agents.get(SessionId(SESSION_ID)) !== undefined,
  162. { timeout: 10_000 },
  163. ).toBe(true)
  164. const source = scaffold.ctx.agents.get(SessionId(SESSION_ID))
  165. if (source === undefined) throw new Error('seeded long-history agent is not attached')
  166. const toolUserMarker = FIXTURE.markers.user(TOOL_TURN)
  167. const toolAssistantMarker = FIXTURE.markers.assistant(TOOL_TURN)
  168. const toolMarker1 = FIXTURE.markers.tool(TOOL_TURN, 1)
  169. const toolMarker2 = FIXTURE.markers.tool(TOOL_TURN, 2)
  170. const toolUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', toolUserMarker)
  171. const toolAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', toolAssistantMarker)
  172. const branchUserMarker = FIXTURE.markers.user(BRANCH_TURN)
  173. const branchAssistantMarker = FIXTURE.markers.assistant(BRANCH_TURN)
  174. const branchUserEvent = requiredEvent(source.session.snapshotEvents(), 'user/message', branchUserMarker)
  175. const branchAssistantEvent = requiredEvent(source.session.snapshotEvents(), 'assistant/message', branchAssistantMarker)
  176. const boundary = source.session.snapshotEvents().find((event): event is SessionEvent<'turn/end'> => (
  177. event.type === 'turn/end' && event.data.turn === BRANCH_TURN
  178. ))
  179. if (boundary === undefined) throw new Error(`turn ${String(BRANCH_TURN)} has no turn/end event`)
  180. const expectedUserText = textContent(branchUserEvent.data.content)
  181. const turnNavigation = page.getByRole('navigation', { name: 'Turn navigation' })
  182. await turnNavigation.waitFor({ state: 'visible', timeout: 15_000 })
  183. // The whole-log outline offers every fixture turn before any paging, with
  184. // the live tail mark current.
  185. const marks = turnNavigation.getByRole('button')
  186. await expect.poll(() => marks.count(), { timeout: 15_000 }).toBe(FIXTURE_TURNS)
  187. expect(await marks.last().getAttribute('aria-current')).toBe('true')
  188. // The oldest turn is an unloaded mark whose outline preview already
  189. // carries both the prompt and the settled response.
  190. const firstTurnButton = turnNavigation
  191. .getByRole('button', { name: 'Load and jump to turn 1', exact: true })
  192. await firstTurnButton.focus()
  193. const preview = page.getByRole('tooltip')
  194. await preview.waitFor({ state: 'visible', timeout: 5_000 })
  195. expect(await preview.textContent()).toContain(FIXTURE.markers.user(1))
  196. expect(await preview.textContent()).toContain(FIXTURE.markers.assistant(1))
  197. const firstTurnPosition = await firstTurnButton.evaluate(button => (
  198. button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
  199. ))
  200. expect(firstTurnPosition).toBe('0px')
  201. const loadEarlier = page.getByRole('button', { name: 'Load earlier', exact: true })
  202. const loadedMarks = turnNavigation.getByRole('button', { name: /^Jump to turn / })
  203. const loadedBefore = await loadedMarks.count()
  204. await loadEarlier.click()
  205. // Paging converts marks to their loaded form without moving the
  206. // fixed-pitch ladder.
  207. await expect.poll(() => loadedMarks.count(), { timeout: 15_000 }).toBeGreaterThan(loadedBefore)
  208. expect(await firstTurnButton.evaluate(button => (
  209. button.parentElement?.style.getPropertyValue('--turn-natural-position') ?? ''
  210. ))).toBe(firstTurnPosition)
  211. // Activating the still-unloaded oldest mark pages the rest in and lands
  212. // on the turn's own row.
  213. await firstTurnButton.focus()
  214. await firstTurnButton.press('Enter')
  215. const firstLoaded = turnNavigation.getByRole('button', { name: 'Jump to turn 1', exact: true })
  216. await firstLoaded.waitFor({ timeout: 60_000 })
  217. await expect.poll(() => firstLoaded.getAttribute('aria-current'), { timeout: 15_000 }).toBe('true')
  218. await expect.poll(
  219. () => page.locator('[data-chat-turn="1"][data-chat-flow-kind="user"]').count(),
  220. { timeout: 5_000 },
  221. ).toBe(1)
  222. // Desktop-only affordance: a narrow Chat container hides the rail outright.
  223. await page.setViewportSize({ width: 800, height: 900 })
  224. await turnNavigation.waitFor({ state: 'hidden', timeout: 5_000 })
  225. await page.setViewportSize({ width: 1_680, height: 900 })
  226. await turnNavigation.waitFor({ state: 'visible', timeout: 5_000 })
  227. await wheelUntilMounted(page, `[data-chat-call-id="${TARGET_CALL_2}"]`, -1_100)
  228. const toolUserKey = messageKey(toolUserEvent)
  229. const toolAssistantKey = assistantKey(toolAssistantEvent)
  230. const toolUserRow = page.locator(`[data-chat-anchor-key="${toolUserKey}"]`)
  231. const toolAssistantRow = page.locator(`[data-chat-anchor-key="${toolAssistantKey}"]`)
  232. const call1 = page.locator(`[data-chat-call-id="${TARGET_CALL_1}"]`)
  233. const call2 = page.locator(`[data-chat-call-id="${TARGET_CALL_2}"]`)
  234. await expect.poll(() => toolUserRow.count(), { timeout: 10_000 }).toBe(1)
  235. await expect.poll(() => toolAssistantRow.count(), { timeout: 10_000 }).toBe(1)
  236. expect(await call1.count()).toBe(1)
  237. expect(await call2.count()).toBe(1)
  238. expect(await toolUserRow.getAttribute('data-chat-flow-kind')).toBe('user')
  239. expect(await toolAssistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
  240. expect(await toolUserRow.textContent()).toContain(toolUserMarker)
  241. expect(await toolAssistantRow.textContent()).toContain(toolAssistantMarker)
  242. expect(await call1.textContent()).toContain(toolMarker1)
  243. expect(await call2.textContent()).toContain(toolMarker2)
  244. const expectedOrder = [
  245. toolUserKey,
  246. conversationContextKey('tool-call', TARGET_CALL_1),
  247. conversationContextKey('tool-call', TARGET_CALL_2),
  248. toolAssistantKey,
  249. ]
  250. const actualOrder = await page.locator('[data-chat-anchor-key]').evaluateAll((rows, keys) => (
  251. rows.map(row => (row as HTMLElement).dataset.chatAnchorKey)
  252. .filter((key): key is string => key !== undefined && keys.includes(key))
  253. ), expectedOrder)
  254. expect(actualOrder).toEqual(expectedOrder)
  255. const toolKinds = await Promise.all([call1, call2].map(row => row.evaluate(element => (
  256. element.closest<HTMLElement>('[data-chat-flow-kind]')?.dataset.chatFlowKind ?? null
  257. ))))
  258. expect(toolKinds).toEqual(['tool-call', 'tool-call'])
  259. const summary1 = call1.locator('[data-sample="bash"]')
  260. const summary2 = call2.locator('[data-sample="bash"]')
  261. await expandOwningTurnProcess(page, call2)
  262. expect(await summary1.getAttribute('aria-expanded')).toBe('false')
  263. expect(await summary2.getAttribute('aria-expanded')).toBe('false')
  264. await summary2.focus()
  265. await summary2.press('Enter')
  266. await expect.poll(() => summary2.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
  267. expect(await summary1.getAttribute('aria-expanded')).toBe('false')
  268. await call2.getByText(`${toolMarker2} output line 12`, { exact: true }).waitFor({ timeout: 10_000 })
  269. const branchUserKey = messageKey(branchUserEvent)
  270. const branchAssistantKey = assistantKey(branchAssistantEvent)
  271. await wheelUntilMounted(page, `[data-chat-anchor-key="${branchUserKey}"]`, -1_100)
  272. const userRow = page.locator(`[data-chat-anchor-key="${branchUserKey}"]`)
  273. const assistantRow = page.locator(`[data-chat-anchor-key="${branchAssistantKey}"]`)
  274. const turnTailRow = page.locator(`[data-chat-anchor-key="${turnTailKey(BRANCH_TURN)}"]`)
  275. expect(await userRow.textContent()).toContain(branchUserMarker)
  276. expect(await assistantRow.textContent()).toContain(branchAssistantMarker)
  277. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
  278. await userRow.hover()
  279. await userRow.getByRole('button', { name: 'Copy', exact: true }).click()
  280. await expect.poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 5_000 })
  281. .toBe(expectedUserText)
  282. await turnTailRow.hover()
  283. await turnTailRow.getByRole('button', { name: 'Branch into a new conversation', exact: true }).click()
  284. await expect.poll(
  285. () => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SESSION_ID)),
  286. { timeout: 15_000 },
  287. ).toBeDefined()
  288. const child = scaffold.ctx.agents.list()
  289. .find(agent => agent.session.header.parentSession === SessionId(SESSION_ID))
  290. if (child === undefined) throw new Error('message branch did not create a child session')
  291. expect(child.session.inheritedEventCount).toBe(boundary.seq + 1)
  292. expect(child.session.snapshotEvents().some(event => carries(event, branchAssistantMarker))).toBe(true)
  293. expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(BRANCH_TURN + 1)))).toBe(false)
  294. expect(child.session.snapshotEvents().some(event => carries(event, FIXTURE.markers.user(FIXTURE.turns)))).toBe(false)
  295. const currentCrumb = page.getByRole('navigation', { name: 'Session hierarchy' })
  296. .getByRole('button').last()
  297. await expect.poll(() => currentCrumb.textContent(), { timeout: 15_000 })
  298. .toBe(`${FIXTURE.title} (1)`)
  299. await page.getByText(branchAssistantMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  300. const settled = scaffold.whenTurnSettled(60_000)
  301. const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
  302. await composer.fill(CONTINUE_PROMPT)
  303. await page.getByRole('button', { name: 'Send message', exact: true }).click()
  304. await expect.poll(() => page.getByText(CONTINUE_PROMPT, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  305. expect(await settled).toBe(child.session.id)
  306. await page.getByText(CONTINUE_DONE, { exact: false }).last().waitFor({ timeout: 15_000 })
  307. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  308. expect(await composer.textContent()).toBe('')
  309. expect(await composer.isEnabled()).toBe(true)
  310. expect(source.session.snapshotEvents().some(event => carries(event, CONTINUE_PROMPT))).toBe(false)
  311. expect(child.session.snapshotEvents().filter(event => (
  312. event.type === 'user/message' && carries(event, CONTINUE_PROMPT)
  313. ))).toHaveLength(1)
  314. const lastTurnEnd = child.session.snapshotEvents().findLast((event): event is SessionEvent<'turn/end'> => (
  315. event.type === 'turn/end'
  316. ))
  317. expect(lastTurnEnd?.data.reason).toEqual({ kind: 'completed' })
  318. expect(tripwire.pageErrors).toEqual([])
  319. expect(tripwire.warnings).toEqual([])
  320. }, 180_000)
  321. })