chat-continuous-conversation.e2e.ts 14 KB

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