1
0
Эх сурвалжийг харах

feat(web): present immutable file deliveries with download cards

yudshj 2 өдөр өмнө
parent
commit
37d27fcdf9
90 өөрчлөгдсөн 2259 нэмэгдсэн , 113 устгасан
  1. 6 0
      .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml
  2. 35 0
      .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md
  3. 35 0
      .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md
  4. 1 0
      apps/cli/package.json
  5. 3 1
      apps/cli/tests/web-agent-presets.e2e.ts
  6. 2 0
      apps/web/tests/live-interactions.e2e.ts
  7. 127 0
      apps/web/tests/present.e2e.ts
  8. 1 0
      apps/web/tests/shipped-composition.e2e.ts
  9. 3 2
      apps/web/tests/turn-tail-actions.e2e.ts
  10. 1 0
      apps/web/tsconfig.json
  11. 2 2
      docs/config-catalog.i18n.yaml
  12. 19 1
      docs/config-catalog.md
  13. 19 1
      docs/config-catalog.zh.md
  14. 2 2
      docs/event-producer-consumer.i18n.yaml
  15. 1 1
      docs/event-producer-consumer.md
  16. 1 1
      docs/event-producer-consumer.zh.md
  17. 2 2
      docs/module-graph.i18n.yaml
  18. 9 0
      docs/module-graph.md
  19. 9 0
      docs/module-graph.zh.md
  20. 2 2
      docs/persistence-catalog.i18n.yaml
  21. 15 0
      docs/persistence-catalog.md
  22. 15 0
      docs/persistence-catalog.zh.md
  23. 2 2
      docs/tool-catalog.i18n.yaml
  24. 44 0
      docs/tool-catalog.md
  25. 44 0
      docs/tool-catalog.zh.md
  26. 1 0
      packages/bundle/base/package.json
  27. 2 2
      packages/client/ui-chat/README.i18n.yaml
  28. 2 0
      packages/client/ui-chat/README.md
  29. 2 0
      packages/client/ui-chat/README.zh.md
  30. 1 1
      packages/client/ui-chat/src/client/apply.ts
  31. 3 2
      packages/client/ui-chat/src/client/contract/slots.ts
  32. 1 1
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  33. 3 1
      packages/client/ui-conversation/src/client/conversation/location-index.ts
  34. 2 2
      packages/client/ui-deliverables/README.i18n.yaml
  35. 12 4
      packages/client/ui-deliverables/README.md
  36. 12 4
      packages/client/ui-deliverables/README.zh.md
  37. 9 2
      packages/client/ui-deliverables/package.json
  38. 13 0
      packages/client/ui-deliverables/src/client/Deliverables.module.css
  39. 51 0
      packages/client/ui-deliverables/src/client/Deliverables.tsx
  40. 6 0
      packages/client/ui-deliverables/src/client/PresentRow.module.css
  41. 45 0
      packages/client/ui-deliverables/src/client/PresentRow.tsx
  42. 23 7
      packages/client/ui-deliverables/src/client/index.ts
  43. 20 0
      packages/client/ui-deliverables/src/client/locales.ts
  44. 30 8
      packages/client/ui-deliverables/src/client/turn-deliverables.ts
  45. 6 3
      packages/client/ui-deliverables/src/index.ts
  46. 72 0
      packages/client/ui-deliverables/src/present-download.ts
  47. 55 0
      packages/client/ui-deliverables/src/presented.ts
  48. 198 0
      packages/client/ui-deliverables/tests/present-download.host.spec.ts
  49. 58 0
      packages/client/ui-deliverables/tests/present-row.client.spec.tsx
  50. 77 4
      packages/client/ui-deliverables/tests/produced-files.client.spec.tsx
  51. 3 0
      packages/client/ui-deliverables/tests/prompt.host.spec.ts
  52. 60 0
      packages/client/ui-deliverables/tsconfig.client.json
  53. 39 0
      packages/client/ui-deliverables/tsconfig.host.json
  54. 3 40
      packages/client/ui-deliverables/tsconfig.json
  55. 1 0
      packages/core/session/src/known-event-types.ts
  56. 1 1
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  57. 8 7
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  58. 2 2
      packages/fs/README.i18n.yaml
  59. 2 1
      packages/fs/README.md
  60. 2 1
      packages/fs/README.zh.md
  61. 6 0
      packages/fs/tool-present/README.i18n.yaml
  62. 105 0
      packages/fs/tool-present/README.md
  63. 105 0
      packages/fs/tool-present/README.zh.md
  64. 63 0
      packages/fs/tool-present/package.json
  65. 121 0
      packages/fs/tool-present/src/index.ts
  66. 18 0
      packages/fs/tool-present/src/types.ts
  67. 65 0
      packages/fs/tool-present/tests/built-errors.e2e.ts
  68. 183 0
      packages/fs/tool-present/tests/present.spec.ts
  69. 39 0
      packages/fs/tool-present/tsconfig.json
  70. 2 2
      packages/preset/agent-presets/README.i18n.yaml
  71. 2 0
      packages/preset/agent-presets/README.md
  72. 2 0
      packages/preset/agent-presets/README.zh.md
  73. 3 0
      packages/preset/agent-presets/presets/cordis/agent.cordis.yml
  74. 3 0
      packages/preset/agent-presets/presets/ptc/agent.cordis.yml
  75. 3 0
      packages/preset/agent-presets/presets/standard/agent.cordis.yml
  76. 76 0
      pnpm-lock.yaml
  77. 1 0
      python/sdk-runtime/package.json
  78. 14 0
      scripts/gen-tool-catalog.ts
  79. 1 0
      scripts/run-gates.ts
  80. 32 0
      snapshots/web/cordis-tool-round/tool-schemas.expected.json
  81. 32 0
      snapshots/web/fresh-round-trip/tool-schemas.expected.json
  82. 13 0
      snapshots/web/present/session.v2.jsonl
  83. 9 0
      snapshots/web/present/snapshot.yml
  84. 109 0
      snapshots/web/present/ui.expected.md
  85. 1 0
      snapshots/web/present/workspace.expected/report.txt
  86. 1 0
      snapshots/web/present/workspace.expected/说明.txt
  87. 19 0
      snapshots/web/ptc-round/system-prompt.expected.md
  88. 2 0
      tsconfig.base.json
  89. 1 1
      tsconfig.client.json
  90. 3 0
      tsconfig.host.json

+ 6 - 0
.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.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-web-explicit-file-delivery.md
+2026-09-08-web-explicit-file-delivery.md: 6ea89c6de392df4b739bb3692a313b53dd22bbe6
+2026-09-08-web-explicit-file-delivery.zh.md: e503bf520a8e08bdd65e1fc3f0f93a527f87ef95

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

@@ -0,0 +1,35 @@
+# Agent Note: Web delivers explicit file snapshots
+
+Status: implemented
+
+English | [中文](2026-09-08-web-explicit-file-delivery.zh.md)
+
+## Problem
+
+Workspace links read live paths, so edits or deletion can invalidate a final deliverable. Files created through shell commands also lack first-party editor mutation records. Delivery needs an explicit operation and saved bytes without expanding Session ZIP exports.
+
+## Decision
+
+The [present tool](../../../../packages/fs/tool-present/README.md) owns execution, immutable snapshots, delivery types, and the durable event. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) owns authenticated downloads and browser rendering, with type-only imports from the tool’s `./types` entry. The `standard`, `ptc`, and `cordis` presets mount the tool package; `minimal` retains its two-tool training configuration. The existing attachment service saves immutable bytes; successful final `tools/result` notifications append `deliverables/presented` to the calling Session. Native and nested calls use the same recorder. A later enclosing program failure does not undo a completed nested delivery. Blocked tool results publish none.
+
+Downloads authorize a reference by the viewed Session, event sequence, and file index. The event stores no Session ID, so forked history uses the child's own log. The existing produced-file row keeps its names and behavior. Session ZIP retains delivery events but does not collect their attachment bytes.
+
+## Alternatives considered
+
+**A Host tool subpath in the UI package** couples preset installation to browser packaging and requires extra published entries. An ordinary tool package preserves shared filesystem and tool error classes through the repository’s peer dependency rules.
+
+**Live workspace links** cannot preserve a delivered version after edits or deletion.
+
+**Generic artifact fields throughout tools, dispatch, and Session** would broaden unrelated APIs for one Web feature. A plugin-owned event uses existing extension points and avoids parent-result forwarding.
+
+**Tool text as the durable index** is unreliable because post-processing and spill can replace ordinary or nested result text. Each plugin instance retains its own completed snapshots by execution identity and publishes them only on a successful final result. Same-name scoped replacements cannot create or duplicate another instance’s delivery records.
+
+**Descriptor-bound filesystem extensions** would change multiple capability providers. This feature uses existing bounded reads with containment and before/after version checks. Those checks reject ordinary concurrent changes but do not guarantee atomic confinement against swap-and-restore; stronger filesystem guarantees belong to the filesystem provider.
+
+## Consequences
+
+The implementation adds no artifact service or attachment format. Unreferenced snapshots can remain after partial failure; attachment retention remains service-owned. A downstream build must understand the new required event to read the log. The generated Session event inventory records that requirement without changing released format generations.
+
+The delivery event is required-on-read because it is the authorization index for saved bytes, not only display metadata. Skipping it would allow an older reader to reconstruct or fork a Session without its completed deliveries. Unsupported readers refuse that loss instead of silently dropping the references.
+
+Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, turn isolation, and fork-addressed links. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, and ZIP exclusion.

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

@@ -0,0 +1,35 @@
+# Agent Note: Web 显式交付文件快照
+
+Status: implemented
+
+[English](2026-09-08-web-explicit-file-delivery.md) | 中文
+
+## 问题
+
+工作区链接读取当前路径,因此编辑或删除会使最终交付文件失效。通过 shell 命令创建的文件也没有第一方编辑器修改记录。交付需要显式操作和保存的字节,同时不扩大 Session ZIP 导出内容。
+
+## 决策
+
+[present 工具](../../../../packages/fs/tool-present/README.zh.md)拥有执行、不可变快照、交付类型和持久事件。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)拥有认证下载和浏览器渲染,仅从工具的 `./types` 入口导入类型。`standard`、`ptc` 与 `cordis` preset 挂载工具包;`minimal` 保留双工具训练配置。现有 attachment 服务保存不可变字节;成功的最终 `tools/result` 通知将 `deliverables/presented` 追加到调用方 Session。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。
+
+下载通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。
+
+## 已考虑的替代方案
+
+**在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。
+
+**实时工作区链接**无法在编辑或删除后保留已交付版本。
+
+**在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。
+
+**将工具文本作为持久索引**并不可靠,因为后处理与 spill 可以替换普通或嵌套结果文本。每个插件实例按执行对象保留自身已完成的快照,仅在最终结果成功时发布。同名作用域替代工具不能创建或重复其他实例的交付记录。
+
+**基于文件描述符的文件系统扩展**会修改多个能力提供方。本功能使用现有有界读取,并检查路径包含关系及读取前后的版本。这些校验会拒绝普通并发变化,但不保证对替换后复原提供原子路径限制;更强的文件系统保证属于文件系统提供方。
+
+## 影响
+
+实现不增加 artifact 服务或 attachment 格式。部分失败后可能留下无引用快照;attachment 保留策略仍由服务拥有。下游构建必须理解新必需事件才能读取日志。生成的 Session 事件清单记录该要求,不修改已发布的格式代际。
+
+交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。
+
+定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、turn 隔离及使用 fork 地址的链接。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载和 ZIP 排除。

+ 1 - 0
apps/cli/package.json

@@ -76,6 +76,7 @@
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
     "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
     "@deepseek-ai/dsh-tool-cordis": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

+ 3 - 1
apps/cli/tests/web-agent-presets.e2e.ts

