Browse Source

Merge pull request #3832 from deepseek-harness/fix/file-delivery-present

fix(deliverables): distinguish file changes and require explicit delivery
Yudong Han 3 tuần trước cách đây
mục cha
commit
ba5ce5700c
35 tập tin đã thay đổi với 392 bổ sung và 52 xóa
  1. 2 2
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  2. 3 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  3. 3 1
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  4. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  5. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  6. 2 0
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  7. 1 1
      apps/web/tests/clickable-links-gallery.e2e.ts
  8. 2 1
      apps/web/tests/details-session-lifecycle.e2e.ts
  9. 1 1
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  10. 20 16
      apps/web/tests/message-feedback-layout.e2e.ts
  11. 121 0
      apps/web/tests/present-svg.e2e.ts
  12. 1 1
      apps/web/tests/produced-file-mentions.e2e.ts
  13. 19 5
      apps/web/tests/produced-files.e2e.ts
  14. 8 0
      apps/web/tests/workspace-management.e2e.ts
  15. 1 0
      apps/web/tsconfig.json
  16. 2 2
      docs/tool-catalog.i18n.yaml
  17. 1 1
      docs/tool-catalog.md
  18. 1 1
      docs/tool-catalog.zh.md
  19. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  20. 1 1
      packages/client/ui-deliverables/README.md
  21. 1 1
      packages/client/ui-deliverables/README.zh.md
  22. 2 2
      packages/client/ui-deliverables/src/client/locales.ts
  23. 3 3
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  24. 2 2
      packages/fs/tool-present/README.i18n.yaml
  25. 1 1
      packages/fs/tool-present/README.md
  26. 1 1
      packages/fs/tool-present/README.zh.md
  27. 4 1
      packages/fs/tool-present/src/index.ts
  28. 1 1
      snapshots/web/cordis-tool-round/tool-schemas.expected.json
  29. 1 1
      snapshots/web/fresh-round-trip/tool-schemas.expected.json
  30. 14 0
      snapshots/web/present-svg/session.v3.jsonl
  31. 9 0
      snapshots/web/present-svg/snapshot.yml
  32. 73 0
      snapshots/web/present-svg/ui.expected.md
  33. 83 0
      snapshots/web/present-svg/workspace.expected/von-neumann.svg
  34. 1 1
      snapshots/web/ptc-round/system-prompt.expected.md
  35. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
-2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d
-2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94
+2026-09-08-present-workspace-source-files.md: 6a8ebfcf1fd7be22a5bda5a209d0fdc3586931bb
+2026-09-08-present-workspace-source-files.zh.md: d9361f2281eeb027e4c44b84c7f01af1639b66f2

+ 3 - 1
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md

@@ -12,9 +12,11 @@ Users need to open and edit the files produced in their workspace, including she
 
 The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them.
 
+The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations.
+
 The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results.
 
-An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior.
+An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The “Files changed” row lists successful file-tool mutations and retains its separate text-preview behavior. Its Chinese label is “本轮文件改动”; neither label implies final delivery.
 
 ## Alternatives considered
 

+ 3 - 1
.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md

@@ -12,9 +12,11 @@ Status: implemented
 
 [present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
 
+工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。
+
 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
 
-经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。“本轮文件改动”行列出成功的文件工具修改,并保留独立的文本预览行为。其英文标签为“Files changed”;两个标签均不表示最终交付。
 
 ## 考虑过的替代方案
 

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: 07a37ada9c2a43f04612048f9bff6b22d022ec40
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 668e612712175821d6ad123ce364a7cb96e01272
+2026-07-24-web-gui-browser-e2e-lane.md: 8276ed1982a7472ba1b825837a933058c32bf840
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 3a7e5ea5547cf5584a2ad3f61b90c91326379de5

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -26,6 +26,8 @@ Keyless model displacement is the disabled adapter row plus `installLlmReplay` f
 
 The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); polling persistence files as a turn-completion or durability barrier is banned (slow on NFS, superseded by `whenIdle`), while a tool-controlled temp readiness marker may be polled only as an interaction gate before that completion barrier; `networkidle` is banned outright (never resolves while an SSE stream is open). Navigation assertions arm both initial `session.list` and `workspace.list` responses before page load, then wait for the seeded DOM projection; the mounted shell alone is not readiness because late bootstrap can replace controlled state.
 
