chat-continuous-conversation.e2e.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357
  1. // Web e2e contract for a conversation grown through the real composer rather
  2. // than pre-seeded history. Twelve deterministic replay turns exercise repeated
  3. // send/settle/render cycles, including two real bash executions and one long,
  4. // multi-chunk final turn. Assertions stay semantic: no host timing, heap, or
  5. // mounted-row cardinality is treated as a correctness contract.
  6. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  7. import { tmpdir } from 'node:os'
  8. import { join } from 'node:path'
  9. import type { Browser, Page } from 'playwright'
  10. import { chromium } from 'playwright'
  11. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  12. import { ToolCallId, expandAssistantStream, type StreamChunk } from '@deepseek-ai/dsh-llm'
  13. import type { ReplayEntry, ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
  14. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  15. import {
  16. launchWebScaffold,
  17. watchConsole,
  18. webSnapshotMode,
  19. type WebScaffold,
  20. } from './scaffold.ts'
  21. import {
  22. connectFreshWorkspace, conversationContextKey, expandOwningTurnProcess, newEnglishPage, saveFailureShot,
  23. } from './support.ts'
  24. const MODE = webSnapshotMode()
  25. const TURN_COUNT = 12
  26. const TOOL_TURNS = [4, 9] as const
  27. const STREAM_PACE_MS = 10
  28. interface TurnSpec {
  29. readonly index: number
  30. readonly prompt: string
  31. readonly userMarker: string
  32. readonly firstMarker: string
  33. readonly doneMarker: string
  34. readonly deltas: readonly string[]
  35. readonly callId?: ReturnType<typeof ToolCallId>
  36. readonly toolResultMarker?: string
  37. }
  38. function suffix(index: number): string {
  39. return String(index).padStart(3, '0')
  40. }
  41. function longFinalPrompt(userMarker: string): string {
  42. return [
  43. `${userMarker} Reconcile this accumulated conversation without losing earlier turn ownership.`,
  44. ...Array.from(
  45. { length: 36 },
  46. (_, index) => `Context ${String(index + 1).padStart(2, '0')}: preserve token-${String(index)} and verify ${'payload '.repeat(12).trimEnd()}.`,
  47. ),
  48. 'Return one continuous response and finish with the requested completion marker.',
  49. ].join('\n')
  50. }
  51. function turnSpec(index: number): TurnSpec {
  52. const id = suffix(index)
  53. const userMarker = `CONTINUOUS_CHAT_USER_${id}`
  54. const firstMarker = `CONTINUOUS_CHAT_FIRST_${id}`
  55. const doneMarker = `CONTINUOUS_CHAT_DONE_${id}`
  56. const deltaCount = index === TURN_COUNT ? 36 : 8
  57. const deltas = Array.from({ length: deltaCount }, (_, chunkIndex) => {
  58. if (chunkIndex === 0) return `${firstMarker} `
  59. if (chunkIndex === deltaCount - 1) return `${doneMarker}.`
  60. return `turn-${id}-chunk-${String(chunkIndex).padStart(2, '0')} keeps semantic ownership stable. `
  61. })
  62. if (!TOOL_TURNS.includes(index as (typeof TOOL_TURNS)[number])) {
  63. return {
  64. index,
  65. prompt: index === TURN_COUNT
  66. ? longFinalPrompt(userMarker)
  67. : `${userMarker} Continue this same conversation through turn ${String(index)}.`,
  68. userMarker,
  69. firstMarker,
  70. doneMarker,
  71. deltas,
  72. }
  73. }
  74. return {
  75. index,
  76. prompt: `${userMarker} Run the requested deterministic tool for turn ${String(index)}, then continue.`,
  77. userMarker,
  78. firstMarker,
  79. doneMarker,
  80. deltas,
  81. callId: ToolCallId(`continuous-chat-tool-${id}`),
  82. toolResultMarker: `CONTINUOUS_CHAT_TOOL_RESULT_${id}`,
  83. }
  84. }
  85. function textStream(spec: TurnSpec): StreamChunk[] {
  86. const response = spec.deltas.join('')
  87. return [
  88. { type: 'block-start', index: 0, blockType: 'text' },
  89. ...spec.deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })),
  90. { type: 'block-end', index: 0, block: { type: 'text', text: response } },
  91. {
  92. type: 'usage',
  93. usage: {
  94. inputTokens: Math.ceil(spec.prompt.length / 4),
  95. outputTokens: Math.ceil(response.length / 4),
  96. },
  97. },
  98. { type: 'finish', reason: { kind: 'stop' } },
  99. ]
  100. }
  101. function toolStream(spec: TurnSpec): StreamChunk[] {
  102. if (spec.callId === undefined || spec.toolResultMarker === undefined) {
  103. throw new Error(`turn ${String(spec.index)} has no tool identity`)
  104. }
  105. const args = JSON.stringify({
  106. command: `printf '${spec.toolResultMarker}\\n'`,
  107. description: spec.toolResultMarker,
  108. })
  109. return [
  110. { type: 'block-start', index: 0, blockType: 'tool-call' },
  111. {
  112. type: 'tool-call-delta',
  113. index: 0,
  114. id: spec.callId,
  115. name: 'bash',
  116. argumentsDelta: args,
  117. },
  118. {
  119. type: 'block-end',
  120. index: 0,
  121. block: { type: 'tool-call', id: spec.callId, name: 'bash', arguments: args },
  122. },
  123. { type: 'usage', usage: { inputTokens: 256, outputTokens: 24 } },
  124. { type: 'finish', reason: { kind: 'tool-calls' } },
  125. ]
  126. }
  127. function replayScript(specs: readonly TurnSpec[]): ReplayOverrideDoc {
  128. return specs.flatMap((spec): ReplayEntry[] => {
  129. const final: ReplayEntry = { kind: 'chunks', chunks: textStream(spec) }
  130. return spec.callId === undefined
  131. ? [final]
  132. : [{ kind: 'chunks', chunks: toolStream(spec) }, final]
  133. })
  134. }
  135. function userText(event: Extract<SessionEvent, { type: 'user/message' }>): string {
  136. return event.data.content
  137. .filter(block => block.type === 'text')
  138. .map(block => block.text)
  139. .join('')
  140. }
  141. function assistantText(event: Extract<SessionEvent, { type: 'assistant/message' }>): string {
  142. return event.data.message.content
  143. .filter(block => block.type === 'text')
  144. .map(block => block.text)
  145. .join('')
  146. }
  147. function toolResultText(event: Extract<SessionEvent, { type: 'tool/result' }>): string {
  148. return event.data.message.content[0].content
  149. .filter(block => block.type === 'text')
  150. .map(block => block.text)
  151. .join('')
  152. }
  153. function messageKey(event: SessionEvent<'user/message'>): string {
  154. return conversationContextKey('input-message', String(event.data.id))
  155. }
  156. function assistantKey(event: SessionEvent<'assistant/message'>): string {
  157. return conversationContextKey('assistant-step', `${event.data.turn}:${event.data.step}`)
  158. }
  159. describe('web e2e: continuous conversation grown through the composer', () => {
  160. let browser: Browser
  161. let page: Page
  162. let replayDir: string
  163. let scaffold: WebScaffold
  164. let tripwire: ReturnType<typeof watchConsole>
  165. const consoleWarnings: string[] = []
  166. const sessionEvents: SessionEvent[] = []
  167. const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
  168. beforeAll(async () => {
  169. replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
  170. const replayOverride = join(replayDir, 'replay.override.json')
  171. await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
  172. scaffold = await launchWebScaffold({
  173. replayFixture: join(replayDir, 'override-only.jsonl'),
  174. replayOverride,
  175. replayContextWindow: 10_000_000,
  176. paceMs: STREAM_PACE_MS,
  177. })
  178. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
  179. sessionEvents.push(event)
  180. })
  181. browser = await chromium.launch()
  182. page = await newEnglishPage(browser, 900)
  183. tripwire = watchConsole(page)
  184. page.on('console', (message) => {
  185. if (message.type() === 'warning') consoleWarnings.push(message.text())
  186. })
  187. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  188. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  189. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
  190. }, 120_000)
  191. afterAll(async () => {
  192. const failures: unknown[] = []
  193. await browser?.close().catch((error: unknown) => failures.push(error))
  194. await scaffold?.close().catch((error: unknown) => failures.push(error))
  195. if (replayDir !== undefined) {
  196. await rm(replayDir, { recursive: true, force: true })
  197. .catch((error: unknown) => failures.push(error))
  198. }
  199. if (failures.length === 1) throw failures[0]
  200. if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
  201. })
  202. it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
  203. onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
  204. const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
  205. await composer.waitFor({ timeout: 15_000 })
  206. let sessionId: SessionId | undefined
  207. for (const spec of specs) {
  208. const eventStart = sessionEvents.length
  209. expect(await composer.textContent()).toBe('')
  210. expect(await composer.isEnabled()).toBe(true)
  211. await composer.fill(spec.prompt)
  212. expect(await composer.textContent()).toBe(spec.prompt)
  213. const settled = scaffold.whenTurnSettled(60_000)
  214. await page.getByRole('button', { name: 'Send message', exact: true }).click()
  215. await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  216. await expect.poll(() => sessionEvents.slice(eventStart).some(event => (
  217. event.type === 'user/message'
  218. && event.data.source.kind === 'user'
  219. && userText(event).includes(spec.userMarker)
  220. )), { timeout: 15_000 }).toBe(true)
  221. const echoedUser = sessionEvents.slice(eventStart).find(
  222. (event): event is SessionEvent<'user/message'> => (
  223. event.type === 'user/message'
  224. && event.data.source.kind === 'user'
  225. && userText(event).includes(spec.userMarker)
  226. ),
  227. )
  228. if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
  229. const userRow = page.locator(`[data-chat-anchor-key="${messageKey(echoedUser)}"]`)
  230. await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
  231. expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
  232. expect(await userRow.textContent()).toContain(spec.userMarker)
  233. await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  234. const settledSessionId = await settled
  235. if (sessionId === undefined) {
  236. sessionId = settledSessionId
  237. } else {
  238. expect(settledSessionId).toBe(sessionId)
  239. }
  240. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  241. await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  242. await expect.poll(() => composer.textContent(), { timeout: 10_000 }).toBe('')
  243. await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
  244. const turnEvents = sessionEvents.slice(eventStart)
  245. const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
  246. event.type === 'turn/start'
  247. ))
  248. const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
  249. event.type === 'user/message' && event.data.source.kind === 'user'
  250. ))
  251. const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
  252. event.type === 'assistant/message'
  253. ))
  254. const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
  255. const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
  256. event.type === 'turn/end'
  257. ))
  258. const chunks = turnEvents.flatMap(event => (
  259. event.type === 'assistant/message' || event.type === 'assistant/attempt'
  260. ? expandAssistantStream(event.data.stream)
  261. : []
  262. ))
  263. expect(turnStarts).toHaveLength(1)
  264. expect(turnStarts[0]?.data.turn).toBe(spec.index)
  265. expect(users).toHaveLength(1)
  266. expect(users[0]?.seq).toBe(echoedUser.seq)
  267. expect(userText(users[0]!)).toBe(spec.prompt)
  268. expect(finalAssistants).toHaveLength(1)
  269. expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
  270. expect(turnEnds).toHaveLength(1)
  271. expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
  272. expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
  273. const assistantRow = page.locator(`[data-chat-anchor-key="${assistantKey(finalAssistants[0]!)}"]`)
  274. await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
  275. expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant-step')
  276. expect(await assistantRow.textContent()).toContain(spec.doneMarker)
  277. const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
  278. const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
  279. if (spec.callId === undefined || spec.toolResultMarker === undefined) {
  280. expect(calls).toHaveLength(0)
  281. expect(results).toHaveLength(0)
  282. continue
  283. }
  284. expect(calls).toHaveLength(1)
  285. expect(results).toHaveLength(1)
  286. expect(calls[0]?.data).toMatchObject({
  287. turn: spec.index,
  288. callId: spec.callId,
  289. name: 'bash',
  290. })
  291. expect(results[0]?.data.turn).toBe(spec.index)
  292. expect(results[0]?.data.message.source.callId).toBe(spec.callId)
  293. expect(results[0]?.data.message.content[0].isError).toBe(false)
  294. expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
  295. const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
  296. await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
  297. expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
  298. await expandOwningTurnProcess(page, toolRow)
  299. const disclosure = toolRow.locator('[data-sample="bash"]')
  300. expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
  301. await disclosure.click()
  302. await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
  303. // The collapsed summary deliberately repeats the result marker; the
  304. // last exact match is the expanded terminal output owned by this call.
  305. await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
  306. await disclosure.click()
  307. await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
  308. }
  309. if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
  310. expect(scaffold.ctx.agents.get(sessionId)?.session.snapshotEvents().filter(event => (
  311. event.type === 'turn/end' && event.data.reason.kind === 'completed'
  312. ))).toHaveLength(TURN_COUNT)
  313. expect(sessionEvents.flatMap(event =>
  314. event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial'])
  315. expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(1)
  316. expect(await page.locator(
  317. '[data-chat-flow-kind="system-prompt"][hidden="until-found"]',
  318. ).count()).toBe(0)
  319. expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
  320. expect(sessionEvents.flatMap(event => (
  321. (event.type === 'assistant/message' || event.type === 'assistant/attempt')
  322. && event.data.turn === TURN_COUNT
  323. ? expandAssistantStream(event.data.stream)
  324. : []
  325. )).length).toBeGreaterThan(30)
  326. expect(consoleWarnings).toEqual([])
  327. expect(tripwire.pageErrors).toEqual([])
  328. expect(tripwire.warnings).toEqual([])
  329. }, 180_000)
  330. })