produced-file-mentions.e2e.ts 6.2 KB

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