@@ -63,6 +63,8 @@ async function bootWeb(
     // back on the next run, so a stored document from any other build decides
     // this test's boot. Same reason the settings row above is pinned.
     { id: 'storage-json', config: { root: storageRoot } },
+    // Fixed Session IDs must stay inside this boot's temporary profile root.
+    { id: 'session-persistence-jsonl', config: { root: join(dirname(settingsFile), 'sessions') } },
     // Host rows with side effects outside this process: a bound port, a served
     // asset tree, a telemetry exporter. `api-gateway` and `directory-picker`
     // stay ENABLED on purpose — the api-proxy is the host row that injects
@@ -243,7 +245,7 @@ describe('the shipped Web composition', () => {
       // depend on ripgrep being present on the machine.
       expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
         'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
-        'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
+        'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'present', 'ralph', 'read', 'read_image', 'send_message', 'skill',
         'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
         'workflow', 'write',
       ])

+ 2 - 0
apps/web/tests/live-interactions.e2e.ts

@@ -99,6 +99,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
     }
     scaffold = await launchWebScaffold({
       replayFixture: FIXTURE,
+      // Throughput snapshots need a nonzero interval between replayed chunks.
+      paceMs: 1,
       ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
       ...(overridePath === undefined ? {} : { compareReplaySession: false }),
       ...(retryPolicy === undefined ? {} : { replayRetryPolicy: retryPolicy }),

+ 127 - 0
apps/web/tests/present.e2e.ts

@@ -0,0 +1,127 @@
+/** Recorded delivery, source deletion, reload, and Session ZIP behavior. */
+import { readFile, unlink, mkdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { unzipSync, strFromU8 } from 'fflate'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-tool-present/types'
+import {
+  acknowledgeReloadConnectionLoss, assertFinalWorkspaceSnapshot, captureExpandedTurnProcessAria,
+  compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture,
+  watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage } from './support.ts'
+
+const DIR = fileURLToPath(new URL('../../../snapshots/web/present', import.meta.url))
+const FIXTURE = join(DIR, 'session.v2.jsonl')
+const MODE = webSnapshotMode()
+const PROMPT = 'Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. '
+  + 'Use bash to run exactly `printf "DELIVERED_REPORT\\n" > report.txt; printf "DELIVERED_NOTE\\n" > 说明.txt`. '
+  + 'Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string "AFTER_PRESENT" (not an Error object) from that same run_code program. '
+  + 'Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph.'
+
+describe('web e2e: explicit file delivery', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  let sessionId: SessionId
+  let cwd: string
+  let disposeApproval: (() => void) | undefined
+  const events: SessionEvent[] = []
+
+  beforeAll(async () => {
+    await mkdir(DIR, { recursive: true })
+    scaffold = await launchWebScaffold({
+      agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true,
+      ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
+    })
+    disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
+    scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    disposeApproval?.()
+    await scaffold?.close()
+  })
+
+  it('delivers nested snapshots 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()
+    await input.fill(PROMPT)
+    await input.press('Enter')
+    sessionId = await settled
+    const workspace = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
+    if (workspace === undefined) throw new Error('present Session has no workspace')
+    cwd = workspace
+    if (MODE === 'record') await recordFixture(scaffold, sessionId, FIXTURE)
+    await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
+    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'])
+    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('downloads after source deletion and reload, while Session ZIP contains only references', async () => {
+    await unlink(join(cwd, 'report.txt'))
+    await unlink(join(cwd, '说明.txt'))
+    for (const reload of [false, true]) {
+      if (reload) {
+        const warningStart = tripwire.warnings.length
+        await page.reload({ waitUntil: 'load' })
+        acknowledgeReloadConnectionLoss(tripwire, warningStart)
+        await page.getByText(/^PRESENT_DONE\.?$/).waitFor({ timeout: 30_000 })
+      }
+      const row = page.locator('[data-presented-files-row]')
+      await row.waitFor()
+      expect(await row.getByRole('link').count()).toBe(2)
+      for (const [name, bytes] of [['report.txt', 'DELIVERED_REPORT\n'], ['说明.txt', 'DELIVERED_NOTE\n']]) {
+        const pending = page.waitForEvent('download')
+        await row.getByRole('link', { name: `Download ${name}`, exact: true }).click()
+        const download = await pending
+        expect(download.suggestedFilename()).toBe(name)
+        expect(await download.failure()).toBeNull()
+        expect(await readFile(await download.path(), 'utf8')).toBe(bytes)
+      }
+    }
+    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')
+    if (MODE !== 'record') {
+      const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
+      await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+      await page.locator('[data-turn-process]').click()
+      const failed = page.locator('[data-tool="present"][data-state="error"]')
+      const delivered = page.locator('[data-tool="present"][data-state="ok"]')
+      expect(await failed.count()).toBe(1)
+      expect(await delivered.count()).toBe(1)
+      expect(await failed.innerText()).toContain('Delivery failed')
+      expect(await delivered.innerText()).toContain('Delivered')
+      await page.locator('[data-turn-process]').click()
+      await page.setViewportSize({ width: 480, height: 900 })
+      const row = page.locator('[data-presented-files-row]')
+      await row.scrollIntoViewIfNeeded()
+      for (const card of await row.getByRole('link').all()) {
+        const bounds = await card.boundingBox()
+        expect(bounds).not.toBeNull()
+        expect(bounds!.x).toBeGreaterThanOrEqual(0)
+        expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480)
+      }
+    }
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  })
+})

+ 1 - 0
apps/web/tests/shipped-composition.e2e.ts

@@ -45,6 +45,7 @@ const EXPECTED_TOOLS = [
   'job_list',
   'job_output',
   'list_agents',
+  'present',
   'ralph',
   'read',
   'read_image',

+ 3 - 2
apps/web/tests/turn-tail-actions.e2e.ts

@@ -64,7 +64,8 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
   /** Boot scaffold + page, materializing the sidecar before the replay row installs. */
   async function launch(
     buildOverride?: (sidecarHome: string) => ReplayOverrideDoc,
-    paceMs?: number,
+    // Throughput snapshots require a nonzero interval between replayed chunks.
+    paceMs = 1,
   ): Promise<void> {
     sessionEvents = []
     let overridePath: string | undefined
@@ -80,7 +81,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
           replayFixture: FIXTURE,
           ...(overridePath === undefined ? {} : { replayOverride: overridePath }),
           compareReplaySession: overridePath === undefined,
-          ...(paceMs === undefined ? {} : { paceMs }),
+          paceMs,
         },
     )
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })

+ 1 - 0
apps/web/tsconfig.json

@@ -61,6 +61,7 @@
     "tests/rail-search-expand.e2e.ts",
     "tests/conversation-column-overflow.e2e.ts",
     "tests/ptc-round.e2e.ts",
+    "tests/present.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
     "tests/web-search-round.e2e.ts",

+ 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: cd6449b29479dabfd9f686c7cd33f30c60d14739
-config-catalog.zh.md: 52e563119240e5fbdbffbb2aa0664d4a6abe3f2b
+config-catalog.md: 58825972327e6a473f04b42a4219aaae049cd4ea
+config-catalog.zh.md: 3b41c549315da44ee604e93fbb3a8718039f1ab1

+ 19 - 1
docs/config-catalog.md

@@ -2869,6 +2869,24 @@ export interface Config {
 
 Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts)
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+Requires: `tools` · `fs` · `attachments` · `sessionProjections`
+
+```ts config-catalog
+/** Per-call snapshot limits. */
+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)
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`
@@ -3441,7 +3459,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` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `attachments` ([`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))

+ 19 - 1
docs/config-catalog.zh.md

