Sfoglia il codice sorgente

test(web): cover Goal actions across turns

creatixchu 1 mese fa
parent
commit
3c6188fb99

+ 168 - 0
apps/web/tests/goal-multi-turn-actions.e2e.ts

@@ -0,0 +1,168 @@
+// Keyless replay of a real two-round Goal run. Each autonomous round ends as
+// its own turn, so the first answer must keep its IconActions when Goal opens
+// round two and the final answer must own a second, distinct action row.
+import { mkdir, readFile, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterEach, describe, expect, it, onTestFailed } from 'vitest'
+import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-goal'
+import {
+  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/goal-multi-turn-actions', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json')
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+const PROMPT = '做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的'
+const COMMAND = `/goal ${PROMPT}`
+
+const PACKAGE_FILES: Readonly<Record<string, string>> = {
+  'packages/client/ui-conversation/README.md': '# UI conversation\n',
+  'packages/client/ui-conversation/package.json': '{"name":"@deepseek-ai/dsh-client-ui-conversation"}\n',
+  'packages/client/ui-conversation/src/client.ts': 'export {}\n',
+  'packages/client/ui-conversation/tests/chat-view.spec.tsx': 'export {}\n',
+  'packages/context/session-reference/README.md': '# Session reference\n',
+  'packages/context/session-reference/package.json': '{"name":"@deepseek-ai/dsh-session-reference"}\n',
+  'packages/context/session-reference/src/index.ts': 'export {}\n',
+  'packages/context/session-reference/src/uri.ts': 'export {}\n',
+  'packages/context/session-reference/tests/session-reference.spec.ts': 'export {}\n',
+  'packages/llm/token-meter/README.md': '# Token meter\n',
+  'packages/llm/token-meter/package.json': '{"name":"@deepseek-ai/dsh-token-meter"}\n',
+  'packages/llm/token-meter/src/index.ts': 'export {}\n',
+  'packages/llm/token-meter/tests/token-meter.spec.ts': 'export {}\n',
+  'packages/skill/skill-local/README.md': '# Local skill provider\n',
+  'packages/skill/skill-local/package.json': '{"name":"@deepseek-ai/dsh-skill-local"}\n',
+  'packages/skill/skill-local/src/index.ts': 'export {}\n',
+  'packages/skill/skill-local/src/invariant.ts': 'export {}\n',
+  'packages/skill/skill-local/tests/skill-local.spec.ts': 'export {}\n',
+}
+
+/** Materialize a stable package inventory inside the isolated session workspace. */
+async function seedPackageInventory(workspaceRoot: string): Promise<void> {
+  for (const [relativePath, content] of Object.entries(PACKAGE_FILES)) {
+    const path = join(workspaceRoot, 'workspace', relativePath)
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, content)
+  }
+}
+
+/** Await exactly the requested number of durable turn ends, then flush the session. */
+function whenTurnsSettled(scaffold: WebScaffold, count: number, timeoutMs: number): Promise<SessionId> {
+  return new Promise<SessionId>((resolve, reject) => {
+    let completed = 0
+    const timer = setTimeout(() => {
+      off()
+      reject(new Error(`only ${completed}/${count} Goal turns ended within ${timeoutMs}ms`))
+    }, timeoutMs)
+    const off = scaffold.ctx.on('session/event', (session, event: SessionEvent) => {
+      if (event.type !== 'turn/end') return
+      completed += 1
+      if (completed !== count) return
+      clearTimeout(timer)
+      off()
+      scaffold.ctx.sessions.flush(session).then(() => { resolve(session.id) }, reject)
+    })
+  })
+}
+
+/** Goal-owned round numbers in durable user-message order. */
+function goalRounds(events: readonly SessionEvent[]): number[] {
+  return events.flatMap(event => event.type === 'user/message' && event.data.source.kind === 'goal'
+    ? [event.data.source.round]
+    : [])
+}
+
+/** Objective written by each durable Goal creation. */
+function createdObjectives(events: readonly SessionEvent[]): string[] {
+  return events.flatMap(event => event.type === 'goal/change' && event.data.operation === 'create'
+    ? [event.data.goal.objective]
+    : [])
+}
+
+describe('web e2e: Goal keeps one assistant action row per completed turn', () => {
+  let scaffold: WebScaffold | undefined
+  let browser: Browser | undefined
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let sessionEvents: SessionEvent[]
+
+  afterEach(async () => {
+    const failures: unknown[] = []
+    await browser?.close().catch((error: unknown) => failures.push(error))
+    browser = undefined
+    const closing = scaffold
+    scaffold = undefined
+    await closing?.close().catch((error: unknown) => failures.push(error))
+    if (failures.length === 1) throw failures[0]
+    if (failures.length > 1) throw new AggregateError(failures, 'goal-multi-turn-actions teardown failed')
+  })
+
+  /** Boot the real Web composition and connect a fresh package fixture workspace. */
+  async function launch(): Promise<void> {
+    sessionEvents = []
+    scaffold = await launchWebScaffold(
+      MODE === 'record' ? {} : { replayFixture: FIXTURE, replayOverride: OVERRIDE },
+    )
+    await seedPackageInventory(scaffold.workspaceCwd)
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }
+
+  /** Submit the Goal command after arming the two-turn barrier. */
+  async function runGoal(timeoutMs: number): Promise<SessionId> {
+    const input = page.locator('textarea').first()
+    await input.waitFor({ timeout: 10_000 })
+    const settled = whenTurnsSettled(scaffold!, 2, timeoutMs)
+    await input.fill(COMMAND)
+    await input.press('Enter')
+    return settled
+  }
+
+  it.skipIf(MODE !== 'record')('records the two-round Goal through the real model', async () => {
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions-record'))
+    const sessionId = await runGoal(360_000)
+    await recordFixture(scaffold!, sessionId, FIXTURE)
+  }, 380_000)
+
+  it.skipIf(MODE === 'record')('keeps actions on both completed Goal turn tails', async () => {
+    const fixtureEvents = parseSessionLog(await readFile(FIXTURE, 'utf8'))
+    expect(createdObjectives(fixtureEvents)).toEqual([PROMPT])
+    expect(goalRounds(fixtureEvents)).toEqual([1, 2])
+
+    await launch()
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-multi-turn-actions'))
+    await runGoal(120_000)
+
+    expect(sessionEvents.flatMap(event => event.type === 'turn/end' ? [event.data.turn] : []))
+      .toEqual([1, 2])
+    expect(goalRounds(sessionEvents)).toEqual([1, 2])
+    const branchButtons = page.getByRole('button', { name: 'Branch into a new conversation' })
+    await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
+    expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
+      .toEqual([null, null])
+    await branchButtons.last().focus()
+    const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
+    await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 140_000)
+
+  it.skipIf(MODE === 'record')('keeps a closed fixture inventory', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['replay.override.json', 'session.jsonl', 'ui.expected.md'])
+  })
+})

+ 20 - 0
apps/web/tests/snapshots/goal-multi-turn-actions/replay.override.json

@@ -0,0 +1,20 @@
+{
+  "patches": [
+    {
+      "at": 10,
+      "entry": {
+        "kind": "chunks",
+        "chunks": [
+          { "type": "block-start", "index": 0, "blockType": "text" },
+          { "type": "text-delta", "index": 0, "text": "两个 turn 均已完成,目标达成,标记 goal 为完成。" },
+          { "type": "block-end", "index": 0, "block": { "type": "text", "text": "两个 turn 均已完成,目标达成,标记 goal 为完成。" } },
+          { "type": "block-start", "index": 1, "blockType": "tool-call" },
+          { "type": "tool-call-delta", "index": 1, "id": "call_goal_complete", "name": "update_goal", "argumentsDelta": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" },
+          { "type": "block-end", "index": 1, "block": { "type": "tool-call", "id": "call_goal_complete", "name": "update_goal", "arguments": "{\"goal_id\":\"{{fromRequest:goal-[0-9a-f-]+}}\",\"revision\":1,\"action\":\"complete\"}" } },
+          { "type": "usage", "usage": { "inputTokens": 132, "outputTokens": 157, "cacheReadTokens": 10368, "reasoningTokens": 44 } },
+          { "type": "finish", "reason": { "kind": "tool-calls" } }
+        ]
+      }
+    }
+  ]
+}

File diff suppressed because it is too large
+ 16 - 0
apps/web/tests/snapshots/goal-multi-turn-actions/session.jsonl


+ 200 - 0
apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md

@@ -0,0 +1,200 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "workspace" [disabled]
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- 'button "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
+  - img
+  - img
+  - text: "goal Goal created Status: active Objective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的 Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
+- button "Context injection goal":
+  - img
+  - img
+  - text: Context injection goal
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- 'button "Think The user''s goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""':
+  - img
+  - img
+  - text: "Think The user's goal is: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\" — which translates to: \"Do two turns, each turn output the file structure of a random package. Note that after you finish one turn, directly output the content and stop; our system will help you open another turn, and you just do something similar.\""
+- button "Bash Show working directory and its contents":
+  - img
+  - img
+  - text: Bash Show working directory and its contents
+- 'button "Tool call get_goal · {}"':
+  - img
+  - img
+  - text: "Tool call get_goal · {}"
+- 'button "Think There''s a `packages` directory. Let me look inside to see what packages are available."':
+  - img
+  - img
+  - text: "Think There's a `packages` directory. Let me look inside to see what packages are available."
+- button "Bash List packages directory contents":
+  - img
+  - img
+  - text: Bash List packages directory contents
+- button "Think There are 4 packages:":
+  - img
+  - img
+  - text: "Think There are 4 packages:"
+- button "Failed Bash Randomly pick one package directory":
+  - img
+  - text: Failed Bash Randomly pick one package directory
+- 'button "Think shuf is not available on macOS. I''ll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."':
+  - img
+  - img
+  - text: "Think shuf is not available on macOS. I'll use a different approach for random selection. Let me use $RANDOM in bash or use `sort -R` or Python."
+- button "Bash Randomly select one package with Python":
+  - img
+  - img
+  - text: Bash Randomly select one package with Python
+- 'button "Think The random pick for turn 1 is `packages/context/session-reference`. Now I''ll output its file structure. Let me get the file tree."':
+  - img
+  - img
+  - text: "Think The random pick for turn 1 is `packages/context/session-reference`. Now I'll output its file structure. Let me get the file tree."
+- button "Bash List all files in session-reference package":
+  - img
+  - img
+  - text: Bash List all files in session-reference package
+- 'button "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."':
+  - img
+  - img
+  - text: "Think Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn."
+- paragraph:
+  - strong: Turn 1 / 2
+  - text: — 随机选中的包:
+  - code: packages/context/session-reference
+  - text: 📦
+- button "Copy"
+- code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts
+- button "Copy"
+- code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试"
+- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
+- button "Copy":
+  - img
+- button "Branch into a new conversation":
+  - img
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Context injection goal":
+  - img
+  - img
+  - text: Context injection goal
+- 'button "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."':
+  - img
+  - img
+  - text: "Think This is turn 2 of 2. I need to randomly pick another package and output its file structure. In the first turn I picked `packages/context/session-reference`. Let me pick a different one randomly this time."
+- button "Bash Randomly select another package with Python":
+  - img
+  - img
+  - text: Bash Randomly select another package with Python
+- 'button "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."':
+  - img
+  - img
+  - text: "Think The random pick returned the same package as turn 1. Since turn 1 already covered `session-reference`, it would be better to pick a different one for variety. Let me re-roll, excluding the already-covered package."
+- button "Bash Re-roll excluding the package from turn 1":
+  - img
+  - img
+  - text: Bash Re-roll excluding the package from turn 1
+- button "Bash List all files in token-meter package":
+  - img
+  - img
+  - text: Bash List all files in token-meter package
+- 'button "Think This is turn 2 of 2. I''ve output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."':
+  - img
+  - img
+  - text: "Think This is turn 2 of 2. I've output the file structure for `packages/llm/token-meter`. Both turns are done. I should output the content, stop, and then mark the goal as complete since both turns are finished."
+- paragraph:
+  - strong: Turn 2 / 2
+  - text: — 随机选中的包:
+  - code: packages/llm/token-meter
+  - text: 📦
+- button "Copy"
+- code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts
+- button "Copy"
+- code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试"
+- paragraph:
+  - text: 两个 turn 都完成了(turn 1:
+  - code: session-reference
+  - text: ,turn 2:
+  - code: token-meter
+  - text: )。这一轮到此结束。
+- 'button "Tool call get_goal · {}"':
+  - img
+  - img
+  - text: "Tool call get_goal · {}"
+- paragraph: 两个 turn 均已完成,目标达成,标记 goal 为完成。
+- button "Tool call update_goal · goal-{{uuid}}":
+  - img
+  - img
+  - text: Tool call update_goal · goal-{{uuid}}
+- 'button "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"':
+  - img
+  - img
+  - text: "Context injection tool-goal complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"
+- paragraph: 两个 turn 都完成了 ✅
+- paragraph:
+  - strong: 总结:
+- list:
+  - listitem:
+    - strong: Turn 1
+    - text: — 随机选中
+    - code: packages/context/session-reference
+    - text: ,输出了文件结构:
+    - button "Copy"
+    - code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts
+  - listitem:
+    - strong: Turn 2
+    - text: — 随机选中
+    - code: packages/llm/token-meter
+    - text: ,输出了文件结构:
+    - button "Copy"
+    - code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts
+- paragraph:
+  - strong: 验证方式:
+  - text: 两个包的随机选择都通过
+  - code: python3 random.choice
+  - text: 从工作区的
+  - code: packages/
+  - text: 目录下实际枚举的 4 个候选包中抽取(
+  - code: ui-conversation
+  - text: 、
+  - code: session-reference
+  - text: 、
+  - code: token-meter
+  - text: 、
+  - code: skill-local
+  - text: ),文件结构则用
+  - code: find
+  - text: 实际列出文件后整理输出,均已排除
+  - code: node_modules
+  - text: 和
+  - code: .git
+  - text: 。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。
+- paragraph:
+  - strong: 说明:
+  - text: 本轮没有产生任何文件修改或提交——任务本身就是"输出文件结构",所有产物都在上面的消息文本里。
+- paragraph:
+  - text: 如果你还想看剩下两个包(
+  - code: ui-conversation
+  - text: 、
+  - code: skill-local
+  - text: )的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。
+- button "Copy":
+  - img
+- button "Branch into a new conversation":
+  - img
+- tooltip "Branch into a new conversation"
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- textbox "Message the agent"
+- button "Commands":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "9% of context used"
+- button "Send message" [disabled]
+- text: 2 turns · 12 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 91% Input 113K tok · Output 2.4K tok

+ 1 - 0
apps/web/tsconfig.json

@@ -63,6 +63,7 @@
     "tests/subagent-conversation.e2e.ts",
     "tests/bash-abort-row.e2e.ts",
     "tests/turn-tail-actions.e2e.ts",
+    "tests/goal-multi-turn-actions.e2e.ts",
     "tests/chat-scroll-fixture.ts",
     "tests/chat-scroll-contract.e2e.ts",
     "tests/chat-long-interactions.e2e.ts",

+ 1 - 0
tsconfig.host.json

@@ -50,6 +50,7 @@
     "apps/web/tests/subagent-conversation.e2e.ts",
     "apps/web/tests/bash-abort-row.e2e.ts",
     "apps/web/tests/turn-tail-actions.e2e.ts",
+    "apps/web/tests/goal-multi-turn-actions.e2e.ts",
     "apps/web/tests/chat-scroll-fixture.ts",
     "apps/web/tests/chat-scroll-contract.e2e.ts",
     "apps/web/tests/chat-long-interactions.e2e.ts",

Some files were not shown because too many files changed in this diff