chat-continuous-conversation.e2e.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  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, 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. describe('web e2e: continuous conversation grown through the composer', () => {
  152. let browser: Browser
  153. let page: Page
  154. let replayDir: string
  155. let scaffold: WebScaffold
  156. let tripwire: ReturnType<typeof watchConsole>
  157. const consoleWarnings: string[] = []
  158. const sessionEvents: SessionEvent[] = []
  159. const specs = Array.from({ length: TURN_COUNT }, (_, offset) => turnSpec(offset + 1))
  160. beforeAll(async () => {
  161. replayDir = await mkdtemp(join(tmpdir(), 'dsh-continuous-chat-replay-'))
  162. const replayOverride = join(replayDir, 'replay.override.json')
  163. await writeFile(replayOverride, JSON.stringify(replayScript(specs)))
  164. scaffold = await launchWebScaffold({
  165. replayFixture: join(replayDir, 'override-only.jsonl'),
  166. replayOverride,
  167. replayContextWindow: 10_000_000,
  168. paceMs: STREAM_PACE_MS,
  169. })
  170. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => {
  171. sessionEvents.push(event)
  172. })
  173. browser = await chromium.launch()
  174. page = await newEnglishPage(browser, 900)
  175. tripwire = watchConsole(page)
  176. page.on('console', (message) => {
  177. if (message.type() === 'warning') consoleWarnings.push(message.text())
  178. })
  179. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  180. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  181. await connectFreshWorkspace(page, scaffold.workspaceCwd, 'continuous-chat-e2e')
  182. }, 120_000)
  183. afterAll(async () => {
  184. const failures: unknown[] = []
  185. await browser?.close().catch((error: unknown) => failures.push(error))
  186. await scaffold?.close().catch((error: unknown) => failures.push(error))
  187. if (replayDir !== undefined) {
  188. await rm(replayDir, { recursive: true, force: true })
  189. .catch((error: unknown) => failures.push(error))
  190. }
  191. if (failures.length === 1) throw failures[0]
  192. if (failures.length > 1) throw new AggregateError(failures, 'continuous Chat e2e cleanup failed')
  193. })
  194. it.skipIf(MODE === 'record')('keeps twelve generated turns and tool rows bound to one live session', async () => {
  195. onTestFailed(() => saveFailureShot(page, 'web-e2e-chat-continuous-conversation'))
  196. const composer = page.locator('textarea:enabled').last()
  197. await composer.waitFor({ timeout: 15_000 })
  198. let sessionId: SessionId | undefined
  199. for (const spec of specs) {
  200. const eventStart = sessionEvents.length
  201. expect(await composer.inputValue()).toBe('')
  202. expect(await composer.isEnabled()).toBe(true)
  203. await composer.fill(spec.prompt)
  204. expect(await composer.inputValue()).toBe(spec.prompt)
  205. const settled = scaffold.whenTurnSettled(60_000)
  206. await page.getByRole('button', { name: 'Send message', exact: true }).click()
  207. await page.getByText(spec.userMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  208. const echoedUser = sessionEvents.slice(eventStart).find(
  209. (event): event is SessionEvent<'user/message'> => (
  210. event.type === 'user/message'
  211. && event.data.source.kind === 'user'
  212. && userText(event).includes(spec.userMarker)
  213. ),
  214. )
  215. if (echoedUser === undefined) throw new Error(`turn ${String(spec.index)} has no user echo event`)
  216. const userRow = page.locator(`[data-chat-anchor-key="node:${String(echoedUser.seq)}"]`)
  217. await expect.poll(() => userRow.count(), { timeout: 10_000 }).toBe(1)
  218. expect(await userRow.getAttribute('data-chat-flow-kind')).toBe('user')
  219. expect(await userRow.textContent()).toContain(spec.userMarker)
  220. await page.getByText(spec.firstMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  221. const settledSessionId = await settled
  222. if (sessionId === undefined) {
  223. sessionId = settledSessionId
  224. } else {
  225. expect(settledSessionId).toBe(sessionId)
  226. }
  227. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 15_000 }).toBe(0)
  228. await page.getByText(spec.doneMarker, { exact: false }).last().waitFor({ timeout: 15_000 })
  229. await expect.poll(() => composer.inputValue(), { timeout: 10_000 }).toBe('')
  230. await expect.poll(() => composer.isEnabled(), { timeout: 10_000 }).toBe(true)
  231. const turnEvents = sessionEvents.slice(eventStart)
  232. const turnStarts = turnEvents.filter((event): event is SessionEvent<'turn/start'> => (
  233. event.type === 'turn/start'
  234. ))
  235. const users = turnEvents.filter((event): event is SessionEvent<'user/message'> => (
  236. event.type === 'user/message' && event.data.source.kind === 'user'
  237. ))
  238. const assistants = turnEvents.filter((event): event is SessionEvent<'assistant/message'> => (
  239. event.type === 'assistant/message'
  240. ))
  241. const finalAssistants = assistants.filter(event => assistantText(event).includes(spec.doneMarker))
  242. const turnEnds = turnEvents.filter((event): event is SessionEvent<'turn/end'> => (
  243. event.type === 'turn/end'
  244. ))
  245. const chunks = turnEvents.filter(event => event.type === 'assistant/chunk')
  246. expect(turnStarts).toHaveLength(1)
  247. expect(turnStarts[0]?.data.turn).toBe(spec.index)
  248. expect(users).toHaveLength(1)
  249. expect(users[0]?.seq).toBe(echoedUser.seq)
  250. expect(userText(users[0]!)).toBe(spec.prompt)
  251. expect(finalAssistants).toHaveLength(1)
  252. expect(assistants).toHaveLength(spec.callId === undefined ? 1 : 2)
  253. expect(turnEnds).toHaveLength(1)
  254. expect(turnEnds[0]?.data).toEqual({ turn: spec.index, reason: { kind: 'completed' } })
  255. expect(chunks).toHaveLength(spec.deltas.length + (spec.callId === undefined ? 4 : 9))
  256. const assistantRow = page.locator(`[data-chat-anchor-key="node:${String(finalAssistants[0]!.seq)}"]`)
  257. await expect.poll(() => assistantRow.count(), { timeout: 10_000 }).toBe(1)
  258. expect(await assistantRow.getAttribute('data-chat-flow-kind')).toBe('assistant')
  259. expect(await assistantRow.textContent()).toContain(spec.doneMarker)
  260. const calls = turnEvents.filter((event): event is SessionEvent<'tool/call'> => event.type === 'tool/call')
  261. const results = turnEvents.filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result')
  262. if (spec.callId === undefined || spec.toolResultMarker === undefined) {
  263. expect(calls).toHaveLength(0)
  264. expect(results).toHaveLength(0)
  265. continue
  266. }
  267. expect(calls).toHaveLength(1)
  268. expect(results).toHaveLength(1)
  269. expect(calls[0]?.data).toMatchObject({
  270. turn: spec.index,
  271. callId: spec.callId,
  272. name: 'bash',
  273. })
  274. expect(results[0]?.data.turn).toBe(spec.index)
  275. expect(results[0]?.data.message.source.callId).toBe(spec.callId)
  276. expect(results[0]?.data.message.content[0].isError).toBe(false)
  277. expect(toolResultText(results[0]!)).toBe(`${spec.toolResultMarker}\n`)
  278. const toolRow = page.locator(`[data-chat-call-id="${spec.callId}"]`)
  279. await expect.poll(() => toolRow.count(), { timeout: 10_000 }).toBe(1)
  280. expect(await toolRow.textContent()).toContain(spec.toolResultMarker)
  281. const disclosure = toolRow.locator('[data-sample="bash"]')
  282. expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
  283. await disclosure.click()
  284. await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
  285. // The collapsed summary deliberately repeats the result marker; the
  286. // last exact match is the expanded terminal output owned by this call.
  287. await toolRow.getByText(spec.toolResultMarker, { exact: true }).last().waitFor({ timeout: 10_000 })
  288. await disclosure.click()
  289. await expect.poll(() => disclosure.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
  290. }
  291. if (sessionId === undefined) throw new Error('continuous conversation completed no turn')
  292. expect(scaffold.ctx.agents.get(sessionId)?.session.events.filter(event => (
  293. event.type === 'turn/end' && event.data.reason.kind === 'completed'
  294. ))).toHaveLength(TURN_COUNT)
  295. expect(specs.at(-1)?.prompt.length).toBeGreaterThan(4_000)
  296. expect(sessionEvents.filter(event => (
  297. event.type === 'assistant/chunk' && event.data.turn === TURN_COUNT
  298. )).length).toBeGreaterThan(30)
  299. expect(consoleWarnings).toEqual([])
  300. expect(tripwire.pageErrors).toEqual([])
  301. expect(tripwire.warnings).toEqual([])
  302. }, 180_000)
  303. })