+Layout assertions wait for loaded fonts, completed frame transitions, and the Conversation width publication before measuring; a synthetic resize also waits for the scheduled React update before reading a portaled panel. Directory-tree reads use the same Remote-read budget for child and root listings. Workspace reload scenarios wait for restored Session selection and composer focus before opening another path editor, because that late focus can cancel its draft. Responsive file-chip scenarios keep their measured lane inside a container-query band with explicit margin for platform font metrics.
+
 No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted through the ordered `agent/assistant-stream` follow path, while the final durable `assistant/message` or `assistant/attempt` embeds the exact stream used for replay. `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly.
 
 Pagination drivers wait for the interactive load row to leave its pending state and record the pre-request row count before scrolling. An immediately committed resident page therefore remains observable instead of becoming the baseline for a request that the scroll gesture does not repeat.

+ 2 - 0
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -10,6 +10,8 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 ## 决策
 
+布局断言先等待字体加载、框架过渡完成及 Conversation 宽度发布,再读取尺寸;触发合成 resize 后,还需等待其调度的 React 更新,才能测量通过 portal 挂载的面板。目录树的子目录和根目录读取使用相同的 Remote 等待预算。工作区重载场景在打开另一个路径编辑器前,等待 Session 选择恢复及输入框获得焦点,因为迟到的聚焦会取消路径草稿。响应式文件标签场景将实测通道宽度放在容器查询档位内部,并为平台字体度量保留明确余量。
+
 `pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组合回放;用户可见状态使用规范化的 aria 预期输出,持久化的世界状态则使用进程内断言。配套的产品约定包括 `dsh-llm-replay` 的节奏控制、消费检查与已校验的索引式覆写 patch;跨包的 `dsh-llm` 失败通过自有数据属性保留经校验的提供方信息;已交付的 web 组合挂载 `llm-retry`,以处理瞬态模型失败。
 
 ### Scaffold:`apps/web/tests/scaffold.ts`

+ 1 - 1
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -348,7 +348,7 @@ describe('web e2e: clickable links gallery', () => {
     const mentions = markdown.locator('code button')
     expect(await mentions.count()).toBe(1)
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
     expect(await page.locator('[class*="centerCol"] button[aria-label^="Open "]').count()).toBeGreaterThanOrEqual(5)
     expect(await page.locator('button[aria-label="Open c/broken.css"]').count()).toBe(0)
 

+ 2 - 1
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -283,7 +283,8 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     await workspaceDirectory.waitFor({ timeout: 15_000 })
     await workspaceDirectory.click()
     await expect.poll(() => workspaceDirectory.getAttribute('aria-expanded')).toBe('true')
-    await expect.poll(() => column.locator('[data-files-row="loading"]').count()).toBe(0)
+    // The child listing crosses the same Remote as the root listing above.
+    await column.locator('[data-files-row="loading"]').waitFor({ state: 'hidden', timeout: 15_000 })
     expect(await column.locator('[data-files-row="failed"]').count()).toBe(0)
     const retainedB = await paneSnapshot(page)
     expect(retainedB.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files']])

+ 1 - 1
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -169,7 +169,7 @@
 - list:
   - listitem:
     - paragraph: Footnote references stay inert superscripts. ↩
-- text: Produced
+- text: Files changed
 - button "Open site/report.html": report.html
 - button "Open a/style.css": style.css
 - button "Open b/style.css": style.css

+ 20 - 16
apps/web/tests/message-feedback-layout.e2e.ts

@@ -231,28 +231,32 @@ describe('web e2e: the feedback note editor floats above the column', () => {
   }
 
   /**
-   * Resize to a viewport and read the row once its width stops moving. The
-   * frame eases its column tracks, so reading straight after a resize can
-   * report the previous viewport's relation.
+   * Resize and wait for fonts, frame transitions, and column-width publication
+   * before reading the row and the portaled editor.
    * @param width - viewport width to settle at.
    * @param editorOpen - whether the note editor is currently open; reads the popover relations when so.
    * @returns the row's (and popover's) readings at that width.
    */
   const settleAt = async (width: number, editorOpen: boolean): Promise<PopoverMetrics> => {
     await page.setViewportSize({ width, height: 900 })
-    let previous = -1
-    await expect.poll(async () => {
-      const current = await page.evaluate(() =>
-        document.querySelector('[data-conversation-scroll]')?.clientWidth ?? -1)
-      const settled = current === previous
-      previous = current
-      return settled
-    }, { timeout: 10_000 }).toBe(true)
-    // The popover is JS-positioned from the trigger rect and re-places on
-    // resize/scroll, so once the column width stops moving we nudge it to the
-    // final layout; otherwise the panel can sit at a transient position from
-    // mid-resize and the anchor reading would be off.
-    await page.evaluate(() => window.dispatchEvent(new Event('resize')))
+    await page.evaluate(async () => { await document.fonts.ready })
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[style*="grid-template-columns"]')
+      const root = frame?.querySelector<HTMLElement>('div[data-phase]')
+      if (frame === null) return false
+      const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat)
+      // This sweep keeps the default sidebar preference and the right column closed.
+      return tracks[0] === (window.innerWidth < 1024 ? 56 : 280) && tracks.at(-1) === 0
+        && frame.getAnimations().every(animation =>
+          animation.playState === 'finished' || animation.playState === 'idle')
+        && root !== undefined && root !== null
+        && root.style.getPropertyValue('--dsh-conversation-column-width') === `${String(root.offsetWidth)}px`
+    }, undefined, { timeout: 10_000 })
+    // The resize listener schedules a React update; its return is not a commit barrier.
+    await page.evaluate(async () => {
+      window.dispatchEvent(new Event('resize'))
+      await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))
+    })
     return measurePopover(page, width, editorOpen)
   }
 

+ 121 - 0
apps/web/tests/present-svg.e2e.ts

@@ -0,0 +1,121 @@
+/** A file request elicits explicit SVG delivery without naming the present tool. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type {} from '@deepseek-ai/dsh-tool-present/types'
+import { deriveReplayScript, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
+import {
+  assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria, compareOrRefreshGolden,
+  fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole,
+  webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts'
+
+const DIR = fileURLToPath(new URL('../../../snapshots/web/present-svg', import.meta.url))
+const FIXTURE = join(DIR, 'session.v3.jsonl')
+const MODE = webSnapshotMode()
+const PROMPT = '简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg'
+const FILE = 'von-neumann.svg'
+
+describe('web e2e: requested SVG is explicitly delivered', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let cwd: string
+  let replayRoot: string | undefined
+
+  beforeAll(async () => {
+    let replayOverride: string | undefined
+    if (MODE !== 'record') {
+      replayRoot = await mkdtemp(join(tmpdir(), 'dsh-present-svg-replay-'))
+      replayOverride = join(replayRoot, 'replay.override.json')
+      const script = deriveReplayScript(parseSessionLog(await readFile(FIXTURE, 'utf8')))
+      // Recorded absolute paths must follow each isolated Session's working directory.
+      const cwdToken = '{{fromRequest:Your working directory is ([^\\n]+)\\.}}'
+      await writeFile(replayOverride, JSON.stringify(script).replaceAll('{{cwd}}', JSON.stringify(cwdToken).slice(1, -1)))
+    }
+    scaffold = await launchWebScaffold({
+      compareReplaySession: true,
+      ...(replayOverride === undefined ? {} : { replayFixture: FIXTURE, replayOverride }),
+    })
+    browser = await chromium.launch()
+    page = await browser.newPage({
+      viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai',
+    })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]')
+    await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (replayRoot !== undefined) await rm(replayRoot, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('writes valid SVG and calls present before the final reply', async () => {
+    if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
+    const settled = scaffold.whenTurnSettled()
+    const input = page.locator('[data-composer-input]').first()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    const sessionId = await settled
+    const session = scaffold.ctx.agents.get(sessionId)?.session
+    if (session?.header.cwd === undefined) throw new Error('SVG Session has no workspace')
+    cwd = session.header.cwd
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+
+    const svg = await readFile(join(cwd, FILE), 'utf8')
+    const document = await page.evaluate((source) => {
+      const parsed = new DOMParser().parseFromString(source, 'image/svg+xml')
+      return {
+        root: parsed.documentElement.localName,
+        namespace: parsed.documentElement.namespaceURI,
+        errors: parsed.querySelectorAll('parsererror').length,
+      }
+    }, svg)
+    expect(document).toEqual({ root: 'svg', namespace: 'http://www.w3.org/2000/svg', errors: 0 })
+
+    const events = session.snapshotEvents()
+    const declarations = events.filter(event => event.type === 'deliverables/presented')
+    const delivery = declarations.find(event => event.data.files.some(file => resolve(cwd, file.path) === join(cwd, FILE)))
+    expect(delivery, 'the file request must produce a successful present declaration').toBeDefined()
+    if (delivery === undefined) throw new Error('SVG was written but not delivered')
+    expect(events.some(event => (
+      event.type === 'tool/call' && event.data.name === 'present' && event.data.callId === delivery.data.callId
+    ) || (
+      event.type === 'tool/ptc-dispatch' && event.data.name === 'present'
+      && event.data.subCallId === delivery.data.callId && !event.data.isError
+    ))).toBe(true)
+    expect(events.some(event => event.type === 'assistant/message' && event.seq > delivery.seq
+      && event.data.message.content.some(block => block.type === 'text'))).toBe(true)
+
+    const card = page.locator('[data-presented-files-row]').getByRole('button').filter({ hasText: FILE })
+    await card.waitFor({ state: 'visible' })
+    expect(await card.count()).toBe(1)
+    expect(await page.getByText('产物', { exact: true }).count()).toBe(0)
+    if (await page.locator('[data-produced-files-row]').count() > 0) {
+      expect(await page.getByText('本轮文件改动', { exact: true }).count()).toBe(1)
+    }
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+
+  it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {
+    await assertFinalWorkspaceSnapshot(DIR, cwd)
+    // Delivery owns the transcript; navigation and composer chrome have separate scenarios.
+    const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+  })
+})

+ 1 - 1
apps/web/tests/produced-file-mentions.e2e.ts

@@ -159,7 +159,7 @@ describe('web e2e: inline-code mentions of produced files', () => {
     expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html')
     expect(await mentions.first().getAttribute('title')).toBe('site/report.html')
     // The turn still ends with its produced-files row (all three writes).
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 19 - 5
apps/web/tests/produced-files.e2e.ts

@@ -146,16 +146,30 @@ describe('web e2e: a finished turn ends with the files it produced', () => {
     await expect.poll(() => chips.count()).toBe(6)
     await expect.poll(() => row.getByText('+ 4 files', { exact: true }).isVisible()).toBe(true)
 
-    await page.setViewportSize({ width: 780, height: 900 })
-    await expect.poll(() => chips.count()).toBe(5)
+    await page.setViewportSize({ width: 750, height: 900 })
+    await page.evaluate(async () => { await document.fonts.ready })
+    await page.waitForFunction(() => {
+      const frame = document.querySelector('[data-sidebar-collapsed][data-rightbar-collapsed]')
+      if (frame === null) return false
+      const tracks = getComputedStyle(frame).gridTemplateColumns.split(' ').map(Number.parseFloat)
+      // The responsive sidebar's settled collapsed track is 56px.
+      return tracks[0] === 56 && tracks.at(-1) === 0
+        && frame.getAnimations().every(animation =>
+          animation.playState === 'finished' || animation.playState === 'idle')
+    }, undefined, { timeout: 10_000 })
+    await expect.poll(() => chips.count()).toBe(4)
+    const laneWidth = await row.evaluate(element => element.clientWidth)
+    // Keep font-metric differences away from the 479px and 583px container-query edges.
+    expect(laneWidth).toBeGreaterThan(503)
+    expect(laneWidth).toBeLessThan(559)
     expect(await chips.nth(0).innerText()).toBe('关于我.md')
     expect(await chips.nth(1).innerText()).toBe('index.html')
-    expect(await chips.nth(4).innerText()).toBe('app.ts')
-    await expect.poll(() => row.getByText('+ 5 files', { exact: true }).isVisible()).toBe(true)
+    expect(await chips.nth(3).innerText()).toBe('styles.css')
+    await expect.poll(() => row.getByText('+ 6 files', { exact: true }).isVisible()).toBe(true)
     // Chips open in the right Sidebar's text preview, and a directory is not
     // something that preview can show, so the row offers no folder action.
     expect(await page.getByRole('button', { name: /folder/i }).count()).toBe(0)
-    expect(await page.getByText('Produced', { exact: true }).count()).toBe(1)
+    expect(await page.getByText('Files changed', { exact: true }).count()).toBe(1)
 
     const tops = await row.locator(':scope > *:visible').evaluateAll(elements =>
       elements.map(element => element.getBoundingClientRect().top))

+ 8 - 0
apps/web/tests/workspace-management.e2e.ts

@@ -626,6 +626,14 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
     acknowledgeReloadConnectionLoss(tripwire, warningStart)
     await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
+    // Initial Workspace reconnection can focus the composer after the tree renders.
+    // Finish that navigation before the next test opens a path editor.
+    await page.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 15_000 })
+    await expect.poll(
+      () => page.locator('[data-composer-input][contenteditable="true"]')
+        .evaluate(element => element === document.activeElement),
+      { timeout: 15_000 },
+    ).toBe(true)
     // The archived row must not resurface (the Ungrouped bucket itself may
     // reappear if selection restore lands on another stray — not this test's
     // concern).

+ 1 - 0
apps/web/tsconfig.json

@@ -64,6 +64,7 @@
     "tests/conversation-column-overflow.e2e.ts",
     "tests/ptc-round.e2e.ts",
     "tests/present.e2e.ts",
+    "tests/present-svg.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
     "tests/composer-placeholder.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948
-tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930
+tool-catalog.md: 928f7537ab212edf6af1dec93077caed2bb003b7
+tool-catalog.zh.md: 5ac34f6188f2b0689e02b72595375f00b02d5f6f

+ 1 - 1
docs/tool-catalog.md

@@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
 
 ### `present`
 
-Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.
+Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.
 
 ```json
 {

+ 1 - 1
docs/tool-catalog.zh.md

@@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 
 ### `present`
 
-声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。
+声明交付已有的工作区文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
 
 ```json
 {

+ 2 - 2
packages/client/ui-deliverables/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md
-README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a
-README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c
+README.md: 4886a5817e6118ef76d5e7b49ed7fe3ed319650f
+README.zh.md: 43c68d4563f9ec32bb15fdd3df9a2e21225ebc13

+ 1 - 1
packages/client/ui-deliverables/README.md

@@ -36,7 +36,7 @@ The `present` tool row shows running, delivered, failed, or interrupted status;
 
 ### The row
 
-The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
+The “Files changed” row lists successful file-tool mutations; final file deliveries require `present`. It uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
 
 ### Inline-code links
 

+ 1 - 1
packages/client/ui-deliverables/README.zh.md

@@ -36,7 +36,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
 
 ### 该行
 
-该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
+“本轮文件改动”行列出成功的文件工具修改;最终文件交付需要调用 `present`。该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
 
 ### 行内代码链接
 

+ 2 - 2
packages/client/ui-deliverables/src/client/locales.ts

@@ -18,7 +18,7 @@ export const zh = {
   'row.stopped': '已中断',
   'row.inspect': '查看调用',
   'presented.open': '在默认程序中打开 {name}',
-  'produced.label': '产物',
+  'produced.label': '本轮文件改动',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
   'produced.open': '打开 {name}',
@@ -39,7 +39,7 @@ export const en: Record<DeliverablesKey, string> = {
   'row.stopped': 'Interrupted',
   'row.inspect': 'Inspect call',
   'presented.open': 'Open {name} in default app',
-  'produced.label': 'Produced',
+  'produced.label': 'Files changed',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',
   'produced.open': 'Open {name}',

+ 3 - 3
packages/client/ui-deliverables/tests/produced-files.client.spec.tsx

@@ -426,7 +426,7 @@ describe('ProducedFiles row', () => {
     const openFile = vi.fn<(path: string) => void>()
 
     const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />)
-    expect(view.getByText('产物')).toBeTruthy()
+    expect(view.getByText('本轮文件改动')).toBeTruthy()
     const row = view.container.querySelector('[data-produced-files-row]')
     if (!(row instanceof HTMLElement)) throw new Error('produced row missing')
     expect(within(row).getAllByRole('button')).toHaveLength(6)
@@ -595,7 +595,7 @@ describe('presented files', () => {
     expect(view.queryByRole('link')).toBeNull()
     fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' }))
     expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0)
-    expect(view.queryByText('Produced')).toBeNull()
+    expect(view.queryByText('Files changed')).toBeNull()
   })
 })
 
@@ -617,7 +617,7 @@ it.each([{}, { turn: '1', callId: 'bad', files: [] },
   const owner = tailOwner(deliverablesOf(value), 5)
   const matched = selectDeliverables(owner)!
   const view = render(<Deliverables {...openProps()} matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
-  expect(view.getByText('Produced')).toBeTruthy()
+  expect(view.getByText('Files changed')).toBeTruthy()
   expect(view.queryByText('Deliverables')).toBeNull()
 })
 

+ 2 - 2
packages/fs/tool-present/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/fs/tool-present/README.md
-README.md: 6767bb3b8d8839ae776fdf441ef853192c5117a2
-README.zh.md: 8255e7be42de273d58d01ca9b4e108d4c65a585d
+README.md: 77c1a83a30bdbe58b3be35d8bc0d879f1669b5a5
+README.zh.md: cec2d64b4e6f95a4e8210e7e24a9c796ed95d749

+ 1 - 1
packages/fs/tool-present/README.md

@@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without
 
 #### What the model sees
 
-The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
+The [present schema](../../../docs/tool-catalog.md#present) asks for existing workspace files: “Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
 
 #### Token effect
 

+ 1 - 1
packages/fs/tool-present/README.zh.md

@@ -73,7 +73,7 @@ kind: "package-reference"
 
 #### 模型看到的内容
 
-[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
+[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
 
 #### Token 影响
 

+ 4 - 1
packages/fs/tool-present/src/index.ts

@@ -37,7 +37,10 @@ export function apply(ctx: Context, config: Config): void {
   const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
   ctx.tools.register(defineTool({
     name: 'present',
-    description: 'Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.',
+    description: 'Declare existing workspace files as final deliverables. '
+      + 'When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. '
+      + 'Mentioning its path in your reply does not replace this call. The files must already exist. '
+      + 'The user opens the current source files; their contents are not copied or preserved.',
     parameters: {
       files: {
         type: 'array', required: true,

+ 1 - 1
snapshots/web/cordis-tool-round/tool-schemas.expected.json

@@ -522,7 +522,7 @@
     },
     {
       "name": "present",
-      "description": "Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.",
+      "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.",
       "parameters": {
         "type": "object",
         "properties": {

+ 1 - 1
snapshots/web/fresh-round-trip/tool-schemas.expected.json

@@ -325,7 +325,7 @@
     },
     {
       "name": "present",
-      "description": "Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool.",
+      "description": "Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved.",
       "parameters": {
         "type": "object",
         "properties": {

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 14 - 0
snapshots/web/present-svg/session.v3.jsonl


+ 9 - 0
snapshots/web/present-svg/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: present-svg
+profile: web
+composition: web-default
+recording: live
+header:
+  class: web-default
+workspace:
+  final: true

+ 73 - 0
snapshots/web/present-svg/ui.expected.md

@@ -0,0 +1,73 @@
+- button "系统提示词":
+  - img
+  - img
+  - text: 系统提示词
+- text: 简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg {{clock}}
+- button "复制":
+  - img
+- button "2 次工具调用 · 1 条消息" [expanded]:
+  - text: 2 次工具调用 · 1 条消息
+  - img
+- button "上下文注入 @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: 上下文注入 @deepseek-ai/dsh-system-prompt
+- button "思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.":
+  - img
+  - img
+  - text: 思考 The user wants a simple SVG diagram of the von Neumann architecture, saved as von-neumann.svg. Let me create a clean SVG showing CPU (control unit, ALU), memory, input, and output with bidirectional data/control buses.
+- paragraph: 我来创建一个冯诺依曼架构的 SVG 示意图。
+- button "写入 von-neumann.svg +83 -0":
+  - img
+  - img
+  - text: 写入
+  - button "von-neumann.svg"
+  - text: +83 -0
+- button "思考 The file is created. Let me present it as a deliverable.":
+  - img
+  - img
+  - text: 思考 The file is created. Let me present it as a deliverable.
+- button "交付文件 已交付 von-neumann.svg":
+  - img
+  - text: 交付文件 已交付 von-neumann.svg
+- paragraph:
+  - text: 已创建
+  - code:
+    - button "在默认程序中打开 von-neumann.svg": von-neumann.svg
+  - text: ,一个简洁的冯诺依曼架构示意图,包含:
+- list:
+  - listitem:
+    - strong: CPU(中央处理器)
+    - text: :内含控制单元 CU、算术逻辑单元 ALU 和寄存器组
+  - listitem:
+    - strong: 内存储器 Memory
+    - text: :标注"指令和数据统一存放"(存储程序思想)
+  - listitem:
+    - strong: 输入设备 / 输出设备
+    - text: :分别用绿色和粉色表示数据流向
+  - listitem:
+    - strong: 总线连接
+    - text: :CPU 与内存间的地址总线,以及 CPU 与 I/O 设备间的数据/控制线,箭头标明数据流向
+- paragraph: 下方附一行核心思想说明:指令与数据以二进制统一存储在存储器中。用浏览器打开该 SVG 文件即可查看。
+- text: 本轮文件改动
+- button "打开 {{cwd}}/workspace/von-neumann.svg": von-neumann.svg
+- text: 交付文件
+- button "在默认程序中打开 von-neumann.svg":
+  - text: von-neumann.svg SVG 冯诺依曼架构示意图 SVG
+  - img
+  - text: 打开
+- button "复制":
+  - img
+- button "好的回答":
+  - img
+- button "有问题的回答":
+  - img
+- button "在新对话中分支":
+  - img
+- button "用量 32K tok":
+  - img
+  - text: 用量 32K tok
+- button "用时 {{duration}}":
+  - img
+  - text: 用时 {{duration}}
+- text: {{clock}}

+ 83 - 0
snapshots/web/present-svg/workspace.expected/von-neumann.svg

@@ -0,0 +1,83 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 560" width="900" height="560" font-family="Helvetica, Arial, sans-serif">
+  <title>冯诺依曼架构</title>
+
+  <defs>
+    <marker id="arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto">
+      <path d="M0,0 L8,3 L0,6 Z" fill="#555"/>
+    </marker>
+  </defs>
+
+  <!-- Background -->
+  <rect x="0" y="0" width="900" height="560" fill="#fafbfc"/>
+  <text x="450" y="34" text-anchor="middle" font-size="22" font-weight="bold" fill="#222">冯诺依曼体系结构 (Von Neumann Architecture)</text>
+
+  <!-- ===== CPU ===== -->
+  <rect x="300" y="70" width="300" height="240" rx="10" fill="#fff" stroke="#1f6feb" stroke-width="2.5"/>
+  <text x="450" y="98" text-anchor="middle" font-size="17" font-weight="bold" fill="#1f6feb">中央处理器 CPU</text>
+
+  <!-- Control Unit -->
+  <rect x="325" y="118" width="120" height="160" rx="8" fill="#ddf4ff" stroke="#54aeff" stroke-width="1.5"/>
+  <text x="385" y="142" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#0969da">控制单元</text>
+  <text x="385" y="158" text-anchor="middle" font-size="11" fill="#0b5394">CU</text>
+  <text x="385" y="262" text-anchor="middle" font-size="10" fill="#555">指令译码 · 时序控制</text>
+
+  <!-- ALU -->
+  <rect x="455" y="118" width="120" height="160" rx="8" fill="#fff8c5" stroke="#d4a72c" stroke-width="1.5"/>
+  <text x="515" y="142" text-anchor="middle" font-size="12.5" font-weight="bold" fill="#9a6700">算术逻辑单元</text>
+  <text x="515" y="158" text-anchor="middle" font-size="11" fill="#7a5200">ALU</text>
+  <text x="515" y="262" text-anchor="middle" font-size="10" fill="#555">算术运算 · 逻辑运算</text>
+
+  <!-- Registers -->
+  <rect x="330" y="186" width="110" height="56" rx="6" fill="#fff" stroke="#8c959f" stroke-width="1.2" stroke-dasharray="4,3"/>
+  <text x="385" y="208" text-anchor="middle" font-size="11" fill="#444">寄存器组</text>
+  <text x="385" y="224" text-anchor="middle" font-size="10" fill="#666">Registers / 缓存</text>
+
+  <!-- ===== Memory ===== -->
+  <rect x="300" y="400" width="300" height="110" rx="10" fill="#fff" stroke="#cf222e" stroke-width="2.5"/>
+  <text x="450" y="428" text-anchor="middle" font-size="17" font-weight="bold" fill="#cf222e">内存储器 Memory</text>
+  <text x="450" y="452" text-anchor="middle" font-size="12" fill="#444">存储单元: 指令和数据</text>
+  <text x="450" y="470" text-anchor="middle" font-size="11" fill="#666">(存放程序与数据,同一总线存取)</text>
+
+  <!-- ===== Input ===== -->
+  <rect x="40" y="150" width="170" height="120" rx="10" fill="#fff" stroke="#1a7f37" stroke-width="2.5"/>
+  <text x="125" y="186" text-anchor="middle" font-size="15" font-weight="bold" fill="#1a7f37">输入设备</text>
+  <text x="125" y="206" text-anchor="middle" font-size="11" fill="#444">Input</text>
+  <text x="125" y="238" text-anchor="middle" font-size="10" fill="#666">键盘 · 鼠标 · 磁盘</text>
+
+  <!-- ===== Output ===== -->
+  <rect x="690" y="150" width="170" height="120" rx="10" fill="#fff" stroke="#bf3989" stroke-width="2.5"/>
+  <text x="775" y="186" text-anchor="middle" font-size="15" font-weight="bold" fill="#bf3989">输出设备</text>
+  <text x="775" y="206" text-anchor="middle" font-size="11" fill="#444">Output</text>
+  <text x="775" y="238" text-anchor="middle" font-size="10" fill="#666">显示器 · 打印机</text>
+
+  <!-- ===== Bus lines ===== -->
+  <!-- Address bus CPU <-> Memory (red) -->
+  <path d="M450,320 L450,398" fill="none" stroke="#cf222e" stroke-width="3"/>
+  <text x="420" y="362" text-anchor="end" font-size="11" fill="#cf222e" transform="rotate(-90 420 362)">地址总线</text>
+
+  <!-- Data/control bus label along the bottom -->
+  <line x1="300" y1="355" x2="600" y2="355" stroke="#555" stroke-width="1" stroke-dasharray="2,3" opacity="0.0"/>
+  <text x="450" y="353" text-anchor="middle" font-size="12" font-weight="bold" fill="#555">← 数据总线 / 控制总线 →</text>
+
+  <!-- CPU <-> Input (bidirectional-ish: input data in + control out) -->
+  <path d="M212,210 L298,210" fill="none" stroke="#1a7f37" stroke-width="3" marker-end="url(#arrow)"/>
+  <path d="M212,196 L298,196" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
+  <text x="255" y="184" text-anchor="middle" font-size="10.5" fill="#1a7f37">数据(输入)</text>
+  <text x="255" y="236" text-anchor="middle" font-size="10" fill="#555">控制</text>
+
+  <!-- CPU <-> Output -->
+  <path d="M602,210 L688,210" fill="none" stroke="#bf3989" stroke-width="3" marker-end="url(#arrow)"/>
+  <path d="M602,196 L688,196" fill="none" stroke="#555" stroke-width="2" marker-end="url(#arrow)"/>
+  <text x="645" y="184" text-anchor="middle" font-size="10.5" fill="#bf3989">数据(输出)</text>
+  <text x="645" y="236" text-anchor="middle" font-size="10" fill="#555">控制</text>
+
+  <!-- Memory <-> Input/Output hints: 存储器与 I/O 也通过总线交换 -->
+  <path d="M125,272 L125,300 Q125,340 175,340 L298,340" fill="none" stroke="#8c959f" stroke-width="1.8" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
+  <text x="160" y="328" text-anchor="middle" font-size="10" fill="#666" transform="rotate(-8 160 328)">总线</text>
+
+  <path d="M775,272 L775,300 Q775,340 725,340 L602,340" fill="none" stroke="#8c959f" stroke-width="1.8" stroke-dasharray="6,4" marker-end="url(#arrow)"/>
+  <text x="740" y="328" text-anchor="middle" font-size="10" fill="#666" transform="rotate(8 740 328)">总线</text>
+
+  <!-- Stored-program note -->
+  <text x="450" y="530" text-anchor="middle" font-size="11.5" fill="#666">核心思想: 指令与数据以二进制形式统一存放在存储器中 —— “存储程序”</text>
+</svg>

+ 1 - 1
snapshots/web/ptc-round/system-prompt.expected.md

@@ -162,7 +162,7 @@ interface ToolArgsMap {
     /** children (default) lists direct children only; descendants walks the complete tree below you. */
     scope?: "children" | "descendants";
   } & Record<string, JsonValue>;
-  /** Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. */
+  /** Declare existing workspace files as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. */
   present: {
     files: {
       /** Path of an existing file inside the workspace. */

+ 1 - 0
tsconfig.host.json

@@ -52,6 +52,7 @@
     "apps/web/tests/conversation-column-overflow.e2e.ts",
     "apps/web/tests/ptc-round.e2e.ts",
     "apps/web/tests/present.e2e.ts",
+    "apps/web/tests/present-svg.e2e.ts",
     "apps/web/tests/composer-draft-scroll.e2e.ts",
     "apps/web/tests/composer-placeholder.e2e.ts",
     "apps/web/tests/cordis-tool-round.e2e.ts",

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác