Просмотр исходного кода

refactor(present): declare and open workspace source files

yudshj 2 дней назад
Родитель
Сommit
c9038b4b31
50 измененных файлов с 517 добавлено и 660 удалено
  1. 2 2
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml
  2. 1 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md
  3. 1 0
      .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md
  4. 3 0
      .agents/notes/archived/manifest.json
  5. 6 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml
  6. 37 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md
  7. 37 0
      .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md
  8. 37 12
      apps/web/tests/present.e2e.ts
  9. 2 2
      docs/config-catalog.i18n.yaml
  10. 4 6
      docs/config-catalog.md
  11. 4 6
      docs/config-catalog.zh.md
  12. 2 2
      docs/persistence-catalog.i18n.yaml
  13. 2 2
      docs/persistence-catalog.md
  14. 2 2
      docs/persistence-catalog.zh.md
  15. 2 2
      docs/tool-catalog.i18n.yaml
  16. 3 3
      docs/tool-catalog.md
  17. 3 3
      docs/tool-catalog.zh.md
  18. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  19. 5 5
      packages/client/ui-deliverables/README.md
  20. 6 6
      packages/client/ui-deliverables/README.zh.md
  21. 0 2
      packages/client/ui-deliverables/package.json
  22. 6 6
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  23. 2 2
      packages/client/ui-deliverables/src/client/present-open.ts
  24. 8 3
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  25. 5 5
      packages/client/ui-deliverables/src/index.ts
  26. 0 84
      packages/client/ui-deliverables/src/present-download.ts
  27. 57 43
      packages/client/ui-deliverables/src/present-open.ts
  28. 10 17
      packages/client/ui-deliverables/src/presented.ts
  29. 0 281
      packages/client/ui-deliverables/tests/present-download.host.spec.ts
  30. 178 0
      packages/client/ui-deliverables/tests/present-open.host.spec.ts
  31. 14 10
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  32. 0 1
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  33. 0 3
      packages/client/ui-deliverables/tsconfig.client.json
  34. 0 4
      packages/client/ui-deliverables/tsconfig.host.json
  35. 2 2
      packages/fs/tool-present/README.i18n.yaml
  36. 14 17
      packages/fs/tool-present/README.md
  37. 14 17
      packages/fs/tool-present/README.zh.md
  38. 1 4
      packages/fs/tool-present/package.json
  39. 14 30
      packages/fs/tool-present/src/index.ts
  40. 3 4
      packages/fs/tool-present/src/types.ts
  41. 3 10
      packages/fs/tool-present/tests/built-errors.e2e.ts
  42. 16 32
      packages/fs/tool-present/tests/present.spec.ts
  43. 0 3
      packages/fs/tool-present/tsconfig.json
  44. 0 12
      pnpm-lock.yaml
  45. 2 3
      scripts/gen-tool-catalog.ts
  46. 1 1
      snapshots/web/cordis-tool-round/tool-schemas.expected.json
  47. 1 1
      snapshots/web/fresh-round-trip/tool-schemas.expected.json
  48. 2 2
      snapshots/web/present/session.v2.jsonl
  49. 2 2
      snapshots/web/present/ui.expected.md
  50. 1 4
      snapshots/web/ptc-round/system-prompt.expected.md

+ 2 - 2
.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml → .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.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-web-explicit-file-delivery.md
-2026-09-08-web-explicit-file-delivery.md: 5ab211d8e8ea3c7f008cf39307f1ddfdd77acfee
-2026-09-08-web-explicit-file-delivery.zh.md: 3077b23a78f0617a1a377bfc37de201169bbdc82
+2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2
+2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f

+ 1 - 0
.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md → .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md

@@ -1,6 +1,7 @@
 # Agent Note: Web delivers explicit file snapshots
 
 Status: implemented
+Archived: 2026-09-08
 
 English | [中文](2026-09-08-web-explicit-file-delivery.zh.md)
 

+ 1 - 0
.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md → .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md

@@ -1,6 +1,7 @@
 # Agent Note: Web 显式交付文件快照
 
 Status: implemented
+Archived: 2026-09-08
 
 [English](2026-09-08-web-explicit-file-delivery.md) | 中文
 

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -1381,6 +1381,9 @@
     "feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58",
     "feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15",
     "feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23",
+    "feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a",
+    "feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093",
+    "feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d",
     "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d",
     "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e",
     "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b",

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

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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

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

@@ -0,0 +1,37 @@
+# Agent Note: Present declares workspace source files
+
+Status: implemented
+
+English | [中文](2026-09-08-present-workspace-source-files.zh.md)
+
+## Problem
+
+Users need to open and edit the files produced in their workspace, including shell-created files that have no editor mutation records. Preserving an independent delivered version adds content storage, copy verification, temporary-file retention, and a second editing destination to this workflow.
+
+## Decision
+
+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 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.
+
+## Alternatives considered
+
+**Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision.
+
+**Opening attachment-store files directly** lets editors mutate immutable objects. A future persistent delivery system needs an owned editing and retention policy, such as copy-on-write, before exposing saved versions to applications.
+
+**Generic artifact fields or a Host tool subpath inside the UI package** broaden unrelated APIs or couple preset installation to browser packaging. A tool-owned event and ordinary package preserve existing extension points and publication rules.
+
+**Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text.
+
+**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees.
+
+## Consequences
+
+The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged.
+
+The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced.
+
+Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export.

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

@@ -0,0 +1,37 @@
+# Agent Note:Present 声明交付工作区源文件
+
+Status: implemented
+
+[English](2026-09-08-present-workspace-source-files.md) | 中文
+
+## 问题
+
+用户需要打开并编辑工作区中产出的文件,包括没有编辑器修改记录的 shell 产出文件。保存独立交付版本会为这一流程增加内容存储、副本校验、临时文件保留,以及第二个编辑目标。
+
+## 决策
+
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。
+
+工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
+
+经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。
+
+## 考虑过的替代方案
+
+**不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。
+
+**直接打开附件存储文件**会让编辑器修改不可变对象。未来持久化交付系统需要先明确编辑和保留策略,例如写时复制,再将保存版本暴露给应用。
+
+**通用 artifact 字段或 UI 包内的 Host 工具子路径**会扩展无关 API,或将 preset 安装与浏览器打包耦合。工具拥有的事件与普通包保留现有扩展点和发布规则。
+
+**以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。
+
+**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。
+
+## 影响
+
+Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。
+
+仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。
+
+定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。

+ 37 - 12
apps/web/tests/present.e2e.ts

@@ -1,5 +1,5 @@
-/** Recorded delivery, source deletion, reload, and Session ZIP behavior. */
-import { readFile, unlink, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises'
+/** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */
+import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises'
 import { join, delimiter } from 'node:path'
 import { fileURLToPath } from 'node:url'
 import { chromium, type Browser, type Page } from 'playwright'
@@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes
   const events: SessionEvent[] = []
   let nativeRoot: string | undefined
   let openLog: string
-  const opened = async (): Promise<string[]> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
+  const opened = async (): Promise<Array<{ path: string; content: string }>> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string })
   const downloads: string[] = []
 
   beforeAll(async () => {
@@ -46,7 +46,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes
     const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
     await writeFile(join(nativeRoot, command), `#!${process.execPath}
 const fs = require('node:fs');
-fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(process.argv[2], 'utf8')) + '\\n');
+fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n');
 `, { mode: 0o700 })
     vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
     await mkdir(DIR, { recursive: true })
@@ -79,7 +79,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro
     }
   })
 
-  it('delivers nested snapshots even when the enclosing program subsequently fails', async () => {
+  it('declares nested deliveries even when the enclosing program subsequently fails', async () => {
     if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
     const settled = scaffold.whenTurnSettled()
     const input = page.locator('[data-composer-input]').first()
@@ -94,13 +94,21 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro
     await assertFinalWorkspaceSnapshot(DIR, cwd)
     expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path)))
       .toEqual(['report.txt', '说明.txt'])
+    for (const event of events) {
+      if (event.type === 'deliverables/presented') {
+        expect(event.data.files).toEqual([
+          { path: 'report.txt', description: 'delivered report' },
+          { path: '说明.txt', description: 'delivered note' },
+        ])
+      }
+    }
     expect(events.some(event => event.type === 'tool/code-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true)
     expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true)
   }, 200_000)
 
-  it('opens saved copies after source deletion and reload, while Session ZIP contains only references', async () => {
-    await unlink(join(cwd, 'report.txt'))
-    await unlink(join(cwd, '说明.txt'))
+  it('opens current source files after edits and reload, and reports deletion without downloading', async () => {
+    await writeFile(join(cwd, 'report.txt'), 'EDITED_REPORT\n')
+    await writeFile(join(cwd, '说明.txt'), 'EDITED_NOTE\n')
     for (const reload of [false, true]) {
       if (reload) {
         const warningStart = tripwire.warnings.length
@@ -111,14 +119,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro
       const row = page.locator('[data-presented-files-row]')
       await row.waitFor()
       expect(await row.getByRole('button').count()).toBe(2)
-      for (const [name, bytes] of [['report.txt', 'DELIVERED_REPORT\n'], ['说明.txt', 'DELIVERED_NOTE\n']]) {
+      for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) {
         const count = (await opened()).length
         const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
         await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click()
         expect((await response).status()).toBe(204)
         await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
         expect(await opened()).toHaveLength(count + 1)
-        expect((await opened()).at(-1)).toBe(bytes)
+        expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes })
       }
     }
     const count = (await opened()).length
@@ -127,13 +135,22 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro
     await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
     expect((await openedResponse).status()).toBe(204)
     expect(await opened()).toHaveLength(count + 1)
-    expect((await opened()).at(-1)).toBe('DELIVERED_REPORT\n')
+    expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' })
     expect(downloads).toEqual([])
     const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href)
     expect(response.status()).toBe(200)
     const entries = unzipSync(await response.body())
     expect(Object.keys(entries)).toHaveLength(1)
-    expect(strFromU8(Object.values(entries)[0]!)).toContain('deliverables/presented')
+    const exported = strFromU8(Object.values(entries)[0]!)
+    expect(exported).toContain('deliverables/presented')
+    const declarations = exported.trim().split('\n').map(line => JSON.parse(line) as SessionEvent)
+      .filter(event => event.type === 'deliverables/presented')
+    expect(declarations).toHaveLength(1)
+    expect(declarations[0]!.data.files).toEqual([
+      { path: 'report.txt', description: 'delivered report' },
+      { path: '说明.txt', description: 'delivered note' },
+    ])
+    expect(exported).not.toContain('EDITED_REPORT')
     if (MODE !== 'record') {
       const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
       await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
@@ -155,6 +172,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro
         expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480)
       }
     }
+    const beforeDelete = (await opened()).length
+    await unlink(join(cwd, 'report.txt'))
+    const missing = page.waitForResponse(response => response.url().includes('/api/present.open?'))
+    await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click()
+    expect((await missing).status()).toBe(404)
+    await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor()
+    expect(await opened()).toHaveLength(beforeDelete)
+    expect(downloads).toEqual([])
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
   })

+ 2 - 2
docs/config-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/config-catalog.md
-config-catalog.md: 89eac550fce4b767023d80d519ca3d60849a7e52
-config-catalog.zh.md: 7231b0867faa9998712e97be8889dc5e6f38a9ed
+config-catalog.md: 0a06bdf478ccc25b3a3cedb45df18ee0dca285a9
+config-catalog.zh.md: 580c11bd2b3cf6c598206eaa6b1bb0398e85713d

+ 4 - 6
docs/config-catalog.md

@@ -2873,19 +2873,17 @@ Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/i
 
 ## `@deepseek-ai/dsh-tool-present`
 