@@ -2871,6 +2871,24 @@ export interface Config {
 
 来源:[`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/index.ts)
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+依赖: `tools` · `fs` · `attachments` · `sessionProjections`
+
+```ts config-catalog
+/** Per-call snapshot limits. */
+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)
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`
@@ -3443,7 +3461,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`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `attachments`([`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/event-producer-consumer.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/event-producer-consumer.md
-event-producer-consumer.md: d5ee3453e8694de70cfd64e44b3ce724aa76cd86
-event-producer-consumer.zh.md: d857561a7d9dbdf4c4d971a1854d43d3ecc4fd2a
+event-producer-consumer.md: 449b7d8f0fb55515e7f1f028e151ce1b26862e92
+event-producer-consumer.zh.md: 8120df6de6776bce8841f75311294583417532c2

+ 1 - 1
docs/event-producer-consumer.md

@@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 1 - 1
docs/event-producer-consumer.zh.md

@@ -68,7 +68,7 @@
 | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
 | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) |
 | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:181`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/fs/tool-present) |
 | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
 | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules` |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/module-graph.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/module-graph.md
-module-graph.md: 9d229ba5d25fbf9f4763a96667150dd2103d0231
-module-graph.zh.md: 830f777dde991098ad2f4279a879ecf9cb144900
+module-graph.md: 831ff287a8be4e5dc6ae5e12db68f69b11c74f6d
+module-graph.zh.md: 3c9a732a5c7aa8e72925a218a4b42cdf4f868d0e

+ 9 - 0
docs/module-graph.md

@@ -56,6 +56,7 @@ flowchart TD
     pkg_fs_sandbox["fs-sandbox"]
     pkg_tool_fs["tool-fs"]
     pkg_tool_fs_search["tool-fs-search"]
+    pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
   end
   subgraph group_skill["packages/skill"]
@@ -739,6 +740,13 @@ flowchart TD
   pkg_tool_fs_search --> pkg_system_prompt
   pkg_tool_fs_search --> pkg_timeout
   pkg_tool_fs_search --> pkg_tools
+  pkg_tool_present --> pkg_agent
+  pkg_tool_present --> pkg_attachment
+  pkg_tool_present --> pkg_fs
+  pkg_tool_present --> pkg_llm
+  pkg_tool_present --> pkg_session
+  pkg_tool_present --> pkg_session_projection
+  pkg_tool_present --> pkg_tools
   pkg_tool_str_replace_editor --> pkg_fs
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
@@ -1370,6 +1378,7 @@ flowchart TD
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |

+ 9 - 0
docs/module-graph.zh.md

@@ -58,6 +58,7 @@ flowchart TD
     pkg_fs_sandbox["fs-sandbox"]
     pkg_tool_fs["tool-fs"]
     pkg_tool_fs_search["tool-fs-search"]
+    pkg_tool_present["tool-present"]
     pkg_tool_str_replace_editor["tool-str-replace-editor"]
   end
   subgraph group_skill["packages/skill"]
@@ -741,6 +742,13 @@ flowchart TD
   pkg_tool_fs_search --> pkg_system_prompt
   pkg_tool_fs_search --> pkg_timeout
   pkg_tool_fs_search --> pkg_tools
+  pkg_tool_present --> pkg_agent
+  pkg_tool_present --> pkg_attachment
+  pkg_tool_present --> pkg_fs
+  pkg_tool_present --> pkg_llm
+  pkg_tool_present --> pkg_session
+  pkg_tool_present --> pkg_session_projection
+  pkg_tool_present --> pkg_tools
   pkg_tool_str_replace_editor --> pkg_fs
   pkg_tool_str_replace_editor --> pkg_sandbox
   pkg_tool_str_replace_editor --> pkg_sandbox_policy
@@ -1372,6 +1380,7 @@ flowchart TD
 | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
 | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
+| [`tool-present`](../packages/fs/tool-present) | `fs` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
 | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) |
 | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) |
 | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) |

+ 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: 06242185a988d6908be0c66e13a45af4e4974e1c
-persistence-catalog.zh.md: bc6f4f623e60962df7adfd3771d4bf95b1c1ff3b
+persistence-catalog.md: 1749faa99beb39940e2581bc58d0543ea5984fd1
+persistence-catalog.zh.md: b3e13c5f4bb9cf973249eaf7b8cf7d93366be96f

+ 15 - 0
docs/persistence-catalog.md

@@ -396,6 +396,21 @@ Types: [ContentBlock](subsystems/core.md) · [TokenUsage](subsystems/llm-streami
 
 Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts)
 
+### `deliverables/*`
+
+<a id="deliverablespresented--log-only"></a>
+
+#### `deliverables/presented` — log-only
+
+```ts persistence-catalog
+/** Saved deliveries 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)
+
 ### `feedback/*`
 
 <a id="feedbackmessage-delete--log-only"></a>

+ 15 - 0
docs/persistence-catalog.zh.md

@@ -398,6 +398,21 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts)
 
+### `deliverables/*`
+
+<a id="deliverablespresented--log-only"></a>
+
+#### `deliverables/presented` — 仅日志
+
+```ts persistence-catalog
+/** Saved deliveries 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)
+
 ### `feedback/*`
 
 <a id="feedbackmessage-delete--log-only"></a>

+ 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: c85201523e66408e6aa536de1f141df650055265
-tool-catalog.zh.md: ce5f5b57f6ac6f6a3db6a1503967fc7ceb7c48fd
+tool-catalog.md: 5a09b07e0a7a24e654fc45a7a477838ce21f30d7
+tool-catalog.zh.md: 528f3a37680ecb5f9482606c5671c4fb056092f1

+ 44 - 0
docs/tool-catalog.md

@@ -19,6 +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-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. |
@@ -218,6 +219,49 @@ Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/sr
 
 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.
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+### `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.
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "files": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "properties": {
+          "path": {
+            "type": "string",
+            "description": "Path of an existing file inside the workspace."
+          },
+          "description": {
+            "type": "string",
+            "description": "Brief description for the user."
+          }
+        },
+        "required": [
+          "path"
+        ]
+      }
+    }
+  },
+  "required": [
+    "files"
+  ]
+}
+```
+
+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.
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`

+ 44 - 0
docs/tool-catalog.zh.md

@@ -23,6 +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-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 后端,并可覆盖面向模型的环境描述。 |
@@ -222,6 +223,49 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
 
 bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。
 
+<a id="deepseek-aidsh-tool-present"></a>
+
+## `@deepseek-ai/dsh-tool-present`
+
+### `present`
+
+向用户交付最终文件。保存每个已有工作区文件的快照,使其在编辑或删除后仍可下载。调用工具前先创建文件。
+
+```json
+{
+  "type": "object",
+  "properties": {
+    "files": {
+      "type": "array",
+      "items": {
+        "type": "object",
+        "additionalProperties": false,
+        "properties": {
+          "path": {
+            "type": "string",
+            "description": "Path of an existing file inside the workspace."
+          },
+          "description": {
+            "type": "string",
+            "description": "Brief description for the user."
+          }
+        },
+        "required": [
+          "path"
+        ]
+      }
+    }
+  },
+  "required": [
+    "files"
+  ]
+}
+```
+
+来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts)
+
+交付归调用方 Session 所有;Web ui-deliverables 提供认证下载与卡片。
+
 <a id="deepseek-aidsh-tool-pwsh"></a>
 
 ## `@deepseek-ai/dsh-tool-pwsh`

+ 1 - 0
packages/bundle/base/package.json

@@ -95,6 +95,7 @@
     "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-tool-bash": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

+ 2 - 2
packages/client/ui-chat/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-chat/README.md
-README.md: b5a837f927b483a1f72e8282032fce29683bc237
-README.zh.md: f6b8018434251e46875bfd5cc0b0a242cdf2a439
+README.md: 7c9b38a1abc6289c9c0f7b8335d2c4a796e32840
+README.zh.md: 80198a89fea2586d350dfc8bbeaf8a122739122c

+ 2 - 0
packages/client/ui-chat/README.md

@@ -10,6 +10,8 @@ English | [中文](README.zh.md)
 
 Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests.
 
+File-mention providers receive the viewed Session ID with the closing-turn owner, so links into inherited history can address the fork itself.
+
 ## Table of Contents
 
 - [System prompt row](#system-prompt-row)

+ 2 - 0
packages/client/ui-chat/README.zh.md

@@ -10,6 +10,8 @@ kind: "package-reference"
 
 使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript 与 steering 提交会立即显示并保留在原区域,在权威 Session 记录到达时原子地消失,而 queued 提交始终不进入 Chat。本包不组装或修改模型请求。
 
+文件引用提供方同时接收当前查看的 Session ID 与收尾 turn 的属主信息,因此继承历史中的链接可以指向 fork 自身。
+
 ## 目录
 
 - [系统提示词行](#system-prompt-row)

+ 1 - 1
packages/client/ui-chat/src/client/apply.ts

@@ -116,7 +116,7 @@ export function apply(ctx: Context): void {
             chatNode: key => chat.getSnapshot().nodes.source(key),
             chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key),
           },
-          fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner),
+          fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
           // Files open in the right Sidebar, not in a desktop application: the
           // content stays in the product, beside the conversation that produced
           // it. A relative path, or an absolute one inside the session's

+ 3 - 2
packages/client/ui-chat/src/client/contract/slots.ts

@@ -1,6 +1,6 @@
 /** Chat-owned Slot declarations and composed component props. */
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
-import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
+import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type {
   CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
   MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
@@ -53,9 +53,10 @@ export interface ChatFileMentions {
   /**
    * Resolve prose links for one closing Turn.
    * @param owner - closing-Turn identity and file opener.
+   * @param sessionId - viewed Session, including when history is inherited from a fork.
    * @returns link resolver when available.
    */
-  forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined
+  forClosing(owner: TurnTailOwnerProps, sessionId: SessionId): MarkdownFileMentions | undefined
 }
 
 declare module '@deepseek-ai/cordis' {

+ 1 - 1
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -182,7 +182,7 @@ describe('Chat inject API', () => {
     const forClosing = vi.fn(() => mentions)
     b.runtime.ctx.provide('chatFileMentions', { forClosing } as never)
     expect(injected.fileMentions(owner)).toBe(mentions)
-    expect(forClosing).toHaveBeenCalledWith(owner)
+    expect(forClosing).toHaveBeenCalledWith(owner, ROOT)
 
     expect(injected.chatScroll.read()).toBeNull()
     const position = { anchorKey: 'node-1', anchorTop: 4, scrollTop: 12 }

+ 3 - 1
packages/client/ui-conversation/src/client/conversation/location-index.ts

@@ -137,7 +137,9 @@ const SESSION_LOCATION = { kind: 'session' } as const
 const UNRESOLVED_LOCATION = { kind: 'unresolved' } as const
 
 function payloadCoordinates(event: SessionEventLike): Coordinates {
-  const data = event.data as unknown as { turn?: unknown; step?: unknown }
+  const payload: unknown = event.data
+  if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return {}
+  const data = payload as { turn?: unknown; step?: unknown }
   if (data.turn === null) return { session: true }
   const turn = Number.isSafeInteger(data.turn) && (data.turn as number) >= 0
     ? data.turn as number

+ 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: bfd4c40a952122db25172bebb077d12800b49308
-README.zh.md: 23c983f938d2d77fa5f33e8dcd2526282268b835
+README.md: 721d7146e3d1d0499c45c5d768e5bb8e64a1fff5
+README.zh.md: 5922815b7fd558df73a62333dfb39c4b5d24bf82

+ 12 - 4
packages/client/ui-deliverables/README.md

@@ -27,6 +27,13 @@ This package renders the deliverables row a finished turn ends with — the file
 
 Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar as a text-preview tab, with relative paths resolved against the session cwd. The row offers no folder action: the Sidebar has no directory form, so an omitted-file remainder is a label only.
 
+<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 download actions, and matching inline-code references download the same snapshots after source edits, deletion, or reload. Forks download through the viewed Session. Repeated delivery of a path selects its latest successful snapshot 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.
+
 ### The row
 
 The row uses CSS container-width bands to show a responsive prefix of up to six file chips. Flexbox shrinks and ellipsizes basename text, while CSS selects the matching localized `+ N files` label for omitted paths; the full path remains available as the title, and the row performs no JavaScript layout observation or horizontal scrolling.
@@ -43,7 +50,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-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 `ProducedFiles` 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.
+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.
 
 </details>
 
@@ -72,7 +79,7 @@ One fixed paragraph instructs the model to name primary files from successful cr
 
 #### Token effect
 
-One fixed prompt paragraph whenever this package is loaded; no tool schema, tool result, or per-Turn context is added.
+One fixed prompt paragraph whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text.
 
 #### KV Cache effect
 
@@ -86,7 +93,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.
-- **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path.
+- **Terminal-created files require explicit delivery** — call `present` to make their snapshots downloadable.
+- **Transferred Session exports contain no delivered bytes** — download links require the same snapshots in the serving host’s attachment store; missing or pruned snapshots return 404.
 - **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>
@@ -99,4 +107,4 @@ None.
 
 </details>
 
-**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, event definition, and optional service registrations are effect-owned with disposal proven by their plugin specs; this package owns no mutable state.
+**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, download route, 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.

+ 12 - 4
packages/client/ui-deliverables/README.zh.md

@@ -27,6 +27,13 @@ kind: "package-reference"
 
 与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。
 
+<a id="explicit-deliveries"></a>
+### 显式交付
+
+Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有快照创建、限制和 Session 交付记录。收尾 turn 显示响应式文件卡片,包含名称、类型、大小、说明和下载操作,匹配的行内代码引用也下载相同快照;修改或删除源文件、重新加载后仍可下载。Fork 通过当前查看的 Session 下载。同一路径重复交付时,选择收尾回复之前最近一次成功的快照。
+
+`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。
+
 ### 该行
 
 该行通过 CSS 容器宽度档位响应式展示至多六个文件标签项。Flexbox 负责收缩文件名并用 ellipsis 省略,CSS 为未展示路径选择匹配的本地化 `+ N 个文件` 标签;完整路径仍保留在 `title` 中,该行不执行 JavaScript 布局观察,也不提供横向滚动。
@@ -43,7 +50,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把 `ProducedFiles` 注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
+Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
 
 </details>
 
@@ -72,7 +79,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 #### Token 影响
 
-加载本包时增加一段固定提示词;不增加工具 schema、工具结果或按轮次变化的上下文
+加载本包时增加一段固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本
 
 #### KV Cache 影响
 
@@ -86,7 +93,8 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。
 
 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。
-- **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。
+- **终端创建的文件需要显式交付**——调用 `present` 使其快照可供下载。
+- **转移的 Session 导出不含交付字节** — 下载链接依赖服务主机附件存储中的同一快照;快照缺失或被清理时返回 404。
 - **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。
 
 <a id="dev-note"></a>
@@ -99,4 +107,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,
 
 </details>
 
-**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态
+**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、下载路由与可选 service 注册都归 effect 所有,释放由插件测试证明;attachment 服务拥有保存的字节,Session 日志拥有交付引用

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

@@ -33,7 +33,8 @@
         "@deepseek-ai/dsh-client-locale",
         "@deepseek-ai/dsh-client-ui-chat",
         "@deepseek-ai/dsh-client-ui-conversation",
-        "@deepseek-ai/dsh-client-ui-renderer"
+        "@deepseek-ai/dsh-client-ui-renderer",
+        "@deepseek-ai/dsh-client-ui-tool"
       ],
       "platform": "web"
     }
@@ -61,7 +62,13 @@
     "react": "^18.2.0",
     "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
-    "@deepseek-ai/dsh-system-prompt": "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:^"
   },
   "files": [
     "lib/index.js",

+ 13 - 0
packages/client/ui-deliverables/src/client/Deliverables.module.css

@@ -0,0 +1,13 @@
+/** Immutable file deliveries at the end of a turn. */
+.root { display: flex; flex-direction: column; gap: 8px; min-width: 0; margin-top: 12px; }
+.label { font-size: 12px; color: var(--dsw-alias-label-secondary); }
+.presented { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 8px; }
+.file { display: flex; align-items: center; gap: 12px; min-width: 0; padding: 14px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); text-decoration: none; }
+.file:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-border-l3); }
+.file:focus-visible { outline: 2px solid var(--dsw-alias-link); outline-offset: 2px; }
+.fileIcon { flex: 0 0 auto; width: 24px; height: 24px; }
+.details { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
+.fileName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; font-weight: 500; }
+.metadata { color: var(--dsw-alias-label-tertiary); font-size: 11px; }
+.description { color: var(--dsw-alias-label-secondary); font-size: 12px; overflow-wrap: anywhere; }
+.download { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; }

+ 51 - 0
packages/client/ui-deliverables/src/client/Deliverables.tsx

@@ -0,0 +1,51 @@
+/** Existing changed-file chips and explicitly delivered snapshots for a closing turn. */
+import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { LinkIcon, classifyLinkPath, fileSizeText, IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
+import { ProducedFiles } from './ProducedFiles.tsx'
+import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts'
+import type { NS } from './locales.ts'
+import { presentedFileUrl } from '../presented.ts'
+import css from './Deliverables.module.css'
+
+interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] }
+
+/**
+ * Claim turns containing modified paths or presented snapshots.
+ * @param owner - closing turn.
+ * @returns matched files, or null for an empty turn.
+ */
+export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch | null {
+  const produced = selectProducedFiles(owner) ?? []
+  const presented = presentedForClosing(owner)
+  return produced.length + presented.length === 0 ? null : { produced, presented }
+}
+
+/**
+ * Render workspace file actions and immutable snapshot downloads.
+ * @param props - matched files, workspace opener, and localized copy.
+ * @returns the closing turn's file rows.
+ */
+export function Deliverables({ matched, openFile, t, sessionId }: Pick<TurnTailOwnerProps, 'openFile'> & {
+  matched: DeliverablesMatch
+} & PropsLocale<typeof NS> & Pick<SessionStandardProps, 'sessionId'>) {
+  return <>
+    {matched.produced.length > 0 && <ProducedFiles matched={matched.produced} openFile={openFile} t={t} />}
+    {matched.presented.length > 0 && <div className={css.root}>
+      <span className={css.label}>{t('presented.label')}</span>
+      <div className={css.presented} data-presented-files-row>
+        {matched.presented.map(file => <a key={file.path} className={css.file}
+          href={presentedFileUrl(sessionId, file.seq, file.index)} download={file.name}
+          title={file.path} aria-label={t('presented.download', { name: file.path })}>
+          <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>
+            {file.description && <span className={css.description}>{file.description}</span>}
+          </span>
+          <span className={css.download}><IconDownloadOutline16 /><span>{t('presented.action')}</span></span>
+        </a>)}
+      </div>
+    </div>}
+  </>
+}

+ 6 - 0
packages/client/ui-deliverables/src/client/PresentRow.module.css

@@ -0,0 +1,6 @@
+/** Present status remains readable beside long workspace paths. */
+.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
+.summary > :first-child { flex-shrink: 0; }
+.paths { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.output { margin: 8px 0; padding: 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
+.inspect { align-self: flex-start; border: none; padding: 4px 0; background: transparent; color: var(--dsw-alias-link); font: inherit; font-size: 12px; cursor: pointer; }

+ 45 - 0
packages/client/ui-deliverables/src/client/PresentRow.tsx

@@ -0,0 +1,45 @@
+/** Present call status and expandable durable result text. */
+import { useState } from 'react'
+import { DisclosureRow, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
+import type { NS } from './locales.ts'
+import css from './PresentRow.module.css'
+
+type PresentRowProps = ToolCallViewProps & PropsLocale<typeof NS>
+
+/** Raw arguments can be partial while a call is streaming. */
+function fileNames(raw: string): string {
+  let args: unknown
+  try { args = JSON.parse(raw) }
+  catch { return raw } // Truncated tool JSON remains visible until the call completes.
+  if (typeof args !== 'object' || args === null || !('files' in args) || !Array.isArray(args.files)) return raw
+  return args.files.flatMap((file: unknown) =>
+    typeof file === 'object' && file !== null && 'path' in file && typeof file.path === 'string'
+      ? [file.path] : [],
+  ).join(', ')
+}
+
+/**
+ * Render a present call using its recorded arguments and result.
+ * @param props - tool call and localized status copy.
+ * @returns a status row with a result disclosure.
+ */
+export function PresentRow({ block, inspect, t }: PresentRowProps) {
+  const settled = 'kind' in block
+  const state = !settled ? 'running' : block.error?.code === 'interrupted' ? 'stopped' : block.isError ? 'error' : 'ok'
+  const args = (settled ? block.call?.argsRaw : block.argsRaw) ?? ''
+  const output = settled ? block.content.map(item => item.type === 'text' ? item.text : JSON.stringify(item)).join('\n') : ''
+  const details = output || (settled && block.error ? `${block.error.name}: ${block.error.code}` : '')
+  const [expanded, setExpanded] = useState(false)
+  return <div data-tool="present" data-state={state}>
+    <DisclosureRow title={t('row.title')}
+      icon={<StateDot state={state === 'running' ? 'ongoing' : state === 'ok' ? 'done' : state === 'stopped' ? 'warning' : 'error'} />}
+      open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen
+      onToggle={() => { setExpanded(value => !value) }}
+      collapsedContent={<span className={css.summary}><span>{t(`row.${state}`)}</span><span className={css.paths}>{fileNames(args)}</span></span>}>
+      <pre className={css.output}>{details}</pre>
+      {inspect && <button type="button" className={css.inspect} onClick={inspect}>{t('row.inspect')}</button>}
+    </DisclosureRow>
+  </div>
+}

+ 23 - 7
packages/client/ui-deliverables/src/client/index.ts

@@ -13,10 +13,12 @@ import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
-import { ProducedFiles } from './ProducedFiles.tsx'
+import { presentedFileUrl } from '../presented.ts'
+import { PresentRow } from './PresentRow.tsx'
+import { Deliverables, selectDeliverables } from './Deliverables.tsx'
 import { en, NS, zh, type DeliverablesKey } from './locales.ts'
 import {
-  deliverablesDefinition, producedFileMentions, selectProducedFiles,
+  deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles,
 } from './turn-deliverables.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -43,20 +45,34 @@ export function apply(ctx: ClientContext): void {
     'conversation.chat.turnTail',
     () => ctx.slots.register({
       name: 'conversation.chat.turnTail',
-      select: selectProducedFiles,
+      select: selectDeliverables,
       locale: NS,
-    }, ProducedFiles),
+    }, Deliverables),
   )
+  ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
+    { name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow,
+  ))
   // The prose side of the same vocabulary: the chat view reaches this face
   // via ctx.get, so its absence — this plugin composed out — is the off state.
   const t = ctx.locale.bind(NS)
   const mentions: ChatFileMentions = {
-    forClosing(owner) {
+    forClosing(owner, sessionId) {
       // Same claim test the turn-tail chain entry runs: no produced files,
       // no vocabulary — the two surfaces agree by construction.
       const paths = selectProducedFiles(owner)
-      if (paths === null) return undefined
-      return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path }))
+      const presented = presentedForClosing(owner)
+      if (paths === null && presented.length === 0) return undefined
+      const deliveries = new Map(presented.map(file => [file.path, file]))
+      return producedFileMentions([...new Set([...paths ?? [], ...deliveries.keys()])], (path) => {
+        const file = deliveries.get(path)
+        if (file === undefined) owner.openFile(path)
+        else {
+          const link = document.createElement('a')
+          link.href = presentedFileUrl(sessionId, file.seq, file.index)
+          link.download = file.name
+          link.click()
+        }
+      }, path => t(deliveries.has(path) ? 'presented.download' : 'produced.open', { name: path }))
     },
   }
   ctx.provide('chatFileMentions', mentions)

+ 20 - 0
packages/client/ui-deliverables/src/client/locales.ts

@@ -5,6 +5,16 @@ export const NS = 'deliverables'
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
+  'presented.label': '交付文件',
+  'presented.action': '下载',
+  'presented.file': '文件',
+  'row.title': '交付文件',
+  'row.running': '正在交付',
+  'row.ok': '已交付',
+  'row.error': '交付失败',
+  'row.stopped': '已中断',
+  'row.inspect': '查看调用',
+  'presented.download': '下载 {name}',
   'produced.label': '产物',
   'produced.moreOne': '+ 1 个文件',
   'produced.more': '+ {count} 个文件',
@@ -13,6 +23,16 @@ export const zh = {
 
 /** English dictionary (same key set). */
 export const en: Record<DeliverablesKey, string> = {
+  'presented.label': 'Deliverables',
+  'presented.action': 'Download',
+  'presented.file': 'File',
+  'row.title': 'Present files',
+  'row.running': 'Delivering',
+  'row.ok': 'Delivered',
+  'row.error': 'Delivery failed',
+  'row.stopped': 'Interrupted',
+  'row.inspect': 'Inspect call',
+  'presented.download': 'Download {name}',
   'produced.label': 'Produced',
   'produced.moreOne': '+ 1 file',
   'produced.more': '+ {count} files',

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

@@ -7,6 +7,14 @@ import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
 import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
 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. */
+export interface PresentedPath extends PresentedFile {
+  readonly seq: number
+  readonly index: number
+}
 
 interface ProducedPath {
   readonly seq: number
@@ -16,6 +24,7 @@ interface ProducedPath {
 /** Immutable produced-file facts published against one Turn. */
 export interface DeliverablesTurnData {
   readonly produced: readonly ProducedPath[]
+  readonly presented?: readonly PresentedPath[]
 }
 
 declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
@@ -150,6 +159,7 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
   match: (event) => {
     if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' }
     if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' }
+    if (event.type === 'deliverables/presented') return isPresentedData(event.data) ? { id: String(event.data.turn), role: 'update' } : null
     if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) {
       return { id: String(event.data.turn), role: 'update' }
     }
@@ -160,6 +170,12 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     return { turn: match.event.data.turn, calls: new Map(), produced: [] }
   },
   update: (context, match) => {
+    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 }] : [])
+      return { ...context.state, presented: [...context.state.presented ?? [], ...presented] }
+    }
     if (match.event.type === 'tool/call') {
       const calls = new Map(context.state.calls)
       calls.set(
@@ -182,26 +198,32 @@ export const deliverablesDefinition: ConversationNodeDefinition<DeliverablesStat
     if (previous?.kind === 'turn'
       && previous.turn === context.state.turn
       && previous.key === 'deliverables'
-      && previous.value.produced === context.state.produced) return previous
+      && previous.value.produced === context.state.produced
+      && previous.value.presented === context.state.presented) return previous
     return {
       kind: 'turn',
       turn: context.state.turn,
       key: 'deliverables',
-      value: { produced: context.state.produced },
+      value: { produced: context.state.produced, ...context.state.presented === undefined ? {} : { presented: context.state.presented } },
     }
   },
 }
 
 /**
- * Trailing path segment, the part that identifies the file at a glance.
- * @param path - Slash- or backslash-separated path.
- * @returns The final segment, or the whole string when separator-free.
+ * Select the latest saved delivery of each path before the closing reply.
+ * @param owner - closing turn and sequence.
+ * @returns replayable deliveries in first-seen path order.
  */
-export function basename(path: string): string {
-  const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
-  return at === -1 ? path : path.slice(at + 1)
+export function presentedForClosing(owner: TurnTailOwnerProps): PresentedPath[] {
+  const files = new Map<string, PresentedPath>()
+  for (const file of owner.turn.data.get('deliverables')?.presented ?? []) {
+    if (file.seq < owner.seq) files.set(file.path, file)
+  }
+  return [...files.values()]
 }
 
+export { basename } from '../presented.ts'
+
 /**
  * File-mention vocabulary over one turn's produced paths, for the closing
  * message's prose: an inline-code token opens the file it names. A token

+ 6 - 3
packages/client/ui-deliverables/src/index.ts

@@ -1,15 +1,17 @@
 /**
  * Deliverables plugin, node half. Registers the response-format guidance that
- * lets the browser half recognize final-response file references. The browser
+ * lets the browser half recognize final-response file references and serves
+ * authenticated snapshot downloads. 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'
 
-/** Services required for the model guidance paired with the browser renderer. */
-export const inject = ['systemPrompt']
+/** Services required for file-reference guidance and authenticated snapshot downloads. */
+export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'attachments']
 
 /** 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. '
@@ -20,6 +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)
   ctx.systemPrompt.section({
     name: 'ui:deliverable-file-references',
     order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'),

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

@@ -0,0 +1,72 @@
+/** Authorize downloads against the Session log, then stream only the saved Presented file 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 } from './presented.ts'
+import type {} from '@deepseek-ai/dsh-session-query'
+
+/**
+ * Register a streaming download inside Connection's existing authentication fence.
+ * @param ctx - Host services owning Session reads, attachments, and HTTP routing.
+ */
+export function registerPresentDownload(ctx: Context): void {
+  ctx.connection.fetch.register({
+    path: PRESENT_DOWNLOAD_PATH,
+    methods: ['GET'],
+    requestBody: 'buffered',
+    fetch: request => download(ctx, request),
+  })
+}
+
+async function download(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 })
+  }
+  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 })
+    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 })
+  }
+}

+ 55 - 0
packages/client/ui-deliverables/src/presented.ts

@@ -0,0 +1,55 @@
+/** Validate durable delivery references and address their Web downloads. */
+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'
+
+/**
+ * Validate a saved delivery read from a Session log.
+ * @param value - decoded durable data.
+ * @returns whether the reference contains the fields used for display and downloads.
+ */
+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>
+  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 an authenticated download coordinate for a saved delivery.
+ * @param sessionId - owning Session.
+ * @param seq - deliverables/presented event sequence.
+ * @param index - original index in the event's files array.
+ * @returns same-origin download URL.
+ */
+export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string {
+  return `${PRESENT_DOWNLOAD_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}`
+}
+
+/**
+ * Validate a delivery event before reading its turn or saved references.
+ * @param value - decoded durable event data.
+ * @returns whether the event identifies a turn, call, and file-reference 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
+  const { turn, callId, files } = value as Record<string, unknown>
+  return typeof turn === 'number' && Number.isSafeInteger(turn) && turn >= 1
+    && typeof callId === 'string' && callId.length > 0 && Array.isArray(files)
+}
+
+/**
+ * Trailing path segment, the part that identifies the file at a glance.
+ * @param path - Slash- or backslash-separated path.
+ * @returns The final segment, or the whole string when separator-free.
+ */
+export function basename(path: string): string {
+  const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
+  return at === -1 ? path : path.slice(at + 1)
+}

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

@@ -0,0 +1,198 @@
+/** Saved Presented file downloads over the real Connection route and local attachment store. */
+import { mkdtemp, rm } from 'node:fs/promises'
+import { once } from 'node:events'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } 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 } 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 connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
+  const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'attachments'], 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 }))
+  return { ctx, fiber, artifact, readEvent, fetch, 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')
+  })
+})

+ 58 - 0
packages/client/ui-deliverables/tests/present-row.client.spec.tsx

@@ -0,0 +1,58 @@
+// @vitest-environment jsdom
+/** Present UI derives statuses and details from durable tool records. */
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { afterEach, expect, it, vi } from 'vitest'
+import type { RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { PresentRow } from '../src/client/PresentRow.tsx'
+import { en } from '../src/client/locales.ts'
+
+afterEach(cleanup)
+type Props = Parameters<typeof PresentRow>[0]
+const running: RunningToolCall = { callId: 'p', name: 'present', argsRaw: '{"files":[{"path":"report.txt"}]}', turn: 1, step: 1, time: 1, subCalls: [] }
+const settled: ToolResultNode = { kind: 'tool-result', seq: 2, time: 2, callId: 'p', call: { name: 'present', argsRaw: running.argsRaw }, callTime: 1, content: [{ type: 'text', text: 'Presented report.txt (4 bytes)' }], isError: false, subCalls: [] }
+function props(block: Props['block'], inspect?: () => void): Props {
+  return { block, callId: 'p', toolName: 'present', openFile: vi.fn(), inspect, t: makeTranslate(en) } as Props
+}
+
+it('discloses the saved result and offers call inspection', () => {
+  const inspect = vi.fn()
+  const view = render(<PresentRow {...props(settled, inspect)} />)
+  expect(view.getByText('Delivered')).toBeTruthy()
+  expect(view.getByText('report.txt')).toBeTruthy()
+  expect(view.queryByText('Presented report.txt (4 bytes)')).toBeNull()
+  const row = view.getByRole('button')
+  fireEvent.keyDown(row, { key: 'Enter' })
+  expect(view.getByText('Presented report.txt (4 bytes)')).toBeTruthy()
+  fireEvent.click(view.getByRole('button', { name: 'Inspect call' }))
+  expect(inspect).toHaveBeenCalledOnce()
+  fireEvent.click(row)
+  expect(row.getAttribute('aria-expanded')).toBe('false')
+})
+
+it.each([
+  [running, 'running', 'Delivering'],
+  [{ ...settled, isError: true }, 'error', 'Delivery failed'],
+  [{ ...settled, error: { name: 'Interrupted', code: 'interrupted' } }, 'stopped', 'Interrupted'],
+] as const)('renders call lifecycle without claiming failed delivery', (block, state, label) => {
+  const view = render(<PresentRow {...props(block)} />)
+  expect(view.container.querySelector('[data-tool="present"]')?.getAttribute('data-state')).toBe(state)
+  expect(view.getByText(label)).toBeTruthy()
+  expect(view.queryByText('Delivered')).toBeNull()
+})
+
+it.each(['', '{', 'null', '[]', '{"files":null}', '{"files":[null,{},1,{"path":false},{"path":"good.txt"}]}'])('tolerates partial arguments %s', (argsRaw) => {
+  const view = render(<PresentRow {...props({ ...running, argsRaw })} />)
+  expect(view.getByText('Delivering')).toBeTruthy()
+  expect(view.queryByRole('button')).toBeNull()
+})
+
+it('shows orphaned error details and non-text results', () => {
+  const view = render(<PresentRow {...props({ ...settled, call: null, content: [], isError: true, error: { name: 'Missing', code: 'missing' } })} />)
+  fireEvent.click(view.getByRole('button'))
+  expect(view.getByText('Missing: missing')).toBeTruthy()
+  view.rerender(<PresentRow {...props({ ...settled, content: [{ type: 'reasoning', text: 'Recorded detail' }] })} />)
+  expect(view.container.textContent).toContain('Recorded detail')
+  view.rerender(<PresentRow {...props({ ...settled, content: [] })} />)
+  expect(view.queryByRole('button')).toBeNull()
+})

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

@@ -21,13 +21,15 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
 import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
 import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
+import { Deliverables, selectDeliverables } from '../src/client/Deliverables.tsx'
 import { ProducedFiles } from '../src/client/ProducedFiles.tsx'
 import {
-  basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles,
+  basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles,
   type DeliverablesTurnData,
 } from '../src/client/turn-deliverables.ts'
 import { apply, inject } from '../src/client/index.ts'
 import { en, zh } from '../src/client/locales.ts'
+import { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
 
 afterEach(() => {
@@ -494,7 +496,7 @@ describe('plugin registration', () => {
     // The owning view's child declaration, stood up by a bench root entry.
     ctx.slots.register({
       name: 'root',
-      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } },
+      children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, 'tool.call.toolview': { kind: 'keyed', scope: 'session' } },
     } as never, () => null)
     // ui-theme's Appearance row binds a durable scope through these two.
     const session = {
@@ -513,6 +515,7 @@ describe('plugin registration', () => {
     await fiber.await()
     const [entry] = ctx.slots.entries('conversation.chat.turnTail')
     expect(entry).toBeDefined()
+    expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(1)
     // The row needs no injected Host capability: it hands a path to its owner
     // and nothing in it reaches the local machine.
     expect(entry?.inject).toBeUndefined()
@@ -526,15 +529,85 @@ describe('plugin registration', () => {
       (path) => { opened.push(path) },
     )
     const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions')
-    const mentions = service?.forClosing(owner)
+    const mentions = service?.forClosing(owner, SessionId('viewed-session'))
     mentions?.resolve('report.html')?.open()
     expect(opened).toEqual(['site/report.html'])
+    const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) {
+      expect(this.getAttribute('href')).toBe('/api/present.download?sessionId=child-session&seq=2&index=0')
+      expect(this.download).toBe('report.docx')
+    })
+    const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', name: 'report.docx', bytes: 4, attachmentId: 'saved' as never, seq: 2, index: 0 }] }, 3)
+    service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open()
+    expect(click).toHaveBeenCalledOnce()
     // A turn that produced nothing yields no vocabulary at all.
-    expect(service?.forClosing(tailOwner(undefined, 2))).toBeUndefined()
+    expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined()
 
     await fiber.dispose()
     expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0)
+    expect(ctx.slots.entries('tool.call.toolview')).toHaveLength(0)
     // Fiber teardown retracts the service: the consumer's ctx.get sees the off state.
     expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined()
   })
 })
+
+
+describe('presented files', () => {
+  const file = (path = 'report.docx') => ({ path, name: path, bytes: 4, attachmentId: 'saved-ref' })
+
+  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(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(presentedForClosing(tailOwner(deliverablesOf(value), 4)))
+      .toMatchObject([{ path: 'report.docx', seq: 3, attachmentId: 'new-ref' }])
+    expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull()
+  })
+
+  it('uses the viewed fork Session in every download and retains all delivered files', () => {
+    const value = assembler([
+      at(1, 'turn/start', { turn: 1 }),
+      at(2, 'deliverables/presented', { turn: 1, callId: 'nested', files: Array.from({ length: 8 }, (_, i) => file(`report-${i}.docx`)) }),
+    ])
+    const owner = tailOwner(deliverablesOf(value), 3)
+    const matched = selectDeliverables(owner)!
+    const view = render(<Deliverables matched={matched} openFile={owner.openFile} sessionId={SessionId('child-session')} t={makeTranslate(en)} />)
+    expect(view.getAllByRole('link')).toHaveLength(8)
+    expect(view.getAllByRole('link')[0]?.getAttribute('href')).toBe('/api/present.download?sessionId=child-session&seq=2&index=0')
+    expect(view.queryByText('Produced')).toBeNull()
+  })
+})
+
+
+it.each([null, [], 'invalid', {}, { turn: '1', callId: 'bad', files: [] },
+  { turn: 1.5, callId: 'bad', files: [] }, { turn: 0, callId: 'bad', files: [] },
+  { turn: 1, files: [] }, { turn: 1, callId: '', files: [] }, { turn: 1, callId: 'bad', files: null },
+])('ignores malformed delivery data and keeps the existing produced row: %j', (data) => {
+  const value = assembler([
+    at(1, 'turn/start', { turn: 1 }),
+    call(2, 'write-a', 'write', { file_path: 'a.txt', content: 'a' }),
+    result(3, 'write-a'),
+    at(4, 'deliverables/presented', data),
+  ])
+  const owner = tailOwner(deliverablesOf(value), 5)
+  const matched = selectDeliverables(owner)!
+  const view = render(<Deliverables matched={matched} openFile={owner.openFile} sessionId={SessionId('session')} t={makeTranslate(en)} />)
+  expect(view.getByText('Produced')).toBeTruthy()
+  expect(view.queryByText('Deliverables')).toBeNull()
+})
+
+it('shows file metadata and descriptions without hiding extensionless deliveries', () => {
+  const view = render(<Deliverables 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 },
+  ] }} 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.getByRole('link', { name: 'Download out/report.txt' }).getAttribute('title')).toBe('out/report.txt')
+})

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

@@ -16,6 +16,9 @@ describe('ui-deliverables node plugin', () => {
   it('registers final-response file-reference guidance only while mounted', async () => {
     ctx = new Context()
     await ctx.plugin(SystemPrompt, { personaPrefix: '' })
+    ctx.provide('connection', { fetch: { register: () => () => {} } } as never)
+    ctx.provide('sessionQuery', {} as never)
+    ctx.provide('attachments', {} as never)
     const mounted = ctx.plugin({ apply, inject })
     await mounted.await()
 

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

@@ -0,0 +1,60 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+  },
+  "include": [
+    "src/client",
+    "src/presented.ts",
+    "src/css-modules.d.ts"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../api/remotes/tsconfig.client.json"
+    },
+    {
+      "path": "../connection/tsconfig.client.json"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../store"
+    },
+    {
+      "path": "../ui-conversation"
+    },
+    {
+      "path": "../ui-chat"
+    },
+    {
+      "path": "../ui-renderer"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../attachment/attachment"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../ui-tool"
+    },
+    {
+      "path": "../../fs/tool-present"
+    }
+  ]
+}

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

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types",
+    "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+  },
+  "files": [
+    "src/index.ts",
+    "src/present-download.ts",
+    "src/presented.ts"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../connection/tsconfig.host.json"
+    },
+    {
+      "path": "../../attachment/attachment"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../core/system-prompt"
+    },
+    {
+      "path": "../../session-query/session-query"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../fs/tool-present"
+    }
+  ]
+}

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

@@ -1,48 +1,11 @@
 {
-  "extends": "../../../tsconfig.base.client.json",
-  "compilerOptions": {
-    "rootDir": "src",
-    "outDir": "lib/types"
-  },
-  "include": [
-    "src"
-  ],
+  "files": [],
   "references": [
     {
-      "path": "../../api/remotes/tsconfig.client.json"
+      "path": "./tsconfig.host.json"
     },
     {
-      "path": "../../../vendor/cordis"
-    },
-    {
-      "path": "../connection/tsconfig.client.json"
-    },
-    {
-      "path": "../locale"
-    },
-    {
-      "path": "../store"
-    },
-    {
-      "path": "../ui-conversation"
-    },
-    {
-      "path": "../ui-chat"
-    },
-    {
-      "path": "../ui-renderer"
-    },
-    {
-      "path": "../ui-primitives"
-    },
-    {
-      "path": "../ui-slots"
-    },
-    {
-      "path": "../../core/system-prompt"
-    },
-    {
-      "path": "../../core/session"
+      "path": "./tsconfig.client.json"
     }
   ]
 }

+ 1 - 0
packages/core/session/src/known-event-types.ts

@@ -33,6 +33,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
   'compaction/prune',
   'compaction/start',
   'compaction/summary',
+  'deliverables/presented',
   'feedback/message-delete',
   'feedback/message-put',
   'feedback/record',

+ 1 - 1
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -30,7 +30,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
       'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop',
       'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep',
       'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
-      'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph',
+      'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'present', 'pwsh', 'pwsh', 'ralph',
       'read', 'read_image', 'run_code', 'schedule_create', 'schedule_delete',
       'schedule_list', 'send_message', 'send_message', 'session_event_read', 'session_event_search',
       'session_event_trace', 'session_search', 'session_trace', 'skill', 'spawn_teammate',

+ 8 - 7
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -174,7 +174,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:212',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:213',
   },
   {
     key: 'conversation.chat.commandview',
@@ -221,7 +221,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:200',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
   },
   {
     key: 'conversation.chat.node',
@@ -289,7 +289,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:181',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:182',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -332,11 +332,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: '',
     declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted',
     occupants: [
-      'client-ui-deliverables ProducedFiles',
+      'client-ui-deliverables Deliverables',
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:206',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:207',
   },
   {
     key: 'conversation.composer',
@@ -983,7 +983,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:194',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:195',
   },
   {
     key: 'conversation.session',
@@ -2576,11 +2576,12 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
       'useProjection: UseProjection',
       'useTrajectory: UseTrajectory',
     ],
-    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, read, read_image, skill, todo_write, web_fetch, web_search, write',
+    keyDomain: 'open: any string the owner dispatches (no compile-time key set), already taken: ask_user_question, bash, cordis_define, cordis_run, cordis_stop, cordis_undefine, edit, glob, grep, present, read, read_image, skill, todo_write, web_fetch, web_search, write',
     hookContext: '',
     slotInject: '',
     declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-tool), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-deliverables PresentRow key \'present\'',
       'client-ui-skill SkillRow key \'skill\'',
       'client-ui-tool AskQuestionRow key \'ask_user_question\'',
       'client-ui-tool BashRow key \'bash\'',

+ 2 - 2
packages/fs/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/README.md
-README.md: c8cfc5f8d5dd5d225149abbf192d23df1b51cf71
-README.zh.md: 9596f72e76241de43072ee590c141b043af6a861
+README.md: dd3e6f1fab3a8439dd8e2ff15f854e2872549eef
+README.zh.md: 6e0b34b2b9ca7a8076f47c4c3302c67b91e200da

+ 2 - 1
packages/fs/README.md

@@ -22,7 +22,7 @@ The `fs/` group gives agents durable, policy-governed access to files: the `ctx.
 <a id="packages"></a>
 ## Packages
 
-Seven packages plus the remote sibling `fs-e2b` play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy.
+Eight packages plus the remote sibling `fs-e2b` play the filesystem roles; the subsystem reference owns the exhaustive contracts and the error taxonomy.
 
 | Package | Role | ctx key |
 |---|---|---|
@@ -34,6 +34,7 @@ Seven packages plus the remote sibling `fs-e2b` play the filesystem roles; the s
 | [`tool-fs/`](tool-fs/README.md) | Model-facing `read`, `read_image`, `write`, and `edit` tools plus their executor | registers on `ctx.tools` |
 | [`tool-fs-search/`](tool-fs-search/README.md) | Model-facing `glob` and `grep` discovery tools backed by the packaged ripgrep binary | registers on `ctx.tools` |
 | [`tool-str-replace-editor/`](tool-str-replace-editor/README.md) | Standalone `str_replace_editor` tool: `view`, `create`, `str_replace`, and `insert` over `ctx.fs` | registers on `ctx.tools` |
+| [`tool-present/`](tool-present/README.md) | Explicit immutable snapshots of delivered files | registers on `ctx.tools` |
 
 The policy is a plugin, not a service the tools inject: removing it leaves the bare provider's unconditional mutation behavior instead of breaking the tools. The mode fence in `fs-sandbox` and the read-before-edit gate compose. `tool-fs-search` deliberately does not extend the provider contract — search is a process-backed ripgrep workflow, so filesystem backends stay free of a universal search API.
 

+ 2 - 1
packages/fs/README.zh.md

@@ -22,7 +22,7 @@ kind: "package-group"
 <a id="packages"></a>
 ## 包
 
-个包加上远程同级 `fs-e2b` 承担文件系统角色;子系统参考文档拥有穷尽式约定与错误分类体系。
+个包加上远程同级 `fs-e2b` 承担文件系统角色;子系统参考文档拥有穷尽式约定与错误分类体系。
 
 | 包 | 职责 | ctx 键 |
 |---|---|---|
@@ -34,6 +34,7 @@ kind: "package-group"
 | [`tool-fs/`](tool-fs/README.zh.md) | 面向模型的 `read`、`read_image`、`write` 与 `edit` 工具及其执行器 | 注册到 `ctx.tools` |
 | [`tool-fs-search/`](tool-fs-search/README.zh.md) | 由打包 ripgrep 二进制支持的面向模型 `glob` 与 `grep` 发现工具 | 注册到 `ctx.tools` |
 | [`tool-str-replace-editor/`](tool-str-replace-editor/README.zh.md) | 独立的 `str_replace_editor` 工具:基于 `ctx.fs` 的 `view`、`create`、`str_replace` 与 `insert` | 注册到 `ctx.tools` |
+| [`tool-present/`](tool-present/README.zh.md) | 显式保存交付文件的不可变快照 | 注册到 `ctx.tools` |
 
 策略是插件,不是工具注入的服务:移除它只会让工具回到裸提供方的无条件变更行为,而不会破坏工具。`fs-sandbox` 的模式围栏与编辑前读取门禁可以组合。`tool-fs-search` 有意不扩展提供方约定——搜索是由进程支持的 ripgrep 工作流,因此文件系统后端无需承担通用搜索 API。
 

+ 6 - 0
packages/fs/tool-present/README.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 packages/fs/tool-present/README.md
+README.md: a4f18712e77eda7f4e6a9da358db3916ef5cd815
+README.zh.md: 2b3671ab39eeaf6679e0f0b76560d2c2a2b6b563

+ 105 - 0
packages/fs/tool-present/README.md

@@ -0,0 +1,105 @@
+---
+description: "Deliver immutable snapshots of workspace files with the present tool; configuration, Session ownership, and download prerequisites."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-tool-present
+
+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.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<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.
+
+Mount it in an agent's Cordis composition with `tools`, `fs`, `attachments`, 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.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<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 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.
+
+**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.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 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.
+
+<a id="model-experience"></a>
+## Model Experience
+
+### present
+
+#### 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.
+
+#### Token effect
+
+One tool schema per mounted agent and one result line per delivered file. File bytes do not enter model messages.
+
+#### KV Cache effect
+
+The tool schema is static for the mount lifetime. Delivery result text extends the conversation without rewriting its prompt prefix.
+
+## Known Limitations and Deferred Work
+
+<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.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>

+ 105 - 0
packages/fs/tool-present/README.zh.md

@@ -0,0 +1,105 @@
+---
+description: "通过 present 工具交付工作区文件的不可变快照;配置、Session 归属与下载前提。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-tool-present
+
+[English](README.md) | 中文
+
+## 概述
+
+使用 `present` 交付最终工作区文件,包括通过 shell 命令创建的文件。每次成功调用都会保存不可变字节,因此源文件被编辑或删除后,用户仍可下载交付时的版本。交付归调用方 Session 所有;Web 交付插件提供下载链接和卡片。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [理解实现](#understand-the-implementation)
+- [进一步探索](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+`standard`、`ptc` 与 `cordis` Agent preset 挂载本插件。创建文件后,以 `files: [{ path, description? }]` 调用 `present`。文件必须存在于 Session 工作区内,且为普通文件。文件缺失、超限、位于工作区外或读取期间发生变化时,调用失败。
+
+在 Agent 的 Cordis 组合中挂载,并提供 `tools`、`fs`、`attachments` 和 `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`。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 理解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+工具通过配置的文件系统提供方解析路径,检查工作区包含关系和有界读取前后的版本,并通过 attachment 服务保存字节。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的交付。被阻止的结果不发布交付。每个插件实例只记录其实际执行调用保存的快照;同名作用域工具不能通过其他实例发布交付。
+
+纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或授权下载前校验持久引用。事件不保存 Session ID,因此 fork 历史通过当前查看的 Session 授权下载。
+
+**运行时不变式:** 不发布伴生入口。工具与事件注册归 effect 所有;attachment 服务拥有不可变字节,Session 日志拥有交付引用。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 进一步探索
+
+- [文件系统子系统](../../../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 归属与读取端必须识别的事件。
+
+<a id="model-experience"></a>
+## 模型体验
+
+### present
+
+#### 模型看到的内容
+
+[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 保留在程序结果和持久事件中。
+
+#### Token 影响
+
+每个挂载的 Agent 增加一个工具 schema,每个交付文件增加一行结果。文件字节不进入模型消息。
+
+#### KV Cache 影响
+
+工具 schema 在挂载期间保持静态。交付结果文本扩展对话,不重写提示词前缀。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+- 路径包含关系和读取前后版本校验会拒绝普通变化,但路径 API 无法原子防御恶意替换后复原。
+- 失败调用此前保存的文件可能作为无引用对象留在 attachment 存储中。
+- Session ZIP 导出在 JSONL 中保留交付引用,不包含交付字节。转移后下载依赖服务主机 attachment 存储中的同一快照。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者的工作上下文——点击展开</summary>
+
+无。
+
+</details>

+ 63 - 0
packages/fs/tool-present/package.json

@@ -0,0 +1,63 @@
+{
+  "name": "@deepseek-ai/dsh-tool-present",
+  "description": "Explicit immutable file delivery snapshots for the DeepSeek Harness",
+  "version": "0.1.3-alpha.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/fs/tool-present"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "license": "MIT",
+  "files": [
+    "lib/index.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "dependencies": {
+    "@deepseek-ai/schemastery": "workspace:^"
+  },
+  "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:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^"
+  },
+  "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:^",
+    "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@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:^"
+  }
+}

+ 121 - 0
packages/fs/tool-present/src/index.ts

@@ -0,0 +1,121 @@
+/** Scoped tool that saves immutable file deliveries and records 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'
+import type { PresentedFile } from './types.ts'
+
+/** Stable Loader identity. */
+export const name = 'tool-present'
+
+/** Per-call snapshot limits. */
+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. */
+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']
+
+/**
+ * Register present with durable file references in its tool result.
+ * @param ctx - agent-scoped services.
+ * @param config - per-file and per-call limits.
+ */
+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')
+  }
+  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.',
+    parameters: {
+      files: {
+        type: 'array', required: true,
+        items: {
+          type: 'object', additionalProperties: false,
+          properties: {
+            path: { type: 'string', required: true, description: 'Path of an existing file inside the workspace.' },
+            description: { type: 'string', description: 'Brief description for the user.' },
+          },
+        },
+      },
+    },
+    output: {
+      schema: {
+        type: 'object', additionalProperties: false,
+        properties: {
+          turn: { type: 'integer', required: true },
+          files: {
+            type: 'array', required: true,
+            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 },
+                description: { type: 'string' },
+              },
+            },
+          },
+        },
+      },
+      render: (_args, value) => [{ type: 'text', text: value.files.map(file => `Presented ${file.path} (${file.bytes} bytes)`).join('\n') }],
+    },
+    async execute(args, exec) {
+      if (exec.agent === undefined) throw new Error('present requires an agent Session')
+      const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary')
+      if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn')
+      if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`)
+      const cwd = exec.agent.session.header.cwd
+      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 = []
+      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)
+        if (!ctx.fs.contains(root, target)) throw new Error(`Cannot present ${file.path}: outside the workspace`)
+        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 })
+      }
+      pending.set(exec, { session: exec.agent.session, turn: boundary.lastTurn, files })
+      return { turn: boundary.lastTurn, files }
+    },
+  }))
+  ctx.on('tools/result', (exec, result) => {
+    const delivery = pending.get(exec)
+    pending.delete(exec)
+    if (delivery === undefined || result.isError) return
+    const { session, turn, files } = delivery
+    session.append('deliverables/presented', {
+      turn, callId: exec.callId, files,
+    })
+  })
+}

+ 18 - 0
packages/fs/tool-present/src/types.ts

@@ -0,0 +1,18 @@
+/** 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 {
+  /** Original workspace path. */
+  path: string
+  /** Optional description supplied by the model. */
+  description?: string
+}
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    /** Saved deliveries from a successful final present result, including nested calls. */
+    'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] }
+  }
+}

+ 65 - 0
packages/fs/tool-present/tests/built-errors.e2e.ts

@@ -0,0 +1,65 @@
+/** Built tool and runtime bundles must preserve their shared structured error classes. */
+import { execFile } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { promisify } from 'node:util'
+import { expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
+const bundle = fileURLToPath(new URL('../lib/index.js', import.meta.url))
+const execFileAsync = promisify(execFile)
+const probe = String.raw`
+import assert from 'node:assert/strict'
+import { Context } from './vendor/cordis/lib/index.js'
+import AgentRegistry from './packages/core/agent/lib/index.js'
+import LocalFileSystem from './packages/fs/fs-local/lib/index.js'
+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 { tmpdir } from 'node:os'
+import { join } from 'node:path'
+const root = await mkdtemp(join(tmpdir(), 'present-built-'))
+const ctx = new Context()
+try {
+  await ctx.plugin(SystemPrompt)
+  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 })
+  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']]) {
+    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)
+    assert.equal(events.at(-1).error.info.code, code)
+    console.log('built ToolRuntime result preserved ' + code)
+  }
+} finally {
+  try { await ctx.fiber.dispose() }
+  finally { await rm(root, { recursive: true, force: true }) }
+}
+`
+
+it.skipIf(!existsSync(bundle))('preserves present error codes through built ToolRuntime results', { retry: 0 }, async ({ signal }) => {
+  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',
+  ])
+})

+ 183 - 0
packages/fs/tool-present/tests/present.spec.ts

@@ -0,0 +1,183 @@
+/** Explicit deliveries commit only after a successful final tool result. */
+import { mkdtemp, rm, writeFile, unlink, 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'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
+import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
+import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools'
+import type { PresentedFile } from '../src/types.ts'
+import * as Present from '../src/index.ts'
+
+const cleanups: Array<() => Promise<unknown>> = []
+let callNumber = 0
+afterEach(async () => {
+  for (const cleanup of cleanups.reverse()) await cleanup()
+  cleanups.length = 0
+  vi.restoreAllMocks()
+})
+async function agent(ctx: Context, cwd: string | undefined): Promise<Agent> {
+  const id = SessionId(`present-owner-${++callNumber}`)
+  let scope: Scope
+  const session = Session.create(id, [], {
+    version: SESSION_FORMAT_VERSION, id, createdAt: 0, ...cwd === undefined ? {} : { cwd }, isSeeded: false,
+  })
+  const value: Agent = {
+    id,
+    options: {},
+    session,
+    inbox: unsupportedInbox(),
+    status: 'idle',
+    get ctx() { return scope.ctx },
+    send: () => {},
+    followup: () => {},
+    steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
+    inject: () => {},
+    cancel() {},
+    runMaintenance: task => task(new AbortController().signal),
+    whenIdle: () => Promise.resolve(),
+  }
+  await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, value) }, { inject: ['tools'] }))
+  ctx.agents.register(value)
+  return value
+}
+
+
+async function setup(maxFileBytes = 1024) {
+  const root = await mkdtemp(join(tmpdir(), 'dsh-present-minimal-'))
+  cleanups.push(() => rm(root, { recursive: true, force: true }))
+  const ctx = new Context()
+  cleanups.push(() => ctx.fiber.dispose())
+  await ctx.plugin(SystemPrompt)
+  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 })
+  await fiber
+  const owner = await agent(ctx, root)
+  owner.session.append('turn/start', { turn: 1 })
+  const execute = (files: unknown) => ctx.tools.execute({
+    signal: new AbortController().signal, callId: ToolCallId(`call-${++callNumber}`),
+    name: 'present', arguments: { files }, agent: owner,
+  })
+  return { ctx, owner, root, fiber, execute }
+}
+
+describe('present', () => {
+  it('saves binary bytes, records one delivery, and survives source deletion', async () => {
+    const { ctx, owner, root, execute, fiber } = await setup()
+    const data = Uint8Array.of(80, 75, 0, 255)
+    await writeFile(join(root, '报告.docx'), data)
+    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))
+    await fiber.dispose()
+    expect(ctx.tools.get('present', owner)).toBeUndefined()
+  })
+
+  it('ignores a different present definition in the calling agent scope', async () => {
+    const { owner, execute } = await setup()
+    owner.ctx.tools.register(defineTool({
+      name: 'present', description: 'Scoped replacement.', parameters: {},
+      output: {
+        schema: {
+          type: 'object', additionalProperties: false,
+          properties: {
+            turn: { type: 'integer', required: true },
+            files: { type: 'array', required: true, items: { type: 'string' } },
+          },
+        },
+        render: () => [],
+      },
+      execute: async () => ({ turn: 1, files: [] }),
+    }))
+    expect((await execute([])).isError).toBe(false)
+    expect(owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')).toEqual([])
+  })
+
+  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 writeFile(join(root, 'a'), 'a')
+    expect((await execute([{ path: 'a' }])).isError).toBe(false)
+    const deliveries = owner.session.snapshotEvents().filter(event => event.type === 'deliverables/presented')
+    expect(deliveries).toHaveLength(1)
+    expect(deliveries[0]?.data.files[0]?.path).toBe('a')
+  })
+
+  it('does not publish deliveries after post-execute blocks a successful snapshot', async () => {
+    const { ctx, root, owner, execute } = await setup()
+    await writeFile(join(root, 'a'), 'a')
+    ctx.on('tools/post-execute', async (_exec, _result, next) => {
+      await next()
+      return { kind: 'block', feedback: [{ type: 'text', text: 'blocked' }] }
+    })
+    expect((await execute([{ path: 'a' }])).isError).toBe(true)
+    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)
+    await writeFile(join(root, 'large'), 'four')
+    await symlink(tmpdir(), join(root, 'outside'))
+    for (const files of [[], [{ path: '' }], [{ path: 'missing' }], [{ path: '.' }], [{ path: 'outside' }], [{ 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')
+  }
+})
+
+it('requires an agent, an open turn, and a workspace', async () => {
+  const { ctx, owner, execute } = await setup()
+  const detached = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('detached'), name: 'present', arguments: { files: [{ path: 'a' }] } })
+  expect(detached.isError).toBe(true)
+  owner.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  expect((await execute([{ path: 'a' }])).isError).toBe(true)
+  const noWorkspace = await agent(ctx, undefined)
+  noWorkspace.session.append('turn/start', { turn: 1 })
+  const absent = await ctx.tools.execute({ signal: new AbortController().signal, callId: ToolCallId('no-workspace'), name: 'present', arguments: { files: [{ path: 'a' }] }, agent: noWorkspace })
+  expect(absent.isError).toBe(true)
+})

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

@@ -0,0 +1,39 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../../vendor/schemastery"
+    },
+    {
+      "path": "../../core/agent"
+    },
+    {
+      "path": "../../attachment/attachment"
+    },
+    {
+      "path": "../fs"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../session/session-projection"
+    },
+    {
+      "path": "../../core/tools"
+    }
+  ]
+}

+ 2 - 2
packages/preset/agent-presets/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/preset/agent-presets/README.md
-README.md: bb9f59d11adc40ee7b428beb3d929829d88ff282
-README.zh.md: 13d30f9b7544f5d42523c1a8560cabf64165cabf
+README.md: 6ef3374a5f1ae6b83005073f80d63d156ee92f9c
+README.zh.md: a1be6d634e592126711befad4ffcb06ca0bdc26c

+ 2 - 0
packages/preset/agent-presets/README.md

@@ -27,6 +27,8 @@ Use `dsh-agent-presets` to give each session the tools, prompt sections, and ski
 
 Mount this package in a composition that should give each agent session its own tools, prompt sections, and skills from a preset file. Every session names a preset — explicitly or through the configured default — and is composed from it; without the package, sessions fall back to whatever the host composition mounts.
 
+The shipped Web `standard`, `ptc`, and `cordis` presets include [explicit file delivery](../../client/ui-deliverables/README.md#explicit-deliveries). The `minimal` preset keeps its fixed two-tool training configuration.
+
 ### What a preset gives a session
 
 A session composed from a preset runs the plugins that preset's `agent.cordis.yml` names: its tools, prompt sections, and skills. Sessions joined to the same preset share one installed composition, and each session's state stays separate. A child agent (subagent) joins its parent's composition, so it sees the same tools and prompt sections as the agent that spawned it.

+ 2 - 0
packages/preset/agent-presets/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 在需要让每个 agent 会话从 preset 文件获得自己的工具、提示词段落与 skill 的组装中挂载本包。每个会话都会命名一个 preset——显式指定或通过配置的默认值——并据此组装;没有本包时,会话只能回退到宿主组装挂载的内容。
 
+随附 Web 的 `standard`、`ptc` 与 `cordis` preset 包含[显式文件交付](../../client/ui-deliverables/README.zh.md#explicit-deliveries)。`minimal` preset 保留固定的双工具训练配置。
+
 ### preset 给会话带来什么
 
 从 preset 组装的会话会运行该 preset `agent.cordis.yml` 所列插件:它的工具、提示词段落与 skill。加入同一 preset 的会话共享一份已安装的组装,且各会话的状态彼此隔离。子 agent(subagent)会加入其父方的组装,因此它看到的工具与提示词段落和创建它的 agent 相同。

+ 3 - 0
packages/preset/agent-presets/presets/cordis/agent.cordis.yml

@@ -261,3 +261,6 @@
 
 - id: tool-skill
   name: '@deepseek-ai/dsh-tool-skill'
+
+- id: present
+  name: '@deepseek-ai/dsh-tool-present'

+ 3 - 0
packages/preset/agent-presets/presets/ptc/agent.cordis.yml

@@ -270,3 +270,6 @@
   name: '@deepseek-ai/dsh-agent-tool-presentation'
   config:
     mode: ptc
+
+- id: present
+  name: '@deepseek-ai/dsh-tool-present'

+ 3 - 0
packages/preset/agent-presets/presets/standard/agent.cordis.yml

@@ -250,3 +250,6 @@
   config:
     fetch: true
     searchTimeoutMs: 60000
+
+- id: present
+  name: '@deepseek-ai/dsh-tool-present'

+ 76 - 0
pnpm-lock.yaml

@@ -292,6 +292,9 @@ importers:
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../packages/jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-present
       '@deepseek-ai/dsh-tool-pwsh':
         specifier: workspace:^
         version: link:../../packages/shell/tool-pwsh
@@ -1524,6 +1527,9 @@ importers:
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../fs/tool-present
       '@deepseek-ai/dsh-tool-pwsh':
         specifier: workspace:^
         version: link:../../shell/tool-pwsh
@@ -2687,6 +2693,12 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
+      '@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
@@ -2714,12 +2726,24 @@ importers:
       '@deepseek-ai/dsh-client-ui-slots':
         specifier: workspace:^
         version: link:../ui-slots
+      '@deepseek-ai/dsh-client-ui-tool':
+        specifier: workspace:^
+        version: link:../ui-tool
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-query':
+        specifier: workspace:^
+        version: link:../../session-query/session-query
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../fs/tool-present
       '@types/react':
         specifier: ~18.3.1
         version: 18.3.31
@@ -6064,6 +6088,55 @@ importers:
         specifier: workspace:^
         version: link:../../core/tools
 
+  packages/fs/tool-present:
+    dependencies:
+      '@deepseek-ai/schemastery':
+        specifier: link:../../../vendor/schemastery
+        version: link:../../../vendor/schemastery
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-agent':
+        specifier: workspace:^
+        version: link:../../core/agent
+      '@deepseek-ai/dsh-agent-loop':
+        specifier: workspace:^
+        version: link:../../core/agent-loop
+      '@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
+      '@deepseek-ai/dsh-fs-local':
+        specifier: workspace:^
+        version: link:../fs-local
+      '@deepseek-ai/dsh-llm':
+        specifier: workspace:^
+        version: link:../../llm/llm
+      '@deepseek-ai/dsh-scope':
+        specifier: workspace:^
+        version: link:../../core/scope
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session/session-projection
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+
   packages/fs/tool-str-replace-editor:
     dependencies:
       '@deepseek-ai/schemastery':
@@ -11007,6 +11080,9 @@ importers:
       '@deepseek-ai/dsh-tool-jobs':
         specifier: workspace:^
         version: link:../../packages/jobs/tool-jobs
+      '@deepseek-ai/dsh-tool-present':
+        specifier: workspace:^
+        version: link:../../packages/fs/tool-present
       '@deepseek-ai/dsh-tool-pwsh':
         specifier: workspace:^
         version: link:../../packages/shell/tool-pwsh

+ 1 - 0
python/sdk-runtime/package.json

@@ -102,6 +102,7 @@
     "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^",
     "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
     "@deepseek-ai/dsh-tool-cordis": "workspace:^",
+    "@deepseek-ai/dsh-tool-present": "workspace:^",
     "@deepseek-ai/dsh-tool-fs": "workspace:^",
     "@deepseek-ai/dsh-tool-fs-search": "workspace:^",
     "@deepseek-ai/dsh-tool-goal": "workspace:^",

+ 14 - 0
scripts/gen-tool-catalog.ts

@@ -46,6 +46,7 @@ import * as ToolBashPersistent from '@deepseek-ai/dsh-tool-bash-persistent'
 import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent'
 import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner'
 import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
+import * as ToolPresent from '@deepseek-ai/dsh-tool-present'
 import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
 import * as ToolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
 import * as ToolStrReplaceEditor from '@deepseek-ai/dsh-tool-str-replace-editor'
@@ -241,6 +242,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
     note:
       '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.',
   },
+  {
+    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'],
+    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.',
+  },
   {
     pkg: '@deepseek-ai/dsh-tool-pwsh',
     dir: 'tool-pwsh',

+ 1 - 0
scripts/run-gates.ts

@@ -787,6 +787,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
     'apps/cli/tests/built-bin.e2e.ts',
     'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
     'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
+    'packages/fs/tool-present/tests/built-errors.e2e.ts',
     'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
     'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
     'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',

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

@@ -520,6 +520,38 @@
         }
       }
     },
+    {
+      "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.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "files": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "path": {
+                  "type": "string",
+                  "description": "Path of an existing file inside the workspace."
+                },
+                "description": {
+                  "type": "string",
+                  "description": "Brief description for the user."
+                }
+              },
+              "required": [
+                "path"
+              ]
+            }
+          }
+        },
+        "required": [
+          "files"
+        ]
+      }
+    },
     {
       "name": "ralph",
       "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",

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

@@ -323,6 +323,38 @@
         }
       }
     },
+    {
+      "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.",
+      "parameters": {
+        "type": "object",
+        "properties": {
+          "files": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "additionalProperties": false,
+              "properties": {
+                "path": {
+                  "type": "string",
+                  "description": "Path of an existing file inside the workspace."
+                },
+                "description": {
+                  "type": "string",
+                  "description": "Brief description for the user."
+                }
+              },
+              "required": [
+                "path"
+              ]
+            }
+          }
+        },
+        "required": [
+          "files"
+        ]
+      }
+    },
     {
       "name": "ralph",
       "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 13 - 0
snapshots/web/present/session.v2.jsonl


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

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

+ 109 - 0
snapshots/web/present/ui.expected.md

@@ -0,0 +1,109 @@
+- banner:
+  - navigation "Session hierarchy":
+    - button "Use one run_code program to" [disabled]
+  - img
+  - text: PTC mode
+  - button "Session log":
+    - text: Session log
+    - img
+  - button "Open the sidebar":
+    - img
+  - tablist:
+    - tab "Chat" [selected]
+    - tab "Trajectory"
+- button "System prompt":
+  - img
+  - img
+  - text: System prompt
+- text: "Use one run_code program to do the following in order. Call present for missing.txt and catch its error without creating that file. Use bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`. Call present for report.txt and 说明.txt. After present succeeds, deliberately throw the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program. Do not retry the program or create any other files. Finish by mentioning `report.txt` and `说明.txt` in inline code, and put PRESENT_DONE in a separate paragraph. {{clock}}"
+- button "Copy":
+  - img
+- button "1 tool call" [expanded]:
+  - text: 1 tool call
+  - img
+- button "Context injection @deepseek-ai/dsh-system-prompt":
+  - img
+  - img
+  - text: Context injection @deepseek-ai/dsh-system-prompt
+- button "Think The user wants one run_code program that:":
+  - img
+  - img
+  - text: "Think The user wants one run_code program that:"
+- text: Failed
+- 'button "Code Error: code run failed (exception): AFTER_PRESENT"':
+  - img
+  - text: "Code Error: code run failed (exception): AFTER_PRESENT"
+- button "Present files Delivery failed missing.txt":
+  - img
+  - text: Present files Delivery failed missing.txt
+- button "Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files":
+  - img
+  - img
+  - text: Bash Write DELIVERED_REPORT and DELIVERED_NOTE to files
+- button "Present files Delivered report.txt, 说明.txt":
+  - img
+  - text: Present files Delivered report.txt, 说明.txt
+- button "Think The program ran as intended:":
+  - img
+  - img
+  - text: "Think The program ran as intended:"
+- paragraph:
+  - text: "The single program ran exactly as ordered:"
+  - code: present
+  - text: for
+  - code: missing.txt
+  - text: failed and was caught (no file created), bash ran the exact command successfully, then
+  - code: present
+  - text: succeeded for
+  - code:
+    - button "Download report.txt": report.txt
+  - text: and
+  - code:
+    - button "Download 说明.txt": 说明.txt
+  - text: ", after which the program deliberately threw the string"
+  - code: AFTER_PRESENT
+  - text: — no retries, no extra files.
+- paragraph: PRESENT_DONE
+- text: Deliverables
+- link "Download report.txt":
+  - /url: /api/present.download?sessionId=session-{{uuid}}&seq=19&index=0
+  - text: report.txt TXT · 17B delivered report
+  - img
+  - text: Download
+- link "Download 说明.txt":
+  - /url: /api/present.download?sessionId=session-{{uuid}}&seq=19&index=1
+  - text: 说明.txt TXT · 15B delivered note
+  - img
+  - text: Download
+- button "Copy":
+  - img
+- button "Good response":
+  - img
+- button "Bad response":
+  - img
+- button "Branch into a new conversation":
+  - img
+- button "Usage 19K tok":
+  - img
+  - text: Usage 19K tok
+- button "Ran for {{duration}}":
+  - img
+  - text: Ran for {{duration}}
+- text: {{clock}}
+- textbox "Message or run a task, / commands, @ files or sessions"
+- button "Commands":
+  - img
+- button "Add attachment":
+  - img
+- 'button "Access mode, current: Workspace Write"': Workspace Write
+- button "Select model, current DeepSeek-V4-Flash":
+  - text: DeepSeek-V4-Flash
+  - img
+- button "8% of context used"
+- button "Send message" [disabled]
+- button "1 turns 2 steps · {{throughput}} tok/s":
+  - img
+  - text: 1 turns 2 steps{{throughput}} tok/s
+- button "19K tok · Cache hit 94%":
+  - img
+  - text: 19K tokCache hit 94%

+ 1 - 0
snapshots/web/present/workspace.expected/report.txt

@@ -0,0 +1 @@
+DELIVERED_REPORT

+ 1 - 0
snapshots/web/present/workspace.expected/说明.txt

@@ -0,0 +1 @@
+DELIVERED_NOTE

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

@@ -162,6 +162,15 @@ 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. */
+  present: {
+    files: {
+      /** Path of an existing file inside the workspace. */
+      path: string;
+      /** Brief description for the user. */
+      description?: string;
+    }[];
+  } & Record<string, JsonValue>;
   /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */
   ralph: {
     /** The immutable completion objective for every fresh Ralph round. */
@@ -399,6 +408,16 @@ interface ToolOutputMap {
     parent?: string;
     depth?: number;
   })[];
+  present: {
+    turn: number;
+    files: {
+      path: string;
+      name: string;
+      attachmentId: string;
+      bytes: number;
+      description?: string;
+    }[];
+  };
   ralph: {
     runId: string;
     agentsStarted: number;

+ 2 - 0
tsconfig.base.json

@@ -195,6 +195,7 @@
       "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"],
       "@deepseek-ai/dsh-client-ui-conversation/client": ["./packages/client/ui-conversation/src/client/index.ts"],
       "@deepseek-ai/dsh-client-ui-tool": ["./packages/client/ui-tool/src"],
+      "@deepseek-ai/dsh-tool-present/types": ["./packages/fs/tool-present/src/types.ts"],
       "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"],
       "@deepseek-ai/dsh-client-ui-workflow-run": ["./packages/client/ui-workflow-run/src"],
       "@deepseek-ai/dsh-client-ui-input-trigger": ["./packages/client/ui-input-trigger/src"],
@@ -417,6 +418,7 @@
       "@deepseek-ai/dsh-tool-goal": ["./packages/goal/tool-goal/src"],
       "@deepseek-ai/dsh-tool-jobs": ["./packages/jobs/tool-jobs/src"],
       "@deepseek-ai/dsh-tool-lsp": ["./packages/lsp/tool-lsp/src"],
+      "@deepseek-ai/dsh-tool-present": ["./packages/fs/tool-present/src"],
       "@deepseek-ai/dsh-tool-pwsh-persistent": ["./packages/shell/tool-pwsh-persistent/src"],
       "@deepseek-ai/dsh-tool-ralph": ["./packages/workflow/tool-ralph/src"],
       "@deepseek-ai/dsh-tool-session-query": ["./packages/session-query/tool-session-query/src"],

+ 1 - 1
tsconfig.client.json

@@ -75,7 +75,7 @@
     { "path": "./packages/client/ui-chat" },
     { "path": "./packages/client/ui-conversation" },
     { "path": "./packages/client/ui-tool" },
-    { "path": "./packages/client/ui-deliverables" },
+    { "path": "./packages/client/ui-deliverables/tsconfig.client.json" },
     { "path": "./packages/client/ui-workflow-run" },
     { "path": "./packages/client/ui-workspace" },
     { "path": "./packages/client/ui-input-trigger" },

+ 3 - 0
tsconfig.host.json

@@ -49,6 +49,7 @@
     "apps/web/tests/rail-search-expand.e2e.ts",
     "apps/web/tests/conversation-column-overflow.e2e.ts",
     "apps/web/tests/ptc-round.e2e.ts",
+    "apps/web/tests/present.e2e.ts",
     "apps/web/tests/composer-draft-scroll.e2e.ts",
     "apps/web/tests/cordis-tool-round.e2e.ts",
     "apps/web/tests/web-search-round.e2e.ts",
@@ -129,6 +130,7 @@
     "scripts/client-bundle-purity.spec.ts"
   ],
   "references": [
+    { "path": "./packages/client/ui-deliverables/tsconfig.host.json" },
     { "path": "./vendor/cosmokit" },
     { "path": "./vendor/schemastery" },
     { "path": "./vendor/cordis" },
@@ -267,6 +269,7 @@
     { "path": "./packages/fs/fs-observation-policy" },
     { "path": "./packages/fs/fs-sandbox" },
     { "path": "./packages/fs/tool-fs" },
+    { "path": "./packages/fs/tool-present" },
     { "path": "./packages/fs/tool-fs-search" },
     { "path": "./packages/fs/tool-str-replace-editor" },
     { "path": "./packages/compaction/compaction" },

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно