From 37d27fcdf970c890c50283bf5c1bd54e8bbf491a Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 8 Sep 2026 12:49:39 +0800 Subject: [PATCH 1/8] feat(web): present immutable file deliveries with download cards --- ...09-08-web-explicit-file-delivery.i18n.yaml | 6 + .../2026-09-08-web-explicit-file-delivery.md | 35 ++++ ...026-09-08-web-explicit-file-delivery.zh.md | 35 ++++ apps/cli/package.json | 1 + apps/cli/tests/web-agent-presets.e2e.ts | 4 +- apps/web/tests/live-interactions.e2e.ts | 2 + apps/web/tests/present.e2e.ts | 127 +++++++++++ apps/web/tests/shipped-composition.e2e.ts | 1 + apps/web/tests/turn-tail-actions.e2e.ts | 5 +- apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 20 +- docs/config-catalog.zh.md | 20 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 + docs/module-graph.zh.md | 9 + docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 15 ++ docs/persistence-catalog.zh.md | 15 ++ docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 44 ++++ docs/tool-catalog.zh.md | 44 ++++ packages/bundle/base/package.json | 1 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + packages/client/ui-chat/src/client/apply.ts | 2 +- .../ui-chat/src/client/contract/slots.ts | 5 +- .../tests/apply-inject.client.spec.tsx | 2 +- .../src/client/conversation/location-index.ts | 4 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 16 +- packages/client/ui-deliverables/README.zh.md | 16 +- packages/client/ui-deliverables/package.json | 11 +- .../src/client/Deliverables.module.css | 13 ++ .../src/client/Deliverables.tsx | 51 +++++ .../src/client/PresentRow.module.css | 6 + .../ui-deliverables/src/client/PresentRow.tsx | 45 ++++ .../ui-deliverables/src/client/index.ts | 30 ++- .../ui-deliverables/src/client/locales.ts | 20 ++ .../src/client/turn-deliverables.ts | 38 +++- packages/client/ui-deliverables/src/index.ts | 9 +- .../ui-deliverables/src/present-download.ts | 72 +++++++ .../client/ui-deliverables/src/presented.ts | 55 +++++ .../tests/present-download.host.spec.ts | 198 ++++++++++++++++++ .../tests/present-row.client.spec.tsx | 58 +++++ .../tests/produced-files.client.spec.tsx | 81 ++++++- ...mpt.client.spec.ts => prompt.host.spec.ts} | 3 + .../ui-deliverables/tsconfig.client.json | 60 ++++++ .../client/ui-deliverables/tsconfig.host.json | 39 ++++ packages/client/ui-deliverables/tsconfig.json | 43 +--- .../core/session/src/known-event-types.ts | 1 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- .../src/client/slot-catalog.ts | 15 +- packages/fs/README.i18n.yaml | 4 +- packages/fs/README.md | 3 +- packages/fs/README.zh.md | 3 +- packages/fs/tool-present/README.i18n.yaml | 6 + packages/fs/tool-present/README.md | 105 ++++++++++ packages/fs/tool-present/README.zh.md | 105 ++++++++++ packages/fs/tool-present/package.json | 63 ++++++ packages/fs/tool-present/src/index.ts | 121 +++++++++++ packages/fs/tool-present/src/types.ts | 18 ++ .../fs/tool-present/tests/built-errors.e2e.ts | 65 ++++++ .../fs/tool-present/tests/present.spec.ts | 183 ++++++++++++++++ packages/fs/tool-present/tsconfig.json | 39 ++++ .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 2 + packages/preset/agent-presets/README.zh.md | 2 + .../presets/cordis/agent.cordis.yml | 3 + .../presets/ptc/agent.cordis.yml | 3 + .../presets/standard/agent.cordis.yml | 3 + pnpm-lock.yaml | 76 +++++++ python/sdk-runtime/package.json | 1 + scripts/gen-tool-catalog.ts | 14 ++ scripts/run-gates.ts | 1 + .../tool-schemas.expected.json | 32 +++ .../tool-schemas.expected.json | 32 +++ snapshots/web/present/session.v2.jsonl | 28 +++ snapshots/web/present/snapshot.yml | 9 + snapshots/web/present/ui.expected.md | 109 ++++++++++ .../web/present/workspace.expected/report.txt | 1 + .../web/present/workspace.expected/说明.txt | 1 + .../web/ptc-round/system-prompt.expected.md | 19 ++ tsconfig.base.json | 2 + tsconfig.client.json | 2 +- tsconfig.host.json | 3 + 90 files changed, 2274 insertions(+), 113 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md create mode 100644 apps/web/tests/present.e2e.ts create mode 100644 packages/client/ui-deliverables/src/client/Deliverables.module.css create mode 100644 packages/client/ui-deliverables/src/client/Deliverables.tsx create mode 100644 packages/client/ui-deliverables/src/client/PresentRow.module.css create mode 100644 packages/client/ui-deliverables/src/client/PresentRow.tsx create mode 100644 packages/client/ui-deliverables/src/present-download.ts create mode 100644 packages/client/ui-deliverables/src/presented.ts create mode 100644 packages/client/ui-deliverables/tests/present-download.host.spec.ts create mode 100644 packages/client/ui-deliverables/tests/present-row.client.spec.tsx rename packages/client/ui-deliverables/tests/{prompt.client.spec.ts => prompt.host.spec.ts} (88%) create mode 100644 packages/client/ui-deliverables/tsconfig.client.json create mode 100644 packages/client/ui-deliverables/tsconfig.host.json create mode 100644 packages/fs/tool-present/README.i18n.yaml create mode 100644 packages/fs/tool-present/README.md create mode 100644 packages/fs/tool-present/README.zh.md create mode 100644 packages/fs/tool-present/package.json create mode 100644 packages/fs/tool-present/src/index.ts create mode 100644 packages/fs/tool-present/src/types.ts create mode 100644 packages/fs/tool-present/tests/built-errors.e2e.ts create mode 100644 packages/fs/tool-present/tests/present.spec.ts create mode 100644 packages/fs/tool-present/tsconfig.json create mode 100644 snapshots/web/present/session.v2.jsonl create mode 100644 snapshots/web/present/snapshot.yml create mode 100644 snapshots/web/present/ui.expected.md create mode 100644 snapshots/web/present/workspace.expected/report.txt create mode 100644 snapshots/web/present/workspace.expected/说明.txt diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml new file mode 100644 index 0000000000..c6374bc3ae --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md new file mode 100644 index 0000000000..6ea89c6de3 --- /dev/null +++ b/.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. diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md new file mode 100644 index 0000000000..e503bf520a --- /dev/null +++ b/.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 排除。 diff --git a/apps/cli/package.json b/apps/cli/package.json index f25e2cfa6c..57084f2ac5 100644 --- a/apps/cli/package.json +++ b/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:^", diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 3ac7af61ce..7a1cc4078f 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/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', ]) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 3df0703839..fa69b3644e 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/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 }), diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts new file mode 100644 index 0000000000..c76377ec23 --- /dev/null +++ b/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 + 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([]) + }) +}) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 61acc027fd..fc0ef1694c 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/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', diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 9457407c0a..0a176a9af0 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/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 { 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) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1e3fbcd850..741fa7f70f 100644 --- a/apps/web/tsconfig.json +++ b/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", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c7b1711528..05a54a98f0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cd6449b294..5882597232 100644 --- a/docs/config-catalog.md +++ b/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) + + +## `@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) + ## `@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)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 52e5631192..3b41c54931 100644 --- a/docs/config-catalog.zh.md +++ b/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) + + +## `@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) + ## `@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)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0117f66a78..b1f10816f3 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/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 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d5ee3453e8..449b7d8f0f 100644 --- a/docs/event-producer-consumer.md +++ b/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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index d857561a7d..8120df6de6 100644 --- a/docs/event-producer-consumer.zh.md +++ b/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) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 594186cd2f..79b96eda8c 100644 --- a/docs/module-graph.i18n.yaml +++ b/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 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9d229ba5d2..831ff287a8 100644 --- a/docs/module-graph.md +++ b/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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 830f777dde..3c9a732a5c 100644 --- a/docs/module-graph.zh.md +++ b/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) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 147c3a869a..c3b0738000 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/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 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 06242185a9..1749faa99b 100644 --- a/docs/persistence-catalog.md +++ b/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/*` + + + +#### `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/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index bc6f4f623e..b3e13c5f4b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -398,6 +398,21 @@ export type SessionEvent = { 来源:[`packages/compaction/compaction/src/types.ts:34`](../packages/compaction/compaction/src/types.ts) +### `deliverables/*` + + + +#### `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/*` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 16319731c8..c43efdd85f 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/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 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index c85201523e..5a09b07e0a 100644 --- a/docs/tool-catalog.md +++ b/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. + + +## `@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. + ## `@deepseek-ai/dsh-tool-pwsh` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index ce5f5b57f6..528f3a3768 100644 --- a/docs/tool-catalog.zh.md +++ b/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)后,该参数会被完全移除。 + + +## `@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 提供认证下载与卡片。 + ## `@deepseek-ai/dsh-tool-pwsh` diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 3d32022816..4f3407903a 100644 --- a/packages/bundle/base/package.json +++ b/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:^", diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4985ac3796..8ea1e5d5d6 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b5a837f927..7c9b38a1ab 100644 --- a/packages/client/ui-chat/README.md +++ b/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) diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index f6b8018434..80198a89fe 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/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) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 2d8de50561..0fc9e82a22 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/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 diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 3fb8bcfcee..b5c3a45b99 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/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' { diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 947eb739ed..71a7b5afe9 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/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 } diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index f7bac8eb1c..018fc92e7c 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/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 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 01417ca259..4e53ba774c 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index bfd4c40a95..721d7146e3 100644 --- a/packages/client/ui-deliverables/README.md +++ b/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. + +### 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
Implementation internals — click to expand -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.
@@ -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. @@ -99,4 +107,4 @@ None. -**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. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 23c983f938..5922815b7f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -27,6 +27,13 @@ kind: "package-reference" 与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经属主的 `openFile` 打开文件——chat 视图把它路由到右侧 Sidebar 作为一个文本预览 tab——相对路径按会话 cwd 解析。该行不提供文件夹动作:Sidebar 没有目录形态,因此省略文件的余数只是一个标签才会打开会话工作区。 + +### 显式交付 + +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"
实现细节——点击展开 -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` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。
@@ -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`。 @@ -99,4 +107,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、event definition 与可选 service 注册都归 effect 所有,释放由插件测试证明;本包不持有可变状态。 +**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、下载路由与可选 service 注册都归 effect 所有,释放由插件测试证明;attachment 服务拥有保存的字节,Session 日志拥有交付引用。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 187b548e24..e07bd902a4 100644 --- a/packages/client/ui-deliverables/package.json +++ b/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", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css new file mode 100644 index 0000000000..e83fc1eb34 --- /dev/null +++ b/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; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx new file mode 100644 index 0000000000..cfab1925bd --- /dev/null +++ b/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 & { + matched: DeliverablesMatch +} & PropsLocale & Pick) { + return <> + {matched.produced.length > 0 && } + {matched.presented.length > 0 && } + +} diff --git a/packages/client/ui-deliverables/src/client/PresentRow.module.css b/packages/client/ui-deliverables/src/client/PresentRow.module.css new file mode 100644 index 0000000000..b97ff7de02 --- /dev/null +++ b/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; } diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx new file mode 100644 index 0000000000..845d2b4dcc --- /dev/null +++ b/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 + +/** 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
+ } + open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen + onToggle={() => { setExpanded(value => !value) }} + collapsedContent={{t(`row.${state}`)}{fileNames(args)}}> +
{details}
+ {inspect && } +
+
+} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 01877b7b57..db6355664f 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/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) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 158b659f59..af3cc1cfae 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/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 = { + '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', diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 90e3248516..ba70191a01 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/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 { 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 { + 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() + 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 diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 00e1adac9c..963d0c1700 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/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'), diff --git a/packages/client/ui-deliverables/src/present-download.ts b/packages/client/ui-deliverables/src/present-download.ts new file mode 100644 index 0000000000..4d5d3ea75b --- /dev/null +++ b/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 { + 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 | undefined = await iterator.next() + const data: AsyncIterableIterator = { + [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 + 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 }) + } +} diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts new file mode 100644 index 0000000000..0e58ff0c49 --- /dev/null +++ b/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 + 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 + 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) +} diff --git a/packages/client/ui-deliverables/tests/present-download.host.spec.ts b/packages/client/ui-deliverables/tests/present-download.host.spec.ts new file mode 100644 index 0000000000..45f039cf07 --- /dev/null +++ b/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> = [] +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>() + 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((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() + 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') + }) +}) diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx new file mode 100644 index 0000000000..05d6b99540 --- /dev/null +++ b/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[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() + 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() + 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() + expect(view.getByText('Delivering')).toBeTruthy() + expect(view.queryByRole('button')).toBeNull() +}) + +it('shows orphaned error details and non-text results', () => { + const view = render() + fireEvent.click(view.getByRole('button')) + expect(view.getByText('Missing: missing')).toBeTruthy() + view.rerender() + expect(view.container.textContent).toContain('Recorded detail') + view.rerender() + expect(view.queryByRole('button')).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9c006c93e7..058db67a8b 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/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() + 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() + expect(view.getByText('Produced')).toBeTruthy() + expect(view.queryByText('Deliverables')).toBeNull() +}) + +it('shows file metadata and descriptions without hiding extensionless deliveries', () => { + const view = render( {}} 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') +}) diff --git a/packages/client/ui-deliverables/tests/prompt.client.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts similarity index 88% rename from packages/client/ui-deliverables/tests/prompt.client.spec.ts rename to packages/client/ui-deliverables/tests/prompt.host.spec.ts index 9c2ee24471..042699d6d3 100644 --- a/packages/client/ui-deliverables/tests/prompt.client.spec.ts +++ b/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() diff --git a/packages/client/ui-deliverables/tsconfig.client.json b/packages/client/ui-deliverables/tsconfig.client.json new file mode 100644 index 0000000000..989b773565 --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json new file mode 100644 index 0000000000..39f2f8a2cc --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index a99881d93b..2eca820546 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/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" } ] } diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index decb838cb5..dd6411240b 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -33,6 +33,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'compaction/prune', 'compaction/start', 'compaction/summary', + 'deliverables/presented', 'feedback/message-delete', 'feedback/message-put', 'feedback/record', diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 0630cfaa8e..c26ef3ff28 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/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', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index d6232bd312..11bda279be 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/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: \'\' },\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: \'\' },\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\'', diff --git a/packages/fs/README.i18n.yaml b/packages/fs/README.i18n.yaml index 5311da4c5b..6f879b8f5f 100644 --- a/packages/fs/README.i18n.yaml +++ b/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 diff --git a/packages/fs/README.md b/packages/fs/README.md index c8cfc5f8d5..dd3e6f1fab 100644 --- a/packages/fs/README.md +++ b/packages/fs/README.md @@ -22,7 +22,7 @@ The `fs/` group gives agents durable, policy-governed access to files: the `ctx. ## 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. diff --git a/packages/fs/README.zh.md b/packages/fs/README.zh.md index 9596f72e76..6e0b34b2b9 100644 --- a/packages/fs/README.zh.md +++ b/packages/fs/README.zh.md @@ -22,7 +22,7 @@ kind: "package-group" ## 包 -七个包加上远程同级 `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。 diff --git a/packages/fs/tool-present/README.i18n.yaml b/packages/fs/tool-present/README.i18n.yaml new file mode 100644 index 0000000000..24d2c2f70f --- /dev/null +++ b/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 diff --git a/packages/fs/tool-present/README.md b/packages/fs/tool-present/README.md new file mode 100644 index 0000000000..a4f18712e7 --- /dev/null +++ b/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) + +----- + + +## 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. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +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. + +
+ +----- + + +## 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. + + +## 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 ( 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 + + + +- 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. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/fs/tool-present/README.zh.md b/packages/fs/tool-present/README.zh.md new file mode 100644 index 0000000000..2b3671ab39 --- /dev/null +++ b/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) + +----- + + +## 使用本包 + +`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`。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +工具通过配置的文件系统提供方解析路径,检查工作区包含关系和有界读取前后的版本,并通过 attachment 服务保存字节。成功的最终 `tools/result` 通知追加 `deliverables/presented`,嵌套调用也适用。外层程序随后失败不会撤销已完成的交付。被阻止的结果不发布交付。每个插件实例只记录其实际执行调用保存的快照;同名作用域工具不能通过其他实例发布交付。 + +纯 `./types` 入口声明 `PresentedFile` 与 Session 事件,不导入 Host 运行时代码。Web 消费方在展示或授权下载前校验持久引用。事件不保存 Session ID,因此 fork 历史通过当前查看的 Session 授权下载。 + +**运行时不变式:** 不发布伴生入口。工具与事件注册归 effect 所有;attachment 服务拥有不可变字节,Session 日志拥有交付引用。 + +
+ +----- + + +## 进一步探索 + +- [文件系统子系统](../../../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 归属与读取端必须识别的事件。 + + +## 模型体验 + +### 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 ( bytes)`;attachment ID 保留在程序结果和持久事件中。 + +#### Token 影响 + +每个挂载的 Agent 增加一个工具 schema,每个交付文件增加一行结果。文件字节不进入模型消息。 + +#### KV Cache 影响 + +工具 schema 在挂载期间保持静态。交付结果文本扩展对话,不重写提示词前缀。 + +## 已知限制与延期工作 + + + +- 路径包含关系和读取前后版本校验会拒绝普通变化,但路径 API 无法原子防御恶意替换后复原。 +- 失败调用此前保存的文件可能作为无引用对象留在 attachment 存储中。 +- Session ZIP 导出在 JSONL 中保留交付引用,不包含交付字节。转移后下载依赖服务主机 attachment 存储中的同一快照。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/fs/tool-present/package.json b/packages/fs/tool-present/package.json new file mode 100644 index 0000000000..6cb32cbc8a --- /dev/null +++ b/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:^" + } +} diff --git a/packages/fs/tool-present/src/index.ts b/packages/fs/tool-present/src/index.ts new file mode 100644 index 0000000000..429ec8f92e --- /dev/null +++ b/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 = 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() + 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, + }) + }) +} diff --git a/packages/fs/tool-present/src/types.ts b/packages/fs/tool-present/src/types.ts new file mode 100644 index 0000000000..0c3d1816af --- /dev/null +++ b/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[] } + } +} diff --git a/packages/fs/tool-present/tests/built-errors.e2e.ts b/packages/fs/tool-present/tests/built-errors.e2e.ts new file mode 100644 index 0000000000..369e21b4d7 --- /dev/null +++ b/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', + ]) +}) diff --git a/packages/fs/tool-present/tests/present.spec.ts b/packages/fs/tool-present/tests/present.spec.ts new file mode 100644 index 0000000000..0d5e4740b0 --- /dev/null +++ b/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> = [] +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 { + 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) +}) diff --git a/packages/fs/tool-present/tsconfig.json b/packages/fs/tool-present/tsconfig.json new file mode 100644 index 0000000000..0a256f850d --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index da31f205d2..2ddf994845 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/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 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index bb9f59d11a..6ef3374a5f 100644 --- a/packages/preset/agent-presets/README.md +++ b/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. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 13d30f9b75..a1be6d634e 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/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 相同。 diff --git a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml b/packages/preset/agent-presets/presets/cordis/agent.cordis.yml index 7b6664a440..940a7c9e48 100644 --- a/packages/preset/agent-presets/presets/cordis/agent.cordis.yml +++ b/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' diff --git a/packages/preset/agent-presets/presets/ptc/agent.cordis.yml b/packages/preset/agent-presets/presets/ptc/agent.cordis.yml index 0bdc7db732..7773f64109 100644 --- a/packages/preset/agent-presets/presets/ptc/agent.cordis.yml +++ b/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' diff --git a/packages/preset/agent-presets/presets/standard/agent.cordis.yml b/packages/preset/agent-presets/presets/standard/agent.cordis.yml index c2f4c51a0b..b2c26e4ef0 100644 --- a/packages/preset/agent-presets/presets/standard/agent.cordis.yml +++ b/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' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 543267a69a..9cd52b76f6 100644 --- a/pnpm-lock.yaml +++ b/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 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d34b14bec4..3a3f128cf5 100644 --- a/python/sdk-runtime/package.json +++ b/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:^", diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 0fcf1c41fe..000f551081 100644 --- a/scripts/gen-tool-catalog.ts +++ b/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', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 9276b51c77..1b08040d06 100644 --- a/scripts/run-gates.ts +++ b/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', diff --git a/snapshots/web/cordis-tool-round/tool-schemas.expected.json b/snapshots/web/cordis-tool-round/tool-schemas.expected.json index 82e5ac7630..2242317f16 100644 --- a/snapshots/web/cordis-tool-round/tool-schemas.expected.json +++ b/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.", diff --git a/snapshots/web/fresh-round-trip/tool-schemas.expected.json b/snapshots/web/fresh-round-trip/tool-schemas.expected.json index da820f02a2..e087897363 100644 --- a/snapshots/web/fresh-round-trip/tool-schemas.expected.json +++ b/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.", diff --git a/snapshots/web/present/session.v2.jsonl b/snapshots/web/present/session.v2.jsonl new file mode 100644 index 0000000000..95046d9d90 --- /dev/null +++ b/snapshots/web/present/session.v2.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":2,"id":"{{session:1}}","createdAt":1788841384234,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0,"agentPreset":"ptc"} +{"type":"permission/preset","data":{"preset":"workspace-write"}} +{"type":"sandbox/mode","data":{"mode":"workspace-write"}} +{"type":"approval/policy","data":{"policy":"ask"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","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."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"user/message","data":{"content":[{"type":"text","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."}],"source":{"kind":"user","rpcId":"{{rpc:1}}","clientTimeZone":"{{clientTimeZone}}"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Use one run_code program to","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants one run_code program that:\n1. Calls present for missing.txt and catches its error without creating that file.\n2. Uses bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n3. Calls present for report.txt and 说明.txt.\n4. After present succeeds, deliberately throws the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program.\n5. Does not retry the program or create other files.\n6. Finishes by mentioning report.txt and 说明.txt in inline code, and put PRESENT_DONE in a separate paragraph.\n\nLet me write the program.\n\npresent tool args: { files: [{ path, description? }] }.\n\nSo first: try present for missing.txt — catch error. It should error because the file doesn't exist (present requires existing workspace file).\n\nThen bash run exact command. Note the command uses `;` inside a single string. Run as a single command string: `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n\nWait — printf interprets the arguments... In bash, `printf \"DELIVERED_REPORT\\n\"` with the format string containing \\n — printf will interpret \\n as newline. Good.\n\nThen present for report.txt and 说明.txt.\n\nThen throw \"AFTER_PRESENT\" string. In TS async function body, `throw \"AFTER_PRESENT\"` works.\n\nLet me write it."},{"type":"tool-call","id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":981,"outputTokens":695,"totalTokens":9356,"cacheReadTokens":7680,"reasoningTokens":329},"stream":[{"type":"chunk","time":1788856033793,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788856033794,"index":0,"dt":[116,24,2,24,9,0,0,10,0,1,0,19,1,0,24,0,20,0,0,0,23,0,0,0,0,0,21,1,0,0,0,23,0,1,21,0,0,1,0,0,21,0,0,0,0,1,21,0,0,0,0,1,21,0,0,0,0,0,45,0,0,0,0,0,0,23,0,1,0,0,45,0,0,0,0,0,0,0,0,1,0,0,19,0,0,0,1,0,22,0,0,0,0,0,23,0,0,0,21,0,1,0,0,0,22,0,0,0,0,0,22,0,22,0,0,0,0,22,1,0,0,24,0,0,0,0,0,21,1,0,0,0,29,0,0,0,0,0,15,0,0,0,0,0,22,1,0,0,24,1,26,1,17,1,0,21,0,23,1,23,1,0,0,0,2,18,0,21,0,0,22,0,0,0,22,1,0,21,22,23,0,0,0,0,1,19,1,31,1,12,1,0,0,0,21,1,22,0,0,22,1,22,22,1,21,23,0,0,22,0,1,22,0,22,0,1,22,0,0,0,21,1,0,0,0,1,22,0,0,0,0,0,21,0,0,0,0,0,22,1,0,0,0,22,0,0,0,23,21,1,22,22,23,1,0,22,0,0,0,0,0,21,1,0,0,0,22,1,22,1,0,22,0,0,22,23,0,0,0,23,0,1,0,0,1,20,0,22,1,0,0,0,0,21,1,0,0,22,1,0,22,0,0,0,0,1,25,19,0,23,1,0,0,0,22,0,0,0,0,1,21,1,21,0,1,0,0,21],"texts":["The"," user"," wants"," one"," run","_code"," program"," that",":\n","1","."," Calls"," present"," for"," missing",".txt"," and"," catches"," its"," error"," without"," creating"," that"," file",".\n","2","."," Uses"," bash"," to"," run"," exactly"," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\""," >"," report",".txt",";"," printf"," \"","DEL","IVER","ED","_N","OTE","\\n","\""," >"," ","说明",".txt","`.\n","3","."," Calls"," present"," for"," report",".txt"," and"," ","说明",".txt",".\n","4","."," After"," present"," succeeds",","," deliberately"," throws"," the"," string"," \"","AF","TER","_P","RES","ENT","\""," (","not"," an"," Error"," object",")"," from"," that"," same"," run","_code"," program",".\n","5","."," Does"," not"," ret","ry"," the"," program"," or"," create"," other"," files",".\n","6","."," Fin","ishes"," by"," mentioning"," report",".txt"," and"," ","说明",".txt"," in"," inline"," code",","," and"," put"," PRES","ENT","_D","ONE"," in"," a"," separate"," paragraph",".\n\n","Let"," me"," write"," the"," program",".\n\n","present"," tool"," args",":"," {"," files",":"," [{"," path",","," description","?"," }","]"," }",".\n\n","So"," first",":"," try"," present"," for"," missing",".txt"," —"," catch"," error","."," It"," should"," error"," because"," the"," file"," doesn","'t"," exist"," (","present"," requires"," existing"," workspace"," file",").\n\n","Then"," bash"," run"," exact"," command","."," Note"," the"," command"," uses"," `",";","`"," inside"," a"," single"," string","."," Run"," as"," a"," single"," command"," string",":"," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\""," >"," report",".txt",";"," printf"," \"","DEL","IVER","ED","_N","OTE","\\n","\""," >"," ","说明",".txt","`.\n\n","Wait"," —"," printf"," interprets"," the"," arguments","..."," In"," bash",","," `","printf"," \"","DEL","IVER","ED","_RE","PORT","\\n","\"","`"," with"," the"," format"," string"," containing"," \\","n"," —"," printf"," will"," interpret"," \\","n"," as"," new","line","."," Good",".\n\n","Then"," present"," for"," report",".txt"," and"," ","说明",".txt",".\n\n","Then"," throw"," \"","AF","TER","_P","RES","ENT","\""," string","."," In"," TS"," async"," function"," body",","," `","throw"," \"","AF","TER","_P","RES","ENT","\"","`"," works",".\n\n","Let"," me"," write"," it","."]},{"type":"chunk","time":1788856035925,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788856035925,"index":1,"dt":[23,1,0,20,1,0,0,23,0,1,0,41,1,0,1,1,23,0,0,0,23,21,0,24,1,0,0,0,0,20,0,0,0,0,1,21,1,21,1,0,0,0,0,21,1,0,0,0,0,23,0,1,0,20,1,0,0,23,0,0,0,24,0,0,0,0,0,21,0,0,0,0,1,21,1,22,0,0,0,0,0,22,1,0,0,21,0,0,0,1,0,21,1,22,0,24,0,0,0,0,0,21,1,0,0,0,0,21,1,0,0,21,1,0,21,1,0,0,21,1,0,22,0,0,0,0,0,23,0,0,0,0,22,1,0,1,0,21,0,0,0,0,1,21,1,0,0,0,0,21,0,0,0,0,1,22,0,0,0,0,0,22,1,0,21,22,1,0,0,0,0,21,0,0,0,0,1,22,22,22,0,1,0,22,0,0,0,0,1,21,0,0,0,0,1,21,0,0,23,0,0,1,0,1,21,0,0,23,0,23,1,0,0,0,0,21,0,0,0,0,1,22,0,0,0,0,1,40,1,0,0,10,1,0,0,0,1,13,0,0,1,0,0,22,0,0,0,0,1,22,0,0,0,0,1,22,0,0,0,21,0,0,0,0,1,21,0,0,0,0,1,21,1,0,0,0,1,36,1,0,0,0,0,6,0,1,21,0,0,22,22,1,0,22,0,0,0,0,0,21,1,0,0,0,1,20,22,1,0,0,0,23,0,21,22,22,22,1,22,0,1,0,21],"id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","args":["","{","\"","code","\"",": ","\"","//"," Step"," ","1",":"," present"," for"," missing",".txt"," —"," must"," fail",","," catch"," the"," error",","," don","'t"," create"," the"," file","\\n","try"," {\\n"," "," await"," tools",".p","resent","({"," files",":"," [{"," path",":"," \\\"","missing",".txt","\\\","," description",":"," \\\"","must"," not"," exist","\\\""," }","]"," });\\n"," "," console",".log","(\\\"","UN","EXP","ECT","ED",":"," present"," succeeded"," for"," missing",".txt","\\\");\\n","}"," catch"," (","err",")"," {\\n"," "," console",".log","(\\\"","Ca","ught"," present"," error"," for"," missing",".txt",":\\\","," typeof"," err",","," String","(err",").","slice","(","0",","," ","200","));\\n","}\\n\\n","//"," Step"," ","2",":"," run"," the"," exact"," bash"," command"," to"," create"," the"," two"," report"," files","\\n","const"," res"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," '","printf"," \\\"","DEL","IVER","ED","_RE","PORT","\\\\n","\\\""," >"," report",".txt",";"," printf"," \\\"","DEL","IVER","ED","_N","OTE","\\\\n","\\\""," >"," ","说明",".txt","',\\n"," "," description",":"," \\\"","Write"," DEL","IVER","ED","_RE","PORT"," and"," DEL","IVER","ED","_N","OTE"," to"," files","\\\",\\n","});\\n","console",".log","(\\\"","bash"," exit",":\\\","," res",".exit","Code",");\\n\\n","//"," Step"," ","3",":"," present"," for"," report",".txt"," and"," ","说明",".txt"," (","must"," succeed",")\\n","const"," p","2"," ="," await"," tools",".p","resent","({\\n"," "," files",":"," [\\n"," "," {"," path",":"," \\\"","report",".txt","\\\","," description",":"," \\\"","del","ivered"," report","\\\""," },\\n"," "," {"," path",":"," \\\"","说明",".txt","\\\","," description",":"," \\\"","del","ivered"," note","\\\""," },\\n"," "," ],\\n","});\\n","console",".log","(\\\"","present"," ok",","," files",":\\\","," p","2",".f","iles",".map","((","f",")"," =>"," f",".path",").","join","(\\\","," \\\"","));\\n\\n","//"," Step"," ","4",":"," deliberately"," throw"," a"," plain"," string"," AFTER","_P","RES","ENT"," (","not"," an"," Error",")\\n","throw"," \\\"","AF","TER","_P","RES","ENT","\\\";","\"",", ","\"","description","\"",": ","\"","Present"," files"," then"," deliberately"," throw"," AFTER","_P","RES","ENT","\"","}"]},{"type":"chunk","time":1788856037718,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants one run_code program that:\n1. Calls present for missing.txt and catches its error without creating that file.\n2. Uses bash to run exactly `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n3. Calls present for report.txt and 说明.txt.\n4. After present succeeds, deliberately throws the string \"AFTER_PRESENT\" (not an Error object) from that same run_code program.\n5. Does not retry the program or create other files.\n6. Finishes by mentioning report.txt and 说明.txt in inline code, and put PRESENT_DONE in a separate paragraph.\n\nLet me write the program.\n\npresent tool args: { files: [{ path, description? }] }.\n\nSo first: try present for missing.txt — catch error. It should error because the file doesn't exist (present requires existing workspace file).\n\nThen bash run exact command. Note the command uses `;` inside a single string. Run as a single command string: `printf \"DELIVERED_REPORT\\n\" > report.txt; printf \"DELIVERED_NOTE\\n\" > 说明.txt`.\n\nWait — printf interprets the arguments... In bash, `printf \"DELIVERED_REPORT\\n\"` with the format string containing \\n — printf will interpret \\n as newline. Good.\n\nThen present for report.txt and 说明.txt.\n\nThen throw \"AFTER_PRESENT\" string. In TS async function body, `throw \"AFTER_PRESENT\"` works.\n\nLet me write it."}}},{"type":"chunk","time":1788856037719,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}}},{"type":"chunk","time":1788856037719,"chunk":{"type":"usage","usage":{"inputTokens":981,"outputTokens":695,"totalTokens":9356,"cacheReadTokens":7680,"reasoningTokens":329}}},{"type":"chunk","time":1788856037719,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590","name":"run_code","arguments":"{\"code\": \"// Step 1: present for missing.txt — must fail, catch the error, don't create the file\\ntry {\\n await tools.present({ files: [{ path: \\\"missing.txt\\\", description: \\\"must not exist\\\" }] });\\n console.log(\\\"UNEXPECTED: present succeeded for missing.txt\\\");\\n} catch (err) {\\n console.log(\\\"Caught present error for missing.txt:\\\", typeof err, String(err).slice(0, 200));\\n}\\n\\n// Step 2: run the exact bash command to create the two report files\\nconst res = await tools.bash({\\n command: 'printf \\\"DELIVERED_REPORT\\\\n\\\" > report.txt; printf \\\"DELIVERED_NOTE\\\\n\\\" > 说明.txt',\\n description: \\\"Write DELIVERED_REPORT and DELIVERED_NOTE to files\\\",\\n});\\nconsole.log(\\\"bash exit:\\\", res.exitCode);\\n\\n// Step 3: present for report.txt and 说明.txt (must succeed)\\nconst p2 = await tools.present({\\n files: [\\n { path: \\\"report.txt\\\", description: \\\"delivered report\\\" },\\n { path: \\\"说明.txt\\\", description: \\\"delivered note\\\" },\\n ],\\n});\\nconsole.log(\\\"present ok, files:\\\", p2.files.map((f) => f.path).join(\\\", \\\"));\\n\\n// Step 4: deliberately throw a plain string AFTER_PRESENT (not an Error)\\nthrow \\\"AFTER_PRESENT\\\";\", \"description\": \"Present files then deliberately throw AFTER_PRESENT\"}"}} +{"type":"tool/code-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:1","name":"present","arguments":{"files":[{"path":"missing.txt","description":"must not exist"}]}}} +{"type":"tool/code-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:1","name":"present","arguments":{"files":[{"path":"missing.txt","description":"must not exist"}]},"isError":true,"content":[{"type":"text","text":"Error: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry."}]}} +{"type":"tool/code-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"}}} +{"type":"tool/code-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:2","name":"bash","arguments":{"command":"printf \"DELIVERED_REPORT\n\" > report.txt; printf \"DELIVERED_NOTE\n\" > 说明.txt","description":"Write DELIVERED_REPORT and DELIVERED_NOTE to files"},"isError":false,"content":[{"type":"text","text":"(no output)"}]}} +{"type":"tool/code-dispatch-start","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]}}} +{"type":"deliverables/presented","data":{"turn":1,"callId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","files":[{"path":"report.txt","description":"delivered report","attachmentId":"sha256:7e7bab1fdcf9c16099c5a1ec237feba0d936c8e57cf521cab61b99a51a49c169","name":"report.txt","bytes":17},{"path":"说明.txt","description":"delivered note","attachmentId":"sha256:5e019bfe8e78d6f01f71b6a8ac6477ee8ed3895861259c6565ecd9e736f1d69f","name":"说明.txt","bytes":15}]}} +{"type":"tool/code-dispatch","data":{"rootCallId":"call_00_GgvYPol45qxGf6GezK3L3590","parentCallId":"call_00_GgvYPol45qxGf6GezK3L3590","subCallId":"call_00_GgvYPol45qxGf6GezK3L3590:code:3","name":"present","arguments":{"files":[{"path":"report.txt","description":"delivered report"},{"path":"说明.txt","description":"delivered note"}]},"isError":false,"content":[{"type":"text","text":"Presented report.txt (17 bytes)\nPresented 说明.txt (15 bytes)"}]}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GgvYPol45qxGf6GezK3L3590"},"content":[{"type":"tool-result","toolCallId":"call_00_GgvYPol45qxGf6GezK3L3590","content":[{"type":"text","text":"Error: code run failed (exception): AFTER_PRESENT\nCaptured output:\nCaught present error for missing.txt: object ToolCallError: Cannot present missing.txt: file not found. Check the path, create the file if needed, and retry.\nbash exit: 0\npresent ok, files: report.txt, 说明.txt"}],"isError":true}],"role":"user","id":"{{message:4}}"},"error":{"name":"CodeRunFailedError","code":"CODE_RUN_FAILED"}},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The program ran as intended:\n1. present for missing.txt failed with a ToolCallError, caught and logged — missing.txt was not created.\n2. bash ran exactly the given command, exit 0.\n3. present succeeded for report.txt and 说明.txt.\n4. Then threw the plain string \"AFTER_PRESENT\" (program failed with that exception, as expected — not an Error object).\n\nI did not retry the program or create other files. Now finish mentioning `report.txt` and `说明.txt` in inline code, with PRESENT_DONE in a separate paragraph."},{"type":"text","text":"The single program ran exactly as ordered: `present` for `missing.txt` failed and was caught (no file created), bash ran the exact command successfully, then `present` succeeded for `report.txt` and `说明.txt`, after which the program deliberately threw the string `AFTER_PRESENT` — no retries, no extra files.\n\nPRESENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":94,"outputTokens":198,"totalTokens":9636,"cacheReadTokens":9344,"reasoningTokens":121},"stream":[{"type":"chunk","time":1788841400011,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788841400011,"index":0,"dt":[117,22,0,0,0,0,1,22,0,0,40,0,0,0,13,1,0,0,0,22,0,21,1,22,0,0,23,0,0,0,0,0,23,1,19,23,0,0,0,0,22,0,0,0,0,0,22,0,0,0,0,0,23,0,0,0,22,0,22,0,0,0,0,1,22,0,1,0,0,21,0,22,1,0,23,0,21,1,22,22,0,0,0,0,0,22,1,0,22,1,0,0,22,0,0,1,21,22,1,0,0,0,0,23,0,1,0,0,0,22,1,19,0,0,0,0,1,22,1,0],"texts":["The"," program"," ran"," as"," intended",":\n","1","."," present"," for"," missing",".txt"," failed"," with"," a"," Tool","Call","Error",","," caught"," and"," logged"," —"," missing",".txt"," was"," not"," created",".\n","2","."," bash"," ran"," exactly"," the"," given"," command",","," exit"," ","0",".\n","3","."," present"," succeeded"," for"," report",".txt"," and"," ","说明",".txt",".\n","4","."," Then"," threw"," the"," plain"," string"," \"","AF","TER","_P","RES","ENT","\""," (","program"," failed"," with"," that"," exception",","," as"," expected"," —"," not"," an"," Error"," object",").\n\n","I"," did"," not"," ret","ry"," the"," program"," or"," create"," other"," files","."," Now"," finish"," mentioning"," `","report",".txt","`"," and"," `","说明",".txt","`"," in"," inline"," code",","," with"," PRES","ENT","_D","ONE"," in"," a"," separate"," paragraph","."]},{"type":"chunk","time":1788841400853,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788841400853,"index":1,"dt":[0,20,23,28,1,0,16,0,25,0,0,0,0,0,19,0,23,0,0,0,22,1,0,23,0,25,1,0,0,0,16,1,22,0,0,0,0,0,22,0,0,0,0,1,21,0,0,23,0,1,0,21,0,1,0,0,21,1,1,0,0,0,20,1,0,22,1,0,22,0,0,0,0,1,22],"texts":["The"," single"," program"," ran"," exactly"," as"," ordered",":"," `","present","`"," for"," `","missing",".txt","`"," failed"," and"," was"," caught"," (","no"," file"," created","),"," bash"," ran"," the"," exact"," command"," successfully",","," then"," `","present","`"," succeeded"," for"," `","report",".txt","`"," and"," `","说明",".txt","`,"," after"," which"," the"," program"," deliberately"," threw"," the"," string"," `","AF","TER","_P","RES","ENT","`"," —"," no"," ret","ries",","," no"," extra"," files",".\n\n","PR","ES","ENT","_D","ONE"]},{"type":"chunk","time":1788841401575,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The program ran as intended:\n1. present for missing.txt failed with a ToolCallError, caught and logged — missing.txt was not created.\n2. bash ran exactly the given command, exit 0.\n3. present succeeded for report.txt and 说明.txt.\n4. Then threw the plain string \"AFTER_PRESENT\" (program failed with that exception, as expected — not an Error object).\n\nI did not retry the program or create other files. Now finish mentioning `report.txt` and `说明.txt` in inline code, with PRESENT_DONE in a separate paragraph."}}},{"type":"chunk","time":1788841401575,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The single program ran exactly as ordered: `present` for `missing.txt` failed and was caught (no file created), bash ran the exact command successfully, then `present` succeeded for `report.txt` and `说明.txt`, after which the program deliberately threw the string `AFTER_PRESENT` — no retries, no extra files.\n\nPRESENT_DONE"}}},{"type":"chunk","time":1788841401575,"chunk":{"type":"usage","usage":{"inputTokens":94,"outputTokens":198,"totalTokens":9636,"cacheReadTokens":9344,"reasoningTokens":121}}},{"type":"chunk","time":1788841401575,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/present/snapshot.yml b/snapshots/web/present/snapshot.yml new file mode 100644 index 0000000000..67b8b12df0 --- /dev/null +++ b/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 diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md new file mode 100644 index 0000000000..3004a21a11 --- /dev/null +++ b/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% diff --git a/snapshots/web/present/workspace.expected/report.txt b/snapshots/web/present/workspace.expected/report.txt new file mode 100644 index 0000000000..d94ec652e9 --- /dev/null +++ b/snapshots/web/present/workspace.expected/report.txt @@ -0,0 +1 @@ +DELIVERED_REPORT diff --git a/snapshots/web/present/workspace.expected/说明.txt b/snapshots/web/present/workspace.expected/说明.txt new file mode 100644 index 0000000000..e0f8e32623 --- /dev/null +++ b/snapshots/web/present/workspace.expected/说明.txt @@ -0,0 +1 @@ +DELIVERED_NOTE diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index 13e7e6ef83..4da368fcf2 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/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; + /** 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; /** 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; diff --git a/tsconfig.base.json b/tsconfig.base.json index 637fc6e454..e6f4d7c9b9 100644 --- a/tsconfig.base.json +++ b/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"], diff --git a/tsconfig.client.json b/tsconfig.client.json index b9a2a55382..9fa63a2b40 100644 --- a/tsconfig.client.json +++ b/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" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index e13180bbdf..ece0365eb9 100644 --- a/tsconfig.host.json +++ b/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" }, From 6d8a400d79ac6905a9b55352314342a4061d14f7 Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 8 Sep 2026 18:26:50 +0800 Subject: [PATCH 2/8] fix(web): open delivered snapshots in the default application --- ...09-08-web-explicit-file-delivery.i18n.yaml | 4 +- .../2026-09-08-web-explicit-file-delivery.md | 10 +- ...026-09-08-web-explicit-file-delivery.zh.md | 10 +- apps/web/tests/present.e2e.ts | 66 +++++++++---- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 12 ++- packages/client/ui-deliverables/README.zh.md | 8 +- packages/client/ui-deliverables/package.json | 3 +- .../src/client/Deliverables.module.css | 5 +- .../src/client/Deliverables.tsx | 45 +++++---- .../ui-deliverables/src/client/index.ts | 19 ++-- .../ui-deliverables/src/client/locales.ts | 14 ++- .../src/client/present-open.ts | 54 +++++++++++ packages/client/ui-deliverables/src/index.ts | 6 +- .../ui-deliverables/src/present-download.ts | 22 ++++- .../ui-deliverables/src/present-open.ts | 53 +++++++++++ .../client/ui-deliverables/src/presented.ts | 12 ++- .../tests/present-download.host.spec.ts | 93 ++++++++++++++++++- .../tests/present-open.client.spec.ts | 63 +++++++++++++ .../tests/produced-files.client.spec.tsx | 56 +++++++---- .../ui-deliverables/tests/prompt.host.spec.ts | 1 + .../client/ui-deliverables/tsconfig.host.json | 6 +- pnpm-lock.yaml | 3 + snapshots/web/present/ui.expected.md | 16 ++-- 27 files changed, 479 insertions(+), 114 deletions(-) create mode 100644 packages/client/ui-deliverables/src/client/present-open.ts create mode 100644 packages/client/ui-deliverables/src/present-open.ts create mode 100644 packages/client/ui-deliverables/tests/present-open.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml index c6374bc3ae..0925919414 100644 --- a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md -2026-09-08-web-explicit-file-delivery.md: 6ea89c6de392df4b739bb3692a313b53dd22bbe6 -2026-09-08-web-explicit-file-delivery.zh.md: e503bf520a8e08bdd65e1fc3f0f93a527f87ef95 +2026-09-08-web-explicit-file-delivery.md: 5ab211d8e8ea3c7f008cf39307f1ddfdd77acfee +2026-09-08-web-explicit-file-delivery.zh.md: 3077b23a78f0617a1a377bfc37de201169bbdc82 diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md index 6ea89c6de3..5ab211d8e8 100644 --- a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md +++ b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md @@ -10,15 +10,17 @@ Workspace links read live paths, so edits or deletion can invalidate a final del ## 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. +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 snapshot actions 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. +Download and native-open requests 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. + +Card and closing-mention gestures open a verified private copy with the existing native-command utility. A POST expresses the desktop side effect; GET remains a byte read. Each gesture receives a new copy so application edits cannot corrupt the immutable attachment or alter later opens. Successful copies survive until plugin disposal for applications that read lazily; failed copies are removed immediately, and disposal awaits cancelled work before cleanup. ## 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. +**Live workspace links** cannot preserve a delivered version after edits or deletion. Opening the attachment store’s own path instead would expose immutable saved bytes to application writes. **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. @@ -32,4 +34,4 @@ The implementation adds no artifact service or attachment format. Unreferenced s 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. +Focused tests cover snapshot bytes, invalid inputs, blocked results, HTTP integrity, native-open copy isolation, retry and disposal, turn isolation, and fork-addressed actions. The recorded Web scenario covers nested completion followed by an enclosing failure, source deletion, reload, native-open gestures without browser downloads, and ZIP exclusion. diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md index e503bf520a..3077b23a78 100644 --- a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md @@ -10,15 +10,17 @@ Status: implemented ## 决策 -[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。原生与嵌套调用使用同一个记录器。外层程序随后失败不会撤销已完成的嵌套交付。被阻止的工具结果不发布交付。 +[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 字节。 +下载与原生打开请求通过当前查看的 Session、事件序号与文件索引授权引用。事件不保存 Session ID,因此 fork 历史使用子 Session 自己的日志。现有产出文件行保留其名称和行为。Session ZIP 保留交付事件,但不收集其中引用的 attachment 字节。 + +卡片和收尾引用操作通过现有 native-command 工具,在默认应用中打开经过校验的私有副本。POST 表达桌面副作用;GET 仍仅读取字节。每次操作创建新副本,避免应用内编辑损坏不可变 attachment 或改变后续打开的内容。成功副本保留到插件释放,以支持延迟读取的应用;失败副本立即删除,释放时先等待取消的操作结束再清理。 ## 已考虑的替代方案 **在 UI 包中提供 Host 工具子路径**会将 preset 安装与浏览器打包耦合,并要求额外发布入口。普通工具包通过仓库 peer dependency 规则保留共享的文件系统和工具错误类。 -**实时工作区链接**无法在编辑或删除后保留已交付版本。 +**实时工作区链接**无法在编辑或删除后保留已交付版本。直接打开 attachment 存储路径则会使不可变保存字节暴露于应用写入。 **在工具、dispatch 和 Session 中增加通用 artifact 字段**会为单个 Web 功能扩大无关 API。插件拥有的事件使用现有扩展点,并省去父调用结果转发。 @@ -32,4 +34,4 @@ Status: implemented 交付事件要求读取端识别,因为它是保存字节的授权索引,不只是显示元数据。跳过事件会让旧读取端在重建或分叉 Session 时丢失已完成的交付。不支持该事件的读取端拒绝读取,避免静默丢弃引用。 -定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、turn 隔离及使用 fork 地址的链接。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载和 ZIP 排除。 +定向测试覆盖快照字节、无效输入、被阻止的结果、HTTP 完整性、原生打开的副本隔离、重试与释放、turn 隔离及使用 fork 地址的操作。录制 Web 场景覆盖嵌套调用完成后外层失败、源文件删除、重新加载、不触发浏览器下载的原生打开操作和 ZIP 排除。 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index c76377ec23..0f48cac2f2 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -1,10 +1,11 @@ /** Recorded delivery, source deletion, reload, and Session ZIP behavior. */ -import { readFile, unlink, mkdir } from 'node:fs/promises' -import { join } from 'node:path' +import { readFile, unlink, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises' +import { join, delimiter } 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { tmpdir, release } from 'node:os' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tool-present/types' import { @@ -22,7 +23,8 @@ const PROMPT = 'Use one run_code program to do the following in order. Call pres + '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', () => { +// The recorded Bash scenario and executable opener fixture require a POSIX host outside WSL. +describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes('microsoft'))('web e2e: explicit file delivery', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -31,8 +33,22 @@ describe('web e2e: explicit file delivery', () => { let cwd: string let disposeApproval: (() => void) | undefined const events: SessionEvent[] = [] + let nativeRoot: string | undefined + let openLog: string + const opened = async (): Promise => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as string) + const downloads: string[] = [] beforeAll(async () => { + nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-present-native-')) + openLog = join(nativeRoot, 'opened.jsonl') + await writeFile(openLog, '') + // Exercise the built Host through its actual OS command, replacing only the desktop application. + const command = process.platform === 'darwin' ? 'open' : 'xdg-open' + await writeFile(join(nativeRoot, command), `#!${process.execPath} +const fs = require('node:fs'); +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(process.argv[2], 'utf8')) + '\\n'); +`, { mode: 0o700 }) + vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, @@ -43,15 +59,24 @@ describe('web e2e: explicit file delivery', () => { browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + page.on('download', (download) => { downloads.push(download.suggestedFilename()) }) 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() + try { + await browser?.close() + } finally { + disposeApproval?.() + try { + await scaffold?.close() + } finally { + vi.unstubAllEnvs() + if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true }) + } + } }) it('delivers nested snapshots even when the enclosing program subsequently fails', async () => { @@ -73,7 +98,7 @@ describe('web e2e: explicit file delivery', () => { 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 () => { + it('opens saved copies after source deletion and reload, while Session ZIP contains only references', async () => { await unlink(join(cwd, 'report.txt')) await unlink(join(cwd, '说明.txt')) for (const reload of [false, true]) { @@ -85,16 +110,25 @@ describe('web e2e: explicit file delivery', () => { } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('link').count()).toBe(2) + expect(await row.getByRole('button').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 count = (await opened()).length + const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click() + expect((await response).status()).toBe(204) + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect(await opened()).toHaveLength(count + 1) + expect((await opened()).at(-1)).toBe(bytes) } } + const count = (await opened()).length + const openedResponse = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('code').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) + expect((await openedResponse).status()).toBe(204) + expect(await opened()).toHaveLength(count + 1) + expect((await opened()).at(-1)).toBe('DELIVERED_REPORT\n') + expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) const entries = unzipSync(await response.body()) @@ -114,7 +148,7 @@ describe('web e2e: explicit file delivery', () => { 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()) { + for (const card of await row.getByRole('button').all()) { const bounds = await card.boundingBox() expect(bounds).not.toBeNull() expect(bounds!.x).toBeGreaterThanOrEqual(0) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 05a54a98f0..9006428dc3 100644 --- a/docs/config-catalog.i18n.yaml +++ b/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: 58825972327e6a473f04b42a4219aaae049cd4ea -config-catalog.zh.md: 3b41c549315da44ee604e93fbb3a8718039f1ab1 +config-catalog.md: 89eac550fce4b767023d80d519ca3d60849a7e52 +config-catalog.zh.md: 7231b0867faa9998712e97be8889dc5e6f38a9ed diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5882597232..89eac550fc 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3459,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` · `connection` · `sessionQuery` · `attachments` ([`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` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3b41c54931..7231b0867f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3461,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` · `connection` · `sessionQuery` · `attachments`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `attachments` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-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)) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 4e53ba774c..d32df8879e 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/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: 721d7146e3d1d0499c45c5d768e5bb8e64a1fff5 -README.zh.md: 5922815b7fd558df73a62333dfb39c4b5d24bf82 +README.md: abf944d0115934d781ae44bf176914263822277e +README.zh.md: e8f556a7469800898b660fe1d81e16db02062b27 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 721d7146e3..abf944d011 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### 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 Web `standard`, `ptc`, and `cordis` presets expose `present` for final files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns snapshot creation, limits, and Session delivery records. The closing turn shows responsive file cards with names, types, sizes, descriptions, and buttons that open a saved copy in the Host’s default application. Matching inline-code references open the same snapshots after source edits, deletion, or reload, without starting a browser download. Forks authorize opening through the viewed Session. Repeated delivery of a path selects its latest successful snapshot before the closing reply. -The `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 `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The row @@ -52,6 +52,8 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. +Native opening uses an authenticated POST addressed by Session, event sequence, and original file index. The Host streams the saved bytes into a private temporary copy and verifies the complete attachment before launching the default application. Each gesture creates a separate copy, so application edits cannot change the stored snapshot. Failed opens remove their copies; successful copies remain until plugin disposal because applications may read lazily. Disposal cancels and awaits pending work before cleanup. The authenticated GET download endpoint remains available to byte consumers. + ----- @@ -93,8 +95,8 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Terminal-created files require explicit delivery** — call `present` to make their 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. +- **Terminal-created files require explicit delivery** — call `present` to make their saved snapshots available. +- **Transferred Session exports contain no delivered bytes** — delivery actions require the same snapshots in the serving host’s attachment store; missing or pruned snapshots return 404. - **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. @@ -107,4 +109,4 @@ None. -**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. +**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, file-action routes, and optional service registrations are effect-owned with disposal proven by their plugin specs; the attachment service owns saved bytes, and the Session log owns delivery references. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 5922815b7f..e8f556a746 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有快照创建、限制和 Session 交付记录。收尾 turn 显示响应式文件卡片,包含名称、类型、大小、说明和下载操作,匹配的行内代码引用也下载相同快照;修改或删除源文件、重新加载后仍可下载。Fork 通过当前查看的 Session 下载。同一路径重复交付时,选择收尾回复之前最近一次成功的快照。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有快照创建、限制和 Session 交付记录。收尾 turn 显示响应式文件卡片,包含名称、类型、大小、说明和在 Host 默认应用中打开保存副本的按钮。匹配的行内代码引用也打开相同快照;修改或删除源文件、重新加载后仍可打开,不触发浏览器下载。Fork 通过当前查看的 Session 授权打开。同一路径重复交付时,选择收尾回复之前最近一次成功的快照。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。 +`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 该行 @@ -52,6 +52,8 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +原生打开使用经过认证的 POST,通过 Session、事件序号和原始文件索引定位文件。Host 将保存的字节流写入私有临时副本,完整校验 attachment 后才启动默认应用。每次操作创建独立副本,因此应用内的编辑不会修改已保存的快照。打开失败时删除副本;成功副本保留到插件释放,因为应用可能延迟读取。释放时先取消并等待进行中的操作,再执行清理。经过认证的 GET 下载端点仍供字节读取方使用。 + ----- @@ -107,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、下载路由与可选 service 注册都归 effect 所有,释放由插件测试证明;attachment 服务拥有保存的字节,Session 日志拥有交付引用。 +**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、文件操作路由与可选 service 注册都归 effect 所有,释放由插件测试证明;attachment 服务拥有保存的字节,Session 日志拥有交付引用。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index e07bd902a4..e43f6745b5 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -68,7 +68,8 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", - "@deepseek-ai/dsh-tool-present": "workspace:^" + "@deepseek-ai/dsh-tool-present": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index e83fc1eb34..97befd2946 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -2,7 +2,7 @@ .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 { 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; font: inherit; text-align: left; cursor: pointer; } .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; } @@ -10,4 +10,5 @@ .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; } +.open { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 0 0 auto; color: var(--dsw-alias-link); font-size: 11px; } +.file:disabled { cursor: wait; opacity: 0.65; } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index cfab1925bd..8f2dab5156 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,7 +1,9 @@ /** 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 { LinkIcon, classifyLinkPath, fileSizeText, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { PresentedOpenController } from './present-open.ts' import { ProducedFiles } from './ProducedFiles.tsx' import { basename, presentedForClosing, selectProducedFiles, type PresentedPath } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -10,6 +12,12 @@ import css from './Deliverables.module.css' interface DeliverablesMatch { produced: readonly string[]; presented: readonly PresentedPath[] } +/** Native-open callbacks and shared gesture status supplied by the plugin. */ +export interface DeliverablesInjected { + hooks: { presentedOpen: ObservableSnapshot> } + openPresented: PresentedOpenController['open'] +} + /** * Claim turns containing modified paths or presented snapshots. * @param owner - closing turn. @@ -22,29 +30,34 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch } /** - * Render workspace file actions and immutable snapshot downloads. + * Render workspace file actions and default-application buttons for saved deliveries. * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ -export function Deliverables({ matched, openFile, t, sessionId }: Pick & { +export function Deliverables({ matched, openFile, t, sessionId, openPresented, usePresentedOpen }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick) { +} & PropsLocale & Pick & InjectFace) { + const states = usePresentedOpen(value => value) return <> {matched.produced.length > 0 && } {matched.presented.length > 0 &&
{t('presented.label')}
- {matched.presented.map(file => - - - {basename(file.path)} - {basename(file.path).match(/\.([^.]+)$/)?.[1]?.toUpperCase() ?? t('presented.file')} · {fileSizeText(file.bytes)} - {file.description && {file.description}} - - {t('presented.action')} - )} + {matched.presented.map((file) => { + const phase = states[presentedFileUrl(sessionId, file.seq, file.index, 'open')] + return })}
} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index db6355664f..09156448b4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -13,9 +13,9 @@ 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 { presentedFileUrl } from '../presented.ts' +import { PresentedOpenController } from './present-open.ts' import { PresentRow } from './PresentRow.tsx' -import { Deliverables, selectDeliverables } from './Deliverables.tsx' +import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' import { deliverablesDefinition, presentedForClosing, producedFileMentions, selectProducedFiles, @@ -39,6 +39,8 @@ export const inject = ['slots', 'locale', 'uiConversation', 'remote', 'remote.se * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { + const opener = new PresentedOpenController() + ctx.effect(() => () => opener.dispose()) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -47,6 +49,10 @@ export function apply(ctx: ClientContext): void { name: 'conversation.chat.turnTail', select: selectDeliverables, locale: NS, + inject: (): DeliverablesInjected => ({ + hooks: { presentedOpen: opener.state }, + openPresented: (sessionId, seq, index) => opener.open(sessionId, seq, index), + }), }, Deliverables), ) ctx.slots.inject('tool.call.toolview', () => ctx.slots.register( @@ -66,13 +72,8 @@ export function apply(ctx: ClientContext): void { 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 })) + else void opener.open(sessionId, file.seq, file.index) + }, path => t(deliveries.has(path) ? 'presented.open' : 'produced.open', { name: path })) }, } ctx.provide('chatFileMentions', mentions) diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index af3cc1cfae..d9b468e15c 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -6,7 +6,10 @@ export const NS = 'deliverables' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'presented.label': '交付文件', - 'presented.action': '下载', + 'presented.action': '打开', + 'presented.opening': '正在打开…', + 'presented.opened': '已在默认程序中打开', + 'presented.error': '打开失败,点击重试', 'presented.file': '文件', 'row.title': '交付文件', 'row.running': '正在交付', @@ -14,7 +17,7 @@ export const zh = { 'row.error': '交付失败', 'row.stopped': '已中断', 'row.inspect': '查看调用', - 'presented.download': '下载 {name}', + 'presented.open': '在默认程序中打开 {name}', 'produced.label': '产物', 'produced.moreOne': '+ 1 个文件', 'produced.more': '+ {count} 个文件', @@ -24,7 +27,10 @@ export const zh = { /** English dictionary (same key set). */ export const en: Record = { 'presented.label': 'Deliverables', - 'presented.action': 'Download', + 'presented.action': 'Open', + 'presented.opening': 'Opening…', + 'presented.opened': 'Opened in default app', + 'presented.error': 'Could not open. Click to retry.', 'presented.file': 'File', 'row.title': 'Present files', 'row.running': 'Delivering', @@ -32,7 +38,7 @@ export const en: Record = { 'row.error': 'Delivery failed', 'row.stopped': 'Interrupted', 'row.inspect': 'Inspect call', - 'presented.download': 'Download {name}', + 'presented.open': 'Open {name} in default app', 'produced.label': 'Produced', 'produced.moreOne': '+ 1 file', 'produced.more': '+ {count} files', diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts new file mode 100644 index 0000000000..350ac7d431 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -0,0 +1,54 @@ +/** Shared native-open status for delivery cards and closing-message file mentions. */ +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { presentedFileUrl } from '../presented.ts' + +/** State of the latest explicit open gesture for one saved file. */ +export type PresentedOpenPhase = 'opening' | 'opened' | 'error' + +/** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ +export class PresentedOpenController { + /** File action URLs key the state across Sessions, turns, and both clickable surfaces. */ + readonly state = createSnapshotStore>({}) + private readonly lifetime = new AbortController() + private readonly pending = new Set>() + + /** + * Open a snapshot once while a request for the same coordinates is pending. + * Failures remain visible on the card and a later gesture retries them. + * @param sessionId - viewed Session, including a fork's own identity. + * @param seq - durable delivery event sequence. + * @param index - original file index within that event. + * @returns after the Host acknowledges opening or the error state is published. + */ + async open(sessionId: SessionId, seq: number, index: number): Promise { + const url = presentedFileUrl(sessionId, seq, index, 'open') + if (this.lifetime.signal.aborted || this.state.getSnapshot()[url] === 'opening') return + this.state.update((state) => { state[url] = 'opening' }) + const task = this.request(url) + this.pending.add(task) + try { + await task + } finally { + this.pending.delete(task) + } + } + + /** Cancel outstanding requests and wait until no request can publish state. */ + async dispose(): Promise { + this.lifetime.abort() + await Promise.all(this.pending) + } + + private async request(url: string): Promise { + let phase: PresentedOpenPhase = 'opened' + try { + const response = await fetch(url, { method: 'POST', signal: this.lifetime.signal }) + if (!response.ok) phase = 'error' + } catch { + // Transport failures share the retryable card state with Host open failures. + phase = 'error' + } + if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) + } +} diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 963d0c1700..de87c6e574 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,7 @@ /** * Deliverables plugin, node half. Registers the response-format guidance that * lets the browser half recognize final-response file references and serves - * authenticated snapshot downloads. The browser + * authenticated snapshot downloads and native opens. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -10,8 +10,8 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-system-prompt' import { registerPresentDownload } from './present-download.ts' -/** Services required for file-reference guidance and authenticated snapshot downloads. */ -export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'attachments'] +/** Services required for file-reference guidance and authenticated snapshot downloads and native opens. */ +export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'attachments', 'sessionController'] /** Stable final-response guidance owned by the matching renderer. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' diff --git a/packages/client/ui-deliverables/src/present-download.ts b/packages/client/ui-deliverables/src/present-download.ts index 4d5d3ea75b..6bcbd42290 100644 --- a/packages/client/ui-deliverables/src/present-download.ts +++ b/packages/client/ui-deliverables/src/present-download.ts @@ -1,26 +1,34 @@ -/** Authorize downloads against the Session log, then stream only the saved Presented file bytes. */ +/** Authorize delivery actions against the Session log, using only saved attachment bytes. */ import { Readable } from 'node:stream' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-client-connection' import type { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' -import { isPresentedData, isPresentedFile, PRESENT_DOWNLOAD_PATH } from './presented.ts' +import { isPresentedData, isPresentedFile, PRESENT_DOWNLOAD_PATH, PRESENT_OPEN_PATH } from './presented.ts' +import { createPresentedOpener } from './present-open.ts' import type {} from '@deepseek-ai/dsh-session-query' /** - * Register a streaming download inside Connection's existing authentication fence. + * Register snapshot downloads and native opening inside Connection's authentication fence. * @param ctx - Host services owning Session reads, attachments, and HTTP routing. */ export function registerPresentDownload(ctx: Context): void { + const open = createPresentedOpener(ctx) ctx.connection.fetch.register({ path: PRESENT_DOWNLOAD_PATH, methods: ['GET'], requestBody: 'buffered', - fetch: request => download(ctx, request), + fetch: request => handleDelivery(ctx, request), + }) + ctx.connection.fetch.register({ + path: PRESENT_OPEN_PATH, + methods: ['POST'], + requestBody: 'buffered', + fetch: request => handleDelivery(ctx, request, open), }) } -async function download(ctx: Context, request: Request): Promise { +async function handleDelivery(ctx: Context, request: Request, open?: ReturnType): Promise { const query = new URL(request.url).searchParams const id = query.get('sessionId') const seq = query.get('seq') @@ -35,6 +43,10 @@ async function download(ctx: Context, request: Request): Promise { }, request.signal) const artifact = target.type === 'deliverables/presented' && isPresentedData(target.data) ? target.data.files[Number(index)] : undefined if (!isPresentedFile(artifact)) return new Response('Presented file not found in this Session result.', { status: 404 }) + if (open !== undefined) { + await open(artifact, request.signal) + return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }) + } const filename = encodeURIComponent(artifact.name.toWellFormed()) .replace(/['()*]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) const iterator = ctx.attachments.readFileStream(artifact, request.signal)[Symbol.asyncIterator]() diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts new file mode 100644 index 0000000000..e25acc39ed --- /dev/null +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -0,0 +1,53 @@ +/** Private editable copies keep native applications away from immutable attachment objects. */ +import { createWriteStream } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pipeline } from 'node:stream/promises' +import type { Context } from '@deepseek-ai/cordis' +import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-api-session-controller' +import { basename } from './presented.ts' + +/** + * Own native-open work and its temporary copies for one plugin lifetime. + * Successful copies remain until disposal because desktop applications may read lazily. + * @param ctx - attachment provider and plugin lifetime. + * @returns an opener that verifies the entire snapshot before launching its default application. + */ +export function createPresentedOpener(ctx: Context): (ref: FileAttachmentRef, signal: AbortSignal) => Promise { + const lifetime = new AbortController() + const directories = new Set() + const pending = new Set>() + ctx.effect(() => async () => { + lifetime.abort() + await Promise.allSettled(pending) + await Promise.all([...directories].map(directory => rm(directory, { recursive: true, force: true }))) + }) + + async function open(ref: FileAttachmentRef, signal: AbortSignal): Promise { + signal.throwIfAborted() + if (basename(ref.name) !== ref.name || ref.name === '.' || ref.name === '..' || ref.name.includes('\0')) { + throw new Error('Presented file name must be a leaf name.') + } + const directory = await mkdtemp(join(tmpdir(), 'dsh-present-')) + directories.add(directory) + try { + const path = join(directory, ref.name) + await pipeline(ctx.attachments.readFileStream(ref, signal), createWriteStream(path, { flags: 'wx', mode: 0o600 }), { signal }) + signal.throwIfAborted() + await ctx.sessionController.openWorkspacePath({ path }, signal) + } catch (error) { + await rm(directory, { recursive: true, force: true }) + directories.delete(directory) + throw error + } + } + + return (ref, signal) => { + const task = open(ref, AbortSignal.any([signal, lifetime.signal])) + pending.add(task) + void task.then(() => { pending.delete(task) }, () => { pending.delete(task) }) + return task + } +} diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts index 0e58ff0c49..87eb0c97b4 100644 --- a/packages/client/ui-deliverables/src/presented.ts +++ b/packages/client/ui-deliverables/src/presented.ts @@ -6,6 +6,9 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' /** Authenticated route for saved file bytes. */ export const PRESENT_DOWNLOAD_PATH = '/api/present.download' +/** Authenticated POST route for opening a saved file on the Host desktop. */ +export const PRESENT_OPEN_PATH = '/api/present.open' + /** * Validate a saved delivery read from a Session log. * @param value - decoded durable data. @@ -22,14 +25,15 @@ export function isPresentedFile(value: unknown): value is PresentedFile { } /** - * Build an authenticated download coordinate for a saved delivery. + * Build authenticated coordinates 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. + * @param action - retrieve the bytes or open a copy on the Host desktop. + * @returns same-origin file action URL. */ -export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string { - return `${PRESENT_DOWNLOAD_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` +export function presentedFileUrl(sessionId: SessionId, seq: number, index: number, action: 'download' | 'open' = 'download'): string { + return `${action === 'open' ? PRESENT_OPEN_PATH : PRESENT_DOWNLOAD_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** diff --git a/packages/client/ui-deliverables/tests/present-download.host.spec.ts b/packages/client/ui-deliverables/tests/present-download.host.spec.ts index 45f039cf07..1d07213108 100644 --- a/packages/client/ui-deliverables/tests/present-download.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-download.host.spec.ts @@ -1,9 +1,9 @@ /** Saved Presented file downloads over the real Connection route and local attachment store. */ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, readFile, writeFile, access } from 'node:fs/promises' import { once } from 'node:events' import { createServer } from 'node:http' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, basename, dirname } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' @@ -17,7 +17,7 @@ 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' +import { presentedFileUrl, PRESENT_DOWNLOAD_PATH, PRESENT_OPEN_PATH } from '../src/presented.ts' const cleanups: Array<() => Promise> = [] afterEach(async () => { @@ -40,12 +40,16 @@ async function fixture() { return { target: { type: 'deliverables/presented', data: { turn: 1, callId: 'present-call', files: [artifact] } } as SessionEvent } }) ctx.provide('sessionQuery', { readEvent } as never) + const opener = vi.fn(async (_request: { path: string }, _signal: AbortSignal) => ({ opened: true as const })) + ctx.provide('sessionController', { openWorkspacePath: opener } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) - const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'attachments'], apply: registerPresentDownload }) + const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'attachments', 'sessionController'], apply: registerPresentDownload }) await fiber const fetch = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => connection .createSharedFetchHandler('/api').fetch(new Request(`http://localhost${PRESENT_DOWNLOAD_PATH}${query}`, { signal: signal ?? null })) - return { ctx, fiber, artifact, readEvent, fetch, handler: connection.createSharedFetchHandler('/api') } + const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => connection + .createSharedFetchHandler('/api').fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null })) + return { ctx, fiber, artifact, readEvent, fetch, open, opener, handler: connection.createSharedFetchHandler('/api') } } describe('Presented file download route', () => { @@ -196,3 +200,82 @@ describe('Presented file download route', () => { await expect(response.arrayBuffer()).rejects.toThrow('corrupt') }) }) + + +describe('Presented file native open route', () => { + it('opens separate verified copies with the original filename and cleans them at disposal', async () => { + const { open, fetch, fiber, artifact, handler, opener } = await fixture() + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`))).status).toBe(404) + for (let i = 0; i < 2; i++) { + const response = await open() + expect(response.status).toBe(204) + expect(response.headers.get('content-disposition')).toBeNull() + const path = opener.mock.calls[i]![0].path + expect(basename(path)).toBe(artifact.name) + expect(await readFile(path)).toEqual(Buffer.from([80, 75, 0, 255])) + await writeFile(path, 'edited by desktop app') + } + expect(opener.mock.calls[0]![0].path).not.toBe(opener.mock.calls[1]![0].path) + expect(new Uint8Array(await (await fetch()).arrayBuffer())).toEqual(Uint8Array.of(80, 75, 0, 255)) + await fiber.dispose() + for (const [{ path }] of opener.mock.calls) await expect(access(dirname(path))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await open()).status).toBe(404) + }) + + it('refuses invalid coordinates, unrelated logs, missing snapshots, and corruption before native launch', async () => { + const { open, artifact, readEvent, opener } = await fixture() + expect((await open('?sessionId=owner&seq=-1&index=0')).status).toBe(400) + expect(readEvent).not.toHaveBeenCalled() + expect((await open('?sessionId=other&seq=7&index=0')).status).toBe(404) + expect((await open('?sessionId=owner&seq=7&index=1')).status).toBe(404) + readEvent.mockResolvedValueOnce({ target: { type: 'turn/start' } as SessionEvent }) + expect((await open()).status).toBe(404) + artifact.bytes = 0 + expect((await open()).status).toBe(500) + artifact.attachmentId = AttachmentId(`sha256:${'0'.repeat(64)}`) + expect((await open()).status).toBe(404) + expect(opener).not.toHaveBeenCalled() + }) + + it.each(['../escape.txt', 'folder\\escape.txt', '.', '..', 'bad\0name'])('rejects unsafe durable filenames: %j', async (name) => { + const { open, artifact, opener } = await fixture() + artifact.name = name + expect((await open()).status).toBe(500) + expect(opener).not.toHaveBeenCalled() + }) + + it('reports launcher failure, removes its copy, and allows retry', async () => { + const { open, opener } = await fixture() + opener.mockRejectedValueOnce(new Error('/private/host/path')) + const response = await open() + expect(response.status).toBe(500) + expect(await response.text()).not.toContain('/private/host/path') + await expect(access(dirname(opener.mock.calls[0]![0].path))).rejects.toMatchObject({ code: 'ENOENT' }) + expect((await open()).status).toBe(204) + }) + + it('disposal aborts a pending native launch and waits for it before deleting the copy', async () => { + const entered = Promise.withResolvers() + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const { open, fiber, opener } = await fixture() + opener.mockImplementation(async (_request, signal) => { + signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + entered.resolve(undefined) + await release.promise + signal.throwIfAborted() + return { opened: true } + }) + const request = open() + await entered.promise + const path = opener.mock.calls[0]![0].path + let disposed = false + const disposal = fiber.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + await access(path) + release.resolve(undefined) + await Promise.all([request, disposal]) + await expect(access(dirname(path))).rejects.toMatchObject({ code: 'ENOENT' }) + }) +}) diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts new file mode 100644 index 0000000000..b201895445 --- /dev/null +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -0,0 +1,63 @@ +/** Delivery gestures share pending state, report failures, and cancel with the plugin. */ +import { afterEach, expect, it, vi } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { PresentedOpenController } from '../src/client/present-open.ts' + +afterEach(() => { vi.unstubAllGlobals() }) + +const id = SessionId('fork') +const url = '/api/present.open?sessionId=fork&seq=2&index=1' + +it('coalesces concurrent card and mention gestures, then allows another open', async () => { + const reply = Promise.withResolvers() + const fetcher = vi.fn().mockReturnValue(reply.promise) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const first = controller.open(id, 2, 1) + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledTimes(1) + expect(fetcher).toHaveBeenCalledWith(url, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + expect(controller.state.getSnapshot()[url]).toBe('opening') + reply.resolve(new Response(null, { status: 204 })) + await first + expect(controller.state.getSnapshot()[url]).toBe('opened') + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledTimes(2) + await controller.dispose() +}) + +it.each(['http', 'network'])('publishes retryable %s failures', async (failure) => { + const fetcher = vi.fn() + if (failure === 'http') fetcher.mockResolvedValueOnce(new Response(null, { status: 500 })) + else fetcher.mockRejectedValueOnce(new Error('offline')) + fetcher.mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('error') + await controller.open(id, 2, 1) + expect(controller.state.getSnapshot()[url]).toBe('opened') + await controller.dispose() +}) + +it('awaits cancellation and prevents late state publication or new requests after disposal', async () => { + const aborted = Promise.withResolvers() + const release = Promise.withResolvers() + const fetcher = vi.fn((_url: string, { signal }: RequestInit) => { + signal!.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) + return release.promise + }) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + const open = controller.open(id, 2, 1) + const state = controller.state.getSnapshot() + let disposed = false + const disposal = controller.dispose().then(() => { disposed = true }) + await aborted.promise + expect(disposed).toBe(false) + release.resolve(new Response(null, { status: 204 })) + await Promise.all([open, disposal]) + expect(controller.state.getSnapshot()).toBe(state) + await controller.open(id, 2, 1) + expect(fetcher).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 058db67a8b..1bfe183592 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -21,7 +21,8 @@ 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 { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' +import { PresentedOpenController } from '../src/client/present-open.ts' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, presentedForClosing, producedFileMentions, producedForClosing, selectProducedFiles, @@ -32,6 +33,14 @@ 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' +function openProps(controller = new PresentedOpenController()) { + return { + openPresented: vi.fn((...args: Parameters) => controller.open(...args)), + usePresentedOpen: (select: (state: ReturnType) => T): T => + select(controller.state.getSnapshot()), + } +} + afterEach(() => { cleanup() vi.restoreAllMocks() @@ -516,9 +525,7 @@ describe('plugin registration', () => { 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() + expect(entry?.inject).toBeDefined() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -532,13 +539,14 @@ describe('plugin registration', () => { 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 fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) const delivered = tailOwner({ produced: [], presented: [{ path: 'report.docx', name: 'report.docx', bytes: 4, attachmentId: 'saved' as never, seq: 2, index: 0 }] }, 3) service?.forClosing(delivered, SessionId('child-session'))?.resolve('report.docx')?.open() - expect(click).toHaveBeenCalledOnce() + expect(fetcher).toHaveBeenCalledWith('/api/present.open?sessionId=child-session&seq=2&index=0', { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + const face = entry!.inject!(SessionId('child-session') as never) as unknown as DeliverablesInjected + await face.openPresented(SessionId('child-session'), 2, 0) + expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') // A turn that produced nothing yields no vocabulary at all. expect(service?.forClosing(tailOwner(undefined, 2), SessionId('viewed-session'))).toBeUndefined() @@ -569,16 +577,20 @@ describe('presented files', () => { expect(selectDeliverables(tailOwner(deliverablesOf(value, 2), 9))).toBeNull() }) - it('uses the viewed fork Session in every download and retains all delivered files', () => { + it('uses the viewed fork Session in every open action 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() - expect(view.getAllByRole('link')).toHaveLength(8) - expect(view.getAllByRole('link')[0]?.getAttribute('href')).toBe('/api/present.download?sessionId=child-session&seq=2&index=0') + const props = openProps() + props.openPresented.mockResolvedValue(undefined) + const view = render() + expect(view.getAllByRole('button')).toHaveLength(8) + expect(view.queryByRole('link')).toBeNull() + fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in default app' })) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0) expect(view.queryByText('Produced')).toBeNull() }) }) @@ -596,18 +608,30 @@ it.each([null, [], 'invalid', {}, { turn: '1', callId: 'bad', files: [] }, ]) const owner = tailOwner(deliverablesOf(value), 5) const matched = selectDeliverables(owner)! - const view = render() + const view = render() expect(view.getByText('Produced')).toBeTruthy() expect(view.queryByText('Deliverables')).toBeNull() }) it('shows file metadata and descriptions without hiding extensionless deliveries', () => { - const view = render( {}} 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') + expect(view.getByRole('button', { name: 'Open out/report.txt in default app' }).getAttribute('title')).toBe('Open out/report.txt in default app') +}) + + +it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => { + const controller = new PresentedOpenController() + controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase }) + const props = openProps(controller) + const view = render( {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) + expect(view.getByRole('status').textContent).toBe(en[`presented.${phase}`]) + expect((view.getByRole('button') as HTMLButtonElement).disabled).toBe(phase === 'opening') }) diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 042699d6d3..84e9d50fc7 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -19,6 +19,7 @@ describe('ui-deliverables node plugin', () => { ctx.provide('connection', { fetch: { register: () => () => {} } } as never) ctx.provide('sessionQuery', {} as never) ctx.provide('attachments', {} as never) + ctx.provide('sessionController', {} as never) const mounted = ctx.plugin({ apply, inject }) await mounted.await() diff --git a/packages/client/ui-deliverables/tsconfig.host.json b/packages/client/ui-deliverables/tsconfig.host.json index 39f2f8a2cc..991e8a4a82 100644 --- a/packages/client/ui-deliverables/tsconfig.host.json +++ b/packages/client/ui-deliverables/tsconfig.host.json @@ -8,7 +8,8 @@ "files": [ "src/index.ts", "src/present-download.ts", - "src/presented.ts" + "src/presented.ts", + "src/present-open.ts" ], "references": [ { @@ -34,6 +35,9 @@ }, { "path": "../../fs/tool-present" + }, + { + "path": "../../api/session-controller/tsconfig.host.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cd52b76f6..6857fc6816 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2693,6 +2693,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment diff --git a/snapshots/web/present/ui.expected.md b/snapshots/web/present/ui.expected.md index 3004a21a11..d023374f68 100644 --- a/snapshots/web/present/ui.expected.md +++ b/snapshots/web/present/ui.expected.md @@ -56,25 +56,25 @@ - code: present - text: succeeded for - code: - - button "Download report.txt": report.txt + - button "Open report.txt in default app": report.txt - text: and - code: - - button "Download 说明.txt": 说明.txt + - button "Open 说明.txt in default app": 说明.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 +- button "Open report.txt in default app": - text: report.txt TXT · 17B delivered report + - status: Opened in default app - img - - text: Download -- link "Download 说明.txt": - - /url: /api/present.download?sessionId=session-{{uuid}}&seq=19&index=1 + - text: Open +- button "Open 说明.txt in default app": - text: 说明.txt TXT · 15B delivered note + - status: Opened in default app - img - - text: Download + - text: Open - button "Copy": - img - button "Good response": From 387e437af4552af908d5d0bbb55f34ea560f2e6d Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 8 Sep 2026 18:37:53 +0800 Subject: [PATCH 3/8] fix(web): use the preview-compatible stream promises entry --- packages/client/ui-deliverables/src/present-open.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index e25acc39ed..120d771a82 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -3,7 +3,7 @@ import { createWriteStream } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { pipeline } from 'node:stream/promises' +import { promises as streamPromises } from 'node:stream' import type { Context } from '@deepseek-ai/cordis' import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-session-controller' @@ -34,7 +34,11 @@ export function createPresentedOpener(ctx: Context): (ref: FileAttachmentRef, si directories.add(directory) try { const path = join(directory, ref.name) - await pipeline(ctx.attachments.readFileStream(ref, signal), createWriteStream(path, { flags: 'wx', mode: 0o600 }), { signal }) + await streamPromises.pipeline( + ctx.attachments.readFileStream(ref, signal), + createWriteStream(path, { flags: 'wx', mode: 0o600 }), + { signal }, + ) signal.throwIfAborted() await ctx.sessionController.openWorkspacePath({ path }, signal) } catch (error) { From c9038b4b31ce605cfcfd95fbac4e51d49deddfe6 Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 8 Sep 2026 19:39:49 +0800 Subject: [PATCH 4/8] refactor(present): declare and open workspace source files --- ...09-08-web-explicit-file-delivery.i18n.yaml | 4 +- .../2026-09-08-web-explicit-file-delivery.md | 1 + ...026-09-08-web-explicit-file-delivery.zh.md | 1 + .agents/notes/archived/manifest.json | 3 + ...8-present-workspace-source-files.i18n.yaml | 6 + ...26-09-08-present-workspace-source-files.md | 37 +++ ...09-08-present-workspace-source-files.zh.md | 37 +++ apps/web/tests/present.e2e.ts | 49 ++- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 10 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 6 +- docs/tool-catalog.zh.md | 6 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 10 +- packages/client/ui-deliverables/README.zh.md | 12 +- packages/client/ui-deliverables/package.json | 2 - .../src/client/Deliverables.tsx | 12 +- .../src/client/present-open.ts | 4 +- .../src/client/turn-deliverables.ts | 11 +- packages/client/ui-deliverables/src/index.ts | 10 +- .../ui-deliverables/src/present-download.ts | 84 ------ .../ui-deliverables/src/present-open.ts | 100 ++++--- .../client/ui-deliverables/src/presented.ts | 27 +- .../tests/present-download.host.spec.ts | 281 ------------------ .../tests/present-open.host.spec.ts | 178 +++++++++++ .../tests/produced-files.client.spec.tsx | 24 +- .../ui-deliverables/tests/prompt.host.spec.ts | 1 - .../ui-deliverables/tsconfig.client.json | 3 - .../client/ui-deliverables/tsconfig.host.json | 4 - packages/fs/tool-present/README.i18n.yaml | 4 +- packages/fs/tool-present/README.md | 31 +- packages/fs/tool-present/README.zh.md | 31 +- packages/fs/tool-present/package.json | 5 +- packages/fs/tool-present/src/index.ts | 44 +-- packages/fs/tool-present/src/types.ts | 7 +- .../fs/tool-present/tests/built-errors.e2e.ts | 13 +- .../fs/tool-present/tests/present.spec.ts | 48 +-- packages/fs/tool-present/tsconfig.json | 3 - pnpm-lock.yaml | 12 - scripts/gen-tool-catalog.ts | 5 +- .../tool-schemas.expected.json | 2 +- .../tool-schemas.expected.json | 2 +- snapshots/web/present/session.v2.jsonl | 4 +- snapshots/web/present/ui.expected.md | 4 +- .../web/ptc-round/system-prompt.expected.md | 5 +- 50 files changed, 517 insertions(+), 660 deletions(-) rename .agents/notes/{implemented => archived}/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/feature/2026-09-08-web-explicit-file-delivery.md (99%) rename .agents/notes/{implemented => archived}/feature/2026-09-08-web-explicit-file-delivery.zh.md (99%) create mode 100644 .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md create mode 100644 .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md delete mode 100644 packages/client/ui-deliverables/src/present-download.ts delete mode 100644 packages/client/ui-deliverables/tests/present-download.host.spec.ts create mode 100644 packages/client/ui-deliverables/tests/present-open.host.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml similarity index 68% rename from .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml rename to .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml index 0925919414..71c2bd9db9 100644 --- a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md -2026-09-08-web-explicit-file-delivery.md: 5ab211d8e8ea3c7f008cf39307f1ddfdd77acfee -2026-09-08-web-explicit-file-delivery.zh.md: 3077b23a78f0617a1a377bfc37de201169bbdc82 +2026-09-08-web-explicit-file-delivery.md: ff2ddeb59ded05b70006dce217df2966eab9d4f2 +2026-09-08-web-explicit-file-delivery.zh.md: 85b09ac82c83365b1c198168b78aa7ac84ef216f diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md similarity index 99% rename from .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md rename to .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md index 5ab211d8e8..ff2ddeb59d 100644 --- a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.md +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.md @@ -1,6 +1,7 @@ # Agent Note: Web delivers explicit file snapshots Status: implemented +Archived: 2026-09-08 English | [中文](2026-09-08-web-explicit-file-delivery.zh.md) diff --git a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md rename to .agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md index 3077b23a78..85b09ac82c 100644 --- a/.agents/notes/implemented/feature/2026-09-08-web-explicit-file-delivery.zh.md +++ b/.agents/notes/archived/feature/2026-09-08-web-explicit-file-delivery.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web 显式交付文件快照 Status: implemented +Archived: 2026-09-08 [English](2026-09-08-web-explicit-file-delivery.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index f540320056..cded9f6c7e 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -1381,6 +1381,9 @@ "feature/2026-09-01-web-superellipse-corner-smoothing.i18n.yaml": "sha256:50afdbe5b5e19889918af6d86ab3218c05205be35938b6d33d158c60777e3b58", "feature/2026-09-01-web-superellipse-corner-smoothing.md": "sha256:b1445101c49e74bbcb4f607af850cd6df105d4034828d0dd47081e8079148f15", "feature/2026-09-01-web-superellipse-corner-smoothing.zh.md": "sha256:1a278c417c0d7de3b4c3c35061b419303b4a1a0707831c283d8f862ab9b6fd23", + "feature/2026-09-08-web-explicit-file-delivery.i18n.yaml": "sha256:99daae539cc8fd7376ce0265538bee21e1e33f3c0d77c8cc4e011f94b4e9568a", + "feature/2026-09-08-web-explicit-file-delivery.md": "sha256:bb416b1e8be081e6cb6af17792eb1a442172ff114c3a3577e5ef06a77eb57093", + "feature/2026-09-08-web-explicit-file-delivery.zh.md": "sha256:00a642380e1f6ac9d5cd840e021f4e3e4ae68a289cb6344eb3dcd73a6fd81f4d", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml new file mode 100644 index 0000000000..27af3c5438 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +2026-09-08-present-workspace-source-files.md: 6239c9bd920849f3c8cf4fdee2e1ded4b758b01d +2026-09-08-present-workspace-source-files.zh.md: 7aa5bebc97558b9b0cb74406303d038483c39d94 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md new file mode 100644 index 0000000000..6239c9bd92 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -0,0 +1,37 @@ +# Agent Note: Present declares workspace source files + +Status: implemented + +English | [中文](2026-09-08-present-workspace-source-files.zh.md) + +## Problem + +Users need to open and edit the files produced in their workspace, including shell-created files that have no editor mutation records. Preserving an independent delivered version adds content storage, copy verification, temporary-file retention, and a second editing destination to this workflow. + +## Decision + +The [present tool](../../../../packages/fs/tool-present/README.md) declares existing regular files inside the calling Session's workspace. It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. + +The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. + +An authenticated POST selects a declaration by viewed Session, event sequence, and original file index. The event carries no owning Session ID; relative paths in inherited history resolve against the viewed Session's workspace. The Host rechecks canonical workspace containment and regular-file existence before native opening. Route disposal cancels and awaits pending commands. The existing produced-file row retains its separate text-preview behavior. + +## Alternatives considered + +**Immutable attachment snapshots and editable temporary copies** preserve delivered versions after source edits or deletion, but make desktop edits diverge from workspace files and introduce retention work without a current product requirement. This decision supersedes the [snapshot-delivery design](../../archived/feature/2026-09-08-web-explicit-file-delivery.md). Neither a download endpoint nor a fallback copy remains; both require an explicit future product decision. + +**Opening attachment-store files directly** lets editors mutate immutable objects. A future persistent delivery system needs an owned editing and retention policy, such as copy-on-write, before exposing saved versions to applications. + +**Generic artifact fields or a Host tool subpath inside the UI package** broaden unrelated APIs or couple preset installation to browser packaging. A tool-owned event and ordinary package preserve existing extension points and publication rules. + +**Tool text as the durable index** cannot survive post-processing or result spill reliably. Execution identity and final successful results retain declaration ownership independently of displayed tool text. + +**Descriptor-bound filesystem extensions** would change every provider without making an external desktop application's later path lookup atomic. Current checks reject ordinary escapes; concurrent swap-and-restore remains outside the path API's guarantees. + +## Consequences + +The Session log persists declarations but no attachment references or file contents from `present`. Session ZIP exports contain these declarations; transferring the log does not transfer workspace files. The event remains required-on-read because silently losing delivery declarations would alter reconstructed or forked history. Released Session format generations remain unchanged. + +The removed file-size cap has no role in a metadata-only declaration; the configurable file-count limit still bounds result size. Cards show file names, types, and descriptions without stale byte-size metadata. No artifact service or speculative storage fallback is introduced. + +Focused tests cover content-free declarations, invalid inputs, blocked results, source-path identity, current bytes after edits, missing files, workspace escapes, fork-relative paths, retry, cancellation, and disposal. The recorded Web scenario covers nested completion followed by enclosing failure, source edits, reload, deletion errors, card and prose opens without browser downloads, and content-free Session export. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md new file mode 100644 index 0000000000..7aa5bebc97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Present 声明交付工作区源文件 + +Status: implemented + +[English](2026-09-08-present-workspace-source-files.md) | 中文 + +## 问题 + +用户需要打开并编辑工作区中产出的文件,包括没有编辑器修改记录的 shell 产出文件。保存独立交付版本会为这一流程增加内容存储、副本校验、临时文件保留,以及第二个编辑目标。 + +## 决策 + +[present 工具](../../../../packages/fs/tool-present/README.zh.md)声明交付调用方 Session 工作区中已存在的普通文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 + +工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 + +经过认证的 POST 按当前查看的 Session、事件序号和原始文件索引选择声明。事件不携带所属 Session ID;继承历史中的相对路径按当前查看的 Session 工作区解析。Host 在原生打开前重新检查规范路径的工作区包含关系和普通文件是否存在。路由释放时取消并等待进行中的命令。原有产出文件行保留独立的文本预览行为。 + +## 考虑过的替代方案 + +**不可变附件快照和可编辑临时副本**可在源文件编辑或删除后保留交付版本,但会使桌面编辑与工作区文件分离,并在缺少当前产品需求时引入保留工作。本决策取代[快照交付设计](../../archived/feature/2026-09-08-web-explicit-file-delivery.md)。不保留下载端点或回退副本;两者都需要未来明确的产品决策。 + +**直接打开附件存储文件**会让编辑器修改不可变对象。未来持久化交付系统需要先明确编辑和保留策略,例如写时复制,再将保存版本暴露给应用。 + +**通用 artifact 字段或 UI 包内的 Host 工具子路径**会扩展无关 API,或将 preset 安装与浏览器打包耦合。工具拥有的事件与普通包保留现有扩展点和发布规则。 + +**以工具文本作为持久索引**无法可靠应对后处理或结果溢出。执行身份与最终成功结果使声明归属独立于展示的工具文本。 + +**绑定文件描述符的文件系统扩展**会改动所有提供方,却无法使外部桌面应用随后按路径打开的动作原子化。当前检查拒绝普通越界;并发替换后复原仍不在路径 API 的保证范围内。 + +## 影响 + +Session 日志持久化声明,不保存来自 `present` 的附件引用或文件内容。Session ZIP 导出包含这些声明;转移日志不会转移工作区文件。该事件仍要求读取端识别,因为静默丢失交付声明会改变重建或 fork 的历史。已发布 Session 格式代际保持不变。 + +仅声明元数据不需要文件大小上限,因此删除该限制;可配置的文件数量上限仍限制结果大小。卡片展示文件名称、类型和说明,不展示可能过时的字节大小。不引入 artifact 服务或推测性的存储回退。 + +定向测试覆盖不读取内容的声明、无效输入、被阻止的结果、源路径身份、编辑后的当前字节、缺失文件、工作区越界、fork 相对路径、重试、取消与释放。录制的 Web 场景覆盖嵌套成功后外层失败、源文件编辑、重新加载、删除错误、卡片与正文打开且无浏览器下载,以及不包含交付内容的 Session 导出。 diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 0f48cac2f2..dd55988dc2 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -1,5 +1,5 @@ -/** Recorded delivery, source deletion, reload, and Session ZIP behavior. */ -import { readFile, unlink, mkdir, mkdtemp, writeFile, rm } from 'node:fs/promises' +/** Recorded source-file delivery, edits, reload, deletion, and Session ZIP behavior. */ +import { readFile, unlink, mkdir, mkdtemp, writeFile, rm, realpath } from 'node:fs/promises' import { join, delimiter } from 'node:path' import { fileURLToPath } from 'node:url' import { chromium, type Browser, type Page } from 'playwright' @@ -35,7 +35,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const events: SessionEvent[] = [] let nativeRoot: string | undefined let openLog: string - const opened = async (): Promise => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as string) + const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; content: string }) const downloads: string[] = [] beforeAll(async () => { @@ -46,7 +46,7 @@ describe.skipIf(process.platform === 'win32' || release().toLowerCase().includes const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!${process.execPath} const fs = require('node:fs'); -fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(process.argv[2], 'utf8')) + '\\n'); +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[2], content: fs.readFileSync(process.argv[2], 'utf8') }) + '\\n'); `, { mode: 0o700 }) vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) @@ -79,7 +79,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro } }) - it('delivers nested snapshots even when the enclosing program subsequently fails', async () => { + it('declares nested deliveries even when the enclosing program subsequently fails', async () => { if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() @@ -94,13 +94,21 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro await assertFinalWorkspaceSnapshot(DIR, cwd) expect(events.filter(event => event.type === 'deliverables/presented').flatMap(event => event.data.files.map(file => file.path))) .toEqual(['report.txt', '说明.txt']) + for (const event of events) { + if (event.type === 'deliverables/presented') { + expect(event.data.files).toEqual([ + { path: 'report.txt', description: 'delivered report' }, + { path: '说明.txt', description: 'delivered note' }, + ]) + } + } expect(events.some(event => event.type === 'tool/code-dispatch' && event.data.name === 'present' && event.data.isError)).toBe(true) expect(events.some(event => event.type === 'tool/result' && event.data.message.content[0].isError)).toBe(true) }, 200_000) - it('opens saved copies after source deletion and reload, while Session ZIP contains only references', async () => { - await unlink(join(cwd, 'report.txt')) - await unlink(join(cwd, '说明.txt')) + it('opens current source files after edits and reload, and reports deletion without downloading', async () => { + await writeFile(join(cwd, 'report.txt'), 'EDITED_REPORT\n') + await writeFile(join(cwd, '说明.txt'), 'EDITED_NOTE\n') for (const reload of [false, true]) { if (reload) { const warningStart = tripwire.warnings.length @@ -111,14 +119,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro const row = page.locator('[data-presented-files-row]') await row.waitFor() expect(await row.getByRole('button').count()).toBe(2) - for (const [name, bytes] of [['report.txt', 'DELIVERED_REPORT\n'], ['说明.txt', 'DELIVERED_NOTE\n']]) { + for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') await row.getByRole('button', { name: `Open ${name} in default app`, exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toBe(bytes) + expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, name)), content: bytes }) } } const count = (await opened()).length @@ -127,13 +135,22 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect((await openedResponse).status()).toBe(204) expect(await opened()).toHaveLength(count + 1) - expect((await opened()).at(-1)).toBe('DELIVERED_REPORT\n') + expect((await opened()).at(-1)).toEqual({ path: await realpath(join(cwd, 'report.txt')), content: 'EDITED_REPORT\n' }) expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) expect(response.status()).toBe(200) const entries = unzipSync(await response.body()) expect(Object.keys(entries)).toHaveLength(1) - expect(strFromU8(Object.values(entries)[0]!)).toContain('deliverables/presented') + const exported = strFromU8(Object.values(entries)[0]!) + expect(exported).toContain('deliverables/presented') + const declarations = exported.trim().split('\n').map(line => JSON.parse(line) as SessionEvent) + .filter(event => event.type === 'deliverables/presented') + expect(declarations).toHaveLength(1) + expect(declarations[0]!.data.files).toEqual([ + { path: 'report.txt', description: 'delivered report' }, + { path: '说明.txt', description: 'delivered note' }, + ]) + expect(exported).not.toContain('EDITED_REPORT') if (MODE !== 'record') { const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) @@ -155,6 +172,14 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify(fs.readFileSync(pro expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(480) } } + const beforeDelete = (await opened()).length + await unlink(join(cwd, 'report.txt')) + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) + await page.locator('[data-presented-files-row]').getByRole('button', { name: 'Open report.txt in default app', exact: true }).click() + expect((await missing).status()).toBe(404) + await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() + expect(await opened()).toHaveLength(beforeDelete) + expect(downloads).toEqual([]) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9006428dc3..7fa075c800 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 89eac550fce4b767023d80d519ca3d60849a7e52 -config-catalog.zh.md: 7231b0867faa9998712e97be8889dc5e6f38a9ed +config-catalog.md: 0a06bdf478ccc25b3a3cedb45df18ee0dca285a9 +config-catalog.zh.md: 580c11bd2b3cf6c598206eaa6b1bb0398e85713d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89eac550fc..0a06bdf478 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2873,19 +2873,17 @@ Source: [`packages/lsp/tool-lsp/src/index.ts:57`](../packages/lsp/tool-lsp/src/i ## `@deepseek-ai/dsh-tool-present` -Requires: `tools` · `fs` · `attachments` · `sessionProjections` +Requires: `tools` · `fs` · `sessionProjections` ```ts config-catalog -/** Per-call snapshot limits. */ +/** Per-call delivery limit. */ export interface Config { - /** Inclusive per-file byte cap; at most 100 MiB. */ - maxFileBytes: number /** Maximum number of files in one call. */ maxFiles: number } ``` -Source: [`packages/fs/tool-present/src/index.ts:16`](../packages/fs/tool-present/src/index.ts) +Source: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts) @@ -3459,7 +3457,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-commands` ([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis` ([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `attachments` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — requires `systemPrompt` · `connection` · `sessionQuery` · `sessionController` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse` ([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7231b0867f..580c11bd2b 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2875,19 +2875,17 @@ export interface Config { ## `@deepseek-ai/dsh-tool-present` -依赖: `tools` · `fs` · `attachments` · `sessionProjections` +依赖: `tools` · `fs` · `sessionProjections` ```ts config-catalog -/** Per-call snapshot limits. */ +/** Per-call delivery limit. */ export interface Config { - /** Inclusive per-file byte cap; at most 100 MiB. */ - maxFileBytes: number /** Maximum number of files in one call. */ maxFiles: number } ``` -来源: [`packages/fs/tool-present/src/index.ts:16`](../packages/fs/tool-present/src/index.ts) +来源: [`packages/fs/tool-present/src/index.ts:15`](../packages/fs/tool-present/src/index.ts) @@ -3461,7 +3459,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-commands`([`packages/client/ui-commands/src/index.ts`](../packages/client/ui-commands/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation`([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) - `@deepseek-ai/dsh-client-ui-cordis`([`packages/extensions/ui-cordis/src/index.ts`](../packages/extensions/ui-cordis/src/index.ts)) -- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `attachments` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` — 需要 `systemPrompt` · `connection` · `sessionQuery` · `sessionController`([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-browse`([`packages/client/ui-directory-picker-browse/src/index.ts`](../packages/client/ui-directory-picker-browse/src/index.ts)) - `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index c3b0738000..8049e2d430 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 1749faa99beb39940e2581bc58d0543ea5984fd1 -persistence-catalog.zh.md: b3e13c5f4bb9cf973249eaf7b8cf7d93366be96f +persistence-catalog.md: a9e1221a564a4ad1af1353278f1215bb33fb9c4f +persistence-catalog.zh.md: 2b74b03b4b783f848385e66e15c40d73f50a788b diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1749faa99b..a9e1221a56 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -403,13 +403,13 @@ Source: [`packages/compaction/compaction/src/types.ts:34`](../packages/compactio #### `deliverables/presented` — log-only ```ts persistence-catalog -/** Saved deliveries from a successful final present result, including nested calls. */ +/** Declared workspace files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` Types: [ToolCallId](subsystems/core.md) -Source: [`packages/fs/tool-present/src/types.ts:16`](../packages/fs/tool-present/src/types.ts) +Source: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts) ### `feedback/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index b3e13c5f4b..2b74b03b4b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -405,13 +405,13 @@ export type SessionEvent = { #### `deliverables/presented` — 仅日志 ```ts persistence-catalog -/** Saved deliveries from a successful final present result, including nested calls. */ +/** Declared workspace files from a successful final present result, including nested calls. */ 'deliverables/presented': { turn: number; callId: ToolCallId; files: PresentedFile[] } ``` 类型: [ToolCallId](subsystems/core.zh.md) -来源: [`packages/fs/tool-present/src/types.ts:16`](../packages/fs/tool-present/src/types.ts) +来源: [`packages/fs/tool-present/src/types.ts:15`](../packages/fs/tool-present/src/types.ts) ### `feedback/*` diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index c43efdd85f..49f6610bd2 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 5a09b07e0a7a24e654fc45a7a477838ce21f30d7 -tool-catalog.zh.md: 528f3a37680ecb5f9482606c5671c4fb056092f1 +tool-catalog.md: c2ea95ff460b65fbba789738b16b0c67a7c91948 +tool-catalog.zh.md: 41fc1eaa3c6a0acf17bde4b69c97413b64872930 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 5a09b07e0a..c2ea95ff46 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.attachments`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies authenticated downloads and cards. | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`, `cordis_inspect_list`, `cordis_inspect_query`, `cordis_inspect_self`, `cordis_run`, `cordis_stop`, `cordis_undefine` | `ctx.tools`, `ctx.dynamicCordisRunner` | `tool/call`, `tool/result`, `process-local dynamic package lifecycle` | - | Not in any shipped tree (a deliberate opt-in — dynamic package code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). The toolset injects `ctx.dynamicCordisRunner` from `@deepseek-ai/dsh-cordis-host-runner`, which owns the definition registry and the vm sandbox; a composition missing it never activates the tools. A running package may register ADDITIONAL model-visible tools until it is stopped, undefined, or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | @@ -225,7 +225,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `present` -Deliver final files to the user. Saves a snapshot of each existing workspace file so it remains downloadable after edits or deletion. Create the files before calling this tool. +Declare existing workspace files as final deliverables. The user opens the current source files; their contents are not copied or preserved. Create the files before calling this tool. ```json { @@ -260,7 +260,7 @@ Deliver final files to the user. Saves a snapshot of each existing workspace fil Source: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts) -Deliveries belong to the calling Session; Web ui-deliverables supplies authenticated downloads and cards. +Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 528f3a3768..41fc1eaa3c 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -23,7 +23,7 @@ | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | -| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.attachments`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供认证下载与卡片。 | +| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | | `@deepseek-ai/dsh-tool-cordis` | `cordis_define`、`cordis_inspect_list`、`cordis_inspect_query`、`cordis_inspect_self`、`cordis_run`、`cordis_stop`、`cordis_undefine` | `ctx.tools`、`ctx.dynamicCordisRunner` | `tool/call`、`tool/result`、`process-local dynamic package lifecycle` | - | 不在任何随产品发布的树中,需要显式选择启用;动态 Package 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。该工具集注入 `@deepseek-ai/dsh-cordis-host-runner` 提供的 `ctx.dynamicCordisRunner`,后者拥有定义注册表和 vm 沙箱;组合缺少它时这些工具不会激活。运行中的 Package 在停止、undefine 或 DSH 重启前可以注册**额外的**模型可见工具;发生这类工具集变化时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | @@ -229,7 +229,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `present` -向用户交付最终文件。保存每个已有工作区文件的快照,使其在编辑或删除后仍可下载。调用工具前先创建文件。 +声明交付已有的工作区文件。用户打开当前源文件;不复制或保存其内容。调用工具前先创建文件。 ```json { @@ -264,7 +264,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac 来源: [`packages/fs/tool-present/src/index.ts`](../packages/fs/tool-present/src/index.ts) -交付归调用方 Session 所有;Web ui-deliverables 提供认证下载与卡片。 +交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index d32df8879e..9cc543ee66 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: abf944d0115934d781ae44bf176914263822277e -README.zh.md: e8f556a7469800898b660fe1d81e16db02062b27 +README.md: ae42abb5a69d29332bd7cfd6d2d2a1191381e79a +README.zh.md: 664dfa3cd2e0d526b43464a80290604a8ecf958c diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index abf944d011..ae42abb5a6 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,7 +30,7 @@ Mount this plugin alongside `ui-conversation`; a finished turn then ends with th ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns snapshot creation, limits, and Session delivery records. The closing turn shows responsive file cards with names, types, sizes, descriptions, and buttons that open a saved copy in the Host’s default application. Matching inline-code references open the same snapshots after source edits, deletion, or reload, without starting a browser download. Forks authorize opening through the viewed Session. Repeated delivery of a path selects its latest successful snapshot before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final workspace files, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../fs/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows responsive cards with file names, types, descriptions, and buttons that open the source in the Host’s default application. Matching inline-code references open the same source files without starting a browser download. Repeated declaration of a path selects its latest description before the closing reply. The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. File cards include every delivered file. Opening shows progress, confirmation, or a retryable error on the card. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. @@ -52,7 +52,7 @@ The closing prose carries the same vocabulary: an inline-code token resolves by The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. -Native opening uses an authenticated POST addressed by Session, event sequence, and original file index. The Host streams the saved bytes into a private temporary copy and verifies the complete attachment before launching the default application. Each gesture creates a separate copy, so application edits cannot change the stored snapshot. Failed opens remove their copies; successful copies remain until plugin disposal because applications may read lazily. Disposal cancels and awaits pending work before cleanup. The authenticated GET download endpoint remains available to byte consumers. +Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host resolves the declaration against that Session’s workspace and checks the current file exists within it before launching the default application. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests. @@ -95,8 +95,8 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Terminal-created files require explicit delivery** — call `present` to make their saved snapshots available. -- **Transferred Session exports contain no delivered bytes** — delivery actions require the same snapshots in the serving host’s attachment store; missing or pruned snapshots return 404. +- **Terminal-created files require explicit delivery** — call `present` to declare them for native opening. +- **Declarations do not preserve file contents** — reopening or transferring a Session requires the source files in the viewed Session’s workspace. Missing files return 404; paths resolving outside the workspace return 403. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. @@ -109,4 +109,4 @@ None. -**Runtime invariant:** No companion is published. The prompt section, slot, dictionary, file-action routes, and optional service registrations are effect-owned with disposal proven by their plugin specs; the attachment service owns saved bytes, and the Session log owns delivery references. +**Runtime invariant:** No companion is published. Prompt, slot, dictionary, file-action route, and optional service registrations are effect-owned; the Session log owns declarations and the workspace owns file contents. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index e8f556a746..664dfa3cd2 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有快照创建、限制和 Session 交付记录。收尾 turn 显示响应式文件卡片,包含名称、类型、大小、说明和在 Host 默认应用中打开保存副本的按钮。匹配的行内代码引用也打开相同快照;修改或删除源文件、重新加载后仍可打开,不触发浏览器下载。Fork 通过当前查看的 Session 授权打开。同一路径重复交付时,选择收尾回复之前最近一次成功的快照。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付最终工作区文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../fs/tool-present/README.zh.md)拥有文件数量限制和 Session 声明。收尾 turn 显示响应式卡片,包含文件名称、类型、说明和在 Host 默认应用中打开源文件的按钮。匹配的行内代码引用打开相同源文件,不触发浏览器下载。同一路径重复声明时,选择收尾回复之前最近一次的说明。 `present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。文件卡片展示全部交付文件。打开时,卡片显示进度、成功确认或可重试的错误。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 @@ -52,7 +52,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于交付最 Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 -原生打开使用经过认证的 POST,通过 Session、事件序号和原始文件索引定位文件。Host 将保存的字节流写入私有临时副本,完整校验 attachment 后才启动默认应用。每次操作创建独立副本,因此应用内的编辑不会修改已保存的快照。打开失败时删除副本;成功副本保留到插件释放,因为应用可能延迟读取。释放时先取消并等待进行中的操作,再执行清理。经过认证的 GET 下载端点仍供字节读取方使用。 +原生打开使用经过认证的 POST,通过当前查看的 Session、事件序号和原始文件索引定位声明。Host 按该 Session 的工作区解析路径,检查当前文件存在且位于工作区内,再启动默认应用。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -95,9 +95,9 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 -- **终端创建的文件需要显式交付**——调用 `present` 使其快照可供下载。 -- **转移的 Session 导出不含交付字节** — 下载链接依赖服务主机附件存储中的同一快照;快照缺失或被清理时返回 404。 -- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。 +- **终端创建的文件需要显式交付**——调用 `present` 声明文件,以便原生打开。 +- **声明不保存文件内容**——重新打开或转移 Session 后,需要当前查看的 Session 工作区中仍有源文件。文件缺失返回 404;解析到工作区外的路径返回 403。 +- **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 ### 开发备注 @@ -109,4 +109,4 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, -**运行时不变式:** 不发布伴生入口。prompt section、slot、dictionary、文件操作路由与可选 service 注册都归 effect 所有,释放由插件测试证明;attachment 服务拥有保存的字节,Session 日志拥有交付引用。 +**运行时不变式:** 不发布伴生入口。提示词、slot、dictionary、文件操作路由与可选 service 注册归 effect 所有;Session 日志拥有声明,工作区拥有文件内容。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index e43f6745b5..67970786a0 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -63,10 +63,8 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-tool-present": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^" diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 8f2dab5156..2faf259ad4 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,6 +1,6 @@ -/** Existing changed-file chips and explicitly delivered snapshots for a closing turn. */ +/** Existing changed-file chips and explicitly declared files for a closing turn. */ import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' -import { LinkIcon, classifyLinkPath, fileSizeText, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { LinkIcon, classifyLinkPath, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' @@ -19,7 +19,7 @@ export interface DeliverablesInjected { } /** - * Claim turns containing modified paths or presented snapshots. + * Claim turns containing modified paths or declared files. * @param owner - closing turn. * @returns matched files, or null for an empty turn. */ @@ -30,7 +30,7 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch } /** - * Render workspace file actions and default-application buttons for saved deliveries. + * Render workspace file actions and default-application buttons for declared files. * @param props - matched files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ @@ -44,7 +44,7 @@ export function Deliverables({ matched, openFile, t, sessionId, openPresented, u {t('presented.label')}
{matched.presented.map((file) => { - const phase = states[presentedFileUrl(sessionId, file.seq, file.index, 'open')] + const phase = states[presentedFileUrl(sessionId, file.seq, file.index)] return