goal-multi-turn-actions.e2e.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. // Keyless replay of a real two-round Goal run. Each autonomous round ends as
  2. // its own turn, so the first answer must keep its IconActions when Goal opens
  3. // round two and the final answer must own a second, distinct action row.
  4. import { mkdir, readFile, writeFile } from 'node:fs/promises'
  5. import { dirname, join } from 'node:path'
  6. import { fileURLToPath } from 'node:url'
  7. import type { Browser, Page } from 'playwright'
  8. import { chromium } from 'playwright'
  9. import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
  10. import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
  11. import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
  12. import type {} from '@deepseek-ai/dsh-goal'
  13. import {
  14. assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
  15. launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
  16. } from './scaffold.ts'
  17. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  18. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/goal-multi-turn-actions', import.meta.url))
  19. const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
  20. const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
  21. const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
  22. const MODE = webSnapshotMode()
  23. const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的'
  24. const COMMAND = `/goal ${PROMPT}`
  25. const PACKAGE_FILES: Readonly<Record<string, string>> = {
  26. 'packages/client/ui-conversation/README.md': '# UI conversation\n',
  27. 'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
  28. 'packages/client/ui-conversation/src/client.ts': 'export {}\n',
  29. 'packages/client/ui-conversation/tests/chat-view.client.spec.tsx': 'export {}\n',
  30. 'packages/context/session-reference/README.md': '# Session reference\n',
  31. 'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
  32. 'packages/context/session-reference/src/index.ts': 'export {}\n',
  33. 'packages/context/session-reference/src/uri.ts': 'export {}\n',
  34. 'packages/context/session-reference/tests/session-reference.spec.ts': 'export {}\n',
  35. 'packages/llm/token-meter/README.md': '# Token meter\n',
  36. 'packages/llm/token-meter/package.json': '{"name":"@deepseek-ai/dsh-token-meter"}\n',
  37. 'packages/llm/token-meter/src/index.ts': 'export {}\n',
  38. 'packages/llm/token-meter/tests/token-meter.spec.ts': 'export {}\n',
  39. 'packages/skill/skill-filesystem/README.md': '# Local skill provider\n',
  40. 'packages/skill/skill-filesystem/package.json': '{"name":"@deepseek-ai/dsh-skill-filesystem"}\n',
  41. 'packages/skill/skill-filesystem/src/index.ts': 'export {}\n',
  42. 'packages/skill/skill-filesystem/src/invariant.ts': 'export {}\n',
  43. 'packages/skill/skill-filesystem/tests/skill-filesystem.spec.ts': 'export {}\n',
  44. }
  45. /** Materialize a stable package inventory inside the isolated session workspace. */
  46. async function seedPackageInventory(workspaceRoot: string): Promise<void> {
  47. for (const [relativePath, content] of Object.entries(PACKAGE_FILES)) {
  48. const path = join(workspaceRoot, 'workspace', relativePath)
  49. await mkdir(dirname(path), { recursive: true })
  50. await writeFile(path, content)
  51. }
  52. }
  53. /** Await exactly the requested number of durable turn ends, then flush the session. */
  54. function whenTurnsSettled(scaffold: WebScaffold, count: number, timeoutMs: number): Promise<SessionId> {
  55. return new Promise<SessionId>((resolve, reject) => {
  56. let completed = 0
  57. const timer = setTimeout(() => {
  58. off()
  59. reject(new Error(`only ${completed}/${count} Goal turns ended within ${timeoutMs}ms`))
  60. }, timeoutMs)
  61. const off = scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
  62. if (event.type !== 'turn/end') return
  63. completed += 1
  64. if (completed !== count) return
  65. clearTimeout(timer)
  66. off()
  67. scaffold.ctx.sessions.flush(session).then(() => { resolve(session.id) }, reject)
  68. })
  69. })
  70. }
  71. /** Goal-owned round numbers in durable user-message order. */
  72. function goalRounds(events: readonly SessionEvent[]): number[] {
  73. return events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
  74. ? [event.data.source.round]
  75. : [])
  76. }
  77. /** Objective written by each durable Goal creation. */
  78. function createdObjectives(events: readonly SessionEvent[]): string[] {
  79. return events.flatMap(event => event.type === 'goal/change' && event.data.operation === 'create'
  80. ? [event.data.goal.objective]
  81. : [])
  82. }
  83. describe('web e2e: Goal keeps one assistant action row per completed turn', () => {
  84. let scaffold: WebScaffold | undefined
  85. let browser: Browser | undefined
  86. let page: Page
  87. let tripwire: ReturnType<typeof watchConsole>
  88. let sessionEvents: SessionEvent[]
  89. afterEach(async () => {
  90. const failures: unknown[] = []
  91. await browser?.close().catch((error: unknown) => failures.push(error))
  92. browser = undefined
  93. const closing = scaffold
  94. scaffold = undefined
  95. await closing?.close().catch((error: unknown) => failures.push(error))
  96. if (failures.length === 1) throw failures[0]
  97. if (failures.length > 1) throw new AggregateError(failures, 'goal-multi-turn-actions teardown failed')
  98. })
  99. /** Boot the real Web composition and connect a fresh package fixture workspace. */
  100. async function launch(): Promise<void> {
  101. sessionEvents = []
  102. scaffold = await launchWebScaffold(
  103. MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE },
  104. )
  105. await seedPackageInventory(scaffold.workspaceCwd)
  106. scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
  107. browser = await chromium.launch()
  108. page = await newEnglishPage(browser)
  109. tripwire = watchConsole(page)
  110. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  111. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  112. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  113. }
  114. /** Submit the Goal command after arming the two-turn barrier. */
  115. async function runGoal(timeoutMs: number): Promise<SessionId> {
  116. const input = page.locator('textarea').first()
  117. await input.waitFor({ timeout: 10_000 })
  118. const settled = whenTurnsSettled(scaffold!, 2, timeoutMs)
  119. await input.fill(COMMAND)
  120. await input.press('Enter')
  121. return settled
  122. }
  123. it.skipIf(MODE !== 'record')('records the two-round Goal through the real model', async () => {
  124. await launch()
  125. onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions-record'))
  126. const sessionId = await runGoal(360_000)
  127. await recordFixture(scaffold!, sessionId, FIXTURE)
  128. }, 380_000)
  129. it.skipIf(MODE === 'record')('keeps actions on both completed Goal turn tails', async () => {
  130. const fixtureEvents = parseSessionLog(await readFile(FIXTURE, 'utf8'))
  131. expect(createdObjectives(fixtureEvents)).toEqual([PROMPT])
  132. expect(goalRounds(fixtureEvents)).toEqual([1, 2])
  133. await launch()
  134. onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions'))
  135. await runGoal(120_000)
  136. expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
  137. .toEqual([1, 2])
  138. expect(goalRounds(sessionEvents)).toEqual([1, 2])
  139. const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
  140. await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
  141. expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
  142. .toEqual([null, null])
  143. await branchButtons.last().focus()
  144. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
  145. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  146. expect(tripwire.pageErrors).toEqual([])
  147. expect(tripwire.warnings).toEqual([])
  148. }, 140_000)
  149. it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
  150. await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md'])
  151. })
  152. })