produced-file-mentions.e2e.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. /** Web e2e coverage for unique, ambiguous, and unknown inline file references. */
  2. import type { Browser, Page } from 'playwright'
  3. import { chromium } from 'playwright'
  4. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  5. import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  6. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  7. import type {} from '@deepseek-ai/dsh-session-title'
  8. import {
  9. launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
  10. } from './scaffold.ts'
  11. import { newEnglishPage, saveFailureShot } from './support.ts'
  12. const MODE = webSnapshotMode()
  13. const SEED_ID = 'produced-file-mentions-web-e2e'
  14. const DONE = 'FILE_MENTION_DONE'
  15. /** One-part text content for a built message. */
  16. function text(value: string): { type: 'text'; text: string }[] {
  17. return [{ type: 'text', text: value }]
  18. }
  19. /** The files the built turn writes; `notes.md` is named in prose but never written. */
  20. const WRITES = ['site/report.html', 'a/style.css', 'b/style.css']
  21. /** Build a settled write turn whose closing prose mentions files in inline code. */
  22. function mentionFixture(): string {
  23. const session = Session.create(SessionId('produced-file-mentions-source'))
  24. const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
  25. session.append('turn/start', { turn: 1 })
  26. const user = session.append('user/message', createUserMessage({
  27. content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }],
  28. source: { kind: 'user' },
  29. }), { surfaceOp: 'append' })
  30. session.append('session/title', {
  31. title: 'Produced file mentions',
  32. messageSeqs: [user.seq],
  33. source: { kind: 'fallback' },
  34. })
  35. session.append('step/start', { turn: 1, step: 1 })
  36. const calls = WRITES.map((path, index) => ({
  37. path,
  38. callId: ToolCallId(`file-mention-${String(index)}`),
  39. args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }),
  40. }))
  41. session.append('assistant/message', {
  42. stream: [],
  43. turn: 1,
  44. step: 1,
  45. message: createAssistantMessage({
  46. content: calls.map(call => ({
  47. type: 'tool-call' as const,
  48. id: call.callId,
  49. name: 'write',
  50. arguments: call.args,
  51. })),
  52. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  53. }),
  54. }, { surfaceOp: 'append' })
  55. for (const call of calls) {
  56. const source = session.append('tool/call', {
  57. turn: 1,
  58. step: 1,
  59. callId: call.callId,
  60. name: 'write',
  61. arguments: call.args,
  62. })
  63. session.append('tool/result', {
  64. turn: 1,
  65. step: 1,
  66. message: createToolResultMessage({
  67. callId: call.callId,
  68. content: text(`Created ${call.path}`),
  69. isError: false,
  70. }),
  71. }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] })
  72. }
  73. session.append('step/end', { turn: 1, step: 1 })
  74. session.append('step/start', { turn: 1, step: 2 })
  75. session.append('assistant/message', {
  76. stream: [],
  77. turn: 1,
  78. step: 2,
  79. message: createAssistantMessage({
  80. content: [{
  81. type: 'text',
  82. text: [
  83. 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.',
  84. '',
  85. DONE,
  86. ].join('\n'),
  87. }],
  88. source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
  89. }),
  90. }, { surfaceOp: 'append' })
  91. session.append('step/end', { turn: 1, step: 2 })
  92. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  93. return [
  94. JSON.stringify({
  95. type: 'session',
  96. version: SESSION_FORMAT_VERSION,
  97. id: '{{sessionId}}',
  98. createdAt: 0,
  99. cwd: '{{cwd}}',
  100. isSeeded: false,
  101. delegationDepth: 0,
  102. }),
  103. ...session.snapshotEvents().map(event => JSON.stringify({
  104. ...event,
  105. time: eventTimeOrigin + event.seq * 1_000,
  106. })),
  107. '',
  108. ].join('\n')
  109. }
  110. describe('web e2e: inline-code mentions of produced files', () => {
  111. let scaffold: WebScaffold
  112. let browser: Browser
  113. let page: Page
  114. let tripwire: ReturnType<typeof watchConsole>
  115. beforeAll(async () => {
  116. scaffold = await launchWebScaffold({})
  117. await seedSession(scaffold, mentionFixture(), SEED_ID)
  118. browser = await chromium.launch()
  119. page = await newEnglishPage(browser)
  120. tripwire = watchConsole(page)
  121. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  122. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  123. }, 120_000)
  124. afterAll(async () => {
  125. await browser?.close()
  126. await scaffold?.close()
  127. })
  128. it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => {
  129. onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions'))
  130. const groupRow = page.locator('[role="treeitem"]').first()
  131. await groupRow.waitFor({ timeout: 15_000 })
  132. await groupRow.click()
  133. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  134. await sessionRow.waitFor({ timeout: 10_000 })
  135. await sessionRow.click()
  136. await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1)
  137. // Exactly one prose mention links: `report.html` resolves to the written
  138. // path; the shared `style.css` basename and unwritten `notes.md` stay code.
  139. const mentions = page.locator('[class*="markdown"] code button')
  140. await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1)
  141. expect(await mentions.first().innerText()).toBe('report.html')
  142. expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html in sidebar')
  143. expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
  144. // Mentions resolve from the mutation calls alone; without a recorded summary no card follows.
  145. expect(await page.locator('[data-changed-files]').count()).toBe(0)
  146. expect(tripwire.pageErrors).toEqual([])
  147. expect(tripwire.warnings).toEqual([])
  148. }, 90_000)
  149. })