-Requires: `tools` · `fs` · `attachments` · `sessionProjections`
+Requires: `tools` · `fs` · `sessionProjections`
 
 ```ts config-catalog
-/** Per-call snapshot limits. */
+/** Per-call delivery limit. */
 export interface Config {
-  /** Inclusive per-file byte cap; at most 100 MiB. */
-  maxFileBytes: number
   /** Maximum number of files in one call. */
   maxFiles: number
 }
 ```
 
-Source: [`packages/fs/tool-present/src/index.ts:16`](../packages/fs/tool-present/src/index.ts)
+Source: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts)
 
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
@@ -3459,7 +3457,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `attachments` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 4 - 6
docs/config-catalog.zh.md

@@ -2875,19 +2875,17 @@ export interface Config {
 
 ## `@deepseek-ai/dsh-tool-present`
 
-依赖: `tools` · `fs` · `attachments` · `sessionProjections`
+依赖: `tools` · `fs` · `sessionProjections`
 
 ```ts config-catalog
-/** Per-call snapshot limits. */
+/** Per-call delivery limit. */
 export interface Config {
-  /** Inclusive per-file byte cap; at most 100 MiB. */
-  maxFileBytes: number
   /** Maximum number of files in one call. */
   maxFiles: number
 }
 ```
 
-来源: [`packages/fs/tool-present/src/index.ts:16`](../packages/fs/tool-present/src/index.ts)
+来源: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts)
 
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
@@ -3461,7 +3459,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts))
-- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `attachments` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 2 - 2
docs/persistence-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/persistence-catalog.md
-persistence-catalog.md: 1749faa99beb39940e2581bc58d0543ea5984fd1
-persistence-catalog.zh.md: b3e13c5f4bb9cf973249eaf7b8cf7d93366be96f
+persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f
+persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b

+ 2 - 2
docs/persistence-catalog.md

@@ -403,13 +403,13 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio
 #### `deliverables/presented` — log-only
 
 ```ts persistence-catalog
-/** Saved deliveries from a successful final present result, including nested calls. */
+/** Declared workspace files from a successful final present result, including nested calls. */
 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
 ```
 
 Types: [ToolCallId](subsystems/core.md)
 
-Source: [`packages/fs/tool-present/src/types.ts:16`](../packages/fs/tool-present/src/types.ts)
+Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
 
 ### `feedback/*`
 

+ 2 - 2
docs/persistence-catalog.zh.md

@@ -405,13 +405,13 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 #### `deliverables/presented` — 仅日志
 
 ```ts persistence-catalog
-/** Saved deliveries from a successful final present result, including nested calls. */
+/** Declared workspace files from a successful final present result, including nested calls. */
 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
 ```
 
 类型: [ToolCallId](subsystems/core.zh.md)
 
-来源: [`packages/fs/tool-present/src/types.ts:16`](../packages/fs/tool-present/src/types.ts)
+来源: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts)
 
 ### `feedback/*`
 

+ 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: 5a09b07e0a7a24e654fc45a7a477838ce21f30d7
-tool-catalog.zh.md: 528f3a37680ecb5f9482606c5671c4fb056092f1
+tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948
+tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930

+ 3 - 3
docs/tool-catalog.md

@@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
 | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
-| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.attachments`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies authenticated downloads and cards. |
+| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. |
 | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
@@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_
 
 ### `present`
 
-Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool.
+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.
 
 ```json
 {
@@ -260,7 +260,7 @@ Deliver final files to the user. Saves a snapshot of each existing workspace fil
 
 Source: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
 
-Deliveries belong to the calling Session; Web ui-deliverables supplies authenticated downloads and cards.
+Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.
 
 <a id="deepseek-aidsh-tool-pwsh"></a>
 

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

@@ -23,7 +23,7 @@
 | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 |
 | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
 | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 |
-| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.attachments`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供认证下载与卡片。 |
+| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 |
 | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
 | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 |
 | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
@@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 
 ### `present`
 
-向用户交付最终文件。保存每个已有工作区文件的快照,使其在编辑或删除后仍可下载。调用工具前先创建文件。
+声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。
 
 ```json
 {
@@ -264,7 +264,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
 
 来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
 
-交付归调用方 Session 所有;Web ui-deliverables 提供认证下载与卡片。
+交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。
 
 <a id="deepseek-aidsh-tool-pwsh"></a>
 

+ 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: abf944d0115934d781ae44bf176914263822277e
-README.zh.md: e8f556a7469800898b660fe1d81e16db02062b27
+README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a
+README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c

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

@@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th
 <a id="explicit-deliveries"></a>
 ### Explicit deliveries
 
-The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns snapshot creation, limits, and Session delivery records. The closing turn shows responsive file cards with names, types, sizes, descriptions, and buttons that open a saved copy in the Host’s default application. Matching inline-code references open the same snapshots after source edits, deletion, or reload, without starting a browser download. Forks authorize opening through the viewed Session. Repeated delivery of a path selects its latest successful snapshot before the closing reply.
+The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply.
 
 The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device.
 
@@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by
 
 The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost.
 
-Native opening uses an authenticated POST addressed by Session, event sequence, and original file index. The Host streams the saved bytes into a private temporary copy and verifies the complete attachment before launching the default application. Each gesture creates a separate copy, so application edits cannot change the stored snapshot. Failed opens remove their copies; successful copies remain until plugin disposal because applications may read lazily. Disposal cancels and awaits pending work before cleanup. The authenticated GET download endpoint remains available to byte consumers.
+Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
 
 </details>
 
@@ -95,8 +95,8 @@ The section is static at first-party order 9000 for the lifetime of the package
 These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog.
 
 - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it.
-- **Terminal-created files require explicit delivery** — call `present` to make their saved snapshots available.
-- **Transferred Session exports contain no delivered bytes** — delivery actions require the same snapshots in the serving host’s attachment store; missing or pruned snapshots return 404.
+- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening.
+- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403.
 - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced.
 
 <a id="dev-note"></a>
@@ -109,4 +109,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, file-action routes, and optional service registrations are effect-owned with disposal proven by their plugin specs; the attachment service owns saved bytes, and the Session log owns delivery references.
+**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents.

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

@@ -30,7 +30,7 @@ kind: "package-reference"
 <a id="explicit-deliveries"></a>
 ### 显式交付
 
-Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有快照创建、限制和 Session 交付记录。收尾 turn 显示响应式文件卡片,包含名称、类型、大小、说明和在 Host 默认应用中打开保存副本的按钮。匹配的行内代码引用也打开相同快照;修改或删除源文件、重新加载后仍可打开,不触发浏览器下载。Fork 通过当前查看的 Session 授权打开。同一路径重复交付时,选择收尾回复之前最近一次成功的快照
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明
 
 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。
 
@@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最
 
 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
 
-原生打开使用经过认证的 POST,通过 Session、事件序号和原始文件索引定位文件。Host 将保存的字节流写入私有临时副本,完整校验 attachment 后才启动默认应用。每次操作创建独立副本,因此应用内的编辑不会修改已保存的快照。打开失败时删除副本;成功副本保留到插件释放,因为应用可能延迟读取。释放时先取消并等待进行中的操作,再执行清理。经过认证的 GET 下载端点仍供字节读取方使用
+原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求
 
 </details>
 
@@ -95,9 +95,9 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端创建的文件需要显式交付**——调用 `present` 使其快照可供下载
-- **转移的 Session 导出不含交付字节** — 下载链接依赖服务主机附件存储中的同一快照;快照缺失或被清理时返回 404
-- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`
+- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开
+- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403
+- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作
 
 <a id="dev-note"></a>
 ### 开发备注
@@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、文件操作路由与可选 service 注册都归 effect 所有,释放由插件测试证明;attachment 服务拥有保存的字节,Session 日志拥有交付引用
+**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容

+ 0 - 2
packages/client/ui-deliverables/package.json

@@ -63,10 +63,8 @@
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
-    "@deepseek-ai/dsh-attachment-local": "workspace:^",
     "@deepseek-ai/dsh-client-ui-tool": "workspace:^",
     "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^"

+ 6 - 6
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -1,6 +1,6 @@
-/** Existing changed-file chips and explicitly delivered snapshots for a closing turn. */
+/** Existing changed-file chips and explicitly declared files for a closing turn. */
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
-import { LinkIcon, classifyLinkPath, fileSizeText, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { PresentedOpenController } from './present-open.ts'
@@ -19,7 +19,7 @@ export interface DeliverablesInjected {
 }
 
 /**
- * Claim turns containing modified paths or presented snapshots.
+ * Claim turns containing modified paths or declared files.
  * @param owner - closing turn.
  * @returns matched files, or null for an empty turn.
  */
@@ -30,7 +30,7 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
 }
 
 /**
- * Render workspace file actions and default-application buttons for saved deliveries.
+ * Render workspace file actions and default-application buttons for declared files.
  * @param props - matched files, workspace opener, and localized copy.
  * @returns the closing turn's file rows.
  */
@@ -44,7 +44,7 @@ export function Deliverables({ matched, openFile, t, sessionId, openPresented, u
       <span className={css.label}>{t('presented.label')}</span>
       <div className={css.presented} data-presented-files-row>
         {matched.presented.map((file) => {
-          const phase = states[presentedFileUrl(sessionId, file.seq, file.index, 'open')]
+          const phase = states[presentedFileUrl(sessionId, file.seq, file.index)]
           return <button key={file.path} type="button" className={css.file}
             disabled={phase === 'opening'}
             onClick={() => { void openPresented(sessionId, file.seq, file.index) }}
@@ -52,7 +52,7 @@ export function Deliverables({ matched, openFile, t, sessionId, openPresented, u
             <LinkIcon kind={classifyLinkPath(file.path)} className={css.fileIcon} />
             <span className={css.details}>
               <span className={css.fileName}>{basename(file.path)}</span>
-              <span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')} · {fileSizeText(file.bytes)}</span>
+              <span className={css.metadata}>{basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')}</span>
               {file.description && <span className={css.description}>{file.description}</span>}
               {phase !== undefined && <span className={css.description} role="status">{t(`presented.${phase}`)}</span>}
             </span>

+ 2 - 2
packages/client/ui-deliverables/src/client/present-open.ts

@@ -14,7 +14,7 @@ export class PresentedOpenController {
   private readonly pending = new Set<Promise<void>>()
 
   /**
-   * Open a snapshot once while a request for the same coordinates is pending.
+   * Open a declared workspace file once while a request for the same coordinates is pending.
    * Failures remain visible on the card and a later gesture retries them.
    * @param sessionId - viewed Session, including a fork's own identity.
    * @param seq - durable delivery event sequence.
@@ -22,7 +22,7 @@ export class PresentedOpenController {
    * @returns after the Host acknowledges opening or the error state is published.
    */
   async open(sessionId: SessionId, seq: number, index: number): Promise<void> {
-    const url = presentedFileUrl(sessionId, seq, index, 'open')
+    const url = presentedFileUrl(sessionId, seq, index)
     if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return
     this.state.update((state) => { state[url] = 'opening' })
     const task = this.request(url)

+ 8 - 3
packages/client/ui-deliverables/src/client/turn-deliverables.ts

@@ -10,7 +10,7 @@ import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives
 import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
 import { basename, isPresentedData, isPresentedFile } from '../presented.ts'
 
-/** A saved delivery with its authorized download coordinate. */
+/** A declared file with its authorized open coordinates. */
 export interface PresentedPath extends PresentedFile {
   readonly seq: number
   readonly index: number
@@ -173,7 +173,12 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     if (match.event.type === 'deliverables/presented') {
       const { files } = match.event.data
       const seq = match.event.seq
-      const presented = files.flatMap((file, index) => isPresentedFile(file) ? [{ ...file, seq, index }] : [])
+      const presented: PresentedPath[] = []
+      for (let index = 0; index < files.length; index += 1) {
+        const file = files[index]
+        if (isPresentedFile(file)) presented.push({ ...file, seq, index })
+      }
+      if (presented.length === 0) return context.state
       return { ...context.state, presented: [...context.state.presented ?? [], ...presented] }
     }
     if (match.event.type === 'tool/call') {
@@ -210,7 +215,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
 }
 
 /**
- * Select the latest saved delivery of each path before the closing reply.
+ * Select the latest declaration of each path before the closing reply.
  * @param owner - closing turn and sequence.
  * @returns replayable deliveries in first-seen path order.
  */

+ 5 - 5
packages/client/ui-deliverables/src/index.ts

@@ -1,17 +1,17 @@
 /**
  * Deliverables plugin, node half. Registers the response-format guidance that
  * lets the browser half recognize final-response file references and serves
- * authenticated snapshot downloads and native opens. The browser
+ * authenticated native opens of workspace files. The browser
  * half ships via exports["./client"], discovered through the package.json
  * dsh.client declaration.
  */
 
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { registerPresentDownload } from './present-download.ts'
+import { registerPresentOpen } from './present-open.ts'
 
-/** Services required for file-reference guidance and authenticated snapshot downloads and native opens. */
-export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'attachments', 'sessionController']
+/** Services required for file-reference guidance and authenticated native opens of workspace files. */
+export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController']
 
 /** Stable final-response guidance owned by the matching renderer. */
 const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. '
@@ -22,7 +22,7 @@ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, men
  * @param ctx - host context carrying the system-prompt registry.
  */
 export function apply(ctx: Context): void {
-  registerPresentDownload(ctx)
+  registerPresentOpen(ctx)
   ctx.systemPrompt.section({
     name: 'ui:deliverable-file-references',
     order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'),

+ 0 - 84
packages/client/ui-deliverables/src/present-download.ts

@@ -1,84 +0,0 @@
-/** Authorize delivery actions against the Session log, using only saved attachment bytes. */
-import { Readable } from 'node:stream'
-import type { Context } from '@deepseek-ai/cordis'
-import type {} from '@deepseek-ai/dsh-attachment'
-import type {} from '@deepseek-ai/dsh-client-connection'
-import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
-import { isPresentedData, isPresentedFile, PRESENT_DOWNLOAD_PATH, PRESENT_OPEN_PATH } from './presented.ts'
-import { createPresentedOpener } from './present-open.ts'
-import type {} from '@deepseek-ai/dsh-session-query'
-
-/**
- * Register snapshot downloads and native opening inside Connection's authentication fence.
- * @param ctx - Host services owning Session reads, attachments, and HTTP routing.
- */
-export function registerPresentDownload(ctx: Context): void {
-  const open = createPresentedOpener(ctx)
-  ctx.connection.fetch.register({
-    path: PRESENT_DOWNLOAD_PATH,
-    methods: ['GET'],
-    requestBody: 'buffered',
-    fetch: request => handleDelivery(ctx, request),
-  })
-  ctx.connection.fetch.register({
-    path: PRESENT_OPEN_PATH,
-    methods: ['POST'],
-    requestBody: 'buffered',
-    fetch: request => handleDelivery(ctx, request, open),
-  })
-}
-
-async function handleDelivery(ctx: Context, request: Request, open?: ReturnType<typeof createPresentedOpener>): Promise<Response> {
-  const query = new URL(request.url).searchParams
-  const id = query.get('sessionId')
-  const seq = query.get('seq')
-  const index = query.get('index')
-  if (!id || seq === null || index === null || !/^\d+$/.test(seq) || !/^\d+$/.test(index)
-    || !Number.isSafeInteger(Number(seq)) || !Number.isSafeInteger(Number(index))) {
-    return new Response('Invalid Presented file coordinates.', { status: 400 })
-  }
-  try {
-    const { target } = await ctx.sessionQuery.readEvent({
-      sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
-    }, request.signal)
-    const artifact = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined
-    if (!isPresentedFile(artifact)) return new Response('Presented file not found in this Session result.', { status: 404 })
-    if (open !== undefined) {
-      await open(artifact, request.signal)
-      return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
-    }
-    const filename = encodeURIComponent(artifact.name.toWellFormed())
-      .replace(/['()*]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`)
-    const iterator = ctx.attachments.readFileStream(artifact, request.signal)[Symbol.asyncIterator]()
-    // Open before sending headers so an absent snapshot is a 404, not an empty successful download.
-    let first: IteratorResult<Uint8Array> | undefined = await iterator.next()
-    const data: AsyncIterableIterator<Uint8Array> = {
-      [Symbol.asyncIterator]() { return this },
-      next() {
-        const pending = first
-        first = undefined
-        return pending === undefined ? iterator.next() : Promise.resolve(pending)
-      },
-      async return() {
-        await iterator.return?.()
-        return { done: true, value: undefined }
-      },
-    }
-    const body = Readable.toWeb(Readable.from(data, { signal: request.signal })) as ReadableStream<Uint8Array>
-    return new Response(body, {
-      headers: {
-        'content-type': 'application/octet-stream',
-        'content-disposition': `attachment; filename*=UTF-8''${filename}`,
-        'cache-control': 'no-store',
-        'x-content-type-options': 'nosniff',
-      },
-    })
-  } catch (error: unknown) {
-    request.signal.throwIfAborted()
-    const missing = error instanceof Error && 'code' in error
-      && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND')
-    const absentSnapshot = ctx.attachments.isAttachmentError(error) && error.code === 'ATTACHMENT_NOT_FOUND'
-    const status = missing || absentSnapshot ? 404 : 500
-    return new Response('Presented file snapshot unavailable.', { status })
-  }
-}

+ 57 - 43
packages/client/ui-deliverables/src/present-open.ts

@@ -1,57 +1,71 @@
-/** Private editable copies keep native applications away from immutable attachment objects. */
-import { createWriteStream } from 'node:fs'
-import { mkdtemp, rm } from 'node:fs/promises'
-import { tmpdir } from 'node:os'
-import { join } from 'node:path'
-import { promises as streamPromises } from 'node:stream'
+/** Open declared source files inside the viewed Session's workspace. */
+import { realpath, stat } from 'node:fs/promises'
+import { isAbsolute, relative, resolve, sep } from 'node:path'
 import type { Context } from '@deepseek-ai/cordis'
-import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import type {} from '@deepseek-ai/dsh-api-session-controller'
-import { basename } from './presented.ts'
+import type {} from '@deepseek-ai/dsh-client-connection'
+import type {} from '@deepseek-ai/dsh-session-query'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
+import { isPresentedData, isPresentedFile, PRESENT_OPEN_PATH } from './presented.ts'
 
 /**
- * Own native-open work and its temporary copies for one plugin lifetime.
- * Successful copies remain until disposal because desktop applications may read lazily.
- * @param ctx - attachment provider and plugin lifetime.
- * @returns an opener that verifies the entire snapshot before launching its default application.
+ * Register native opening inside Connection's authentication fence.
+ * @param ctx - Session lookup, native opener, and route lifetime.
  */
-export function createPresentedOpener(ctx: Context): (ref: FileAttachmentRef, signal: AbortSignal) => Promise<void> {
+export function registerPresentOpen(ctx: Context): void {
   const lifetime = new AbortController()
-  const directories = new Set<string>()
-  const pending = new Set<Promise<void>>()
+  const pending = new Set<Promise<Response>>()
   ctx.effect(() => async () => {
     lifetime.abort()
     await Promise.allSettled(pending)
-    await Promise.all([...directories].map(directory => rm(directory, { recursive: true, force: true })))
   })
+  ctx.connection.fetch.register({
+    path: PRESENT_OPEN_PATH,
+    methods: ['POST'],
+    requestBody: 'buffered',
+    fetch: (request) => {
+      const task = handlePresentOpen(ctx, new Request(request, {
+        signal: AbortSignal.any([request.signal, lifetime.signal]),
+      }))
+      pending.add(task)
+      void task.then(() => { pending.delete(task) }, () => { pending.delete(task) })
+      return task
+    },
+  })
+}
 
-  async function open(ref: FileAttachmentRef, signal: AbortSignal): Promise<void> {
-    signal.throwIfAborted()
-    if (basename(ref.name) !== ref.name || ref.name === '.' || ref.name === '..' || ref.name.includes('\0')) {
-      throw new Error('Presented file name must be a leaf name.')
-    }
-    const directory = await mkdtemp(join(tmpdir(), 'dsh-present-'))
-    directories.add(directory)
-    try {
-      const path = join(directory, ref.name)
-      await streamPromises.pipeline(
-        ctx.attachments.readFileStream(ref, signal),
-        createWriteStream(path, { flags: 'wx', mode: 0o600 }),
-        { signal },
-      )
-      signal.throwIfAborted()
-      await ctx.sessionController.openWorkspacePath({ path }, signal)
-    } catch (error) {
-      await rm(directory, { recursive: true, force: true })
-      directories.delete(directory)
-      throw error
-    }
+async function handlePresentOpen(ctx: Context, request: Request): Promise<Response> {
+  const query = new URL(request.url).searchParams
+  const id = query.get('sessionId')
+  const seq = query.get('seq')
+  const index = query.get('index')
+  if (!id || seq === null || index === null || !/^\d+$/.test(seq) || !/^\d+$/.test(index)
+    || !Number.isSafeInteger(Number(seq)) || !Number.isSafeInteger(Number(index))) {
+    return new Response('Invalid Presented file coordinates.', { status: 400 })
   }
-
-  return (ref, signal) => {
-    const task = open(ref, AbortSignal.any([signal, lifetime.signal]))
-    pending.add(task)
-    void task.then(() => { pending.delete(task) }, () => { pending.delete(task) })
-    return task
+  try {
+    request.signal.throwIfAborted()
+    const { session, target } = await ctx.sessionQuery.readEvent({
+      sessionId: id as SessionId, seq: Number(seq) as SessionSeq, before: 0, after: 0,
+    }, request.signal)
+    const file = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined
+    if (!isPresentedFile(file)) return new Response('Presented file not found in this Session result.', { status: 404 })
+    if (session.cwd === undefined) return new Response('Session workspace unavailable.', { status: 404 })
+    const root = await realpath(session.cwd)
+    const path = await realpath(resolve(root, file.path))
+    const within = relative(root, path)
+    if (isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`)) {
+      return new Response('Presented file is outside the workspace.', { status: 403 })
+    }
+    if (!(await stat(path)).isFile()) return new Response('Presented path is not a file.', { status: 404 })
+    request.signal.throwIfAborted()
+    await ctx.sessionController.openWorkspacePath({ path }, request.signal)
+    return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } })
+  } catch (error: unknown) {
+    request.signal.throwIfAborted()
+    const missing = error instanceof Error && 'code' in error
+      && (error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error.code === 'SESSION_QUERY_EVENT_NOT_FOUND'
+        || error.code === 'ENOENT' || error.code === 'ENOTDIR')
+    return new Response('Presented workspace file unavailable.', { status: missing ? 404 : 500 })
   }
 }

+ 10 - 17
packages/client/ui-deliverables/src/presented.ts

@@ -1,45 +1,38 @@
-/** Validate durable delivery references and address their Web downloads. */
+/** Validate declared workspace paths and address their native-open actions. */
 import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 
-/** Authenticated route for saved file bytes. */
-export const PRESENT_DOWNLOAD_PATH = '/api/present.download'
-
-/** Authenticated POST route for opening a saved file on the Host desktop. */
+/** Authenticated POST route for opening a workspace file on the Host desktop. */
 export const PRESENT_OPEN_PATH = '/api/present.open'
 
 /**
- * Validate a saved delivery read from a Session log.
+ * Validate a file declaration read from a Session log.
  * @param value - decoded durable data.
- * @returns whether the reference contains the fields used for display and downloads.
+ * @returns whether the declaration contains a path and optional description.
  */
 export function isPresentedFile(value: unknown): value is PresentedFile {
   if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
-  const { path, name, bytes, attachmentId, description } = value as Record<string, unknown>
+  const { path, description } = value as Record<string, unknown>
   return typeof path === 'string' && path.trim().length > 0
-    && typeof name === 'string' && name.trim().length > 0
-    && typeof attachmentId === 'string' && attachmentId.length > 0
-    && typeof bytes === 'number' && Number.isSafeInteger(bytes) && bytes >= 0
     && (description === undefined || typeof description === 'string')
 }
 
 /**
- * Build authenticated coordinates for a saved delivery.
+ * Build authenticated coordinates for a declared file.
  * @param sessionId - owning Session.
  * @param seq - deliverables/presented event sequence.
  * @param index - original index in the event's files array.
- * @param action - retrieve the bytes or open a copy on the Host desktop.
  * @returns same-origin file action URL.
  */
-export function presentedFileUrl(sessionId: SessionId, seq: number, index: number, action: 'download' | 'open' = 'download'): string {
-  return `${action === 'open' ? PRESENT_OPEN_PATH : PRESENT_DOWNLOAD_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
+export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string {
+  return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
 }
 
 /**
- * Validate a delivery event before reading its turn or saved references.
+ * Validate a delivery event before reading its turn or file declarations.
  * @param value - decoded durable event data.
- * @returns whether the event identifies a turn, call, and file-reference list.
+ * @returns whether the event identifies a turn, call, and file list.
  */
 export function isPresentedData(value: unknown): value is { turn: number; callId: ToolCallId; files: unknown[] } {
   if (typeof value !== 'object' || value === null || Array.isArray(value)) return false

+ 0 - 281
packages/client/ui-deliverables/tests/present-download.host.spec.ts

@@ -1,281 +0,0 @@
-/** Saved Presented file downloads over the real Connection route and local attachment store. */
-import { mkdtemp, rm, readFile, writeFile, access } from 'node:fs/promises'
-import { once } from 'node:events'
-import { createServer } from 'node:http'
-import { tmpdir } from 'node:os'
-import { join, basename, dirname } from 'node:path'
-import { Context } from '@deepseek-ai/cordis'
-import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
-import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
-import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
-import { bridge } from '@deepseek-ai/dsh-client-connection/src/http-bridge.ts'
-import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
-import type { PresentedFile } from '@deepseek-ai/dsh-tool-present/types'
-import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
-import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import { registerPresentDownload } from '../src/present-download.ts'
-import { presentedFileUrl, PRESENT_DOWNLOAD_PATH, PRESENT_OPEN_PATH } from '../src/presented.ts'
-
-const cleanups: Array<() => Promise<unknown>> = []
-afterEach(async () => {
-  vi.restoreAllMocks()
-  for (const cleanup of cleanups.reverse()) await cleanup()
-  cleanups.length = 0
-})
-
-async function fixture() {
-  const root = await mkdtemp(join(tmpdir(), 'dsh-present-download-'))
-  cleanups.push(() => rm(root, { recursive: true, force: true }))
-  const ctx = new Context()
-  cleanups.push(() => ctx.fiber.dispose())
-  await ctx.plugin(LocalAttachmentStore, { dshHome: root })
-  const ref = await ctx.attachments.saveFile({ data: Uint8Array.of(80, 75, 0, 255), name: "日记模板('1').docx" })
-  const artifact: PresentedFile = { ...ref, path: 'deleted/日记模板.docx' }
-  const readEvent = vi.fn(async (request: SessionEventReadRequest) => {
-    if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
-    if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
-    return { target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [artifact] } } as SessionEvent }
-  })
-  ctx.provide('sessionQuery', { readEvent } as never)
-  const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const }))
-  ctx.provide('sessionController', { openWorkspacePath: opener } as never)
-  const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
-  const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'attachments', 'sessionController'], apply: registerPresentDownload })
-  await fiber
-  const fetch = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => connection
-    .createSharedFetchHandler('/api').fetch(new Request(`http://localhost${PRESENT_DOWNLOAD_PATH}${query}`, { signal: signal ?? null }))
-  const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => connection
-    .createSharedFetchHandler('/api').fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }))
-  return { ctx, fiber, artifact, readEvent, fetch, open, opener, handler: connection.createSharedFetchHandler('/api') }
-}
-
-describe('Presented file download route', () => {
-  it('downloads the saved bytes with a Unicode filename without opening the workspace path', async () => {
-    const { fetch, fiber } = await fixture()
-    const response = await fetch()
-    expect(response.status).toBe(200)
-    expect(response.headers.get('content-disposition')).toBe("attachment; filename*=UTF-8''%E6%97%A5%E8%AE%B0%E6%A8%A1%E6%9D%BF%28%271%27%29.docx")
-    expect(response.headers.get('content-length')).toBeNull()
-    expect(response.headers.get('x-content-type-options')).toBe('nosniff')
-    expect(response.headers.get('cache-control')).toBe('no-store')
-    expect(new Uint8Array(await response.arrayBuffer())).toEqual(Uint8Array.of(80, 75, 0, 255))
-    expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_DOWNLOAD_PATH}?sessionId=owner&seq=7&index=0`)
-    await fiber.dispose()
-    expect((await fetch()).status).toBe(404)
-  })
-
-  it.each(['correct', 'smaller', 'zero', 'larger'] as const)('finishes HTTP downloads only after integrity verification: %s length', async (length) => {
-    const { ctx, artifact, handler } = await fixture()
-    const data = new Uint8Array(131072).fill(65)
-    Object.assign(artifact, await ctx.attachments.saveFile({ data, name: 'data.bin' }))
-    if (length !== 'correct') artifact.bytes = length === 'smaller' ? 1 : length === 'zero' ? 0 : data.byteLength + 1
-    const errors: unknown[] = []
-    const requests = new Set<Promise<void>>()
-    const server = createServer((req, res) => {
-      const pending = bridge(req, res, handler).catch((error: unknown) => {
-        errors.push(error)
-        res.destroy()
-      }).finally(() => { requests.delete(pending) })
-      requests.add(pending)
-    })
-    cleanups.push(async () => {
-      await new Promise<void>((resolve, reject) => {
-        server.close((error) => {
-          if (error) reject(error)
-          else resolve()
-        })
-        server.closeAllConnections()
-      })
-      await Promise.all(requests)
-    })
-    server.listen(0, '127.0.0.1')
-    await once(server, 'listening')
-    const address = server.address()
-    if (address === null || typeof address === 'string') throw new Error('Expected a TCP listener')
-    const download = async () => {
-      const response = await fetch(`http://127.0.0.1:${address.port}${PRESENT_DOWNLOAD_PATH}?sessionId=owner&seq=7&index=0`)
-      return new Uint8Array(await response.arrayBuffer())
-    }
-    if (length === 'correct') {
-      expect(await download()).toEqual(data)
-      expect(errors).toEqual([])
-    } else {
-      await expect(download()).rejects.toThrow()
-      expect(errors).toEqual([expect.objectContaining({ code: 'ATTACHMENT_CORRUPT' })])
-    }
-  })
-
-  it.each(['', '?seq=7&index=0', '?sessionId=owner&index=0', '?sessionId=owner&seq=7',
-    '?sessionId=owner&seq=-1&index=0', '?sessionId=owner&seq=7&index=0.1',
-    '?sessionId=owner&seq=9007199254740992&index=0', '?sessionId=owner&seq=7&index=9007199254740992',
-  ])('rejects invalid coordinates before reading: %s', async (query) => {
-    const { fetch, readEvent } = await fixture()
-    expect((await fetch(query)).status).toBe(400)
-    expect(readEvent).not.toHaveBeenCalled()
-  })
-
-  it('refuses unrelated Sessions, absent events, and undeclared Presented file indices', async () => {
-    const { fetch, readEvent } = await fixture()
-    expect((await fetch('?sessionId=other&seq=7&index=0')).status).toBe(404)
-    expect((await fetch('?sessionId=owner&seq=8&index=0')).status).toBe(404)
-    expect((await fetch('?sessionId=owner&seq=7&index=1')).status).toBe(404)
-    readEvent.mockResolvedValueOnce({ target: { type: 'turn/start' } as SessionEvent })
-    expect((await fetch()).status).toBe(404)
-    readEvent.mockResolvedValueOnce({ target: { type: 'tool/result', data: {} } as SessionEvent })
-    expect((await fetch()).status).toBe(404)
-  })
-
-  it('reports an absent snapshot and query failures without leaking Host paths', async () => {
-    const { fetch, artifact, readEvent } = await fixture()
-    artifact.attachmentId = AttachmentId(`sha256:${'0'.repeat(64)}`)
-    expect((await fetch()).status).toBe(404)
-    readEvent.mockRejectedValueOnce(new Error('/private/host/path'))
-    const response = await fetch()
-    expect(response.status).toBe(500)
-    expect(await response.text()).not.toContain('/private/host/path')
-    readEvent.mockRejectedValueOnce(new SessionQueryError('corrupt', 'SESSION_QUERY_CORRUPT_SESSION'))
-    expect((await fetch()).status).toBe(500)
-  })
-
-  it.each([null, { name: 4 }, 'invalid'])('returns 404 for a malformed recorded Presented file: %j', async (artifact) => {
-    const { ctx, fetch, readEvent } = await fixture()
-    const data: unknown = JSON.parse(JSON.stringify({ type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [artifact] } }))
-    readEvent.mockResolvedValueOnce({ target: data as SessionEvent })
-    const read = vi.spyOn(ctx.attachments, 'readFileStream')
-    expect((await fetch()).status).toBe(404)
-    expect(read).not.toHaveBeenCalled()
-  })
-
-  it.each([null, [], 'invalid', {}, { turn: 1, callId: 'call', files: null }])('returns 404 for malformed delivery data: %j', async (data) => {
-    const { ctx, fetch, readEvent } = await fixture()
-    readEvent.mockResolvedValueOnce({ target: { type: 'deliverables/presented', data } as unknown as SessionEvent })
-    const read = vi.spyOn(ctx.attachments, 'readFileStream')
-    expect((await fetch()).status).toBe(404)
-    expect(read).not.toHaveBeenCalled()
-  })
-
-  it('closes the provider iterator on browser cancellation, even before the first browser read', async () => {
-    const { ctx, fetch } = await fixture()
-    const returned = Promise.withResolvers<undefined>()
-    const stop = vi.fn(async () => { returned.resolve(undefined); return { done: true as const, value: undefined } })
-    vi.spyOn(ctx.attachments, 'readFileStream').mockReturnValue({
-      [Symbol.asyncIterator]: () => ({ next: async () => ({ done: false, value: new Uint8Array(65536) }), return: stop }),
-    })
-    const response = await fetch()
-    await response.body!.cancel()
-    await returned.promise
-    expect(stop).toHaveBeenCalledOnce()
-  })
-
-  it('delivers an empty saved file and reports a corrupt snapshot as a server failure', async () => {
-    const { ctx, fetch, artifact } = await fixture()
-    Object.assign(artifact, await ctx.attachments.saveFile({ data: new Uint8Array(), name: 'empty.txt' }))
-    const empty = await fetch()
-    expect(empty.status).toBe(200)
-    expect(await empty.text()).toBe('')
-    vi.spyOn(ctx.attachments, 'readFileStream').mockImplementation(async function* () {
-      throw new AttachmentError('corrupt', 'ATTACHMENT_CORRUPT')
-    })
-    expect((await fetch()).status).toBe(500)
-  })
-
-  it('propagates cancellation while authorizing the download', async () => {
-    const { readEvent, fetch } = await fixture()
-    const controller = new AbortController()
-    controller.abort(new Error('cancelled'))
-    readEvent.mockRejectedValueOnce(controller.signal.reason)
-    await expect(fetch(undefined, controller.signal)).rejects.toThrow('cancelled')
-  })
-
-  it('fails the response stream if stored-byte integrity verification fails', async () => {
-    const { ctx, fetch } = await fixture()
-    vi.spyOn(ctx.attachments, 'readFileStream').mockImplementation(async function* () {
-      yield Uint8Array.of(1)
-      throw new AttachmentError('corrupt', 'ATTACHMENT_CORRUPT')
-    })
-    const response = await fetch()
-    await expect(response.arrayBuffer()).rejects.toThrow('corrupt')
-  })
-})
-
-
-describe('Presented file native open route', () => {
-  it('opens separate verified copies with the original filename and cleans them at disposal', async () => {
-    const { open, fetch, fiber, artifact, handler, opener } = await fixture()
-    expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`))).status).toBe(404)
-    for (let i = 0; i < 2; i++) {
-      const response = await open()
-      expect(response.status).toBe(204)
-      expect(response.headers.get('content-disposition')).toBeNull()
-      const path = opener.mock.calls[i]![0].path
-      expect(basename(path)).toBe(artifact.name)
-      expect(await readFile(path)).toEqual(Buffer.from([80, 75, 0, 255]))
-      await writeFile(path, 'edited by desktop app')
-    }
-    expect(opener.mock.calls[0]![0].path).not.toBe(opener.mock.calls[1]![0].path)
-    expect(new Uint8Array(await (await fetch()).arrayBuffer())).toEqual(Uint8Array.of(80, 75, 0, 255))
-    await fiber.dispose()
-    for (const [{ path }] of opener.mock.calls) await expect(access(dirname(path))).rejects.toMatchObject({ code: 'ENOENT' })
-    expect((await open()).status).toBe(404)
-  })
-
-  it('refuses invalid coordinates, unrelated logs, missing snapshots, and corruption before native launch', async () => {
-    const { open, artifact, readEvent, opener } = await fixture()
-    expect((await open('?sessionId=owner&seq=-1&index=0')).status).toBe(400)
-    expect(readEvent).not.toHaveBeenCalled()
-    expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404)
-    expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404)
-    readEvent.mockResolvedValueOnce({ target: { type: 'turn/start' } as SessionEvent })
-    expect((await open()).status).toBe(404)
-    artifact.bytes = 0
-    expect((await open()).status).toBe(500)
-    artifact.attachmentId = AttachmentId(`sha256:${'0'.repeat(64)}`)
-    expect((await open()).status).toBe(404)
-    expect(opener).not.toHaveBeenCalled()
-  })
-
-  it.each(['../escape.txt', 'folder\\escape.txt', '.', '..', 'bad\0name'])('rejects unsafe durable filenames: %j', async (name) => {
-    const { open, artifact, opener } = await fixture()
-    artifact.name = name
-    expect((await open()).status).toBe(500)
-    expect(opener).not.toHaveBeenCalled()
-  })
-
-  it('reports launcher failure, removes its copy, and allows retry', async () => {
-    const { open, opener } = await fixture()
-    opener.mockRejectedValueOnce(new Error('/private/host/path'))
-    const response = await open()
-    expect(response.status).toBe(500)
-    expect(await response.text()).not.toContain('/private/host/path')
-    await expect(access(dirname(opener.mock.calls[0]![0].path))).rejects.toMatchObject({ code: 'ENOENT' })
-    expect((await open()).status).toBe(204)
-  })
-
-  it('disposal aborts a pending native launch and waits for it before deleting the copy', async () => {
-    const entered = Promise.withResolvers<undefined>()
-    const aborted = Promise.withResolvers<undefined>()
-    const release = Promise.withResolvers<undefined>()
-    const { open, fiber, opener } = await fixture()
-    opener.mockImplementation(async (_request, signal) => {
-      signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
-      entered.resolve(undefined)
-      await release.promise
-      signal.throwIfAborted()
-      return { opened: true }
-    })
-    const request = open()
-    await entered.promise
-    const path = opener.mock.calls[0]![0].path
-    let disposed = false
-    const disposal = fiber.dispose().then(() => { disposed = true })
-    await aborted.promise
-    expect(disposed).toBe(false)
-    await access(path)
-    release.resolve(undefined)
-    await Promise.all([request, disposal])
-    await expect(access(dirname(path))).rejects.toMatchObject({ code: 'ENOENT' })
-  })
-})

+ 178 - 0
packages/client/ui-deliverables/tests/present-open.host.spec.ts

@@ -0,0 +1,178 @@
+/** Native delivery actions resolve the viewed Session's current workspace files. */
+import { mkdtemp, rm, readFile, writeFile, mkdir, realpath, symlink, unlink } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { Context } from '@deepseek-ai/cordis'
+import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
+import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
+import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { registerPresentOpen } from '../src/present-open.ts'
+import { presentedFileUrl, PRESENT_OPEN_PATH } from '../src/presented.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+  vi.restoreAllMocks()
+})
+
+async function fixture() {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-present-open-'))
+  cleanups.push(() => rm(root, { recursive: true, force: true }))
+  const cwd = join(root, 'workspace')
+  await mkdir(cwd)
+  const file = { path: '日记模板.docx' }
+  await writeFile(join(cwd, file.path), Uint8Array.of(80, 75, 0, 255))
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  const session: { cwd?: string } = { cwd }
+  const readEvent = vi.fn(async (request: SessionEventReadRequest) => {
+    if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
+    if (request.seq !== 7) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
+    return { session, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [file] } } as SessionEvent }
+  })
+  ctx.provide('sessionQuery', { readEvent } as never)
+  const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const }))
+  ctx.provide('sessionController', { openWorkspacePath: opener } as never)
+  const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
+  const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController'], apply: registerPresentOpen })
+  await fiber
+  const handler = connection.createSharedFetchHandler('/api')
+  const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request(
+    `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null },
+  ))
+  return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler }
+}
+
+describe('Presented workspace file native open route', () => {
+  it('opens the source itself with current bytes and leaves it intact at disposal', async () => {
+    const { cwd, open, file, fiber, opener, handler, ctx } = await fixture()
+    const source = await realpath(join(cwd, file.path))
+    expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`)
+    expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404)
+    expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404)
+    for (const contents of ['current source', 'edited source']) {
+      await writeFile(source, contents)
+      const response = await open()
+      expect(response.status).toBe(204)
+      expect(response.headers.get('content-disposition')).toBeNull()
+      expect(response.headers.get('cache-control')).toBe('no-store')
+      expect(opener.mock.lastCall?.[0].path).toBe(source)
+      expect(await readFile(opener.mock.lastCall![0].path, 'utf8')).toBe(contents)
+    }
+    expect(ctx.get('attachments')).toBeUndefined()
+    await fiber.dispose()
+    expect(await readFile(source, 'utf8')).toBe('edited source')
+    expect((await open()).status).toBe(404)
+  })
+
+  it('resolves inherited declarations in the viewed fork workspace', async () => {
+    const { root, file, readEvent, session, open, opener } = await fixture()
+    const fork = join(root, 'fork')
+    await mkdir(fork)
+    await writeFile(join(fork, file.path), 'child source')
+    readEvent.mockResolvedValueOnce({ session: { ...session, cwd: fork }, target: { type: 'deliverables/presented', data: { turn: 1, callId: 'inherited', files: [file] } } as SessionEvent })
+    expect((await open('?sessionId=fork&seq=7&index=0')).status).toBe(204)
+    expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(fork, file.path)))
+  })
+
+  it.each(['', '?seq=7&index=0', '?sessionId=owner&index=0', '?sessionId=owner&seq=7',
+    '?sessionId=owner&seq=-1&index=0', '?sessionId=owner&seq=7&index=0.1',
+    '?sessionId=owner&seq=9007199254740992&index=0', '?sessionId=owner&seq=7&index=9007199254740992',
+  ])('rejects invalid coordinates before reading: %s', async (query) => {
+    const { open, readEvent } = await fixture()
+    expect((await open(query)).status).toBe(400)
+    expect(readEvent).not.toHaveBeenCalled()
+  })
+
+  it('refuses unrelated Sessions, absent events, and undeclared file indices', async () => {
+    const { open, readEvent, session, opener } = await fixture()
+    expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404)
+    expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404)
+    expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404)
+    readEvent.mockResolvedValueOnce({ session, target: { type: 'turn/start' } as SessionEvent })
+    expect((await open()).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it.each([null, [], 'invalid', {}, { turn: 1, callId: 'call', files: null },
+    { turn: 1, callId: 'call', files: [null] }, { turn: 1, callId: 'call', files: [{ path: '' }] },
+    { turn: 1, callId: 'call', files: [{ path: 'a', description: 1 }] },
+  ])('refuses malformed recorded delivery data: %j', async (data) => {
+    const { open, readEvent, session, opener } = await fixture()
+    readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent })
+    expect((await open()).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('reports removed files, directories, and absent workspaces without launching', async () => {
+    const { cwd, file, session, open, opener } = await fixture()
+    await unlink(join(cwd, file.path))
+    expect((await open()).status).toBe(404)
+    file.path = '.'
+    expect((await open()).status).toBe(404)
+    delete session.cwd
+    expect((await open()).status).toBe(404)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('refuses traversal and a source replaced by a symlink outside the workspace', async () => {
+    const { root, cwd, file, open, opener } = await fixture()
+    const outside = join(root, 'outside.txt')
+    await writeFile(outside, 'outside')
+    const source = join(cwd, file.path)
+    await unlink(source)
+    await symlink(outside, source)
+    expect((await open()).status).toBe(403)
+    file.path = '../outside.txt'
+    expect((await open()).status).toBe(403)
+    file.path = outside
+    expect((await open()).status).toBe(403)
+    expect(opener).not.toHaveBeenCalled()
+  })
+
+  it('reports query and launcher failures without leaking Host paths and allows retry', async () => {
+    const { open, readEvent, opener } = await fixture()
+    readEvent.mockRejectedValueOnce(new SessionQueryError('corrupt', 'SESSION_QUERY_CORRUPT_SESSION'))
+    expect((await open()).status).toBe(500)
+    opener.mockRejectedValueOnce(new Error('/private/host/path'))
+    const response = await open()
+    expect(response.status).toBe(500)
+    expect(await response.text()).not.toContain('/private/host/path')
+    expect((await open()).status).toBe(204)
+  })
+
+  it('honors cancellation before lookup', async () => {
+    const { open, readEvent } = await fixture()
+    const controller = new AbortController()
+    controller.abort(new Error('cancelled'))
+    await expect(open(undefined, controller.signal)).rejects.toThrow('cancelled')
+    expect(readEvent).not.toHaveBeenCalled()
+  })
+
+  it('disposal aborts and awaits a pending native launch', async () => {
+    const entered = Promise.withResolvers<undefined>()
+    const aborted = Promise.withResolvers<undefined>()
+    const release = Promise.withResolvers<undefined>()
+    const { open, fiber, opener } = await fixture()
+    opener.mockImplementation(async (_request, signal) => {
+      signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true })
+      entered.resolve(undefined)
+      await release.promise
+      signal.throwIfAborted()
+      return { opened: true }
+    })
+    const request = open().catch((error: unknown) => error)
+    await entered.promise
+    let disposed = false
+    const disposal = fiber.dispose().then(() => { disposed = true })
+    await aborted.promise
+    expect(disposed).toBe(false)
+    release.resolve(undefined)
+    await Promise.all([request, disposal])
+  })
+})

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

@@ -379,6 +379,10 @@ describe('produced-file Turn data', () => {
     ))
       .toThrow('deliverables start requires turn/start')
     expect(deliverablesDefinition.update(context, unrelated)).toBe(state)
+    for (const files of [[], [null, { path: '' }]]) {
+      const declaration = matched(at(3, 'deliverables/presented', { turn: 1, callId: 'present', files }), 'update')
+      expect(deliverablesDefinition.update(context, declaration)).toBe(state)
+    }
   })
 
   it('replays a tail page once prepend supplies its missing Turn start', () => {
@@ -541,7 +545,7 @@ describe('plugin registration', () => {
     expect(opened).toEqual(['site/report.html'])
     const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
     vi.stubGlobal('fetch', fetcher)
-    const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', name: 'report.docx', bytes: 4, attachmentId: 'saved' as never, seq: 2, index: 0 }] }, 3)
+    const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', seq: 2, index: 0 }] }, 3)
     service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open()
     expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal })
     const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected
@@ -560,20 +564,20 @@ describe('plugin registration', () => {
 
 
 describe('presented files', () => {
-  const file = (path = 'report.docx') => ({ path, name: path, bytes: 4, attachmentId: 'saved-ref' })
+  const file = (path = 'report.docx') => ({ path })
 
   it('replays deliveries without mutation calls, preserves indices, and isolates turns', () => {
     const value = assembler([
       at(1, 'turn/start', { turn: 1 }),
       at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: [null, { ...file(), description: 'Final report' }] }),
-      at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), attachmentId: 'new-ref' }] }),
+      at(3, 'deliverables/presented', { turn: 1, callId: 'again', files: [{ ...file(), description: 'Updated report' }] }),
       at(4, 'turn/end', { turn: 1 }),
       at(5, 'turn/start', { turn: 2 }),
     ])
     const first = presentedForClosing(tailOwner(deliverablesOf(value), 3))
-    expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, attachmentId: 'saved-ref', description: 'Final report' }])
+    expect(first).toMatchObject([{ path: 'report.docx', seq: 2, index: 1, description: 'Final report' }])
     expect(presentedForClosing(tailOwner(deliverablesOf(value), 4)))
-      .toMatchObject([{ path: 'report.docx', seq: 3, attachmentId: 'new-ref' }])
+      .toMatchObject([{ path: 'report.docx', seq: 3, description: 'Updated report' }])
     expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull()
   })
 
@@ -615,12 +619,12 @@ it.each([null, [], 'invalid', {}, { turn: '1', callId: 'bad', files: [] },
 
 it('shows file metadata and descriptions without hiding extensionless deliveries', () => {
   const view = render(<Deliverables {...openProps()} matched={{ produced: [], presented: [
-    { path: 'out/report.txt', name: 'report.txt', bytes: 4096, description: 'Quarterly summary', attachmentId: 'ref' as never, seq: 2, index: 0 },
-    { path: 'LICENSE', name: 'LICENSE', bytes: 0, attachmentId: 'ref2' as never, seq: 2, index: 1 },
+    { path: 'out/report.txt', description: 'Quarterly summary', seq: 2, index: 0 },
+    { path: 'LICENSE', seq: 2, index: 1 },
   ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   expect(view.getByText('Quarterly summary')).toBeTruthy()
-  expect(view.getByText('TXT · 4.0KB')).toBeTruthy()
-  expect(view.getByText('File · 0B')).toBeTruthy()
+  expect(view.getByText('TXT')).toBeTruthy()
+  expect(view.getByText('File')).toBeTruthy()
   expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app')
 })
 
@@ -630,7 +634,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits
   controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase })
   const props = openProps(controller)
   const view = render(<Deliverables {...props} matched={{ produced: [], presented: [
-    { path: 'report.txt', name: 'report.txt', bytes: 4, attachmentId: 'ref' as never, seq: 2, index: 0 },
+    { path: 'report.txt', seq: 2, index: 0 },
   ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />)
   expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`])
   expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening')

+ 0 - 1
packages/client/ui-deliverables/tests/prompt.host.spec.ts

@@ -18,7 +18,6 @@ describe('ui-deliverables node plugin', () => {
     await ctx.plugin(SystemPrompt, { personaPrefix: '' })
     ctx.provide('connection', { fetch: { register: () => () => {} } } as never)
     ctx.provide('sessionQuery', {} as never)
-    ctx.provide('attachments', {} as never)
     ctx.provide('sessionController', {} as never)
     const mounted = ctx.plugin({ apply, inject })
     await mounted.await()

+ 0 - 3
packages/client/ui-deliverables/tsconfig.client.json

@@ -44,9 +44,6 @@
     {
       "path": "../../core/session"
     },
-    {
-      "path": "../../attachment/attachment"
-    },
     {
       "path": "../../llm/llm"
     },

+ 0 - 4
packages/client/ui-deliverables/tsconfig.host.json

@@ -7,7 +7,6 @@
   },
   "files": [
     "src/index.ts",
-    "src/present-download.ts",
     "src/presented.ts",
     "src/present-open.ts"
   ],
@@ -18,9 +17,6 @@
     {
       "path": "../connection/tsconfig.host.json"
     },
-    {
-      "path": "../../attachment/attachment"
-    },
     {
       "path": "../../core/session"
     },

+ 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: a4f18712e77eda7f4e6a9da358db3916ef5cd815
-README.zh.md: 2b3671ab39eeaf6679e0f0b76560d2c2a2b6b563
+README.md: 6767bb3b8d8839ae776fdf441ef853192c5117a2
+README.zh.md: 8255e7be42de273d58d01ca9b4e108d4c65a585d

+ 14 - 17
packages/fs/tool-present/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Deliver immutable snapshots of workspace files with the present tool; configuration, Session ownership, and download prerequisites."
+description: "Declare workspace files as deliverables with present; configuration, Session ownership, and source-file opening."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Use `present` to deliver final workspace files, including files created through shell commands. Each successful call saves immutable bytes, so users can download the delivered version after source edits or deletion. The calling Session owns the delivery; the Web deliverables plugin supplies download links and cards.
+Use `present` to declare final workspace files, including files created through shell commands. Users open the current source files in their default application. The tool records paths and optional descriptions without copying file contents.
 
 ## Table of Contents
 
@@ -25,23 +25,21 @@ Use `present` to deliver final workspace files, including files created through
 <a id="use-this-package"></a>
 ## Use this package
 
-The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing, oversized, outside-workspace, or concurrently modified files fail the call.
+The `standard`, `ptc`, and `cordis` agent presets mount this plugin. Call `present` with `files: [{ path, description? }]` after creating the files. Files must exist inside the Session workspace and be regular files. Missing files, directories, and paths outside the workspace fail the call.
 
-Mount it in an agent's Cordis composition with `tools`, `fs`, `attachments`, and the `turnBoundary` Session projection available:
+Mount it in an agent's Cordis composition with `tools`, `fs`, and the `turnBoundary` Session projection available:
 
 ```yaml
 - name: '@deepseek-ai/dsh-tool-present'
   config:
-    maxFileBytes: 104857600
     maxFiles: 8
 ```
 
 | Field | Default | Meaning |
 |---|---|---|
-| `maxFileBytes` | `104857600` | Positive per-file byte cap, at most 100 MiB |
 | `maxFiles` | `8` | Positive maximum file count per call |
 
-Limits are validated at mount. The tool requires an agent Session with a workspace and an open turn. Delivery belongs to the calling Session; a parent must call `present` itself to offer its own download links for files created by a subagent.
+The file-count limit is validated at mount. The tool requires an agent Session with a workspace and an open turn. Delivery belongs to the calling Session; a parent must call `present` itself to declare files created by a subagent.
 
 -----
 
@@ -51,11 +49,11 @@ Limits are validated at mount. The tool requires an agent Session with a workspa
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-The tool resolves paths through the configured filesystem provider, checks workspace containment and versions around bounded reads, and saves bytes through the attachment service. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed delivery. Blocked results publish no delivery. Each plugin instance records only snapshots from calls it executed; scoped tools with the same name cannot publish through another instance.
+The tool resolves paths through the configured filesystem provider and checks workspace containment and regular-file metadata without reading contents. Successful final `tools/result` notifications append `deliverables/presented`, including nested calls. A later enclosing program failure does not revoke an already completed declaration. Blocked results publish none. Each plugin instance records only calls it executed; scoped tools with the same name cannot publish through another instance.
 
-The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted references before displaying them or authorizing downloads. The event stores no Session ID, so forked history authorizes downloads through the viewed Session.
+The pure `./types` entry declares `PresentedFile` and the Session event without importing Host runtime code. The Web consumer validates persisted declarations before displaying or opening them. The event stores no Session ID, so forked history resolves relative paths against the viewed Session's workspace.
 
-**Runtime invariant:** No companion is published. Tool and event registrations are effect-owned; the attachment service owns immutable bytes, and the Session log owns delivery references.
+**Runtime invariant:** No companion is published. Tool and event registrations are effect-owned, and the Session log owns file declarations; the plugin maintains no independent file-content store.
 
 </details>
 
@@ -65,9 +63,8 @@ The pure `./types` entry declares `PresentedFile` and the Session event without
 ## Further Exploration
 
 - [Filesystem subsystem](../../../docs/subsystems/filesystem.md) — provider paths and errors.
-- [Attachment service](../../attachment/attachment/README.md) — saved bytes and retention.
-- [Web deliverables](../../client/ui-deliverables/README.md) — authenticated downloads and cards.
-- [Delivery decision](../../../.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md) — Session ownership and required-on-read events.
+- [Web deliverables](../../client/ui-deliverables/README.md) — source-file opening and cards.
+- [Delivery decision](../../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md) — Session ownership and required-on-read events.
 
 <a id="model-experience"></a>
 ## Model Experience
@@ -76,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: “Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool.” Results report `Presented <path> (<bytes> bytes)` for each file; attachment IDs remain in the program result and durable event.
+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.
 
 #### Token effect
 
@@ -90,9 +87,9 @@ The tool schema is static for the mount lifetime. Delivery result text extends t
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- Containment and before/after version checks reject ordinary changes, but the path API cannot atomically defend against malicious swap-and-restore.
-- Files saved before a failed call can remain unreferenced in the attachment store.
-- Session ZIP exports retain delivery references in JSONL and omit delivered bytes. Downloads after transfer require the same snapshots in the serving host's attachment store.
+- Path containment checks are best effort; they cannot atomically defend against a concurrent symlink replacement before the desktop application opens the file.
+- Edits change what opens. Deleted or moved source files cannot be opened from their declarations.
+- Session ZIP exports contain declarations, not file contents. Persistent delivery versions and copy-on-write storage are deferred.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 14 - 17
packages/fs/tool-present/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "通过 present 工具交付工作区文件的不可变快照;配置、Session 归属与下载前提。"
+description: "通过 present 声明交付工作区文件;配置、Session 归属与源文件打开。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-使用 `present` 交付最终工作区文件,包括通过 shell 命令创建的文件。每次成功调用都会保存不可变字节,因此源文件被编辑或删除后,用户仍可下载交付时的版本。交付归调用方 Session 所有;Web 交付插件提供下载链接和卡片
+使用 `present` 声明交付最终工作区文件,包括通过 shell 命令创建的文件。用户使用默认应用打开当前源文件。工具记录路径和可选说明,不复制文件内容
 
 ## 目录
 
@@ -25,23 +25,21 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、超限、位于工作区外或读取期间发生变化时,调用失败。
+`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、为目录或路径位于工作区外时,调用失败。
 
-在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs`、`attachments` 和 `turnBoundary` Session 投影:
+在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs` 和 `turnBoundary` Session 投影:
 
 ```yaml
 - name: '@deepseek-ai/dsh-tool-present'
   config:
-    maxFileBytes: 104857600
     maxFiles: 8
 ```
 
 | 字段 | 默认值 | 含义 |
 |---|---|---|
-| `maxFileBytes` | `104857600` | 每个文件的字节上限,为正整数且不超过 100 MiB |
 | `maxFiles` | `8` | 每次调用的最大文件数,为正整数 |
 
-挂载时校验限。工具要求 Agent Session 具有工作区和已开始的 turn。交付归调用方 Session 所有;父 Session 如需为子 Agent 创建的文件提供自己的下载链接,必须自行调用 `present`。
+挂载时校验文件数量上限。工具要求 Agent Session 具有工作区和已开始的 turn。交付归调用方 Session 所有;父 Session 如需声明交付子 Agent 创建的文件,必须自行调用 `present`。
 
 -----
 
@@ -51,11 +49,11 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-工具通过配置的文件系统提供方解析路径,检查工作区包含关系和有界读取前后的版本,并通过 attachment 服务保存字节。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的交付。被阻止的结果不发布交付。每个插件实例只记录其实际执行调用保存的快照;同名作用域工具不能通过其他实例发布交付。
+工具通过配置的文件系统提供方解析路径,检查工作区包含关系和普通文件元数据,不读取内容。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的声明。被阻止的结果不发布声明。每个插件实例只记录其实际执行的调用;同名作用域工具不能通过其他实例发布交付。
 
-纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或授权下载前校验持久引用。事件不保存 Session ID,因此 fork 历史通过当前查看的 Session 授权下载
+纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或打开文件前校验持久声明。事件不保存 Session ID,因此 fork 历史中的相对路径按当前查看的 Session 工作区解析
 
-**运行时不变式:** 不发布伴生入口。工具与事件注册归 effect 所有;attachment 服务拥有不可变字节,Session 日志拥有交付引用
+**运行时不变式:** 不发布伴生入口。工具与事件注册归 effect 所有,Session 日志拥有文件声明;插件不维护独立的文件内容存储
 
 </details>
 
@@ -65,9 +63,8 @@ kind: "package-reference"
 ## 进一步探索
 
 - [文件系统子系统](../../../docs/subsystems/filesystem.zh.md)——提供方路径与错误。
-- [Attachment 服务](../../attachment/attachment/README.zh.md)——保存的字节与保留策略。
-- [Web 交付](../../client/ui-deliverables/README.zh.md)——认证下载与卡片。
-- [交付决策](../../../.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md)——Session 归属与读取端必须识别的事件。
+- [Web 交付](../../client/ui-deliverables/README.zh.md)——源文件打开与卡片。
+- [交付决策](../../../.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md)——Session 归属与读取端必须识别的事件。
 
 <a id="model-experience"></a>
 ## 模型体验
@@ -76,7 +73,7 @@ kind: "package-reference"
 
 #### 模型看到的内容
 
-[present schema](../../../docs/tool-catalog.zh.md#present)要求已有的工作区文件:“Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool.” 每个文件的结果为 `Presented <path> (<bytes> bytes)`;attachment ID 保留在程序结果和持久事件中
+[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>`;程序结果和持久事件包含路径及可选说明
 
 #### Token 影响
 
@@ -90,9 +87,9 @@ kind: "package-reference"
 
 <a id="known-limitations-and-deferred-work"></a>
 
-- 路径包含关系和读取前后版本校验会拒绝普通变化,但路径 API 无法原子防御恶意替换后复原
-- 失败调用此前保存的文件可能作为无引用对象留在 attachment 存储中
-- Session ZIP 导出在 JSONL 中保留交付引用,不包含交付字节。转移后下载依赖服务主机 attachment 存储中的同一快照
+- 路径包含关系检查是尽力而为的;无法原子防御桌面应用打开文件前发生的并发符号链接替换
+- 编辑会改变打开的内容。源文件删除或移动后,无法通过原声明打开
+- Session ZIP 导出包含声明,不包含文件内容。交付版本持久化和写时复制存储延期实现
 
 <a id="dev-note"></a>
 ### 开发备注

+ 1 - 4
packages/fs/tool-present/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-tool-present",
-  "description": "Explicit immutable file delivery snapshots for the DeepSeek Harness",
+  "description": "Explicit workspace file delivery declarations for the DeepSeek Harness",
   "version": "0.1.3-alpha.2",
   "publishConfig": {
     "access": "public"
@@ -37,7 +37,6 @@
   "peerDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
@@ -47,7 +46,6 @@
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
-    "@deepseek-ai/dsh-attachment": "workspace:^",
     "@deepseek-ai/dsh-fs": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
@@ -55,7 +53,6 @@
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
     "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
-    "@deepseek-ai/dsh-attachment-local": "workspace:^",
     "@deepseek-ai/dsh-fs-local": "workspace:^",
     "@deepseek-ai/dsh-system-prompt": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^"

+ 14 - 30
packages/fs/tool-present/src/index.ts

@@ -1,9 +1,8 @@
-/** Scoped tool that saves immutable file deliveries and records their owning Session. */
+/** Scoped tool that declares workspace file deliveries in their owning Session. */
 import type { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
 import { FsError } from '@deepseek-ai/dsh-fs'
 import { defineTool, type ToolExecution } from '@deepseek-ai/dsh-tools'
-import type {} from '@deepseek-ai/dsh-attachment'
 import type {} from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-session-projection'
 import type { Session } from '@deepseek-ai/dsh-session'
@@ -12,37 +11,33 @@ import type { PresentedFile } from './types.ts'
 /** Stable Loader identity. */
 export const name = 'tool-present'
 
-/** Per-call snapshot limits. */
+/** Per-call delivery limit. */
 export interface Config {
-  /** Inclusive per-file byte cap; at most 100 MiB. */
-  maxFileBytes: number
   /** Maximum number of files in one call. */
   maxFiles: number
 }
 
-/** Validated snapshot limits. */
+/** Validated delivery limit. */
 export const Config: z<Config> = z.object({
-  maxFileBytes: z.number().default(100 * 1024 * 1024),
   maxFiles: z.number().default(8),
 })
 
-/** Services used by the scoped snapshot tool. */
-export const inject = ['tools', 'fs', 'attachments', 'sessionProjections']
+/** Services used by the scoped delivery tool. */
+export const inject = ['tools', 'fs', 'sessionProjections']
 
 /**
  * Register present with durable file references in its tool result.
  * @param ctx - agent-scoped services.
- * @param config - per-file and per-call limits.
+ * @param config - maximum files per call.
  */
 export function apply(ctx: Context, config: Config): void {
-  if (!Number.isSafeInteger(config.maxFileBytes) || config.maxFileBytes < 1 || config.maxFileBytes > 100 * 1024 * 1024
-    || !Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) {
-    throw new Error('present requires positive integer limits; maxFileBytes must not exceed 100 MiB')
+  if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) {
+    throw new Error('present requires a positive integer maxFiles')
   }
   const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
   ctx.tools.register(defineTool({
     name: 'present',
-    description: 'Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool.',
+    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.',
     parameters: {
       files: {
         type: 'array', required: true,
@@ -65,15 +60,14 @@ export function apply(ctx: Context, config: Config): void {
             items: {
               type: 'object', additionalProperties: false,
               properties: {
-                path: { type: 'string', required: true }, name: { type: 'string', required: true },
-                attachmentId: { type: 'string', required: true }, bytes: { type: 'integer', required: true },
+                path: { type: 'string', required: true },
                 description: { type: 'string' },
               },
             },
           },
         },
       },
-      render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path} (${file.bytes} bytes)`).join('\n') }],
+      render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path}`).join('\n') }],
     },
     async execute(args, exec) {
       if (exec.agent === undefined) throw new Error('present requires an agent Session')
@@ -84,7 +78,7 @@ export function apply(ctx: Context, config: Config): void {
       if (cwd === undefined) throw new Error('present requires a workspace')
       const options = { cwd, signal: exec.signal }
       const root = await ctx.fs.resolve('.', options)
-      const admitted = []
+      const files: PresentedFile[] = []
       for (const file of args.files) {
         if (file.path.trim().length === 0) throw new Error('present requires a non-empty file path')
         const target = await ctx.fs.resolve(file.path, options)
@@ -92,19 +86,9 @@ export function apply(ctx: Context, config: Config): void {
         const info = await ctx.fs.stat(target, exec.signal)
         if (info === undefined) throw new FsError(`Cannot present ${file.path}: file not found. Check the path, create the file if needed, and retry.`, 'FS_NOT_FOUND')
         if (info.type !== 'file') throw new Error(`Cannot present ${file.path}: not a regular file`)
-        if (info.size !== undefined && info.size > config.maxFileBytes) throw new FsError(`Cannot present ${file.path}: file exceeds ${config.maxFileBytes} bytes`, 'FS_TOO_LARGE')
-        admitted.push({ file, target, version: info.version })
-      }
-      const files = []
-      for (const { file, target, version } of admitted) {
-        const data = await ctx.fs.readBytes(target, exec.signal, config.maxFileBytes)
-        const after = await ctx.fs.stat(target, exec.signal)
-        if (after?.version !== version) throw new FsError(`Cannot present ${file.path}: file changed while reading; retry.`, 'FS_STALE_VERSION')
-        exec.signal.throwIfAborted()
-        const name = file.path.slice(Math.max(file.path.lastIndexOf('/'), file.path.lastIndexOf('\\')) + 1)
-        const ref = await ctx.attachments.saveFile({ data, name })
-        files.push({ ...file, ...ref })
+        files.push({ ...file })
       }
+      exec.signal.throwIfAborted()
       pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files })
       return { turn: boundary.lastTurn, files }
     },

+ 3 - 4
packages/fs/tool-present/src/types.ts

@@ -1,9 +1,8 @@
 /** Durable file deliveries produced by the present tool. */
-import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment/types'
 import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
 
-/** A saved file and the model-selected path it came from. */
-export interface PresentedFile extends FileAttachmentRef {
+/** A declared workspace file whose current contents remain at its source path. */
+export interface PresentedFile {
   /** Original workspace path. */
   path: string
   /** Optional description supplied by the model. */
@@ -12,7 +11,7 @@ export interface PresentedFile extends FileAttachmentRef {
 
 declare module '@deepseek-ai/dsh-session/types' {
   interface SessionEventMap {
-    /** Saved deliveries from a successful final present result, including nested calls. */
+    /** Declared workspace files from a successful final present result, including nested calls. */
     'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
   }
 }

+ 3 - 10
packages/fs/tool-present/tests/built-errors.e2e.ts

@@ -17,7 +17,7 @@ import SystemPrompt from './packages/core/system-prompt/lib/index.js'
 import ToolRuntime from './packages/core/tools/lib/index.js'
 import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js'
 import * as Present from './packages/fs/tool-present/lib/index.js'
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 const root = await mkdtemp(join(tmpdir(), 'present-built-'))
@@ -27,21 +27,16 @@ try {
   await ctx.plugin(ToolRuntime)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(LocalFileSystem, { cwd: root })
-  ctx.provide('attachments', { saveFile() { throw new Error('must reject before saving') } })
   ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } })
-  await ctx.plugin(Present, { maxFiles: 2, maxFileBytes: 3 })
+  await ctx.plugin(Present, { maxFiles: 2 })
   const scope = ctx.plugin(() => {})
   const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false })
   const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' }
   ctx.agents.register(owner)
-  await writeFile(join(root, 'large'), 'four')
-  await writeFile(join(root, 'changed'), 'a')
-  const read = ctx.fs.readBytes.bind(ctx.fs)
-  ctx.fs.readBytes = async (...args) => { const data = await read(...args); await writeFile(join(root, 'changed'), 'ab'); return data }
   const events = []
   ctx.on('tools/result', (_exec, result) => events.push(result))
   let n = 0
-  for (const [files, code] of [[[{ path: 'missing' }], 'FS_NOT_FOUND'], [[{ path: 'large' }], 'FS_TOO_LARGE'], [[{ path: 'changed' }], 'FS_STALE_VERSION'], [[{ path: 42 }], 'INVALID_ARGS']]) {
+  for (const [files, code] of [[[{ path: 'missing' }], 'FS_NOT_FOUND'], [[{ path: 42 }], 'INVALID_ARGS']]) {
     const result = await ctx.tools.execute({ signal: new AbortController().signal, name: 'present', callId: 'call-' + (++n), arguments: { files }, agent: owner })
     assert.equal(result.isError, true)
     assert.equal(result.error.info.code, code)
@@ -58,8 +53,6 @@ it.skipIf(!existsSync(bundle))('preserves present error codes through built Tool
   const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { cwd: repoRoot, signal })
   expect(stdout.trim().split('\n')).toEqual([
     'built ToolRuntime result preserved FS_NOT_FOUND',
-    'built ToolRuntime result preserved FS_TOO_LARGE',
-    'built ToolRuntime result preserved FS_STALE_VERSION',
     'built ToolRuntime result preserved INVALID_ARGS',
   ])
 })

+ 16 - 32
packages/fs/tool-present/tests/present.spec.ts

@@ -1,12 +1,11 @@
 /** Explicit deliveries commit only after a successful final tool result. */
-import { mkdtemp, rm, writeFile, unlink, symlink } from 'node:fs/promises'
+import { mkdtemp, rm, writeFile, symlink } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
 import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
-import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local'
 import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
 import { createScope, type Scope } from '@deepseek-ai/dsh-scope'
 import { ToolCallId } from '@deepseek-ai/dsh-llm'
@@ -52,7 +51,7 @@ async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
 }
 
 
-async function setup(maxFileBytes = 1024) {
+async function setup() {
   const root = await mkdtemp(join(tmpdir(), 'dsh-present-minimal-'))
   cleanups.push(() => rm(root, { recursive: true, force: true }))
   const ctx = new Context()
@@ -61,10 +60,9 @@ async function setup(maxFileBytes = 1024) {
   await ctx.plugin(ToolRuntime)
   await ctx.plugin(AgentRegistry)
   await ctx.plugin(LocalFileSystem, { cwd: root })
-  await ctx.plugin(LocalAttachmentStore, { dshHome: join(root, 'home') })
   await ctx.plugin(SessionProjectionRegistry)
   ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
-  const fiber = ctx.plugin(Present, { maxFileBytes, maxFiles: 2 })
+  const fiber = ctx.plugin(Present, { maxFiles: 2 })
   await fiber
   const owner = await agent(ctx, root)
   owner.session.append('turn/start', { turn: 1 })
@@ -76,21 +74,20 @@ async function setup(maxFileBytes = 1024) {
 }
 
 describe('present', () => {
-  it('saves binary bytes, records one delivery, and survives source deletion', async () => {
+  it('declares binary files without reading or copying contents, and records one delivery', async () => {
     const { ctx, owner, root, execute, fiber } = await setup()
     const data = Uint8Array.of(80, 75, 0, 255)
     await writeFile(join(root, '报告.docx'), data)
+    const read = vi.spyOn(ctx.fs, 'readBytes')
     const result = await execute([{ path: '报告.docx', description: 'Report' }])
     expect(result.isError).toBe(false)
     if (result.isError) throw new Error('present failed')
     const files = (result.value as unknown as { files: PresentedFile[] }).files
     expect(files).toHaveLength(1)
     expect(owner.session.snapshotEvents().find(event => event.type === 'deliverables/presented')?.data.files).toEqual(files)
-    const file = files[0]!
-    await unlink(join(root, '报告.docx'))
-    const chunks = []
-    for await (const chunk of ctx.attachments.readFileStream(file)) chunks.push(chunk)
-    expect(Buffer.concat(chunks)).toEqual(Buffer.from(data))
+    expect(files).toEqual([{ path: '报告.docx', description: 'Report' }])
+    expect(read).not.toHaveBeenCalled()
+    expect(ctx.get('attachments')).toBeUndefined()
     await fiber.dispose()
     expect(ctx.tools.get('present', owner)).toBeUndefined()
   })
@@ -117,7 +114,7 @@ describe('present', () => {
 
   it('records once when ancestor and agent scopes both mount present', async () => {
     const { owner, root, execute } = await setup()
-    await owner.ctx.plugin(Present, { maxFileBytes: 1024, maxFiles: 2 })
+    await owner.ctx.plugin(Present, { maxFiles: 2 })
     await writeFile(join(root, 'a'), 'a')
     expect((await execute([{ path: 'a' }])).isError).toBe(false)
     const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')
@@ -125,7 +122,7 @@ describe('present', () => {
     expect(deliveries[0]?.data.files[0]?.path).toBe('a')
   })
 
-  it('does not publish deliveries after post-execute blocks a successful snapshot', async () => {
+  it('does not publish deliveries after post-execute blocks a successful declaration', async () => {
     const { ctx, root, owner, execute } = await setup()
     await writeFile(join(root, 'a'), 'a')
     ctx.on('tools/post-execute', async (_exec, _result, next) => {
@@ -136,37 +133,24 @@ describe('present', () => {
     expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
   })
 
-  it('rejects missing, non-file, outside-workspace, empty, and oversized inputs', async () => {
-    const { root, owner, execute } = await setup(3)
+  it('rejects missing, non-file, outside-workspace, empty, and excessive inputs', async () => {
+    const { root, owner, execute } = await setup()
     await writeFile(join(root, 'large'), 'four')
     await symlink(tmpdir(), join(root, 'outside'))
-    for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }]]) {
+    for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ path: 'large' }, { path: 'large' }, { path: 'large' }]]) {
       const result = await execute(files)
       expect(result.isError, JSON.stringify(files)).toBe(true)
     }
     expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
   })
 
-  it('refuses a file changed during the bounded read before saving it', async () => {
-    const { ctx, root, owner, execute } = await setup()
-    await writeFile(join(root, 'a'), 'old')
-    const read = ctx.fs.readBytes.bind(ctx.fs)
-    vi.spyOn(ctx.fs, 'readBytes').mockImplementation(async (...args) => {
-      const data = await read(...args)
-      await writeFile(join(root, 'a'), 'changed')
-      return data
-    })
-    const save = vi.spyOn(ctx.attachments, 'saveFile')
-    expect((await execute([{ path: 'a' }])).isError).toBe(true)
-    expect(save).not.toHaveBeenCalled()
-    expect(owner.session.snapshotEvents().some(event => event.type === 'deliverables/presented')).toBe(false)
-  })
+
 })
 
 
 it('validates deployment limits before registering the tool', () => {
-  for (const config of [{ maxFileBytes: 0, maxFiles: 2 }, { maxFileBytes: 104857601, maxFiles: 2 }, { maxFileBytes: 3, maxFiles: 0 }]) {
-    expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer limits')
+  for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) {
+    expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles')
   }
 })
 

+ 0 - 3
packages/fs/tool-present/tsconfig.json

@@ -17,9 +17,6 @@
     {
       "path": "../../core/agent"
     },
-    {
-      "path": "../../attachment/attachment"
-    },
     {
       "path": "../fs"
     },

+ 0 - 12
pnpm-lock.yaml

@@ -2696,12 +2696,6 @@ importers:
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
-      '@deepseek-ai/dsh-attachment':
-        specifier: workspace:^
-        version: link:../../attachment/attachment
-      '@deepseek-ai/dsh-attachment-local':
-        specifier: workspace:^
-        version: link:../../attachment/attachment-local
       '@deepseek-ai/dsh-client-connection':
         specifier: workspace:^
         version: link:../connection
@@ -6109,12 +6103,6 @@ importers:
       '@deepseek-ai/dsh-agent-loop-testkit':
         specifier: workspace:^
         version: link:../../test-support/agent-loop-testkit
-      '@deepseek-ai/dsh-attachment':
-        specifier: workspace:^
-        version: link:../../attachment/attachment
-      '@deepseek-ai/dsh-attachment-local':
-        specifier: workspace:^
-        version: link:../../attachment/attachment-local
       '@deepseek-ai/dsh-fs':
         specifier: workspace:^
         version: link:../fs

+ 2 - 3
scripts/gen-tool-catalog.ts

@@ -246,14 +246,13 @@ const TOOL_PACKAGES: ToolPackage[] = [
     pkg: '@deepseek-ai/dsh-tool-present',
     dir: 'tool-present',
     source: 'packages/fs/tool-present/src/index.ts',
-    requires: ['ctx.tools', 'ctx.fs', 'ctx.attachments', 'ctx.sessionProjections'],
+    requires: ['ctx.tools', 'ctx.fs', 'ctx.sessionProjections'],
     writes: ['tool/call', 'deliverables/presented after a successful final result', 'tool/result'],
     async mount(ctx) {
       await ctx.plugin(LocalFileSystem)
-      await ctx.plugin(CatalogAttachmentStore)
       await ctx.plugin(ToolPresent)
     },
-    note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies authenticated downloads and cards.',
+    note: 'Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards.',
   },
   {
     pkg: '@deepseek-ai/dsh-tool-pwsh',

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

@@ -522,7 +522,7 @@
     },
     {
       "name": "present",
-      "description": "Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool.",
+      "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.",
       "parameters": {
         "type": "object",
         "properties": {

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

@@ -325,7 +325,7 @@
     },
     {
       "name": "present",
-      "description": "Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool.",
+      "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.",
       "parameters": {
         "type": "object",
         "properties": {

+ 2 - 2
snapshots/web/present/session.v2.jsonl

@@ -18,8 +18,8 @@
 {"type":"tool/code-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"}}}
 {"type":"tool/code-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}}
 {"type":"tool/code-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}}}
-{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","files":[{"path":"report.txt","description":"delivered report","attachmentId":"sha256:7e7bab1fdcf9c16099c5a1ec237feba0d936c8e57cf521cab61b99a51a49c169","name":"report.txt","bytes":17},{"path":"说明.txt","description":"delivered note","attachmentId":"sha256:5e019bfe8e78d6f01f71b6a8ac6477ee8ed3895861259c6565ecd9e736f1d69f","name":"说明.txt","bytes":15}]}}
-{"type":"tool/code-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]},"isError":false,"content":[{"type":"text","text":"Presented report.txt (17 bytes)\nPresented 说明.txt (15 bytes)"}]}}
+{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}}
+{"type":"tool/code-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]},"isError":false,"content":[{"type":"text","text":"Presented report.txt\nPresented 说明.txt"}]}}
 {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GgvYPol45qxGf6GezK3L3590"},"content":[{"type":"tool-result","toolCallId":"call_00_GgvYPol45qxGf6GezK3L3590","content":[{"type":"text","text":"Error: code run failed (exception): AFTER_PRESENT\nCaptured output:\nCaught present error for missing.txt: object ToolCallError: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry.\nbash exit: 0\npresent ok, files: report.txt, 说明.txt"}],"isError":true}],"role":"user","id":"{{message:4}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[13],"surfaceOp":"append"}
 {"type":"step/end","data":{"turn":1,"step":1}}
 {"type":"step/start","data":{"turn":1,"step":2}}

+ 2 - 2
snapshots/web/present/ui.expected.md

@@ -66,12 +66,12 @@
 - paragraph: PRESENT_DONE
 - text: Deliverables
 - button "Open report.txt in default app":
-  - text: report.txt TXT · 17B delivered report
+  - text: report.txt TXT delivered report
   - status: Opened in default app
   - img
   - text: Open
 - button "Open 说明.txt in default app":
-  - text: 说明.txt TXT · 15B delivered note
+  - text: 说明.txt TXT delivered note
   - status: Opened in default app
   - img
   - text: Open

+ 1 - 4
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>;
-  /** Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool. */
+  /** 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. */
   present: {
     files: {
       /** Path of an existing file inside the workspace. */
@@ -412,9 +412,6 @@ interface ToolOutputMap {
     turn: number;
     files: {
       path: string;
-      name: string;
-      attachmentId: string;
-      bytes: number;
       description?: string;
     }[];
   };