From ae22f461f35fce66ec4210bccd37f1c165a2e0c6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 16:51:13 +0800 Subject: [PATCH 01/73] feat(session-log-export): add Session Markdown copy Add Copy as Markdown to the Session Header more-actions menu. A new authenticated /api/session.markdown route reads complete live-preferred history through sessionQuery.readSession and renders append-only user, assistant, and tool events as Markdown. Compaction surface replacements, reasoning, injected context, and request metadata stay out of the transcript. One plugin-wide copy operation owns the browser clipboard, with an overlay toast for success and failure. --- .../copy-markdown.expected.md | 75 +++ apps/web/tests/navigation-panes.e2e.ts | 38 ++ docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../src/client/slot-catalog.ts | 1 + .../references/packages.md | 2 +- .../session-log-export/README.i18n.yaml | 58 +- .../session-log-export/README.md | 35 +- .../session-log-export/README.zh.md | 35 +- .../session-log-export/package.json | 4 +- .../src/client/CopyToast.tsx | 39 ++ .../src/client/HeaderAction.tsx | 38 +- .../src/client/copy-controller.ts | 121 +++++ .../session-log-export/src/client/index.ts | 23 +- .../session-log-export/src/client/locales.ts | 6 + .../session-log-export/src/index.ts | 67 ++- .../session-log-export/src/markdown.ts | 302 +++++++++++ .../session-log-export/src/routes.ts | 10 +- .../tests/client-apply.client.spec.tsx | 24 +- .../tests/copy-controller.client.spec.ts | 175 ++++++ .../tests/copy-toast.client.spec.tsx | 56 ++ .../tests/header-action.client.spec.tsx | 52 +- .../tests/markdown-route.host.spec.ts | 176 ++++++ .../tests/markdown.host.spec.ts | 508 ++++++++++++++++++ .../session-log-export/tsconfig.client.json | 3 + .../session-log-export/tsconfig.host.json | 1 + pnpm-lock.yaml | 3 + 27 files changed, 1788 insertions(+), 68 deletions(-) create mode 100644 apps/web/tests/expected/navigation-panes/copy-markdown.expected.md create mode 100644 packages/session-query/session-log-export/src/client/CopyToast.tsx create mode 100644 packages/session-query/session-log-export/src/client/copy-controller.ts create mode 100644 packages/session-query/session-log-export/src/markdown.ts create mode 100644 packages/session-query/session-log-export/tests/copy-controller.client.spec.ts create mode 100644 packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx create mode 100644 packages/session-query/session-log-export/tests/markdown-route.host.spec.ts create mode 100644 packages/session-query/session-log-export/tests/markdown.host.spec.ts diff --git a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md new file mode 100644 index 0000000000..1be7dec876 --- /dev/null +++ b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md @@ -0,0 +1,75 @@ +# NavScenario: first run bash to + +> NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop. + +
3 previous messages + +**Tool: `bash`** + +```json +{ + "command": "echo NAVIGATION_OK", + "description": "Print NAVIGATION_OK" +} +``` + +**Result** + +``` +NAVIGATION_OK +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-a.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-a.md +file + +1: # alpha nav + +(End of file - total 1 lines) + +``` + +**Tool: `read`** + +```json +{ + "file_path": "nav-b.md" +} +``` + +**Result** + +``` +{{cwd}}/workspace/nav-b.md +file + +1: # beta nav + +(End of file - total 1 lines) + +``` + +
+ +FIRST_DONE + +> Reply in markdown with: a level-2 heading "Navigation Summary", a bulleted list of exactly two items, and a fenced code block containing echo WATERFALL. Then stop. + +## Navigation Summary + +- alpha nav +- beta nav + +``` +echo WATERFALL +``` diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 75202aea47..92999d864f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -31,6 +31,7 @@ const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') const CODE_CARD_EXPECTED = join(CODE_CARD_DIR, 'code-card.expected.md') +const COPY_MARKDOWN_EXPECTED = join(CODE_CARD_DIR, 'copy-markdown.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'navigation-panes-web-e2e' const EXPORTED_LOG_FILE = `session.v${SESSION_FORMAT_VERSION}.jsonl` @@ -412,6 +413,43 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } }, 120_000) + it.skipIf(MODE === 'record')('copies the complete Session as Markdown from the Session Header', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown')) + await ensureSeedOpen(page) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + const markdownResponse = page.waitForResponse(response => + response.request().method() === 'GET' + && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) + const copyButton = page.getByRole('button', { name: 'More actions' }) + await copyButton.click() + await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click() + const response = await markdownResponse + expect(response.status()).toBe(200) + expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') + await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) + + const markdown = await page.evaluate(() => navigator.clipboard.readText()) + const normalized = markdown.split(scaffold.workspaceCwd).join('{{cwd}}') + // compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one. + await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE) + expect(normalized).toContain('# NavScenario: first run bash to') + expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK') + expect(normalized).toContain('
3 previous messages') + expect(normalized).toContain('**Tool: `bash`**') + expect(normalized).toContain('NAVIGATION_OK') + expect(normalized).toContain('FIRST_DONE') + expect(normalized).toContain('Navigation Summary') + expect(normalized).not.toContain('The user wants me to follow') + expect(normalized).not.toContain('{{system}}') + expect(normalized).not.toContain('request/header') + expect(normalized.endsWith('\n')).toBe(true) + expect(normalized.endsWith('\n\n')).toBe(false) + + await copyButton.click() + expect(await page.getByRole('menuitem', { name: 'Copy as Markdown' }).isDisabled()).toBe(false) + await page.keyboard.press('Escape') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8031ee9b3b..2ee5fbad73 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2859,7 +2859,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 393f5827de..eb55be3fc1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2861,7 +2861,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:46`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ 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 50bcbe529a..7c4cd14488 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -3399,6 +3399,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'', 'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'', 'client-ui-workspace RowActionToast id \'workspace.row-toast\'', + 'session-log-export SessionMarkdownCopyToast id \'session-log-download.copy-toast\'', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'shell.overlay\', () => ctx.slots.register(\n { name: \'shell.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index cd38b17726..fcf4213fd2 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -381,7 +381,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | Package | Config | Description | |---|---|---| -| `@deepseek-ai/dsh-session-log-export` | yes | Web Session-log export command and shared download dialog | +| `@deepseek-ai/dsh-session-log-export` | yes | Web Session-log ZIP export and Session Markdown copy | | `@deepseek-ai/dsh-session-query-sqlite` | yes | Concrete ctx.sessionQuery backend with SQLite FTS5 search | ## settings diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 5bcd32d6c0..94e9c5ec76 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -3,20 +3,23 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md /: - en: 3b67077a6dfacd19 - zh: e5b78d039d310dc2 + en: 2ed4e25828621cc5 + zh: e9aa38b58d716093 /deepseek-ai-dsh-session-log-export: en: eea87f5da57ff494 zh: db388203522b840f /deepseek-ai-dsh-session-log-export/summary: - en: 26be400b3e9f7bb7 - zh: b6e78f1f417a0e4e + en: 9268b3aaeaf82673 + zh: 99cc5b0de0b5a8bd /deepseek-ai-dsh-session-log-export/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-session-log-export/use-this-package: - en: 1135b03eb3423c74 - zh: 2fd2a18d503df70c + en: f37f81af3601784d + zh: 89b47be0660c8b8b +/deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: + en: 36a79c163dd92469 + zh: b31cb116dc950941 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 2b9dbec4877d332e zh: dcf6638938eedb01 @@ -33,38 +36,41 @@ en: 44be1f1d1903d12a zh: c8f1eb5b92b71a30 /deepseek-ai-dsh-session-log-export/use-this-package/failures: - en: 3fc8985946d67024 - zh: 5bb40a4b531d68f4 + en: 3c274dea47f7f2c6 + zh: e83b2d6ca29f22ec /deepseek-ai-dsh-session-log-export/understand-the-implementation: en: 7cb82a6851405f95 zh: b8c525170d19dc6c /deepseek-ai-dsh-session-log-export/understand-the-implementation/design-split: - en: d135d90a02c6d829 - zh: 587d7ff2d17b056b + en: 3bb8e18b107c353f + zh: 3cc45ce2b5699066 /deepseek-ai-dsh-session-log-export/understand-the-implementation/download-flow: - en: 028ea3bff05250ba - zh: 63250c4d3369ebbd + en: bd46d2fa1273a6a4 + zh: 4d3eb2cf0a9bf12b +/deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: + en: fc740b3b80678249 + zh: 106f93ce651127d4 /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 /deepseek-ai-dsh-session-log-export/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control: - en: 55c1a61a041a2aeb - zh: 48e05043cc42304f -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control/what-the-model-sees: - en: 6b3c3cfc5d68d5f9 - zh: b1689c57ee3b4ccb -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control/token-effect: - en: 380535e564a4ea2c - zh: 2672eac76afd90f5 -/deepseek-ai-dsh-session-log-export/model-experience/human-export-control/kv-cache-effect: - en: 78163c30fdf0b43d - zh: 165043e307b38cb3 +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls: + en: fc738fb50c699115 + zh: d9184c11b904d36c +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls/what-the-model-sees: + en: e7784b93213942e5 + zh: 598c2ce6af076d2f +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls/token-effect: + en: 312b2def8b2cd7d4 + zh: 9af5e3a7cd12ce65 +/deepseek-ai-dsh-session-log-export/model-experience/human-export-and-copy-controls/kv-cache-effect: + en: 61e7dbf79010d071 + zh: 29294355598c0476 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work: - en: 9978d234cbfe7e73 - zh: 9ba0b12300ff4d38 + en: 0c0c46a6ec873533 + zh: 7c4183401c37d356 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note: en: 4ee4d24c26c127bf zh: 1facdfbfa3ccefbc diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 79eefc0a3b..01155f799b 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -1,5 +1,5 @@ --- -description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command." +description: "Web Session-log ZIP export and Markdown copy: Host streaming, authenticated routes, Session Header actions, and the /export command." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` lets the Web interface download a session's full history: a `Download session log` menu item under the Session Header's more-actions button and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow. +`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,9 +25,17 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Download session log` from the Session Header's more-actions menu or type `/export`; the browser downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`. -When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; export remains available independently. +### Copy as Markdown + +`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. + +The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. The text ends with exactly one newline. + +The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header disables its copy row until the operation settles, then restores it. A success or failure toast is shown through the root overlay, so it survives the menu closing or the Session changing. + +When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently. ### When to choose it @@ -63,7 +71,7 @@ Attachment collection reads declared content fields of built-in Session events a ### Failures -The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. +The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. A Markdown copy reports a failure toast when the Host route is unavailable or the browser rejects clipboard access; it never reports success for a partial or missing transcript. ----- @@ -77,7 +85,7 @@ This section explains how the package wires the export control and points at the ### Design split -The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download. +The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` and `/api/session.markdown` Fetch routes to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream, and [`src/markdown.ts`](src/markdown.ts) renders the Markdown transcript. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download and Markdown copy controllers and UI, and observes `command/executed` so only the submitting browser starts a download. The Header’s More action uses the shared compact Button, with a 28px square target and the same radius and hover fill as the right-sidebar expand control. @@ -87,6 +95,12 @@ Both entry paths issue a `HEAD` preflight to the document-relative `api/session. The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. +### Markdown copy flow + +The Header asks the Markdown copy controller for the clicked Session. The controller fetches the document-relative `api/session.markdown?sessionId=` route and writes the response text with the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title through the same query service, and passes the raw events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. + +The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its success notice always describe the first click's Session. Disposal aborts the fetch and suppresses a late clipboard write or notice. +
----- @@ -106,19 +120,19 @@ Read these pages when the package-level contract is not enough. They move from t ## Model Experience -### Human `/export` control +### Human export and copy controls #### What the model sees -Nothing. `/export` stays on the human-command plane, and the ZIP download does not enter model history. +Nothing. `/export` stays on the human-command plane, and the ZIP download does not enter model history. The Markdown copy action is also human-only: it reads the committed log, writes the browser clipboard, and appends no Session event. #### Token effect -Zero. The command creates no model turn. +Zero. The command creates no model turn, and neither export control changes a request. #### KV Cache effect -None. The log-only command lifecycle and browser download do not change the derived request prefix. +None. The log-only command lifecycle, browser download, and clipboard write do not change the derived request prefix. ## Known Limitations and Deferred Work @@ -129,6 +143,7 @@ These limits define when this package is a poor fit or needs special operational - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned. - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. +- **Markdown contains committed history only** — an unsettled live token chunk has no committed text, so it is absent from the copied transcript. Compaction and fork inheritance follow the raw log rather than the current model context. ### Dev Note diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 148102ec04..e067ac8e43 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。" +description: "Web 会话日志 ZIP 导出与 Markdown 复制:Host 流式传输、认证路由、Session Header 操作与 /export 命令。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 更多操作按钮下的 `下载 Session 日志` 菜单项与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控件和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。 +`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,9 +25,17 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `下载 Session 日志` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。 -挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;导出功能保持独立可用。 +### 复制为 Markdown + +`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。 + +格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。文本以恰好一个换行结尾。 + +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 在操作结束前禁用各自的复制行,结束后恢复。成功或失败浮层通过根 overlay 显示,因此菜单关闭或 Session 切换后仍然存在。 + +挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 ### 何时选择 @@ -63,7 +71,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 失败 -当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 +当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Host 路由不可用或浏览器拒绝剪贴板访问时,Markdown 复制显示失败浮层;它不会为不完整或缺失的记录报告成功。 ----- @@ -77,7 +85,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 设计拆分 -本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 +本包分为两部分。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` 与 `/api/session.markdown` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流,[`src/markdown.ts`](src/markdown.ts) 渲染 Markdown 记录。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载与 Markdown 复制控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正方形,与右侧栏展开控件共用圆角和 hover 底色。 @@ -87,6 +95,12 @@ Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正 Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 +### Markdown 复制流程 + +Header 向 Markdown 复制控制器请求被点击的 Session。控制器读取文档相对的 `api/session.markdown?sessionId=` 路由,并用共享剪贴板助手写入响应文本。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,通过同一查询服务折叠最新标题,再把原始事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 + +控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其成功提示始终描述第一次点击的 Session。释放时中止请求,并抑制迟到的剪贴板写入或提示。 +
----- @@ -106,19 +120,19 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ## 模型体验 -### 用户 `/export` 控制 +### 用户导出与复制控制 #### 模型看到什么 -无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。 +无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。Markdown 复制操作同样只属于人类:它读取已提交日志、写入浏览器剪贴板,且不追加 Session 事件。 #### Token 影响 -为零。该命令不创建模型轮次。 +为零。该命令不创建模型轮次,两个导出控件也不会改变请求。 #### KV Cache 影响 -无。仅日志命令生命周期与浏览器下载不会改变派生请求前缀。 +无。仅日志命令生命周期、浏览器下载与剪贴板写入不会改变派生请求前缀。 ## 已知限制与延期工作 @@ -129,6 +143,7 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。 - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 +- **Markdown 只包含已提交历史**——未稳定的实时 token 分块没有已提交文本,因此不会出现在复制的记录中。压缩与 fork 继承遵循原始日志,而非当前模型上下文。 ### 开发备注 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index ec82c4759c..d5c457baa6 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-session-log-export", - "description": "Web Session-log export command and shared download dialog", + "description": "Web Session-log ZIP export and Session Markdown copy", "version": "0.2.1-rc.2", "publishConfig": { "access": "public" @@ -58,6 +58,7 @@ "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-commands": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", @@ -75,6 +76,7 @@ "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", "@deepseek-ai/dsh-client-ui-renderer", "@deepseek-ai/dsh-client-ui-session" ], diff --git a/packages/session-query/session-log-export/src/client/CopyToast.tsx b/packages/session-query/session-log-export/src/client/CopyToast.tsx new file mode 100644 index 0000000000..3d49e52e04 --- /dev/null +++ b/packages/session-query/session-log-export/src/client/CopyToast.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from 'react' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type { SessionMarkdownCopyState } from './copy-controller.ts' +import { NS } from './locales.ts' + +/** Copy state and dismissal bound into the root overlay entry. */ +export interface SessionMarkdownCopyToastInjected { + hooks: { sessionMarkdownCopy: ObservableSnapshot } + dismiss: () => void +} + +/** Props of the root overlay copy notice. */ +export type SessionMarkdownCopyToastProps = PropsLocale + & InjectFace + +/** + * Show the settled copy outcome outside the Session Header and menu lifetime. + * @param props - copy notice hook, dismissal action, and localized copy. + * @returns the success or failure toast, or null while no notice shows. + */ +export function SessionMarkdownCopyToast({ + useSessionMarkdownCopy, dismiss, t, +}: SessionMarkdownCopyToastProps): ReactNode { + const notice = useSessionMarkdownCopy(state => state.notice) + if (notice === null) return null + return notice.kind === 'success' + ? + : ( + } + onDone={dismiss} + /> + ) +} diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index 5104dee910..55af4ab1e0 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -3,14 +3,31 @@ import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { Button, IconDownloadOutlineRegular, IconEllipsisOutlineRegular, IconPaperPlaneOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconCopyOutlineRegular, IconDownloadOutlineRegular, IconEllipsisOutlineRegular, + IconPaperPlaneOutlineRegular, Menu, +} from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' +import type { SessionMarkdownCopyState } from './copy-controller.ts' import css from './HeaderAction.module.css' -/** Session download controls with observable feedback availability and a Session feedback action. */ -export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected { - hooks: SessionLogDownloadDialogInjected['hooks'] & { feedbackAvailable: ObservableSnapshot } +/** Markdown copy state and action bound into the Session Header contribution. */ +export interface SessionMarkdownCopyHeaderInjected { + hooks: { sessionMarkdownCopy: ObservableSnapshot } + /** + * Copy one complete Session transcript to the clipboard. + * @param sessionId - Session captured by the menu click. + */ + copyMarkdown: (sessionId: SessionId) => Promise +} + +/** Session download controls plus Markdown copy and observable feedback availability. */ +export interface SessionLogDownloadHeaderInjected + extends SessionLogDownloadDialogInjected, SessionMarkdownCopyHeaderInjected { + hooks: SessionLogDownloadDialogInjected['hooks'] + & SessionMarkdownCopyHeaderInjected['hooks'] + & { feedbackAvailable: ObservableSnapshot } /** * Open the existing Session feedback draft without recording feedback; no-op after the feedback plugin unloads. * @param sessionId - Session whose feedback form to open. @@ -22,14 +39,19 @@ export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDial export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace /** - * Render the Session Header menu with download and optional feedback actions. - * @param props - Session runtime, download controller, and localized copy. + * Render the Session Header menu with Markdown copy, download, and optional + * feedback actions. + * @param props - Session runtime, download and copy state, and localized copy. * @returns the persistent Header action and Session-scoped dialog. */ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode { - const { sessionId, useSessionLogDownload, useFeedbackAvailable, request, openFeedback, t } = props + const { + sessionId, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable, + request, copyMarkdown, openFeedback, t, + } = props const feedbackAvailable = useFeedbackAvailable(value => value) const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) + const copying = useSessionMarkdownCopy(state => state.copying) const busy = entry?.status === 'downloading' const [open, setOpen] = useState(false) @@ -41,12 +63,14 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderPr dense onClose={() => { setOpen(false) }} items={[ + { id: 'copy', label: t('menu.copyMarkdown'), icon: , disabled: copying }, { id: 'download', label: t('menu.download'), icon: , disabled: busy }, ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [], ]} onSelect={(id) => { setOpen(false) if (id === 'feedback') openFeedback(sessionId) + else if (id === 'copy') void copyMarkdown(sessionId) else void request(sessionId) }} anchor={( diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts new file mode 100644 index 0000000000..effb39e190 --- /dev/null +++ b/packages/session-query/session-log-export/src/client/copy-controller.ts @@ -0,0 +1,121 @@ +/** + * Browser controller for the Session Header's "Copy as Markdown" action. One + * plugin instance owns one pending copy: the clipboard is a browser-wide + * destination, so a second request joins the pending operation instead of + * racing it. The Header disables its copy row from this state; the toast lives + * in a separate `shell.overlay` entry so it survives the menu closing or the + * Session changing. + * @module + */ + +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +import { SESSION_MARKDOWN_EXPORT_ROUTE } from '../routes.ts' + +/** Whether the latest copy attempt succeeded or failed. */ +export type SessionMarkdownCopyNoticeKind = 'success' | 'failed' + +/** One settled copy notice; `seq` keys the toast so a repeat copy restarts it. */ +export interface SessionMarkdownCopyNotice { + readonly kind: SessionMarkdownCopyNoticeKind + readonly seq: number +} + +/** Copy state shared by every Header row and the root overlay toast. */ +export interface SessionMarkdownCopyState { + /** Whether this plugin instance has a copy operation still pending. */ + copying: boolean + /** Latest settled copy notice, or null while none shows. */ + notice: SessionMarkdownCopyNotice | null +} + +/** HTTP carrier for the Markdown route. */ +type Fetch = (input: string | URL, init?: RequestInit) => Promise +/** Host clipboard write; false means the browser rejected it. */ +type Write = (text: string) => Promise + +const INITIAL: SessionMarkdownCopyState = { copying: false, notice: null } + +/** Owns the one pending Markdown copy for this plugin instance. */ +export class SessionMarkdownCopyController { + /** uSES-safe state source shared by every Header row and the overlay toast. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + private pending: { readonly abort: AbortController; readonly done: Promise } | undefined + private disposed = false + private noticeSeq = 0 + + constructor( + private readonly fetcher: Fetch = (input, init) => fetch(input, init), + private readonly write: Write = writeClipboard, + ) {} + + /** + * Copy one Session's complete transcript. A request received while a copy is + * pending joins that operation and keeps its originally captured Session. + * @param sessionId - Session captured by the click. + * @returns after the clipboard write settles, a failure notice is published, or a late post-disposal request is ignored. + */ + copy(sessionId: SessionId): Promise { + if (this.disposed) return Promise.resolve() + if (this.pending !== undefined) return this.pending.done + const abort = new AbortController() + const done = this.run(sessionId, abort.signal).finally(() => { + this.pending = undefined + }) + this.pending = { abort, done } + return done + } + + /** Clear the shown toast without affecting a pending copy. */ + dismiss(): void { + if (this.store.getSnapshot().notice === null) return + this.store.update((state) => { state.notice = null }) + } + + /** + * Abort a pending fetch, suppress a late clipboard write or notice, and + * reach quiescence. + * @returns after the pending operation settles. + */ + async dispose(): Promise { + this.disposed = true + const pending = this.pending + pending?.abort.abort() + await pending?.done + this.store.set(INITIAL) + } + + private async run(sessionId: SessionId, signal: AbortSignal): Promise { + this.store.update((state) => { state.copying = true }) + try { + const query = new URLSearchParams({ sessionId }) + const route = `${SESSION_MARKDOWN_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { signal }) + if (!response.ok) throw new Error(`Session Markdown request failed: HTTP ${String(response.status)}`) + const markdown = await response.text() + // The plugin may unload while the fetch or a clipboard call is pending; + // never write or report after disposal. + if (this.isCancelled()) return + if (!await this.write(markdown)) throw new Error('the browser rejected the clipboard write') + if (this.isCancelled()) return + this.publishNotice('success') + } catch { + if (this.isCancelled()) return + this.publishNotice('failed') + } finally { + this.store.update((state) => { state.copying = false }) + } + } + + private isCancelled(): boolean { + return this.disposed + } + + private publishNotice(kind: SessionMarkdownCopyNoticeKind): void { + this.store.update((state) => { + state.notice = { kind, seq: ++this.noticeSeq } + }) + } +} diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index bcba0dd581..cf97b268c2 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -10,6 +10,8 @@ import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-message-feedback/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from './controller.ts' +import { SessionMarkdownCopyController } from './copy-controller.ts' +import { SessionMarkdownCopyToast, type SessionMarkdownCopyToastInjected } from './CopyToast.tsx' import { SessionLogDownloadHeaderAction, type SessionLogDownloadHeaderInjected } from './HeaderAction.tsx' import { en, NS, zh, type SessionLogDownloadKey } from './locales.ts' @@ -30,13 +32,16 @@ export type { SessionLogDownloadEntry, SessionLogDownloadState } from './control export const inject = ['slots', 'locale'] /** - * Provide the download controller and mount its modal into the Session Header. + * Provide the download controller and mount its modal, the Markdown copy + * controller and its root toast, and both Header actions. * @param ctx - browser context carrying slots and locale services. */ export function apply(ctx: ClientContext): void { const controller = new SessionLogDownloadController() + const copyController = new SessionMarkdownCopyController() ctx.provide('sessionLogDownload', controller) ctx.effect(() => async () => { await controller.dispose() }, 'session-log-download: browser download lifecycle') + ctx.effect(() => async () => { await copyController.dispose() }, 'session-log-download: browser Markdown copy lifecycle') ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-log-download: browser dictionaries') const feedbackAvailable = createSnapshotStore(false) ctx.inject(['feedbackUi'], (scope: ClientContext) => { @@ -48,13 +53,27 @@ export function apply(ctx: ClientContext): void { ctx.on('command/executed', (sessionId, commandName, result) => { if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId) }) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', + id: 'session-log-download.copy-toast', + locale: NS, + inject: (): SessionMarkdownCopyToastInjected => ({ + hooks: { sessionMarkdownCopy: copyController.store }, + dismiss: () => { copyController.dismiss() }, + }), + }, SessionMarkdownCopyToast)) ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ name: 'conversation.session.header.utilities', id: 'session-log-download', locale: NS, inject: (): SessionLogDownloadHeaderInjected => ({ - hooks: { sessionLogDownload: controller.store, feedbackAvailable }, + hooks: { + sessionLogDownload: controller.store, + sessionMarkdownCopy: copyController.store, + feedbackAvailable, + }, request: (sessionId: SessionId) => controller.download(sessionId), + copyMarkdown: (sessionId: SessionId) => copyController.copy(sessionId), dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, // The feedback plugin can unload between the menu render and this click. openFeedback: (sessionId: SessionId) => { ctx.get('feedbackUi')?.openSession(sessionId) }, diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 1fd687a379..86d37f125a 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -4,8 +4,11 @@ export const NS = 'session-log-download' /** Simplified-Chinese Session export strings. */ export const zh = { 'header.more': '更多操作', + 'menu.copyMarkdown': '复制为 Markdown', 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', + 'toast.copied': 'Session 已复制为 Markdown', + 'toast.copyFailed': '无法复制 Session,请检查剪贴板权限后重试', 'dialog.preparingTitle': '正在导出 Session', 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', 'dialog.successTitle': 'Session 导出已开始下载', @@ -18,8 +21,11 @@ export const zh = { /** English Session export strings. */ export const en: Record = { 'header.more': 'More actions', + 'menu.copyMarkdown': 'Copy as Markdown', 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', + 'toast.copied': 'Session copied as Markdown.', + 'toast.copyFailed': 'Could not copy the Session. Check clipboard access and try again.', 'dialog.preparingTitle': 'Exporting Session', 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 98870d0618..900aadf469 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -17,7 +17,8 @@ import { type SessionLogCompressionLevel, type SessionLogExportReady, } from './archive.ts' -import { SESSION_LOG_EXPORT_PATH } from './routes.ts' +import { formatSessionMarkdown } from './markdown.ts' +import { SESSION_LOG_EXPORT_PATH, SESSION_MARKDOWN_EXPORT_PATH } from './routes.ts' export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -40,7 +41,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -export { SESSION_LOG_EXPORT_PATH } from './routes.ts' +export { SESSION_LOG_EXPORT_PATH, SESSION_MARKDOWN_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { @@ -99,12 +100,74 @@ export function apply(ctx: Context, config: Config = {}): void { return new Response(null, { status: response.status, headers: response.headers }) }, }) + connectionOf(ctx).fetch.register({ + path: SESSION_MARKDOWN_EXPORT_PATH, + methods: ['GET', 'HEAD'], + requestBody: 'buffered', + fetch: async (request) => { + const response = await sessionMarkdownResponse(ctx, request) + if (request.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) + }, + }) } function connectionOf(ctx: Context): SessionLogConnection { return Reflect.get(ctx, 'connection') as SessionLogConnection } +/** + * Produce one complete Session transcript as Markdown. The read is a detached + * export observation through session-query's live-preferred corpus, so it + * creates no Agent, retains no Session, and writes no Session data. Abort is + * observed before and after each read; the corpus read itself has no + * cancellation parameter. + * @param ctx - host context carrying Connection and session-query. + * @param request - authenticated Fetch request with a required `sessionId` query. + * @returns the Markdown response, or a sanitized status for invalid, missing, or unavailable reads. + */ +async function sessionMarkdownResponse(ctx: Context, request: Request): Promise { + const sessionIdValue = new URL(request.url).searchParams.get('sessionId') + if (sessionIdValue === null || sessionIdValue.length === 0) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + const sessionQuery = ctx.get('sessionQuery') + if (sessionQuery === undefined) { + return new Response('session markdown export is unavailable: missing session-query service', { status: 500 }) + } + const sessionId = brandString(sessionIdValue) + try { + request.signal.throwIfAborted() + const snapshot = await sessionQuery.readSession(sessionId) + request.signal.throwIfAborted() + const title = await sessionQuery.readTitle(sessionId, request.signal) + request.signal.throwIfAborted() + return new Response(formatSessionMarkdown({ title: title?.title, events: snapshot.events }), { + headers: { 'content-type': 'text/markdown; charset=utf-8' }, + }) + } catch (error: unknown) { + request.signal.throwIfAborted() + // Absence is session-query's decision: match its stable error code rather + // than adding a Host value dependency on the session-query error class. + if (isSessionNotFound(error)) return new Response('session not found', { status: 404 }) + // Every other failure stays opaque: the message may carry absolute host + // paths into the browser toast. + return new Response('session markdown export failed to read the stored log', { status: 500 }) + } +} + +/** + * Identify the session-query "not found" failure by its stable machine code. + * @param error - an arbitrary rejection from the query service. + * @returns whether the error reports a missing Session. + */ +function isSessionNotFound(error: unknown): boolean { + return error instanceof Error + && 'code' in error + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND' +} + async function sessionLogExportResponse( ctx: Context, request: Request, diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts new file mode 100644 index 0000000000..fa5ba7b433 --- /dev/null +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -0,0 +1,302 @@ +/** + * Renders one complete committed Session log as Markdown for the "Copy as + * Markdown" action. The raw log is authoritative: compaction surface + * replacements never remove earlier messages, and fork-inherited history + * appears once. Reasoning, system and developer messages, injected non-human + * context, request headers, and runtime records are omitted. + * @module + */ + +import type { + AssistantMessage, + SessionEvent, + ToolResultMessage, + UserMessage, +} from '@deepseek-ai/dsh-session' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' + +/** Heading used when the log carries no title and the Session has no name. */ +export const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session' + +/** Input to {@link formatSessionMarkdown}. */ +export interface SessionMarkdownInput { + /** Latest log-backed title, or `undefined` when the log has no title event. */ + readonly title: string | undefined + /** Complete committed raw event log in seq order, including inherited history. */ + readonly events: readonly SessionEvent[] +} + +interface AssistantEntry { + readonly kind: 'assistant' + readonly text: string + readonly toolCalls: boolean +} + +interface ToolEntry { + readonly kind: 'tool' + readonly name: string + readonly arguments: string + result: ToolResult | undefined +} + +interface ToolResult { + readonly text: string + readonly isError: boolean + readonly reason: string | undefined +} + +interface UserSegment { + readonly kind: 'user' + readonly text: string +} + +type TurnEntry = AssistantEntry | ToolEntry +type TurnSegment = UserSegment | TurnEntry + +interface Turn { + readonly segments: TurnSegment[] + readonly calls: Map +} + +/** + * Format one complete Session log as Markdown. + * @param input - the latest title and the complete committed event log. + * @returns Markdown ending in exactly one newline; an empty Session returns its title heading only. + */ +export function formatSessionMarkdown(input: SessionMarkdownInput): string { + const sections = [`# ${normalizedTitle(input.title)}`] + for (const turn of collectTurns(input.events)) { + const finalIndex = finalReplyIndex(turn.segments) + const end = finalIndex < 0 ? turn.segments.length : finalIndex + let pending: TurnEntry[] = [] + for (const segment of turn.segments.slice(0, end)) { + if (segment.kind === 'user') { + if (pending.length > 0) { + sections.push(collapsibleSection(pending)) + pending = [] + } + sections.push(quote(segment.text)) + } else { + pending.push(segment) + } + } + if (pending.length > 0) sections.push(collapsibleSection(pending)) + const final = turn.segments[finalIndex] + const finalText = final?.kind === 'assistant' ? final.text.trim() : '' + if (finalText !== '') sections.push(finalText) + } + return `${sections.join('\n\n').trimEnd()}\n` +} + +function normalizedTitle(title: string | undefined): string { + const line = title?.replace(/\r\n?|\n/gu, ' ').trim() ?? '' + return line === '' ? UNTITLED_SESSION_MARKDOWN_TITLE : line +} + +function collectTurns(events: readonly SessionEvent[]): Turn[] { + const turns: Turn[] = [] + let current: Turn | undefined + const begin = (): Turn => { + current = { segments: [], calls: new Map() } + turns.push(current) + return current + } + const active = (): Turn => current ?? begin() + for (const event of events) { + switch (event.type) { + case 'turn/start': + begin() + break + case 'turn/end': + current = undefined + break + case 'user/message': { + if (event.surfaceOp !== 'append' || event.data.source.kind !== 'user') break + const text = visibleContent(event.data.content) + if (text !== '') active().segments.push({ kind: 'user', text }) + break + } + case 'assistant/message': { + if (event.surfaceOp !== 'append') break + const message = event.data.message + const text = assistantText(message) + const toolCalls = message.content.some(block => block.type === 'tool-call') + if (text.trim() !== '') active().segments.push({ kind: 'assistant', text, toolCalls }) + break + } + case 'tool/call': { + const turn = active() + const entry: ToolEntry = { + kind: 'tool', + name: event.data.name, + arguments: event.data.arguments, + result: undefined, + } + turn.segments.push(entry) + turn.calls.set(String(event.data.callId), entry) + break + } + case 'tool/result': { + if (event.surfaceOp !== 'append') break + const turn = active() + const callId = String(event.data.message.toolCallId) + let entry = turn.calls.get(callId) + if (entry === undefined) { + entry = { kind: 'tool', name: 'unknown', arguments: '', result: undefined } + turn.segments.push(entry) + turn.calls.set(callId, entry) + } + entry.result = toolResult(event.data) + break + } + default: + break + } + } + return turns.filter(turn => turn.segments.length > 0) +} + +function finalReplyIndex(segments: readonly TurnSegment[]): number { + const index = segments.length - 1 + const entry = segments[index] + return entry?.kind === 'assistant' && !entry.toolCalls ? index : -1 +} + +function collapsibleSection(entries: readonly TurnEntry[]): string { + const summary = entries.length === 1 ? '1 previous message' : `${String(entries.length)} previous messages` + const body = entries.map(renderEntry).join('\n\n') + return `
${summary}\n\n${body}\n\n
` +} + +function renderEntry(entry: TurnEntry): string { + if (entry.kind === 'assistant') { + return `**Assistant**\n\n${escapeWrapperTagsOutsideFences(entry.text.trim())}` + } + const heading = `**Tool: \`${escapeWrapperTags(entry.name)}\`**` + const argumentsText = entry.arguments === '' + ? '(no arguments)' + : fenced(jsonArguments(entry.arguments), 'json') + const result = entry.result + const resultHeading = result?.isError === true ? '**Result (error)**' : '**Result**' + let resultBody = '' + if (result === undefined) { + resultBody = '(no result recorded)' + } else if (result.reason !== undefined && result.reason !== '') { + resultBody = `Error: ${escapeWrapperTags(result.reason)}` + if (result.text !== '') resultBody += `\n\n${fenced(result.text)}` + } else if (result.text === '') { + resultBody = '(no output)' + } else { + resultBody = fenced(result.text) + } + return `${heading}\n\n${argumentsText}\n\n${resultHeading}\n\n${resultBody}` +} + +function visibleContent(content: UserMessage['content']): string { + const parts: string[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + if (block.text !== '') parts.push(block.text) + break + case 'image': + parts.push(imageLabel(block.attachment)) + break + case 'file': + parts.push(fileLabel(block.attachment)) + break + default: + break + } + } + return parts.join('\n\n') +} + +function assistantText(message: AssistantMessage): string { + return visibleContent(message.content) +} + +function toolResult(data: { + readonly message: ToolResultMessage + readonly error?: { readonly name: string; readonly code: string; readonly reason?: string } +}): ToolResult { + return { + text: visibleContent(data.message.content), + isError: data.message.isError === true, + reason: data.error?.reason, + } +} + +function quote(text: string): string { + const normalized = text.replace(/\r\n?/gu, '\n').replace(/\n+$/u, '') + return normalized.split('\n').map(line => line === '' ? '>' : `> ${line}`).join('\n') +} + +function imageLabel(ref: ImageAttachmentRef): string { + return `[Image: ${labelName(ref.name)} (${String(ref.width)}×${String(ref.height)}, ${String(ref.bytes)} bytes)]` +} + +function fileLabel(ref: FileAttachmentRef): string { + return `[File: ${labelName(ref.name)} (${String(ref.bytes)} bytes)]` +} + +function labelName(name: string | undefined): string { + const normalized = name?.replace(/\s+/gu, ' ').replace(/\]/gu, '\\]').trim() ?? '' + return normalized === '' ? 'attachment' : normalized +} + +function jsonArguments(argumentsText: string): string { + let parsed: unknown + try { + parsed = JSON.parse(argumentsText) + } catch { + return argumentsText + } + return JSON.stringify(parsed, null, 2) +} + +function fenced(text: string, language = ''): string { + const content = text.replace(/\n+$/u, '') + let longest = 0 + for (const match of content.matchAll(/`+/gu)) longest = Math.max(longest, match[0].length) + const fence = '`'.repeat(Math.max(3, longest + 1)) + return `${fence}${language}\n${content}\n${fence}` +} + +// Arbitrary commentary inside a generated details section must not close the +// wrapper; fenced code blocks keep their exact text because their backtick +// fence already prevents HTML parsing. +function escapeWrapperTagsOutsideFences(markdown: string): string { + const lines = markdown.replace(/\r\n?/gu, '\n').split('\n') + let fenceCharacter: string | undefined + let fenceLength = 0 + const output: string[] = [] + for (const line of lines) { + if (fenceCharacter === undefined) { + const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line) + if (opening !== null) { + const marker = String(opening[1]) + const info = String(opening[2]) + if (marker.startsWith('~') || !info.includes('`')) { + fenceCharacter = marker[0] + fenceLength = marker.length + output.push(line) + continue + } + } + output.push(escapeWrapperTags(line)) + continue + } + const closing = new RegExp(`^ {0,3}${fenceCharacter}{${String(fenceLength)},}\\s*$`, 'u') + if (closing.test(line)) { + fenceCharacter = undefined + fenceLength = 0 + } + output.push(line) + } + return output.join('\n') +} + +function escapeWrapperTags(text: string): string { + return text.replace(/<(?=\/?(?:details|summary)(?=[\s/>]))/giu, '<') +} diff --git a/packages/session-query/session-log-export/src/routes.ts b/packages/session-query/session-log-export/src/routes.ts index e603351995..9df9224466 100644 --- a/packages/session-query/session-log-export/src/routes.ts +++ b/packages/session-query/session-log-export/src/routes.ts @@ -1,6 +1,6 @@ /** - * The absolute pathname the Host registers the export under and the - * document-relative form the browser addresses; see + * The absolute pathnames the Host registers the export routes under and the + * document-relative forms the browser addresses; see * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. */ @@ -9,3 +9,9 @@ export const SESSION_LOG_EXPORT_PATH = '/api/session.export' /** Browser-relative form of {@link SESSION_LOG_EXPORT_PATH}. */ export const SESSION_LOG_EXPORT_ROUTE = SESSION_LOG_EXPORT_PATH.slice(1) + +/** Absolute registration path for the Markdown copy route. */ +export const SESSION_MARKDOWN_EXPORT_PATH = '/api/session.markdown' + +/** Browser-relative form of {@link SESSION_MARKDOWN_EXPORT_PATH}. */ +export const SESSION_MARKDOWN_EXPORT_ROUTE = SESSION_MARKDOWN_EXPORT_PATH.slice(1) diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index caaf8a4e95..d481acf6bd 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -4,13 +4,18 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' +import type { SessionMarkdownCopyToastInjected } from '../src/client/CopyToast.tsx' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import { apply, inject } from '../src/client/index.ts' import type { SessionLogDownloadHeaderInjected } from '../src/client/HeaderAction.tsx' const SID = 'session-export-apply' as SessionId -afterEach(() => { vi.unstubAllGlobals() }) +afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) function declare(slots: SlotRegistry): () => void { return slots.register({ @@ -18,6 +23,7 @@ function declare(slots: SlotRegistry): () => void { children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, + 'shell.overlay': { kind: 'list', scope: 'root' }, }, } as never, () => null) } @@ -54,7 +60,7 @@ describe('session-log-download browser plugin', () => { await b.fiber.dispose() }) - it('provides one controller and removes its Header contribution on disposal', async () => { + it('provides one controller and removes its Header and overlay contributions on disposal', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 }))) const b = await bench() expect(inject).toEqual(['slots', 'locale']) @@ -63,14 +69,24 @@ describe('session-log-download browser plugin', () => { const entry = b.slots.entries('conversation.session.header.utilities')[0] expect(entry?.component).toBe(SessionLogDownloadHeaderAction) expect(entry?.options).toMatchObject({ id: 'session-log-download' }) - const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionLogDownloadDialogInjected)() + const injected = (entry?.inject as unknown as () => SessionLogDownloadHeaderInjected)() await injected.request(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.open).toBe(false) + const overlay = b.slots.entries('shell.overlay')[0] + expect(overlay?.component).toBe(SessionMarkdownCopyToast) + expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) + const overlayInjected = (overlay?.inject as unknown as () => SessionMarkdownCopyToastInjected)() + await injected.copyMarkdown(SID) + expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('failed') + overlayInjected.dismiss() + expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() + await b.fiber.dispose() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) }) it('downloads only for an export execution acknowledged by this browser client', async () => { @@ -98,9 +114,11 @@ describe('session-log-download browser plugin', () => { const b = await bench() b.declaration() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) + expect(b.slots.entries('shell.overlay')).toHaveLength(0) const redeclare = declare(b.slots) await Promise.resolve() expect(b.slots.entries('conversation.session.header.utilities')[0]?.component).toBe(SessionLogDownloadHeaderAction) + expect(b.slots.entries('shell.overlay')[0]?.component).toBe(SessionMarkdownCopyToast) redeclare() await b.fiber.dispose() }) diff --git a/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts new file mode 100644 index 0000000000..bbcaa27711 --- /dev/null +++ b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts @@ -0,0 +1,175 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' + +const SID = 'session-copy-controller' as SessionId +const OTHER = 'session-copy-other' as SessionId + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('SessionMarkdownCopyController', () => { + it('fetches the Markdown route and writes the complete response', async () => { + const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('# Title\n\nText\n', { status: 200 })) + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(fetcher, write) + + await controller.copy(SID) + + expect(fetcher).toHaveBeenCalledOnce() + const [route, init] = fetcher.mock.calls[0]! + expect(route).toBe(`api/session.markdown?sessionId=${SID}`) + expect(init?.method).toBeUndefined() + expect(init?.signal).toBeInstanceOf(AbortSignal) + expect(write).toHaveBeenCalledWith('# Title\n\nText\n') + expect(controller.store.getSnapshot()).toMatchObject({ + copying: false, + notice: { kind: 'success' }, + }) + }) + + it('joins a second-Session request to the pending copy and keeps its captured target', async () => { + const responses = Promise.withResolvers() + const fetcher = vi.fn((_input: string | URL, _init?: RequestInit) => responses.promise) + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(fetcher, write) + + const first = controller.copy(SID) + const second = controller.copy(OTHER) + expect(first).toBe(second) + expect(controller.store.getSnapshot().copying).toBe(true) + expect(fetcher).toHaveBeenCalledOnce() + expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.markdown?sessionId=${SID}`) + + responses.resolve(new Response('markdown for first')) + await first + expect(write).toHaveBeenCalledOnce() + expect(write).toHaveBeenCalledWith('markdown for first') + expect(controller.store.getSnapshot().copying).toBe(false) + + await controller.copy(OTHER) + expect(fetcher).toHaveBeenCalledTimes(2) + expect(fetcher.mock.calls[1]?.[0]).toBe(`api/session.markdown?sessionId=${OTHER}`) + }) + + it('publishes failures for HTTP, transport, and clipboard rejection without leaking rejections', async () => { + const http = new SessionMarkdownCopyController( + async () => new Response('hidden detail', { status: 500 }), + vi.fn(async () => true), + ) + await http.copy(SID) + expect(http.store.getSnapshot()).toMatchObject({ + copying: false, + notice: { kind: 'failed' }, + }) + + const write = vi.fn() + const transport = new SessionMarkdownCopyController(async () => { throw new Error('offline') }, write) + await transport.copy(SID) + expect(transport.store.getSnapshot().notice?.kind).toBe('failed') + expect(write).not.toHaveBeenCalled() + + const rejected = new SessionMarkdownCopyController( + async () => new Response('markdown'), + async () => false, + ) + await rejected.copy(SID) + expect(rejected.store.getSnapshot().notice?.kind).toBe('failed') + }) + + it('accepts a new copy after a failed operation settles', async () => { + const fetcher = vi.fn() + .mockResolvedValueOnce(new Response('hidden', { status: 500 })) + .mockResolvedValueOnce(new Response('markdown')) + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(fetcher, write) + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('failed') + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + expect(fetcher).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenCalledOnce() + }) + + it('dismisses a notice and ignores a dismissal with no notice', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + async () => true, + ) + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + }) + + it('aborts pending fetches and suppresses a write that settles after disposal', async () => { + const gate = Promise.withResolvers() + const write = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController(() => gate.promise, write) + + const pending = controller.copy(SID) + const disposing = controller.dispose() + gate.resolve(new Response('late markdown')) + await disposing + await pending + + expect(write).not.toHaveBeenCalled() + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + await expect(controller.copy(SID)).resolves.toBeUndefined() + }) + + it('suppresses success when disposal starts while the clipboard write is pending', async () => { + const writeGate = Promise.withResolvers() + const write = vi.fn(() => writeGate.promise) + const controller = new SessionMarkdownCopyController(async () => new Response('markdown'), write) + const pending = controller.copy(SID) + await vi.waitFor(() => { expect(write).toHaveBeenCalledOnce() }) + + const disposing = controller.dispose() + writeGate.resolve(true) + await disposing + await pending + + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('suppresses a rejection caused by disposal instead of publishing failure', async () => { + const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + })) + const controller = new SessionMarkdownCopyController(fetcher, vi.fn(async () => true)) + const pending = controller.copy(SID) + + await controller.dispose() + await pending + + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('uses the document-relative default carrier and the host clipboard writer', async () => { + const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('default markdown')) + const writeText = vi.fn(async () => {}) + vi.stubGlobal('fetch', fetcher) + Object.defineProperty(navigator, 'clipboard', { + configurable: true, + value: { writeText }, + }) + const controller = new SessionMarkdownCopyController() + + await controller.copy(SID) + + expect(fetcher).toHaveBeenCalledOnce() + const [route, init] = fetcher.mock.calls[0]! + expect(route).toBe(`api/session.markdown?sessionId=${SID}`) + expect(init?.signal).toBeInstanceOf(AbortSignal) + expect(writeText).toHaveBeenCalledWith('default markdown') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) +}) diff --git a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx new file mode 100644 index 0000000000..f03b5e7702 --- /dev/null +++ b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useSyncExternalStore } from 'react' +import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionMarkdownCopyState } from '../src/client/copy-controller.ts' +import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' +import type { SessionMarkdownCopyToastProps } from '../src/client/CopyToast.tsx' +import { en } from '../src/client/locales.ts' + +function bindSnapshot(store: ObservableSnapshot) { + return function useSessionMarkdownCopy(selector: (state: SessionMarkdownCopyState) => T): T { + return useSyncExternalStore( + listener => store.subscribe(listener), + () => selector(store.getSnapshot()), + ) + } +} + +function bench(notice: SessionMarkdownCopyState['notice']) { + const store = createSnapshotStore({ copying: false, notice }) + const dismiss = vi.fn() + const props = { + useSessionMarkdownCopy: bindSnapshot(store), + dismiss, + t: (key: keyof typeof en): string => en[key], + } as unknown as SessionMarkdownCopyToastProps + return { ...render(), store, dismiss } +} + +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +describe('Session Markdown copy toast', () => { + it('shows the success outcome and dismisses after the banner completes', () => { + vi.useFakeTimers() + const b = bench({ kind: 'success', seq: 1 }) + expect(b.getByRole('alert').textContent).toBe('Session copied as Markdown.') + act(() => { vi.advanceTimersByTime(4_000) }) + expect(b.dismiss).toHaveBeenCalledOnce() + }) + + it('shows the failure outcome with its actionable copy', () => { + const b = bench({ kind: 'failed', seq: 2 }) + expect(b.getByRole('alert').textContent).toBe( + 'Could not copy the Session. Check clipboard access and try again.', + ) + }) + + it('renders nothing while no notice shows', () => { + const b = bench(null) + expect(b.container.childElementCount).toBe(0) + }) +}) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index dc0da23e36..e3d6499439 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -5,6 +5,7 @@ import { useSyncExternalStore } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' +import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' import { en } from '../src/client/locales.ts' @@ -20,24 +21,31 @@ function bindSnapshot(store: ObservableSnapshot) { } } -function bench(feedbackAvailable = false) { +function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController( + async () => new Response('markdown'), + async () => true, +)) { const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) + const copyMarkdown = vi.fn((sessionId: SessionId) => copyController.copy(sessionId)) const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) const openFeedback = vi.fn() const feedback = createSnapshotStore(feedbackAvailable) const useSessionLogDownload = bindSnapshot(controller.store) + const useSessionMarkdownCopy = bindSnapshot(copyController.store) const props = { sessionId: SID, useSessionLogDownload, + useSessionMarkdownCopy, useFeedbackAvailable: bindSnapshot(feedback), openFeedback, request, + copyMarkdown, dismiss, t: (key: keyof typeof en): string => en[key], } as unknown as SessionLogDownloadHeaderProps const view = render() - return { controller, request, openFeedback, feedback, view } + return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view } } afterEach(cleanup) @@ -56,6 +64,7 @@ describe('Session export Header action', () => { const b = bench() fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() + expect(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })).toBeTruthy() expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() }) @@ -77,6 +86,42 @@ describe('Session export Header action', () => { expect(b.view.queryByRole('menu')).toBeNull() }) + it('copies Markdown from the menu and closes it without starting a download', async () => { + const b = bench() + fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) + expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ + 'Copy as Markdown', + 'Download session log', + ]) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })) + await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) }) + expect(b.request).not.toHaveBeenCalled() + expect(b.view.queryByRole('menu')).toBeNull() + expect(b.copyController.store.getSnapshot().notice?.kind).toBe('success') + }) + + it('disables only the copy row while a copy is pending', async () => { + const gate = Promise.withResolvers() + const copyController = new SessionMarkdownCopyController(() => gate.promise, async () => true) + const b = bench(true, copyController) + const pending = copyController.copy(SID) + expect(copyController.store.getSnapshot().copying).toBe(true) + + const button = b.view.getByRole('button', { name: 'More actions' }) + await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) + fireEvent.click(button) + const copyItem = b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) + await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) + expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) + expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) + expect(button.getAttribute('aria-busy')).toBe('false') + + gate.resolve(new Response('markdown')) + await pending + expect(b.copyController.store.getSnapshot().copying).toBe(false) + await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) }) + }) + it('opens the more-actions menu and downloads through the shared controller', async () => { const b = bench() const button = b.view.getByRole('button', { name: 'More actions' }) @@ -108,9 +153,11 @@ describe('Session export Header action', () => { b.view.rerender( controller.download(sessionId), + copyMarkdown: b.copyMarkdown, dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, t: (key: keyof typeof en): string => en[key], } as unknown as SessionLogDownloadHeaderProps)} />) @@ -121,6 +168,7 @@ describe('Session export Header action', () => { fireEvent.click(button) const item = b.view.getByRole('menuitem', { name: 'Download session log' }) expect((item as HTMLButtonElement).disabled).toBe(true) + expect((b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) release(new Response('zip')) await download diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts new file mode 100644 index 0000000000..3abfa2e855 --- /dev/null +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -0,0 +1,176 @@ +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import type { MessageId } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionHeader, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' +import { describe, expect, it } from 'vitest' +import { SESSION_MARKDOWN_EXPORT_PATH, apply, inject } from '../src/index.ts' + +const SESSION_ID = 'session-markdown-route' as SessionId + +const HEADER: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: SESSION_ID, + createdAt: 1, + isSeeded: false, + cwd: '/workspace', + delegationDepth: 0, +} + +const EVENTS: SessionEvent[] = [ + { type: 'turn/start', seq: brandNumber(1), time: 1, data: { turn: 1 } }, + { + type: 'user/message', + seq: brandNumber(2), + time: 2, + surfaceOp: 'append', + data: { + role: 'user', + id: brandString('message-1'), + source: { kind: 'user' }, + content: [{ type: 'text', text: 'Route question' }], + }, + }, + { + type: 'assistant/message', + seq: brandNumber(3), + time: 3, + surfaceOp: 'append', + data: { + turn: 1, + step: 1, + message: { + role: 'assistant', + id: brandString('message-2'), + source: { kind: 'model', provider: 'test', model: 'test' }, + content: [{ type: 'text', text: 'Route answer' }], + }, + stream: [], + }, + }, + { type: 'turn/end', seq: brandNumber(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, +] + +function missingSessionError(): Error { + return Object.assign(new Error('not found'), { code: 'SESSION_QUERY_SESSION_NOT_FOUND' }) +} + +function query(overrides: { + readonly readSession?: (id: SessionId) => Promise + readonly readTitle?: (id: SessionId, signal?: AbortSignal) => Promise +} = {}): unknown { + return { + readSession: overrides.readSession + ?? (async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })), + readTitle: overrides.readTitle + ?? (async () => ({ + title: 'Route title', messageSeqs: [], source: { kind: 'fallback' }, eventSeq: 2, updatedAt: 2, + })), + } +} + +async function mounted(sessionQuery: unknown): Promise<{ + readonly shared: ReturnType + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + ctx.provide('commands', { register: () => () => {} } as never) + if (sessionQuery !== undefined) ctx.provide('sessionQuery', sessionQuery as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { + shared: connection.createSharedFetchHandler('/api'), + connection, + dispose: () => fiber.dispose(), + } +} + +function request(path: string, init?: RequestInit): Request { + return new Request(`http://host${path}`, init) +} + +describe('Session Markdown Fetch route', () => { + it('registers one GET/HEAD route and removes it with the plugin fiber', async () => { + const { shared, connection, dispose } = await mounted(query()) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + const markdown = await response.text() + expect(markdown).toContain('# Route title') + expect(markdown).toContain('> Route question') + expect(markdown).toContain('Route answer') + expect(markdown.endsWith('\n')).toBe(true) + + const head = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`, { method: 'HEAD' }), + connection.browserPeer, + ) + expect(head.status).toBe(200) + expect(head.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + expect(head.body).toBeNull() + + await dispose() + expect((await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + )).status).toBe(404) + }) + + it('validates the query before reporting missing Markdown services', async () => { + const { shared, connection, dispose } = await mounted(undefined) + expect((await shared.fetch(request(SESSION_MARKDOWN_EXPORT_PATH), connection.browserPeer)).status).toBe(400) + expect((await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=`), + connection.browserPeer, + )).status).toBe(400) + expect((await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + )).status).toBe(500) + await dispose() + }) + + it('maps a missing Session to 404 and sanitizes every other read failure', async () => { + const missing = await mounted(query({ readSession: async () => { throw missingSessionError() } })) + const missingResponse = await missing.shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + missing.connection.browserPeer, + ) + expect(missingResponse.status).toBe(404) + expect(await missingResponse.text()).toBe('session not found') + await missing.dispose() + + const broken = await mounted(query({ + readSession: async () => { throw new Error('failed at /private/host/session.jsonl') }, + })) + const brokenResponse = await broken.shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + broken.connection.browserPeer, + ) + expect(brokenResponse.status).toBe(500) + const detail = await brokenResponse.text() + expect(detail).toBe('session markdown export failed to read the stored log') + expect(detail).not.toContain('/private/host') + await broken.dispose() + }) + + it('returns a sanitized 500 when the title read fails after the log read', async () => { + const { shared, connection, dispose } = await mounted(query({ + readTitle: async () => { throw new Error('title path /private/host') }, + })) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(500) + expect(await response.text()).toBe('session markdown export failed to read the stored log') + await dispose() + }) +}) diff --git a/packages/session-query/session-log-export/tests/markdown.host.spec.ts b/packages/session-query/session-log-export/tests/markdown.host.spec.ts new file mode 100644 index 0000000000..1b42768ca4 --- /dev/null +++ b/packages/session-query/session-log-export/tests/markdown.host.spec.ts @@ -0,0 +1,508 @@ +import { describe, expect, it } from 'vitest' +import type { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ContentBlock, MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' +import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' +import { formatSessionMarkdown } from '../src/markdown.ts' + +const messageId = (value: string): MessageId => brandString(value) +const callId = (value: string): ToolCallId => brandString(value) +const attachmentId = (value: string): AttachmentId => brandString(value) +const sequence = (value: number): SessionSeq => brandNumber(value) + +function start(seq: number, turn: number): SessionEvent<'turn/start'> { + return { type: 'turn/start', seq: sequence(seq), time: seq, data: { turn } } +} + +function end(seq: number, turn: number): SessionEvent<'turn/end'> { + return { type: 'turn/end', seq: sequence(seq), time: seq, data: { turn, reason: { kind: 'completed' } } } +} + +function user(seq: number, text: string, kind: 'user' | 'injected' = 'user'): SessionEvent<'user/message'> { + return { + type: 'user/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { + role: 'user', + id: messageId(`user-${String(seq)}`), + source: kind === 'user' ? { kind: 'user' } : { kind: 'model', provider: 'test', model: 'test' }, + content: [{ type: 'text', text }], + }, + } +} + +function userWithAttachments(seq: number): SessionEvent<'user/message'> { + const content: ContentBlock[] = [ + { type: 'text', text: 'See the attachments.' }, + { + type: 'image', + attachment: { + attachmentId: attachmentId('img-secret-1'), + mediaType: 'image/png', + bytes: 123, + width: 640, + height: 480, + name: 'diagram.png', + }, + }, + { + type: 'file', + attachment: { attachmentId: attachmentId('file-secret-1'), name: 'notes.txt', bytes: 42 }, + }, + ] + return { + type: 'user/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { role: 'user', id: messageId(`user-${String(seq)}`), source: { kind: 'user' }, content }, + } +} + +function userBlocks(seq: number, content: ContentBlock[]): SessionEvent<'user/message'> { + return { + type: 'user/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { role: 'user', id: messageId(`user-${String(seq)}`), source: { kind: 'user' }, content }, + } +} + +function assistant(seq: number, content: ContentBlock[], turn = 1): SessionEvent<'assistant/message'> { + return { + type: 'assistant/message', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { + turn, + step: 1, + message: { + role: 'assistant', + id: messageId(`assistant-${String(seq)}`), + source: { kind: 'model', provider: 'test', model: 'test' }, + content, + }, + stream: [], + }, + } +} + +function toolCall(seq: number, id: string, name: string, argumentsText: string): SessionEvent<'tool/call'> { + return { + type: 'tool/call', + seq: sequence(seq), + time: seq, + data: { turn: 1, step: 1, callId: callId(id), name, arguments: argumentsText }, + } +} + +function toolResult( + seq: number, + id: string, + content: ContentBlock[], + options: { readonly isError?: boolean; readonly reason?: string } = {}, +): SessionEvent<'tool/result'> { + return { + type: 'tool/result', + seq: sequence(seq), + time: seq, + surfaceOp: 'append', + data: { + turn: 1, + step: 1, + message: { + role: 'tool', + id: messageId(`result-${String(seq)}`), + source: { kind: 'tool', callId: callId(id) }, + toolCallId: callId(id), + content, + ...options.isError === true ? { isError: true } : {}, + }, + ...options.reason === undefined + ? {} + : { error: { name: 'ToolError', code: 'TOOL_FAILED', reason: options.reason } }, + }, + } +} + +function text(value: string): ContentBlock { + return { type: 'text', text: value } +} + +describe('formatSessionMarkdown', () => { + it('keeps complete chronology across inherited history and a compaction surface replacement', () => { + const markdown = formatSessionMarkdown({ + title: 'Chronology', + events: [ + start(1, 1), + user(2, 'Original question'), + assistant(3, [text('Original working note')]), + toolCall(4, 'call-1', 'read', '{"file_path":"a.md"}'), + toolResult(5, 'call-1', [text('alpha')]), + assistant(6, [text('Original answer')]), + end(7, 1), + { type: 'session/end-seed', seq: sequence(8), time: 8, data: {} }, + { + ...user(9, 'COMPACTED SUMMARY TEXT', 'injected'), + surfaceOp: { op: 'replace', startSeq: sequence(2), endSeq: sequence(6) }, + }, + start(10, 2), + user(11, 'Follow-up question'), + assistant(12, [text('Second answer')]), + end(13, 2), + ], + }) + + expect(markdown).toContain('> Original question') + expect(markdown).toContain('Original working note') + expect(markdown).toContain('Original answer') + expect(markdown).toContain('> Follow-up question') + expect(markdown).toContain('Second answer') + expect(markdown).not.toContain('COMPACTED SUMMARY TEXT') + expect(markdown.indexOf('Original answer')).toBeLessThan(markdown.indexOf('Second answer')) + expect(markdown.match(/Original answer/gu)).toHaveLength(1) + expect(markdown.endsWith('\n')).toBe(true) + expect(markdown.endsWith('\n\n')).toBe(false) + }) + + it('renders a turn with a collapsible commentary/tool section before the final reply', () => { + const markdown = formatSessionMarkdown({ + title: 'Turn layout', + events: [ + start(1, 1), + user(2, 'Please inspect'), + assistant(3, [text('I will inspect.')]), + toolCall(4, 'call-1', 'bash', '{"command":"echo hi"}'), + toolResult(5, 'call-1', [text('hi\n')]), + assistant(6, [text('Done.')]), + end(7, 1), + ], + }) + + expect(markdown).toBe([ + '# Turn layout', + '', + '> Please inspect', + '', + '
2 previous messages', + '', + '**Assistant**', + '', + 'I will inspect.', + '', + '**Tool: `bash`**', + '', + '```json', + '{', + ' "command": "echo hi"', + '}', + '```', + '', + '**Result**', + '', + '```', + 'hi', + '```', + '', + '
', + '', + 'Done.', + '', + ].join('\n')) + }) + + it('keeps a mid-turn steering message after the activity that preceded it', () => { + const markdown = formatSessionMarkdown({ + title: 'Steering order', + events: [ + start(1, 1), + user(2, 'Start the task'), + assistant(3, [text('Working before steering')]), + toolCall(4, 'call-1', 'bash', '{"command":"first"}'), + toolResult(5, 'call-1', [text('first result')]), + user(6, 'Steer to the second path'), + assistant(7, [text('Working after steering')]), + toolCall(8, 'call-2', 'bash', '{"command":"second"}'), + toolResult(9, 'call-2', [text('second result')]), + assistant(10, [text('Final answer')]), + end(11, 1), + ], + }) + + const before = markdown.indexOf('Working before steering') + const firstResult = markdown.indexOf('first result') + const steering = markdown.indexOf('> Steer to the second path') + const after = markdown.indexOf('Working after steering') + const secondResult = markdown.indexOf('second result') + const final = markdown.indexOf('Final answer') + expect(before).toBeGreaterThan(-1) + expect(before).toBeLessThan(firstResult) + expect(firstResult).toBeLessThan(steering) + expect(steering).toBeLessThan(after) + expect(after).toBeLessThan(secondResult) + expect(secondResult).toBeLessThan(final) + expect(markdown.match(/
/gu)).toHaveLength(2) + expect(markdown.match(/
2 previous messages<\/summary>/gu)).toHaveLength(2) + }) + + it('preserves authored details and code fences in the final reply', () => { + const authored = [ + 'Before', + '', + '
', + 'Authored', + '', + '```html', + 'code', + '```', + '', + '
', + '', + 'After', + ].join('\n') + const markdown = formatSessionMarkdown({ + title: 'Authored final', + events: [start(1, 1), user(2, 'Show details'), assistant(3, [text(authored)]), end(4, 1)], + }) + + expect(markdown).toContain(authored) + expect(markdown).not.toContain('<') + expect(markdown.endsWith(`${authored}\n`)).toBe(true) + }) + + it('ignores replacement surface events and keeps original append chronology', () => { + const markdown = formatSessionMarkdown({ + title: 'Replacements', + events: [ + start(1, 1), + user(2, 'Original prompt'), + assistant(3, [text('Original commentary')]), + toolCall(4, 'call-1', 'bash', '{"command":"original"}'), + toolResult(5, 'call-1', [text('original full output')]), + assistant(6, [text('Original final')]), + end(7, 1), + { + ...assistant(8, [text('REPLACEMENT ASSISTANT')]), + surfaceOp: { op: 'replace', startSeq: sequence(3), endSeq: sequence(6) }, + }, + { + ...toolResult(9, 'call-1', [text('REPLACEMENT TOOL OUTPUT')]), + surfaceOp: { op: 'replace', startSeq: sequence(5), endSeq: sequence(5) }, + }, + { + ...user(10, 'REPLACEMENT USER'), + surfaceOp: { op: 'replace', startSeq: sequence(2), endSeq: sequence(2) }, + }, + ], + }) + + expect(markdown).toContain('> Original prompt') + expect(markdown).toContain('Original commentary') + expect(markdown).toContain('original full output') + expect(markdown.match(/original full output/gu)).toHaveLength(1) + expect(markdown).toContain('Original final') + expect(markdown).not.toContain('REPLACEMENT ASSISTANT') + expect(markdown).not.toContain('REPLACEMENT TOOL OUTPUT') + expect(markdown).not.toContain('REPLACEMENT USER') + expect(markdown).not.toContain('**Tool: `unknown`**') + expect(markdown.match(/
/gu)).toHaveLength(1) + }) + + it('emits no empty assistant section and exactly one trailing newline', () => { + const markdown = formatSessionMarkdown({ + title: 'Whitespace', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text(' \n\t')]), + assistant(4, [text('Answer')]), + end(5, 1), + ], + }) + + expect(markdown).toBe('# Whitespace\n\n> Question\n\nAnswer\n') + }) + + it('escapes wrapper tags outside fences and grows fences past tool backtick runs', () => { + const markdown = formatSessionMarkdown({ + title: 'Escaping', + events: [ + start(1, 1), + user(2, 'Run it'), + assistant(3, [text('Before\n\n
\n\n```md\n
\n```\n\n```bad`info')]), + toolCall(4, 'call-1', '', '{"value":"````"}'), + toolResult(5, 'call-1', [text('`````\n
')]), + assistant(6, [text('Finished')]), + end(7, 1), + ], + }) + + expect(markdown).toContain('</details>') + expect(markdown).toContain('```md\n
\n```') + expect(markdown).toContain('`````json') + expect(markdown).toContain('``````') + expect(markdown).toContain('**Tool: `</summary>`**') + expect(markdown.endsWith('\n\nFinished\n')).toBe(true) + }) + + it('labels attachments and omits reasoning, system prompts, and injected context', () => { + const markdown = formatSessionMarkdown({ + title: 'Hidden input', + events: [ + start(1, 1), + { + type: 'system/message', + seq: sequence(2), + time: 2, + surfaceOp: 'append', + data: { + turn: 1, + step: 1, + message: { + role: 'system', + id: messageId('system-1'), + source: { kind: 'system-prompt' }, + content: [text('SYSTEM ONLY')], + }, + }, + }, + userWithAttachments(3), + user(4, 'SECRET INJECTED', 'injected'), + assistant(5, [ + { type: 'reasoning', text: 'REASONING ONLY' }, + text('Visible answer'), + ]), + end(6, 1), + ], + }) + + expect(markdown).toContain('> See the attachments.') + expect(markdown).toContain('> [Image: diagram.png (640×480, 123 bytes)]') + expect(markdown).toContain('> [File: notes.txt (42 bytes)]') + expect(markdown).not.toContain('img-secret-1') + expect(markdown).not.toContain('file-secret-1') + expect(markdown).not.toContain('SECRET INJECTED') + expect(markdown).not.toContain('SYSTEM ONLY') + expect(markdown).not.toContain('REASONING ONLY') + expect(markdown).toContain('Visible answer') + }) + + it('exports only the heading for a blank Session and escapes title newlines', () => { + expect(formatSessionMarkdown({ title: 'One\nTwo', events: [] })).toBe('# One Two\n') + expect(formatSessionMarkdown({ title: ' ', events: [] })).toBe('# Untitled Session\n') + expect(formatSessionMarkdown({ title: undefined, events: [] })).toBe('# Untitled Session\n') + }) + + it('renders tool-only and incomplete turns predictably', () => { + const markdown = formatSessionMarkdown({ + title: 'Incomplete', + events: [ + start(1, 1), + user(2, 'Run the tool'), + assistant(3, [{ type: 'tool-call', id: callId('call-1'), name: 'bash', arguments: '{}' }]), + toolCall(4, 'call-1', 'bash', '{}'), + toolResult(5, 'call-1', [text('done')], { isError: true, reason: 'exit 1' }), + start(6, 2), + user(7, 'Another'), + toolCall(8, 'call-2', 'read', '{"file_path":"b.md"}'), + toolResult(9, 'call-2', [{ type: 'image', attachment: { + attachmentId: attachmentId('result-image'), + mediaType: 'image/png', + bytes: 10, + width: 1, + height: 2, + } }]), + toolCall(10, 'call-3', 'bash', '{}'), + ], + }) + + expect(markdown).toContain('
1 previous message') + expect(markdown).toContain('**Result (error)**') + expect(markdown).toContain('Error: exit 1') + expect(markdown).toContain('[Image: attachment (1×2, 10 bytes)]') + expect(markdown).not.toContain('
0 previous messages') + expect(markdown).toContain('(no result recorded)') + expect(markdown.endsWith('\n')).toBe(true) + }) + + it('renders assistant-only, user-only, empty, and malformed content predictably', () => { + const markdown = formatSessionMarkdown({ + title: 'Edge content', + events: [ + // A committed assistant message before the first turn marker still + // renders, and forms an assistant-only turn with no user blockquote. + assistant(1, [text('No turn marker')]), + start(2, 1), + user(3, 'Only question'), + end(4, 1), + start(5, 2), + userBlocks(6, [ + text(''), + { type: 'tool-call', id: callId('user-tool'), name: 'read', arguments: '{}' }, + ]), + assistant(7, [ + text(''), + { + type: 'image', + attachment: { + attachmentId: attachmentId('assistant-image'), + mediaType: 'image/png', + bytes: 8, + width: 2, + height: 3, + }, + }, + { + type: 'file', + attachment: { attachmentId: attachmentId('assistant-file'), name: 'a.txt', bytes: 4 }, + }, + ]), + end(8, 2), + start(9, 3), + user(10, 'Tools'), + toolCall(11, 'call-1', 'bash', 'not-json'), + toolResult(12, 'call-1', [ + text(''), + { type: 'file', attachment: { attachmentId: attachmentId('result-file'), name: 'b.txt', bytes: 5 } }, + ]), + toolCall(13, 'call-2', 'bash', '{}'), + toolResult(14, 'call-2', [], { isError: true, reason: 'boom' }), + toolCall(15, 'call-3', 'bash', '{}'), + toolResult(16, 'call-3', []), + assistant(17, [text('Final')]), + end(18, 3), + ], + }) + + expect(markdown).toContain('No turn marker') + expect(markdown).toContain('> Only question') + expect(markdown).toContain('[Image: attachment (2×3, 8 bytes)]') + expect(markdown).toContain('[File: a.txt (4 bytes)]') + expect(markdown).toContain('[File: b.txt (5 bytes)]') + expect(markdown).toContain('not-json') + expect(markdown).toContain('Error: boom') + expect(markdown).toContain('(no output)') + expect(markdown).toContain('Final') + }) + + it('renders an orphan tool result without inventing a call name', () => { + const markdown = formatSessionMarkdown({ + title: 'Orphan', + events: [ + start(1, 1), + user(2, 'Question'), + toolResult(3, 'missing-call', [text('orphan output')]), + assistant(4, [text('Answer')]), + end(5, 1), + ], + }) + + expect(markdown).toContain('**Tool: `unknown`**') + expect(markdown).toContain('orphan output') + expect(markdown).toContain('
1 previous message') + }) +}) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index 1735561ff3..c78dbf6893 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -6,9 +6,11 @@ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" }, "files": [ + "src/client/CopyToast.tsx", "src/client/Dialog.tsx", "src/client/HeaderAction.tsx", "src/client/controller.ts", + "src/client/copy-controller.ts", "src/client/index.ts", "src/client/locales.ts", "src/routes.ts", @@ -20,6 +22,7 @@ { "path": "../../client/store" }, { "path": "../../client/ui-commands" }, { "path": "../../client/ui-conversation/tsconfig.client.json" }, + { "path": "../../client/ui-layout" }, { "path": "../../client/ui-message-feedback" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 0832fbd1ac..22dad514a4 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -8,6 +8,7 @@ "files": [ "src/archive.ts", "src/index.ts", + "src/markdown.ts", "src/routes.ts" ], "references": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da43e12301..c24f6d915f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12258,6 +12258,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:* version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-layout': + specifier: workspace:* + version: link:../../client/ui-layout '@deepseek-ai/dsh-client-ui-message-feedback': specifier: workspace:* version: link:../../client/ui-message-feedback From 6118b51fa8e13d8a6d1c377df97286b2e36988d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:00:45 +0800 Subject: [PATCH 02/73] test(session-log-export): remove unknown casts from fixtures Type the Header and toast test fixtures with the standard Session/global props they render with, and read slot injections through a narrow object helper. Prune the four retired unknown-cast baseline entries. No product behavior changes. --- .../tests/client-apply.client.spec.tsx | 11 +++++-- .../tests/copy-toast.client.spec.tsx | 7 +++-- .../tests/header-action.client.spec.tsx | 30 ++++++++++++++----- scripts/no-unknown-casts.baseline.json | 8 ----- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index d481acf6bd..0613c9c9c2 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -28,6 +28,11 @@ function declare(slots: SlotRegistry): () => void { } as never, () => null) } +function injectedOf(entry: { inject?: ((...args: never[]) => object) | undefined } | undefined): object { + if (entry?.inject === undefined) throw new Error('slot entry has no injection') + return entry.inject() +} + async function bench() { const ctx = new Context() await ctx.plugin(SlotRegistry).await() @@ -43,7 +48,7 @@ describe('session-log-download browser plugin', () => { it('follows feedback plugin availability and opens the selected Session form', async () => { const b = await bench() const entry = b.slots.entries('conversation.session.header.utilities')[0]! - const injected = (entry.inject as unknown as () => SessionLogDownloadHeaderInjected)() + const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected expect(injected.hooks.feedbackAvailable.getSnapshot()).toBe(false) injected.openFeedback(SID) @@ -69,7 +74,7 @@ describe('session-log-download browser plugin', () => { const entry = b.slots.entries('conversation.session.header.utilities')[0] expect(entry?.component).toBe(SessionLogDownloadHeaderAction) expect(entry?.options).toMatchObject({ id: 'session-log-download' }) - const injected = (entry?.inject as unknown as () => SessionLogDownloadHeaderInjected)() + const injected = injectedOf(entry) as SessionLogDownloadHeaderInjected await injected.request(SID) expect(b.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) @@ -78,7 +83,7 @@ describe('session-log-download browser plugin', () => { const overlay = b.slots.entries('shell.overlay')[0] expect(overlay?.component).toBe(SessionMarkdownCopyToast) expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) - const overlayInjected = (overlay?.inject as unknown as () => SessionMarkdownCopyToastInjected)() + const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected await injected.copyMarkdown(SID) expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('failed') overlayInjected.dismiss() diff --git a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx index f03b5e7702..52a34b1a97 100644 --- a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx @@ -20,11 +20,12 @@ function bindSnapshot(store: ObservableSnapshot) { function bench(notice: SessionMarkdownCopyState['notice']) { const store = createSnapshotStore({ copying: false, notice }) const dismiss = vi.fn() - const props = { + const dictionary: Record = en + const props: SessionMarkdownCopyToastProps = { useSessionMarkdownCopy: bindSnapshot(store), dismiss, - t: (key: keyof typeof en): string => en[key], - } as unknown as SessionMarkdownCopyToastProps + t: key => dictionary[key] ?? key, + } return { ...render(), store, dismiss } } diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index e3d6499439..91400e6680 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -3,6 +3,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' @@ -11,6 +12,18 @@ import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.t import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId +const dictionary: Record = en +const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key +const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } +const standard: GlobalStandardProps & SessionStandardProps = { + sessionId: SID, useSession: unused, useProjection: unused, useAccess: () => true, + useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, + useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, + useConversation: unused, useInput: unused, useChat: unused, useTrajectory: unused, + usePanelInfo: unused, useResource: unused, useWorkspaces: unused, + inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, + addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, +} function bindSnapshot(store: ObservableSnapshot) { return function useSnapshot(selector: (state: State) => T): T { @@ -33,8 +46,8 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo const feedback = createSnapshotStore(feedbackAvailable) const useSessionLogDownload = bindSnapshot(controller.store) const useSessionMarkdownCopy = bindSnapshot(copyController.store) - const props = { - sessionId: SID, + const props: SessionLogDownloadHeaderProps = { + ...standard, useSessionLogDownload, useSessionMarkdownCopy, useFeedbackAvailable: bindSnapshot(feedback), @@ -42,8 +55,8 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo request, copyMarkdown, dismiss, - t: (key: keyof typeof en): string => en[key], - } as unknown as SessionLogDownloadHeaderProps + t: translate, + } const view = render() return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view } } @@ -150,8 +163,8 @@ describe('Session export Header action', () => { const pending = new Promise((resolve) => { release = resolve }) const controller = new SessionLogDownloadController(() => pending, vi.fn()) const useSessionLogDownload = bindSnapshot(controller.store) - b.view.rerender( { request: (sessionId: SessionId) => controller.download(sessionId), copyMarkdown: b.copyMarkdown, dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, - t: (key: keyof typeof en): string => en[key], - } as unknown as SessionLogDownloadHeaderProps)} />) + t: translate, + } + b.view.rerender() const download = controller.download(SID) const button = b.view.getByRole('button', { name: 'More actions' }) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 41520f4e27..9070545648 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1530,10 +1530,6 @@ "cb524acd0159b942e7974d76b581b1f419b8653d1734946a3f154dda8e8f4762": 1, "f10c6a0a3a762be54c2de87ab5d787e37a21a89c9d468bf81d0658c2bf15d581": 1 }, - "packages/session-query/session-log-export/tests/client-apply.client.spec.tsx": { - "04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1, - "eee35b4d8641824c6eaacfe03ed60d353d81d5257d1111132f34d46a7b13ef35": 1 - }, "packages/session-query/session-log-export/tests/controller.client.spec.ts": { "746ebc5aade01a50cc9a74fc07b1023addf021aef80b62c069a437ac140c9e64": 1, "c920d88f6eb309ef5991608642a1ab0fd972690538b33c34d86c68d07ddd6edf": 1 @@ -1541,10 +1537,6 @@ "packages/session-query/session-log-export/tests/dialog.client.spec.tsx": { "a9b1969d76db0b05f87c862956f8ff45c52f1f4a5b32a206339de0c9fee99a77": 1 }, - "packages/session-query/session-log-export/tests/header-action.client.spec.tsx": { - "238a292bbe3a61acbc4f2d96fff474df5fba1577357219a88c914813786b56a7": 1, - "47d3a14a42ef7142ea22071677b42265f02fba5a2a0cf4210f4f9c7c5a876d10": 1 - }, "packages/session-query/session-log-export/tests/loader-composition.host.spec.ts": { "59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1, "e12d49327a6708ae3ace371e28ed0deb471dc8895c584ba94a86af344c15cec3": 1 From 36c89de7bd38edd13a77b2e3cb466147a57c25b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:54 +0800 Subject: [PATCH 03/73] test(web): include copy Markdown fixture in inventory The navigation-panes recorded-fixture inventory now lists the owner-local copy-markdown.expected.md file. No assertion was weakened; no snapshot or production code changed. --- apps/web/tests/navigation-panes.e2e.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 92999d864f..4c77ccf395 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -587,7 +587,9 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { - await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) + await assertFixtureInventory(CODE_CARD_DIR, [ + 'code-card.expected.md', 'command-wrap.expected.md', 'copy-markdown.expected.md', + ]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', From e671c9e28d11757206bcab4c30193c5553c8d796 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:52:54 +0800 Subject: [PATCH 04/73] fix(session-log-export): address Markdown copy review Preserve click activation by admitting a promised ClipboardItem write synchronously, own its preparation and write promises, and distinguish preparation from clipboard failures. Fold the title from the single readSession snapshot, preserve indentation, close unfinished assistant fences, and restore the shell.overlay props contract. Update the README pair and browser fixture inventory. --- apps/web/tests/navigation-panes.e2e.ts | 15 + docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../session-log-export/README.i18n.yaml | 20 +- .../session-log-export/README.md | 17 +- .../session-log-export/README.zh.md | 17 +- .../session-log-export/package.json | 1 + .../src/client/CopyToast.tsx | 7 +- .../src/client/HeaderAction.tsx | 2 +- .../src/client/copy-controller.ts | 128 +++++-- .../session-log-export/src/client/locales.ts | 6 +- .../session-log-export/src/index.ts | 17 +- .../session-log-export/src/markdown.ts | 118 ++++-- .../tests/client-apply.client.spec.tsx | 2 +- .../tests/copy-controller.client.spec.ts | 348 ++++++++++++++---- .../tests/copy-toast.client.spec.tsx | 22 +- .../tests/header-action.client.spec.tsx | 14 +- .../tests/markdown-route.host.spec.ts | 34 +- .../tests/markdown.host.spec.ts | 107 ++++++ .../session-log-export/tsconfig.host.json | 1 + pnpm-lock.yaml | 3 + 21 files changed, 689 insertions(+), 194 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 4c77ccf395..ce979f4774 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -417,6 +417,20 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-copy-markdown')) await ensureSeedOpen(page) await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + expect(await page.evaluate(() => typeof ClipboardItem)).toBe('function') + expect(await page.evaluate(() => typeof navigator.clipboard.write)).toBe('function') + await page.evaluate(() => { + const clipboard = navigator.clipboard + const originalWrite = clipboard.write.bind(clipboard) + Reflect.set(globalThis, '__dshClipboardWrites', 0) + Object.defineProperty(clipboard, 'write', { + configurable: true, + value: (...args: Parameters) => { + Reflect.set(globalThis, '__dshClipboardWrites', Number(Reflect.get(globalThis, '__dshClipboardWrites')) + 1) + return originalWrite(...args) + }, + }) + }) const markdownResponse = page.waitForResponse(response => response.request().method() === 'GET' && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) @@ -427,6 +441,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(response.status()).toBe(200) expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1) const markdown = await page.evaluate(() => navigator.clipboard.readText()) const normalized = markdown.split(scaffold.workspaceCwd).join('{{cwd}}') diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2ee5fbad73..6ffbfde471 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2859,7 +2859,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index eb55be3fc1..ace7ab4961 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2861,7 +2861,7 @@ export interface Config { ## `@deepseek-ai/dsh-session-log-export` - `inject`: `commands` · `connection` -- `source`: [`packages/session-query/session-log-export/src/index.ts:47`](../packages/session-query/session-log-export/src/index.ts) +- `source`: [`packages/session-query/session-log-export/src/index.ts:48`](../packages/session-query/session-log-export/src/index.ts) ```ts config-catalog /** Session-log archive policy. */ diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 94e9c5ec76..ff192b51ec 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -18,14 +18,14 @@ en: f37f81af3601784d zh: 89b47be0660c8b8b /deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: - en: 36a79c163dd92469 - zh: b31cb116dc950941 + en: ff52c54e4c6486b8 + zh: 9ea4810f86ac4d6c /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 2b9dbec4877d332e zh: dcf6638938eedb01 /deepseek-ai-dsh-session-log-export/use-this-package/composition: - en: 2fef1fe4411cc5da - zh: cff5dbcc4901402b + en: 4168a2d3e653ab73 + zh: 8bf955cfab7b08e2 /deepseek-ai-dsh-session-log-export/use-this-package/configuration: en: 377a0d8860c46f63 zh: 9eb7698f813b2fa3 @@ -36,8 +36,8 @@ en: 44be1f1d1903d12a zh: c8f1eb5b92b71a30 /deepseek-ai-dsh-session-log-export/use-this-package/failures: - en: 3c274dea47f7f2c6 - zh: e83b2d6ca29f22ec + en: e92a382bebfe99e3 + zh: 671178b06808bfb9 /deepseek-ai-dsh-session-log-export/understand-the-implementation: en: 7cb82a6851405f95 zh: b8c525170d19dc6c @@ -48,8 +48,8 @@ en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: - en: fc740b3b80678249 - zh: 106f93ce651127d4 + en: 20fbc8fb02bf8c5e + zh: c6ad27c484f0f2bf /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 @@ -69,8 +69,8 @@ en: 61e7dbf79010d071 zh: 29294355598c0476 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work: - en: 0c0c46a6ec873533 - zh: 7c4183401c37d356 + en: 9aad3e0c329709e4 + zh: 41f16123976897bf /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note: en: 4ee4d24c26c127bf zh: 1facdfbfa3ccefbc diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 01155f799b..3e6d5baf1f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -29,11 +29,11 @@ Use this package when the Web bundle should let users export a session log. It r ### Copy as Markdown -`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. +`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. -The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. The text ends with exactly one newline. +The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. -The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header disables its copy row until the operation settles, then restores it. A success or failure toast is shown through the root overlay, so it survives the menu closing or the Session changing. +The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin's availability; both export actions remain available independently. @@ -48,7 +48,7 @@ Choose it for a Web deployment that needs user-facing session export with a visi name: '@deepseek-ai/dsh-session-log-export' ``` -The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. +The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, `dsh-client-ui-conversation`, and `dsh-client-ui-layout`. The layout package declares the `shell.overlay` seat used by copy feedback; the other client packages provide the Header seat, locale, and command surface. ### Configuration @@ -71,7 +71,7 @@ Attachment collection reads declared content fields of built-in Session events a ### Failures -The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. A Markdown copy reports a failure toast when the Host route is unavailable or the browser rejects clipboard access; it never reports success for a partial or missing transcript. +The dialog shows a preparation error when the preflight fails before ZIP streaming starts — for example an unreachable or misconfigured host endpoint. A descendant or attachment read failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. A Markdown preparation failure reports a toast asking the user to retry the Session read; a clipboard write failure reports a separate toast asking the user to check clipboard permission. Neither reports success for a partial or missing transcript, and no pending toast is shown. ----- @@ -97,9 +97,9 @@ The Host route is a feature-owned exact Fetch contribution. Connection applies i ### Markdown copy flow -The Header asks the Markdown copy controller for the clicked Session. The controller fetches the document-relative `api/session.markdown?sessionId=` route and writes the response text with the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title through the same query service, and passes the raw events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. +The Header asks the Markdown copy controller for the clicked Session. The controller starts a deferred `ClipboardItem` write synchronously when the host supports it, then fetches the document-relative `api/session.markdown?sessionId=` route; otherwise it fetches first and uses the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title from the same returned events with `foldSessionTitle`, and passes the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. -The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its success notice always describe the first click's Session. Disposal aborts the fetch and suppresses a late clipboard write or notice. +The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its notice always describe the first click's Session. Both the preparation promise and the admitted clipboard write are observed; every rejection is contained. Disposal aborts the read, prevents later text from reaching the promised Blob, suppresses notices, and awaits admitted clipboard work.
@@ -144,6 +144,9 @@ These limits define when this package is a poor fit or needs special operational - **Browser download, not a Host-path writer** — the browser chooses the local destination; no Host path or native folder action is returned. - **Preflight reports only pre-stream failures** — a descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the dialog. - **Markdown contains committed history only** — an unsettled live token chunk has no committed text, so it is absent from the copied transcript. Compaction and fork inheritance follow the raw log rather than the current model context. +- **Markdown copy is the selected Session only** — descendant Session logs are exported by the ZIP download, not by the Markdown copy. +- **Transcript text is buffered in memory** — the complete Markdown string and its Blob are held before the clipboard accepts them. Browser clipboard limits can reject a large export; this package does not add a byte cap or truncate the requested complete transcript. +- **Accepted clipboard writes cannot be rolled back** — disposal stops pending preparation and feedback, but an operating-system write that already succeeded remains on the clipboard. ### Dev Note diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index e067ac8e43..723ba43674 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -29,11 +29,11 @@ kind: "package-reference" ### 复制为 Markdown -`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。 +`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 -格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。文本以恰好一个换行结尾。 +格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 -剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 在操作结束前禁用各自的复制行,结束后恢复。成功或失败浮层通过根 overlay 显示,因此菜单关闭或 Session 切换后仍然存在。 +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,写入会在点击时同步开始,并以承诺的 `text/plain` Blob 在读取记录期间继续;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入 Blob;操作系统已经接受的剪贴板写入无法回滚。 挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 @@ -48,7 +48,7 @@ kind: "package-reference" name: '@deepseek-ai/dsh-session-log-export' ``` -Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。 +Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`、`dsh-client-ui-conversation` 和 `dsh-client-ui-layout` 一起挂载。layout 包声明复制反馈使用的 `shell.overlay` 座位;其他客户端包提供 Header 座位、locale 与命令界面。 ### 配置 @@ -71,7 +71,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 失败 -当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Host 路由不可用或浏览器拒绝剪贴板访问时,Markdown 复制显示失败浮层;它不会为不完整或缺失的记录报告成功。 +当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Markdown 准备失败显示重试 Session 读取的浮层;剪贴板写入失败显示单独的检查剪贴板权限浮层。二者都不会为不完整或缺失的记录报告成功,也不会显示进行中的浮层。 ----- @@ -97,9 +97,9 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ### Markdown 复制流程 -Header 向 Markdown 复制控制器请求被点击的 Session。控制器读取文档相对的 `api/session.markdown?sessionId=` 路由,并用共享剪贴板助手写入响应文本。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,通过同一查询服务折叠最新标题,再把原始事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 +Header 向 Markdown 复制控制器请求被点击的 Session。宿主支持时,控制器同步开始延迟的 `ClipboardItem` 写入,然后读取文档相对的 `api/session.markdown?sessionId=` 路由;否则先读取,再使用共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,用 `foldSessionTitle` 从同一批返回事件折叠最新标题,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 -控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其成功提示始终描述第一次点击的 Session。释放时中止请求,并抑制迟到的剪贴板写入或提示。 +控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入承诺的 Blob、抑制提示,并等待已接受的剪贴板工作完成。
@@ -144,6 +144,9 @@ Header 向 Markdown 复制控制器请求被点击的 Session。控制器读取 - **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。 - **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 - **Markdown 只包含已提交历史**——未稳定的实时 token 分块没有已提交文本,因此不会出现在复制的记录中。压缩与 fork 继承遵循原始日志,而非当前模型上下文。 +- **Markdown 复制仅限选中的 Session**——子会话日志由 ZIP 下载导出,而不是 Markdown 复制。 +- **记录文本缓冲在内存中**——完整的 Markdown 字符串及其 Blob 在剪贴板接受前都会保留。浏览器剪贴板限制可能拒绝大型导出;本包不增加字节上限,也不截断请求的完整记录。 +- **已接受的剪贴板写入无法回滚**——释放会停止待处理的准备与反馈,但操作系统已经成功的写入会留在剪贴板中。 ### 开发备注 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index d5c457baa6..dd21cd9446 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -68,6 +68,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", "react": "^18.2.0" }, "dsh": { diff --git a/packages/session-query/session-log-export/src/client/CopyToast.tsx b/packages/session-query/session-log-export/src/client/CopyToast.tsx index 3d49e52e04..2a7ac3d4cc 100644 --- a/packages/session-query/session-log-export/src/client/CopyToast.tsx +++ b/packages/session-query/session-log-export/src/client/CopyToast.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { SessionMarkdownCopyState } from './copy-controller.ts' import { NS } from './locales.ts' @@ -13,7 +13,8 @@ export interface SessionMarkdownCopyToastInjected { } /** Props of the root overlay copy notice. */ -export type SessionMarkdownCopyToastProps = PropsLocale +export type SessionMarkdownCopyToastProps = PropsRuntime<'shell.overlay'> + & PropsLocale & InjectFace /** @@ -31,7 +32,7 @@ export function SessionMarkdownCopyToast({ : ( } onDone={dismiss} /> diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index 55af4ab1e0..fcf0cfd862 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -80,7 +80,7 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderPr aria-label={t('header.more')} aria-haspopup="menu" aria-expanded={open} - aria-busy={busy} + aria-busy={busy || copying} onClick={() => { setOpen(value => !value) }} > diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts index effb39e190..50eeb0d1cb 100644 --- a/packages/session-query/session-log-export/src/client/copy-controller.ts +++ b/packages/session-query/session-log-export/src/client/copy-controller.ts @@ -2,9 +2,14 @@ * Browser controller for the Session Header's "Copy as Markdown" action. One * plugin instance owns one pending copy: the clipboard is a browser-wide * destination, so a second request joins the pending operation instead of - * racing it. The Header disables its copy row from this state; the toast lives - * in a separate `shell.overlay` entry so it survives the menu closing or the - * Session changing. + * racing it. + * + * On hosts with `ClipboardItem` and `navigator.clipboard.write`, the controller + * starts that write synchronously from the click with a promised text Blob, so + * the browser keeps the user activation while the Host prepares the + * transcript. Other hosts fall back to `writeClipboard` after preparation. A + * disposal aborts the read and prevents later text from reaching the Blob, but + * an accepted operating-system clipboard write cannot be rolled back. * @module */ @@ -13,8 +18,8 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import { SESSION_MARKDOWN_EXPORT_ROUTE } from '../routes.ts' -/** Whether the latest copy attempt succeeded or failed. */ -export type SessionMarkdownCopyNoticeKind = 'success' | 'failed' +/** Whether the latest copy attempt succeeded, prepared no transcript, or could not write. */ +export type SessionMarkdownCopyNoticeKind = 'success' | 'preparationFailed' | 'clipboardFailed' /** One settled copy notice; `seq` keys the toast so a repeat copy restarts it. */ export interface SessionMarkdownCopyNotice { @@ -30,13 +35,42 @@ export interface SessionMarkdownCopyState { notice: SessionMarkdownCopyNotice | null } +/** Browser clipboard operations used by one copy attempt. */ +export interface SessionMarkdownClipboard { + /** Whether a deferred write can start inside the click's activation window. */ + supportsDeferredWrite(): boolean + /** Start one deferred text write; the browser observes the data promise as it settles. */ + writeDeferred(data: Promise): Promise + /** Write prepared text on hosts without the deferred API; false means the browser rejected it. */ + writeText(text: string): Promise +} + /** HTTP carrier for the Markdown route. */ type Fetch = (input: string | URL, init?: RequestInit) => Promise -/** Host clipboard write; false means the browser rejected it. */ -type Write = (text: string) => Promise const INITIAL: SessionMarkdownCopyState = { copying: false, notice: null } +const browserClipboard: SessionMarkdownClipboard = { + supportsDeferredWrite() { + const clipboard: unknown = Reflect.get(navigator, 'clipboard') + return typeof globalThis.ClipboardItem === 'function' + && typeof clipboard === 'object' && clipboard !== null + && typeof Reflect.get(clipboard, 'write') === 'function' + }, + writeDeferred(data) { + return navigator.clipboard.write([new ClipboardItem({ 'text/plain': data })]) + }, + writeText: writeClipboard, +} + +function startWrite(start: () => Promise): Promise { + try { + return start() + } catch (error) { + return Promise.reject(new Error('clipboard write could not start', { cause: error })) + } +} + /** Owns the one pending Markdown copy for this plugin instance. */ export class SessionMarkdownCopyController { /** uSES-safe state source shared by every Header row and the overlay toast. */ @@ -48,14 +82,14 @@ export class SessionMarkdownCopyController { constructor( private readonly fetcher: Fetch = (input, init) => fetch(input, init), - private readonly write: Write = writeClipboard, + private readonly clipboard: SessionMarkdownClipboard = browserClipboard, ) {} /** * Copy one Session's complete transcript. A request received while a copy is * pending joins that operation and keeps its originally captured Session. * @param sessionId - Session captured by the click. - * @returns after the clipboard write settles, a failure notice is published, or a late post-disposal request is ignored. + * @returns after the clipboard work settles, a failure notice is published, or a late post-disposal request is ignored. */ copy(sessionId: SessionId): Promise { if (this.disposed) return Promise.resolve() @@ -75,9 +109,10 @@ export class SessionMarkdownCopyController { } /** - * Abort a pending fetch, suppress a late clipboard write or notice, and - * reach quiescence. - * @returns after the pending operation settles. + * Abort a pending read, suppress a late clipboard write or notice, and reach + * quiescence. A write already accepted by the operating system cannot be + * undone. + * @returns after the pending operation and any admitted clipboard work settle. */ async dispose(): Promise { this.disposed = true @@ -87,23 +122,68 @@ export class SessionMarkdownCopyController { this.store.set(INITIAL) } - private async run(sessionId: SessionId, signal: AbortSignal): Promise { + private run(sessionId: SessionId, signal: AbortSignal): Promise { this.store.update((state) => { state.copying = true }) + const preparation = this.prepare(sessionId, signal) + if (this.clipboard.supportsDeferredWrite()) { + const data = preparation.then((markdown) => { + if (this.isCancelled() || signal.aborted) throw new Error('Session Markdown copy cancelled') + return new Blob([markdown], { type: 'text/plain' }) + }) + const write = startWrite(() => this.clipboard.writeDeferred(data)) + return this.settleDeferred(preparation, data, write) + } + return this.settleFallback(preparation) + } + + private async prepare(sessionId: SessionId, signal: AbortSignal): Promise { + const query = new URLSearchParams({ sessionId }) + const route = `${SESSION_MARKDOWN_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { signal }) + if (!response.ok) throw new Error(`Session Markdown request failed: HTTP ${String(response.status)}`) + return response.text() + } + + private async settleDeferred( + preparation: Promise, + data: Promise, + write: Promise, + ): Promise { try { - const query = new URLSearchParams({ sessionId }) - const route = `${SESSION_MARKDOWN_EXPORT_ROUTE}?${query.toString()}` - const response = await this.fetcher(route, { signal }) - if (!response.ok) throw new Error(`Session Markdown request failed: HTTP ${String(response.status)}`) - const markdown = await response.text() - // The plugin may unload while the fetch or a clipboard call is pending; - // never write or report after disposal. - if (this.isCancelled()) return - if (!await this.write(markdown)) throw new Error('the browser rejected the clipboard write') + const [prepared, blob, written] = await Promise.allSettled([preparation, data, write]) if (this.isCancelled()) return + if (prepared.status === 'rejected' || blob.status === 'rejected') { + this.publishNotice('preparationFailed') + return + } + if (written.status === 'rejected') { + this.publishNotice('clipboardFailed') + return + } this.publishNotice('success') - } catch { + } finally { + this.store.update((state) => { state.copying = false }) + } + } + + private async settleFallback(preparation: Promise): Promise { + try { + let markdown: string + try { + markdown = await preparation + } catch { + if (!this.isCancelled()) this.publishNotice('preparationFailed') + return + } if (this.isCancelled()) return - this.publishNotice('failed') + let accepted = false + try { + accepted = await this.clipboard.writeText(markdown) + } catch { + accepted = false + } + if (this.isCancelled()) return + this.publishNotice(accepted ? 'success' : 'clipboardFailed') } finally { this.store.update((state) => { state.copying = false }) } diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 86d37f125a..d52237fa13 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -8,7 +8,8 @@ export const zh = { 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', 'toast.copied': 'Session 已复制为 Markdown', - 'toast.copyFailed': '无法复制 Session,请检查剪贴板权限后重试', + 'toast.preparationFailed': '无法读取 Session 进行复制,请重试', + 'toast.clipboardFailed': '无法写入剪贴板,请检查剪贴板权限后重试', 'dialog.preparingTitle': '正在导出 Session', 'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。', 'dialog.successTitle': 'Session 导出已开始下载', @@ -25,7 +26,8 @@ export const en: Record = { 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', 'toast.copied': 'Session copied as Markdown.', - 'toast.copyFailed': 'Could not copy the Session. Check clipboard access and try again.', + 'toast.preparationFailed': 'Could not read the Session for copying. Try again.', + 'toast.clipboardFailed': 'Could not write to the clipboard. Check clipboard permission and try again.', 'dialog.preparingTitle': 'Exporting Session', 'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.', 'dialog.successTitle': 'Session download started', diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 900aadf469..956db40856 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -6,6 +6,7 @@ import Schema from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' +import type {} from '@deepseek-ai/dsh-session-title' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -118,11 +119,12 @@ function connectionOf(ctx: Context): SessionLogConnection { } /** - * Produce one complete Session transcript as Markdown. The read is a detached - * export observation through session-query's live-preferred corpus, so it - * creates no Agent, retains no Session, and writes no Session data. Abort is - * observed before and after each read; the corpus read itself has no - * cancellation parameter. + * Produce one complete Session transcript as Markdown from one Session + * observation. The read is a detached export observation through + * session-query's live-preferred corpus, so it creates no Agent, retains no + * Session, and writes no Session data. The title is the latest logged title + * value from the same event snapshot as the body. Abort is observed before and + * after the read; the corpus read itself has no cancellation parameter. * @param ctx - host context carrying Connection and session-query. * @param request - authenticated Fetch request with a required `sessionId` query. * @returns the Markdown response, or a sanitized status for invalid, missing, or unavailable reads. @@ -141,9 +143,8 @@ async function sessionMarkdownResponse(ctx: Context, request: Request): Promise< request.signal.throwIfAborted() const snapshot = await sessionQuery.readSession(sessionId) request.signal.throwIfAborted() - const title = await sessionQuery.readTitle(sessionId, request.signal) - request.signal.throwIfAborted() - return new Response(formatSessionMarkdown({ title: title?.title, events: snapshot.events }), { + const title = snapshot.events.findLast(event => event.type === 'session/title')?.data.title + return new Response(formatSessionMarkdown({ title, events: snapshot.events }), { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts index fa5ba7b433..715ebbd6f0 100644 --- a/packages/session-query/session-log-export/src/markdown.ts +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -82,7 +82,9 @@ export function formatSessionMarkdown(input: SessionMarkdownInput): string { } if (pending.length > 0) sections.push(collapsibleSection(pending)) const final = turn.segments[finalIndex] - const finalText = final?.kind === 'assistant' ? final.text.trim() : '' + const finalText = final?.kind === 'assistant' + ? closeUnfinishedFence(trimBlankEdgeLines(final.text)) + : '' if (finalText !== '') sections.push(finalText) } return `${sections.join('\n\n').trimEnd()}\n` @@ -121,7 +123,7 @@ function collectTurns(events: readonly SessionEvent[]): Turn[] { const message = event.data.message const text = assistantText(message) const toolCalls = message.content.some(block => block.type === 'tool-call') - if (text.trim() !== '') active().segments.push({ kind: 'assistant', text, toolCalls }) + if (trimBlankEdgeLines(text) !== '') active().segments.push({ kind: 'assistant', text, toolCalls }) break } case 'tool/call': { @@ -170,7 +172,10 @@ function collapsibleSection(entries: readonly TurnEntry[]): string { function renderEntry(entry: TurnEntry): string { if (entry.kind === 'assistant') { - return `**Assistant**\n\n${escapeWrapperTagsOutsideFences(entry.text.trim())}` + const text = escapeWrapperTagsOutsideFences( + closeUnfinishedFence(trimBlankEdgeLines(entry.text)), + ) + return `**Assistant**\n\n${text}` } const heading = `**Tool: \`${escapeWrapperTags(entry.name)}\`**` const argumentsText = entry.arguments === '' @@ -263,38 +268,93 @@ function fenced(text: string, language = ''): string { return `${fence}${language}\n${content}\n${fence}` } -// Arbitrary commentary inside a generated details section must not close the -// wrapper; fenced code blocks keep their exact text because their backtick -// fence already prevents HTML parsing. -function escapeWrapperTagsOutsideFences(markdown: string): string { +interface Fence { + readonly character: string + readonly length: number +} + +interface ScannedLine { + readonly text: string + readonly code: boolean +} + +interface MarkdownScan { + readonly lines: readonly ScannedLine[] + readonly openFence: Fence | undefined +} + +// Remove only blank edge lines, preserving the first and last content line's +// indentation and interior blank lines. +function trimBlankEdgeLines(text: string): string { + const lines = text.replace(/\r\n?/gu, '\n').split('\n') + let start = 0 + let end = lines.length + while (start < end && lines[start]?.trim() === '') start += 1 + while (end > start && lines[end - 1]?.trim() === '') end -= 1 + return lines.slice(start, end).join('\n') +} + +// Scan fenced and indented code once so wrapper escaping and unfinished-fence +// closure agree on what is code. This is a narrow Markdown block heuristic, not +// a parser. +function scanMarkdown(markdown: string): MarkdownScan { const lines = markdown.replace(/\r\n?/gu, '\n').split('\n') - let fenceCharacter: string | undefined - let fenceLength = 0 - const output: string[] = [] + const scanned: ScannedLine[] = [] + let fence: Fence | undefined + let indented = false for (const line of lines) { - if (fenceCharacter === undefined) { - const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line) - if (opening !== null) { - const marker = String(opening[1]) - const info = String(opening[2]) - if (marker.startsWith('~') || !info.includes('`')) { - fenceCharacter = marker[0] - fenceLength = marker.length - output.push(line) - continue - } - } - output.push(escapeWrapperTags(line)) + if (fence !== undefined) { + scanned.push({ text: line, code: true }) + if (closesFence(line, fence)) fence = undefined continue } - const closing = new RegExp(`^ {0,3}${fenceCharacter}{${String(fenceLength)},}\\s*$`, 'u') - if (closing.test(line)) { - fenceCharacter = undefined - fenceLength = 0 + const opening = openingFence(line) + if (opening !== undefined) { + fence = opening + indented = false + scanned.push({ text: line, code: true }) + continue } - output.push(line) + if (/^(?: {4}|\t)/u.test(line)) { + indented = true + scanned.push({ text: line, code: true }) + continue + } + scanned.push({ text: line, code: indented && line.trim() === '' }) + if (line.trim() !== '') indented = false } - return output.join('\n') + return { lines: scanned, openFence: fence } +} + +function openingFence(line: string): Fence | undefined { + const opening = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line) + if (opening === null) return undefined + const marker = String(opening[1]) + const info = String(opening[2]) + if (marker.startsWith('`') && info.includes('`')) return undefined + return { character: marker.slice(0, 1), length: marker.length } +} + +function closesFence(line: string, fence: Fence): boolean { + const closing = new RegExp(`^ {0,3}${fence.character}{${String(fence.length)},}\\s*$`, 'u') + return closing.test(line) +} + +// Close an assistant message that ended inside a code fence so the next +// message or generated details closer cannot be swallowed by that block. +function closeUnfinishedFence(markdown: string): string { + const scan = scanMarkdown(markdown) + if (scan.openFence === undefined) return markdown + const content = markdown.replace(/\n+$/u, '') + return `${content}\n${scan.openFence.character.repeat(scan.openFence.length)}` +} + +// Arbitrary commentary inside a generated details section must not close the +// wrapper. Fenced and indented code keep their exact text. +function escapeWrapperTagsOutsideFences(markdown: string): string { + return scanMarkdown(markdown).lines + .map(line => line.code ? line.text : escapeWrapperTags(line.text)) + .join('\n') } function escapeWrapperTags(text: string): string { diff --git a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx index 0613c9c9c2..f8ed7792bf 100644 --- a/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/client-apply.client.spec.tsx @@ -85,7 +85,7 @@ describe('session-log-download browser plugin', () => { expect(overlay?.options).toMatchObject({ id: 'session-log-download.copy-toast' }) const overlayInjected = injectedOf(overlay) as SessionMarkdownCopyToastInjected await injected.copyMarkdown(SID) - expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('failed') + expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed') overlayInjected.dismiss() expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice).toBeNull() diff --git a/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts index bbcaa27711..08f04acb25 100644 --- a/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/copy-controller.client.spec.ts @@ -1,7 +1,10 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' +import { + SessionMarkdownCopyController, + type SessionMarkdownClipboard, +} from '../src/client/copy-controller.ts' const SID = 'session-copy-controller' as SessionId const OTHER = 'session-copy-other' as SessionId @@ -11,20 +14,41 @@ afterEach(() => { vi.unstubAllGlobals() }) +function fallbackClipboard(writeText: (text: string) => Promise): SessionMarkdownClipboard { + return { + supportsDeferredWrite: () => false, + writeDeferred: () => Promise.reject(new Error('deferred write must not start')), + writeText, + } +} + describe('SessionMarkdownCopyController', () => { - it('fetches the Markdown route and writes the complete response', async () => { - const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('# Title\n\nText\n', { status: 200 })) - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(fetcher, write) + it('starts the deferred write synchronously and resolves its Blob promise later', async () => { + const responses = Promise.withResolvers() + let deferred: Promise | undefined + const writeDeferred = vi.fn((data: Promise) => { + deferred = data + return Promise.resolve() + }) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + { + supportsDeferredWrite: () => true, + writeDeferred, + writeText: vi.fn(async () => false), + }, + ) - await controller.copy(SID) + const pending = controller.copy(SID) + expect(writeDeferred).toHaveBeenCalledOnce() + expect(controller.store.getSnapshot().copying).toBe(true) + responses.resolve(new Response('# Title\n\nText\n')) + await pending - expect(fetcher).toHaveBeenCalledOnce() - const [route, init] = fetcher.mock.calls[0]! - expect(route).toBe(`api/session.markdown?sessionId=${SID}`) - expect(init?.method).toBeUndefined() - expect(init?.signal).toBeInstanceOf(AbortSignal) - expect(write).toHaveBeenCalledWith('# Title\n\nText\n') + if (deferred === undefined) throw new Error('clipboard data promise was not provided') + const blob = await deferred + expect(blob).toBeInstanceOf(Blob) + expect(await blob.text()).toBe('# Title\n\nText\n') expect(controller.store.getSnapshot()).toMatchObject({ copying: false, notice: { kind: 'success' }, @@ -34,20 +58,20 @@ describe('SessionMarkdownCopyController', () => { it('joins a second-Session request to the pending copy and keeps its captured target', async () => { const responses = Promise.withResolvers() const fetcher = vi.fn((_input: string | URL, _init?: RequestInit) => responses.promise) - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(fetcher, write) + const controller = new SessionMarkdownCopyController(fetcher, { + supportsDeferredWrite: () => true, + writeDeferred: async () => {}, + writeText: vi.fn(async () => false), + }) const first = controller.copy(SID) const second = controller.copy(OTHER) expect(first).toBe(second) - expect(controller.store.getSnapshot().copying).toBe(true) expect(fetcher).toHaveBeenCalledOnce() expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.markdown?sessionId=${SID}`) responses.resolve(new Response('markdown for first')) await first - expect(write).toHaveBeenCalledOnce() - expect(write).toHaveBeenCalledWith('markdown for first') expect(controller.store.getSnapshot().copying).toBe(false) await controller.copy(OTHER) @@ -55,83 +79,120 @@ describe('SessionMarkdownCopyController', () => { expect(fetcher.mock.calls[1]?.[0]).toBe(`api/session.markdown?sessionId=${OTHER}`) }) - it('publishes failures for HTTP, transport, and clipboard rejection without leaking rejections', async () => { + it('falls back to writing prepared text only when deferred APIs are missing', async () => { + const responses = Promise.withResolvers() + const writeText = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(writeText), + ) + + const pending = controller.copy(SID) + expect(writeText).not.toHaveBeenCalled() + responses.resolve(new Response('# Fallback')) + await pending + + expect(writeText).toHaveBeenCalledWith('# Fallback') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) + + it('classifies HTTP, transport, and deferred preparation failures', async () => { const http = new SessionMarkdownCopyController( async () => new Response('hidden detail', { status: 500 }), - vi.fn(async () => true), + { + supportsDeferredWrite: () => true, + writeDeferred: async () => {}, + writeText: vi.fn(async () => false), + }, ) await http.copy(SID) - expect(http.store.getSnapshot()).toMatchObject({ - copying: false, - notice: { kind: 'failed' }, - }) + expect(http.store.getSnapshot().notice?.kind).toBe('preparationFailed') - const write = vi.fn() - const transport = new SessionMarkdownCopyController(async () => { throw new Error('offline') }, write) + const transport = new SessionMarkdownCopyController( + async () => { throw new Error('offline') }, + fallbackClipboard(vi.fn(async () => true)), + ) await transport.copy(SID) - expect(transport.store.getSnapshot().notice?.kind).toBe('failed') - expect(write).not.toHaveBeenCalled() + expect(transport.store.getSnapshot().notice?.kind).toBe('preparationFailed') + }) + + it('classifies native denial and fallback rejection as clipboard failures', async () => { + const denied = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: async () => { throw new Error('denied') }, + writeText: vi.fn(async () => false), + }, + ) + await denied.copy(SID) + expect(denied.store.getSnapshot().notice?.kind).toBe('clipboardFailed') const rejected = new SessionMarkdownCopyController( async () => new Response('markdown'), - async () => false, + fallbackClipboard(async () => false), ) await rejected.copy(SID) - expect(rejected.store.getSnapshot().notice?.kind).toBe('failed') + expect(rejected.store.getSnapshot().notice?.kind).toBe('clipboardFailed') }) - it('accepts a new copy after a failed operation settles', async () => { - const fetcher = vi.fn() - .mockResolvedValueOnce(new Response('hidden', { status: 500 })) - .mockResolvedValueOnce(new Response('markdown')) - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(fetcher, write) - - await controller.copy(SID) - expect(controller.store.getSnapshot().notice?.kind).toBe('failed') - await controller.copy(SID) - expect(controller.store.getSnapshot().notice?.kind).toBe('success') - expect(fetcher).toHaveBeenCalledTimes(2) - expect(write).toHaveBeenCalledOnce() - }) - - it('dismisses a notice and ignores a dismissal with no notice', async () => { + it('contains an immediate native denial followed by a later preparation rejection', async () => { + const responses = Promise.withResolvers() const controller = new SessionMarkdownCopyController( - async () => new Response('markdown'), - async () => true, + (_input: string | URL, _init?: RequestInit) => responses.promise, + { + supportsDeferredWrite: () => true, + writeDeferred: async () => { throw new Error('denied immediately') }, + writeText: vi.fn(async () => false), + }, ) - controller.dismiss() - expect(controller.store.getSnapshot().notice).toBeNull() - await controller.copy(SID) - expect(controller.store.getSnapshot().notice?.kind).toBe('success') - controller.dismiss() - expect(controller.store.getSnapshot().notice).toBeNull() + const pending = controller.copy(SID) + responses.reject(new Error('read failed later')) + await pending + + expect(controller.store.getSnapshot().notice?.kind).toBe('preparationFailed') + expect(controller.store.getSnapshot().copying).toBe(false) }) - it('aborts pending fetches and suppresses a write that settles after disposal', async () => { - const gate = Promise.withResolvers() - const write = vi.fn(async () => true) - const controller = new SessionMarkdownCopyController(() => gate.promise, write) + it('aborts the read and prevents text after disposal before readiness', async () => { + const responses = Promise.withResolvers() + let deferred: Promise | undefined + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + { + supportsDeferredWrite: () => true, + writeDeferred: (data) => { + deferred = data + return Promise.resolve() + }, + writeText: vi.fn(async () => false), + }, + ) const pending = controller.copy(SID) const disposing = controller.dispose() - gate.resolve(new Response('late markdown')) + responses.resolve(new Response('late markdown')) await disposing await pending - expect(write).not.toHaveBeenCalled() + if (deferred === undefined) throw new Error('clipboard data promise was not provided') + await expect(deferred).rejects.toThrow('cancelled') expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) - await expect(controller.copy(SID)).resolves.toBeUndefined() }) - it('suppresses success when disposal starts while the clipboard write is pending', async () => { + it('awaits admitted clipboard work and suppresses its notice on disposal', async () => { const writeGate = Promise.withResolvers() - const write = vi.fn(() => writeGate.promise) - const controller = new SessionMarkdownCopyController(async () => new Response('markdown'), write) - const pending = controller.copy(SID) - await vi.waitFor(() => { expect(write).toHaveBeenCalledOnce() }) + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: () => writeGate.promise.then(() => {}), + writeText: vi.fn(async () => false), + }, + ) + const pending = controller.copy(SID) const disposing = controller.dispose() writeGate.resolve(true) await disposing @@ -140,27 +201,160 @@ describe('SessionMarkdownCopyController', () => { expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) }) - it('suppresses a rejection caused by disposal instead of publishing failure', async () => { - const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - })) - const controller = new SessionMarkdownCopyController(fetcher, vi.fn(async () => true)) - const pending = controller.copy(SID) + it('falls back to writing on a host without deferred clipboard APIs', async () => { + const responses = Promise.withResolvers() + const writeText = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(writeText), + ) - await controller.dispose() + const pending = controller.copy(SID) + expect(writeText).not.toHaveBeenCalled() + responses.resolve(new Response('fallback markdown')) + await pending + + expect(writeText).toHaveBeenCalledWith('fallback markdown') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) + + + it('contains a synchronous deferred-write failure', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: () => { throw new Error('constructor failed') }, + writeText: vi.fn(async () => false), + }, + ) + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('clipboardFailed') + }) + + it('does not write fallback text after cancellation before readiness', async () => { + const responses = Promise.withResolvers() + const writeText = vi.fn(async () => true) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(writeText), + ) + + const pending = controller.copy(SID) + const disposing = controller.dispose() + responses.resolve(new Response('late fallback')) + await disposing + await pending + + expect(writeText).not.toHaveBeenCalled() + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('classifies a throwing fallback writer and awaits it during disposal', async () => { + const throwing = new SessionMarkdownCopyController( + async () => new Response('markdown'), + fallbackClipboard(async () => { throw new Error('write failed') }), + ) + await throwing.copy(SID) + expect(throwing.store.getSnapshot().notice?.kind).toBe('clipboardFailed') + + const writeGate = Promise.withResolvers() + const writeText = vi.fn(() => writeGate.promise) + const pendingWrite = new SessionMarkdownCopyController( + async () => new Response('markdown'), + fallbackClipboard(writeText), + ) + const pending = pendingWrite.copy(SID) + await vi.waitFor(() => { expect(writeText).toHaveBeenCalledOnce() }) + const disposing = pendingWrite.dispose() + writeGate.resolve(true) + await disposing + await pending + expect(pendingWrite.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + + it('suppresses a fallback preparation failure after disposal', async () => { + const responses = Promise.withResolvers() + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + fallbackClipboard(vi.fn(async () => true)), + ) + + const pending = controller.copy(SID) + const disposing = controller.dispose() + responses.reject(new Error('late read failure')) + await disposing await pending expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) }) - it('uses the document-relative default carrier and the host clipboard writer', async () => { + it('ignores copy after disposal', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + fallbackClipboard(vi.fn(async () => true)), + ) + await controller.dispose() + await expect(controller.copy(SID)).resolves.toBeUndefined() + expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null }) + }) + + it('dismisses a notice and ignores a dismissal with no notice', async () => { + const controller = new SessionMarkdownCopyController( + async () => new Response('markdown'), + { + supportsDeferredWrite: () => true, + writeDeferred: async () => {}, + writeText: vi.fn(async () => false), + }, + ) + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + + await controller.copy(SID) + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + controller.dismiss() + expect(controller.store.getSnapshot().notice).toBeNull() + }) + + it('starts the browser ClipboardItem write synchronously through the default adapter', async () => { + const responses = Promise.withResolvers() + let deferred: Promise | undefined + class TestClipboardItem { + readonly text: Promise + constructor(items: Record>) { + const text = items['text/plain'] + if (text === undefined) throw new Error('text/plain data is required') + this.text = text + deferred = text + } + } + const write = vi.fn(async () => {}) + vi.stubGlobal('ClipboardItem', TestClipboardItem) + vi.stubGlobal('navigator', { clipboard: { write } }) + const controller = new SessionMarkdownCopyController( + (_input: string | URL, _init?: RequestInit) => responses.promise, + ) + + const pending = controller.copy(SID) + expect(write).toHaveBeenCalledOnce() + expect(controller.store.getSnapshot().copying).toBe(true) + responses.resolve(new Response('# ClipboardItem')) + await pending + + if (deferred === undefined) throw new Error('ClipboardItem did not receive text data') + const blob = await deferred + expect(await blob.text()).toBe('# ClipboardItem') + expect(controller.store.getSnapshot().notice?.kind).toBe('success') + }) + + it('uses the document-relative default carrier and the fallback clipboard helper', async () => { const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('default markdown')) const writeText = vi.fn(async () => {}) vi.stubGlobal('fetch', fetcher) - Object.defineProperty(navigator, 'clipboard', { - configurable: true, - value: { writeText }, - }) + vi.stubGlobal('navigator', { clipboard: { writeText } }) const controller = new SessionMarkdownCopyController() await controller.copy(SID) diff --git a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx index 52a34b1a97..e491848066 100644 --- a/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/copy-toast.client.spec.tsx @@ -3,11 +3,19 @@ import { act, cleanup, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { useSyncExternalStore } from 'react' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionMarkdownCopyState } from '../src/client/copy-controller.ts' import { SessionMarkdownCopyToast } from '../src/client/CopyToast.tsx' import type { SessionMarkdownCopyToastProps } from '../src/client/CopyToast.tsx' import { en } from '../src/client/locales.ts' +const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } +const standard: GlobalStandardProps = { + useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, + useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, + usePanelInfo: unused, useResource: unused, useWorkspaces: unused, +} + function bindSnapshot(store: ObservableSnapshot) { return function useSessionMarkdownCopy(selector: (state: SessionMarkdownCopyState) => T): T { return useSyncExternalStore( @@ -22,6 +30,7 @@ function bench(notice: SessionMarkdownCopyState['notice']) { const dismiss = vi.fn() const dictionary: Record = en const props: SessionMarkdownCopyToastProps = { + ...standard, useSessionMarkdownCopy: bindSnapshot(store), dismiss, t: key => dictionary[key] ?? key, @@ -43,10 +52,15 @@ describe('Session Markdown copy toast', () => { expect(b.dismiss).toHaveBeenCalledOnce() }) - it('shows the failure outcome with its actionable copy', () => { - const b = bench({ kind: 'failed', seq: 2 }) - expect(b.getByRole('alert').textContent).toBe( - 'Could not copy the Session. Check clipboard access and try again.', + it('shows separate preparation and clipboard failure copy', () => { + const preparation = bench({ kind: 'preparationFailed', seq: 2 }) + expect(preparation.getByRole('alert').textContent).toBe( + 'Could not read the Session for copying. Try again.', + ) + cleanup() + const clipboard = bench({ kind: 'clipboardFailed', seq: 3 }) + expect(clipboard.getByRole('alert').textContent).toBe( + 'Could not write to the clipboard. Check clipboard permission and try again.', ) }) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 91400e6680..86a35d5e04 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -6,12 +6,17 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { GlobalStandardProps, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { SessionLogDownloadController } from '../src/client/controller.ts' -import { SessionMarkdownCopyController } from '../src/client/copy-controller.ts' +import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId +const clipboard: SessionMarkdownClipboard = { + supportsDeferredWrite: () => false, + writeDeferred: async () => {}, + writeText: async () => true, +} const dictionary: Record = en const translate: SessionLogDownloadHeaderProps['t'] = key => dictionary[key] ?? key const unused = (): never => { throw new Error('This isolated component does not consume framework hooks') } @@ -36,7 +41,7 @@ function bindSnapshot(store: ObservableSnapshot) { function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController( async () => new Response('markdown'), - async () => true, + clipboard, )) { const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn()) const request = vi.fn((sessionId: SessionId) => controller.download(sessionId)) @@ -115,7 +120,7 @@ describe('Session export Header action', () => { it('disables only the copy row while a copy is pending', async () => { const gate = Promise.withResolvers() - const copyController = new SessionMarkdownCopyController(() => gate.promise, async () => true) + const copyController = new SessionMarkdownCopyController(() => gate.promise, clipboard) const b = bench(true, copyController) const pending = copyController.copy(SID) expect(copyController.store.getSnapshot().copying).toBe(true) @@ -127,12 +132,13 @@ describe('Session export Header action', () => { await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) - expect(button.getAttribute('aria-busy')).toBe('false') + expect(button.getAttribute('aria-busy')).toBe('true') gate.resolve(new Response('markdown')) await pending expect(b.copyController.store.getSnapshot().copying).toBe(false) await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(false) }) + expect(button.getAttribute('aria-busy')).toBe('false') }) it('opens the more-actions menu and downloads through the shared controller', async () => { diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts index 3abfa2e855..ef3decdfd4 100644 --- a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -4,8 +4,9 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser import type { MessageId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { SESSION_MARKDOWN_EXPORT_PATH, apply, inject } from '../src/index.ts' const SESSION_ID = 'session-markdown-route' as SessionId @@ -34,9 +35,15 @@ const EVENTS: SessionEvent[] = [ }, }, { - type: 'assistant/message', + type: 'session/title', seq: brandNumber(3), time: 3, + data: { title: 'Route title from events', messageSeqs: [brandNumber(2)], source: { kind: 'fallback' } }, + }, + { + type: 'assistant/message', + seq: brandNumber(4), + time: 4, surfaceOp: 'append', data: { turn: 1, @@ -50,7 +57,7 @@ const EVENTS: SessionEvent[] = [ stream: [], }, }, - { type: 'turn/end', seq: brandNumber(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/end', seq: brandNumber(5), time: 5, data: { turn: 1, reason: { kind: 'completed' } } }, ] function missingSessionError(): Error { @@ -59,15 +66,10 @@ function missingSessionError(): Error { function query(overrides: { readonly readSession?: (id: SessionId) => Promise - readonly readTitle?: (id: SessionId, signal?: AbortSignal) => Promise } = {}): unknown { return { readSession: overrides.readSession ?? (async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })), - readTitle: overrides.readTitle - ?? (async () => ({ - title: 'Route title', messageSeqs: [], source: { kind: 'fallback' }, eventSeq: 2, updatedAt: 2, - })), } } @@ -103,7 +105,7 @@ describe('Session Markdown Fetch route', () => { expect(response.status).toBe(200) expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') const markdown = await response.text() - expect(markdown).toContain('# Route title') + expect(markdown).toContain('# Route title from events') expect(markdown).toContain('> Route question') expect(markdown).toContain('Route answer') expect(markdown.endsWith('\n')).toBe(true) @@ -161,16 +163,18 @@ describe('Session Markdown Fetch route', () => { await broken.dispose() }) - it('returns a sanitized 500 when the title read fails after the log read', async () => { - const { shared, connection, dispose } = await mounted(query({ - readTitle: async () => { throw new Error('title path /private/host') }, - })) + it('folds the title and body from one returned snapshot without a second title read', async () => { + const readSession = vi.fn(async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })) + const readTitle = vi.fn(async () => { throw new Error('must not read a second observation') }) + const { shared, connection, dispose } = await mounted({ readSession, readTitle }) const response = await shared.fetch( request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), connection.browserPeer, ) - expect(response.status).toBe(500) - expect(await response.text()).toBe('session markdown export failed to read the stored log') + expect(response.status).toBe(200) + expect(await response.text()).toContain('# Route title from events') + expect(readSession).toHaveBeenCalledOnce() + expect(readTitle).not.toHaveBeenCalled() await dispose() }) }) diff --git a/packages/session-query/session-log-export/tests/markdown.host.spec.ts b/packages/session-query/session-log-export/tests/markdown.host.spec.ts index 1b42768ca4..80fa21c6a6 100644 --- a/packages/session-query/session-log-export/tests/markdown.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown.host.spec.ts @@ -327,6 +327,113 @@ describe('formatSessionMarkdown', () => { expect(markdown).toBe('# Whitespace\n\n> Question\n\nAnswer\n') }) + + it('preserves four-space and tab indentation in final and commentary prose', () => { + const markdown = formatSessionMarkdown({ + title: 'Indented code', + events: [ + start(1, 1), + user(2, 'Show code'), + assistant(3, [text('
\n literal code')]), + assistant(4, [text('\tif (ready) {\n\t run()\n\t}')]), + end(5, 1), + ], + }) + + expect(markdown).toContain('**Assistant**\n\n
\n literal code') + expect(markdown).toContain('\tif (ready) {\n\t run()\n\t}') + expect(markdown).not.toContain('<details>') + expect(markdown.endsWith('\t}\n')).toBe(true) + }) + + it('closes unfinished commentary fences before the generated details closer', () => { + const backtick = formatSessionMarkdown({ + title: 'Backtick fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('Before\n\n```ts\nconst x = 1')]), + assistant(4, [text('After backtick')]), + end(5, 1), + ], + }) + expect(backtick).toContain('const x = 1\n```\n\n
\n\nAfter backtick') + + const tilde = formatSessionMarkdown({ + title: 'Tilde fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('~~~js\nconst y = 2')]), + assistant(4, [text('After tilde')]), + end(5, 1), + ], + }) + expect(tilde).toContain('const y = 2\n~~~\n\n
\n\nAfter tilde') + + const longer = formatSessionMarkdown({ + title: 'Longer fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('````md\nlonger fence')]), + assistant(4, [text('After longer')]), + end(5, 1), + ], + }) + expect(longer).toContain('longer fence\n````\n\n
\n\nAfter longer') + }) + + it('does not treat an invalid backtick info string as an open fence', () => { + const markdown = formatSessionMarkdown({ + title: 'Invalid fence', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('```bad`info\nstill prose')]), + assistant(4, [text('After invalid')]), + end(5, 1), + ], + }) + + expect(markdown).toContain('```bad`info\nstill prose') + expect(markdown.match(/```/gu)).toHaveLength(1) + expect(markdown).toContain('still prose\n\n
\n\nAfter invalid') + }) + + it('closes an unfinished final fence before the next turn', () => { + const markdown = formatSessionMarkdown({ + title: 'Final fence', + events: [ + start(1, 1), + user(2, 'First question'), + assistant(3, [text('```sh\necho hi')]), + end(4, 1), + start(5, 2), + user(6, 'Second question'), + end(7, 2), + ], + }) + + expect(markdown).toContain('echo hi\n```\n\n> Second question') + expect(markdown.indexOf('echo hi')).toBeLessThan(markdown.indexOf('> Second question')) + }) + + + it('strips blank edge lines without removing indented code blank lines', () => { + const markdown = formatSessionMarkdown({ + title: 'Edge blanks', + events: [ + start(1, 1), + user(2, 'Question'), + assistant(3, [text('\n first\n\n second\n\n')]), + end(4, 1), + ], + }) + + expect(markdown).toBe('# Edge blanks\n\n> Question\n\n first\n\n second\n') + }) + it('escapes wrapper tags outside fences and grows fences past tool backtick runs', () => { const markdown = formatSessionMarkdown({ title: 'Escaping', diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 22dad514a4..cfcb54a364 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -20,6 +20,7 @@ { "path": "../../interaction/commands" }, { "path": "../../session/session-format" }, { "path": "../../session/session-persistence" }, + { "path": "../../session/session-title" }, { "path": "../session-query" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c24f6d915f..81f40f1c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12288,6 +12288,9 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:* version: link:../session-query + '@deepseek-ai/dsh-session-title': + specifier: workspace:* + version: link:../../session/session-title react: specifier: ^18.2.0 version: 18.3.1 From e0b32d1d90904f15ecadd4156770278471443e76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:56:42 +0800 Subject: [PATCH 05/73] docs(session-log-export): describe Markdown copy routes Update the client and Host module descriptions and apply JSDoc to mention the authenticated Markdown copy route alongside ZIP download and feedback. Comment-only change. --- packages/session-query/session-log-export/src/client/index.ts | 2 +- packages/session-query/session-log-export/src/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/session-query/session-log-export/src/client/index.ts b/packages/session-query/session-log-export/src/client/index.ts index cf97b268c2..1205617322 100644 --- a/packages/session-query/session-log-export/src/client/index.ts +++ b/packages/session-query/session-log-export/src/client/index.ts @@ -1,4 +1,4 @@ -/** Browser plugin owning Session export download state and its shared modal. */ +/** Browser plugin owning ZIP download and Markdown copy state, plus feedback controls. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 956db40856..8dec1692c2 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -1,4 +1,4 @@ -/** Session-log download command and Host-owned streaming route. */ +/** Session export command and Host-owned ZIP download and Markdown copy routes. */ import type { Context } from '@deepseek-ai/cordis' import type { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' @@ -73,7 +73,7 @@ const REQUESTED: CommandResult = { } /** - * Register the Web-only `/export` command and authenticated ZIP download route. + * Register the `/export` command and authenticated ZIP download and Markdown copy routes. * @param ctx - Host context carrying the human-command registry. * @param config - resolved compression policy. */ From 8888db9545910fac889d8c7c11e42e3bf3feb714 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:05:37 +0800 Subject: [PATCH 06/73] docs(session-log-export): correct Markdown copy facts Describe the direct session/title field read and same-click deferred clipboard request, cover Markdown copy in the selection guidance, and tighten the Chinese Blob and compaction wording. Re-record the README pair. --- .../session-query/session-log-export/README.i18n.yaml | 10 +++++----- packages/session-query/session-log-export/README.md | 4 ++-- packages/session-query/session-log-export/README.zh.md | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ff192b51ec..0a8e02bfea 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -19,10 +19,10 @@ zh: 89b47be0660c8b8b /deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: en: ff52c54e4c6486b8 - zh: 9ea4810f86ac4d6c + zh: 88c52f4ed7b07308 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: - en: 2b9dbec4877d332e - zh: dcf6638938eedb01 + en: a9e2aa65a0a903c5 + zh: 0f928d0130a4c452 /deepseek-ai-dsh-session-log-export/use-this-package/composition: en: 4168a2d3e653ab73 zh: 8bf955cfab7b08e2 @@ -48,8 +48,8 @@ en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: - en: 20fbc8fb02bf8c5e - zh: c6ad27c484f0f2bf + en: 0f71c07df6fe181e + zh: d1cadf6ac1b8a21f /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 3e6d5baf1f..830a5ca74f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -39,7 +39,7 @@ When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, whi ### When to choose it -Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. +Choose it for a Web deployment that needs user-facing session export: a readable Markdown transcript for copying and a ZIP archive for downloading. Avoid it when a programmatic or Host-side export is needed: both operations keep the result in the browser, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. ### Composition @@ -97,7 +97,7 @@ The Host route is a feature-owned exact Fetch contribution. Connection applies i ### Markdown copy flow -The Header asks the Markdown copy controller for the clicked Session. The controller starts a deferred `ClipboardItem` write synchronously when the host supports it, then fetches the document-relative `api/session.markdown?sessionId=` route; otherwise it fetches first and uses the shared clipboard helper. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession`, folds the latest title from the same returned events with `foldSessionTitle`, and passes the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. +The Header asks the Markdown copy controller for the clicked Session. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=` route; other hosts fall back to the shared clipboard helper after preparation. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession` and takes the latest `session/title` event's title field from that same snapshot before passing the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its notice always describe the first click's Session. Both the preparation promise and the admitted clipboard write are observed; every rejection is contained. Disposal aborts the read, prevents later text from reaching the promised Blob, suppresses notices, and awaits admitted clipboard work. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 723ba43674..484d3b8328 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -31,15 +31,15 @@ kind: "package-reference" `复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 -格式化器读取已提交的原始日志,因此压缩产生的表面替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 +格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 -剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,写入会在点击时同步开始,并以承诺的 `text/plain` Blob 在读取记录期间继续;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入 Blob;操作系统已经接受的剪贴板写入无法回滚。 +剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。 ### 何时选择 -为需要带可见下载弹窗的面向用户的会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 +为需要面向用户的会话导出——复制可读的 Markdown 记录与下载 ZIP 归档——的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:两个操作都把结果留在浏览器中,而非写入 Host 路径。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 ### 组合 @@ -97,9 +97,9 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ### Markdown 复制流程 -Header 向 Markdown 复制控制器请求被点击的 Session。宿主支持时,控制器同步开始延迟的 `ClipboardItem` 写入,然后读取文档相对的 `api/session.markdown?sessionId=` 路由;否则先读取,再使用共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,用 `foldSessionTitle` 从同一批返回事件折叠最新标题,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 +Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并从同一快照取出最新 `session/title` 事件的 title 字段,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 -控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入承诺的 Blob、抑制提示,并等待已接受的剪贴板工作完成。 +控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入由 Promise 提供的 Blob、抑制提示,并等待已接受的剪贴板工作完成。
From e16c52f610f2e3ddb2e18280d983fce3923fbd40 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:28:26 +0800 Subject: [PATCH 07/73] fix(session-log-export): keep Markdown route path internal Remove the unused SESSION_MARKDOWN_EXPORT_PATH package re-export while keeping the internal routes definition and Host import. Read the constant directly from routes in the same-package route test. SESSION_LOG_EXPORT_PATH remains public. --- packages/session-query/session-log-export/src/index.ts | 2 +- .../session-log-export/tests/markdown-route.host.spec.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 8dec1692c2..57c2a850b7 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -42,7 +42,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -export { SESSION_LOG_EXPORT_PATH, SESSION_MARKDOWN_EXPORT_PATH } from './routes.ts' +export { SESSION_LOG_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts index ef3decdfd4..6483dcff52 100644 --- a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -7,7 +7,8 @@ import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' import { describe, expect, it, vi } from 'vitest' -import { SESSION_MARKDOWN_EXPORT_PATH, apply, inject } from '../src/index.ts' +import { apply, inject } from '../src/index.ts' +import { SESSION_MARKDOWN_EXPORT_PATH } from '../src/routes.ts' const SESSION_ID = 'session-markdown-route' as SessionId From 687aa118f1ae54df7676b70576e432f2fe931d29 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:46:27 +0800 Subject: [PATCH 08/73] feat(session-query): export backend Session Markdown converter Expose the pure Host formatSessionMarkdown(events, sourcePath?) converter at the package entry. Persistence stat/list now report the selected materialized JSONL artifact path, and the Markdown copy route includes that exact optional path after the title. Frontend progressive display and Session format remain unchanged. --- .../copy-markdown.expected.md | 6 + apps/web/tests/navigation-panes.e2e.ts | 14 ++- docs/subsystems/persistence.i18n.yaml | 6 +- docs/subsystems/persistence.md | 18 ++- docs/subsystems/persistence.zh.md | 18 ++- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- .../session-log-export/README.i18n.yaml | 19 +-- .../session-log-export/README.md | 20 ++- .../session-log-export/README.zh.md | 20 ++- .../session-log-export/src/index.ts | 13 +- .../session-log-export/src/markdown.ts | 49 ++++---- .../tests/markdown-route.host.spec.ts | 65 +++++++++- .../tests/markdown.host.spec.ts | 119 ++++++++++++++---- .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 2 + .../tests/jsonl.spec.ts | 36 +++++- .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 2 +- .../session/session-persistence/README.zh.md | 2 +- .../session/session-persistence/src/errors.ts | 8 +- .../session/session-persistence/src/index.ts | 7 ++ 23 files changed, 344 insertions(+), 98 deletions(-) diff --git a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md index 1be7dec876..7de5e961c1 100644 --- a/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md +++ b/apps/web/tests/expected/navigation-panes/copy-markdown.expected.md @@ -1,5 +1,11 @@ # NavScenario: first run bash to +Session file: + +```text +{{sessionFile}} +``` + > NavScenario: first run bash to print exactly NAVIGATION_OK, then read nav-a.md and nav-b.md using two read calls in ONE assistant message, then reply with the single word FIRST_DONE and stop.
3 previous messages diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index ce979f4774..254e1ac9c5 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -6,7 +6,7 @@ // under the standard discipline: turn 1 produces a bash call plus two // parallel reads in one assistant message (tool-call density for the // trajectory ledger/timing lanes), turn 2 a markdown-rich reply. -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' @@ -14,7 +14,7 @@ import { chromium } from 'playwright' import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import { SESSION_FORMAT_VERSION, type SessionEvent } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, @@ -443,10 +443,18 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await page.getByText('Session copied as Markdown.', { exact: true }).waitFor({ timeout: 30_000 }) expect(await page.evaluate(() => Number(Reflect.get(globalThis, '__dshClipboardWrites')))).toBe(1) + const sourcePath = (await scaffold.ctx.sessionPersistence.stat(SessionId(SEED_ID)))?.location?.path + if (sourcePath === undefined) throw new Error('seeded Session has no materialized artifact location') + expect((await stat(sourcePath)).isFile()).toBe(true) + expect(sourcePath.startsWith(`${scaffold.persistenceRoot}/`)).toBe(true) const markdown = await page.evaluate(() => navigator.clipboard.readText()) - const normalized = markdown.split(scaffold.workspaceCwd).join('{{cwd}}') + expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``) + const normalized = markdown + .split(sourcePath).join('{{sessionFile}}') + .split(scaffold.workspaceCwd).join('{{cwd}}') // compareOrRefreshGolden appends its own newline; the clipboard text already ends with exactly one. await compareOrRefreshGolden(COPY_MARKDOWN_EXPECTED, normalized.replace(/\n$/u, ''), MODE) + expect(normalized).toContain('Session file:\n\n```text\n{{sessionFile}}\n```') expect(normalized).toContain('# NavScenario: first run bash to') expect(normalized).toContain('> NavScenario: first run bash to print exactly NAVIGATION_OK') expect(normalized).toContain('
3 previous messages') diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index aaf59a0f16..054615f66d 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -14,9 +14,9 @@ /session-persistence/crash-recovery-preserves-an-interrupted-turn: en: 534044a0ce482d52 zh: d2e8a7dfeb066946 -/session-persistence/sessionlocation-refusal-diagnostics-artifact-target: - en: 886589f052a916d7 - zh: 2ca126aa700c933c +/session-persistence/sessionlocation-per-session-artifact-metadata: + en: e310ff426d848262 + zh: 8a7667349b0549a7 /session-persistence/sessionheader-metadata-beside-the-log: en: 5795ae03d2d163cd zh: a46ceeb22cfbb6a1 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 3c0c2586a7..8f35e17f05 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -113,16 +113,16 @@ Repair therefore writes only under write ownership: a live session's write handl Read-only observation is `open(id, 'read')`: the handle serves validated contiguous prefix slices, never a torn tail, and repeated reads on one handle never observe an older state than a prior read. There is no persistence-side prepared-Session cache: session-query owns its cold-read cache, keying one balanced cold Session per id on the `stat().revision` change token and re-reading only when the token changes. The [handle-based persistence Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.md) owns this lifecycle; the archived [Session preparation record](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md) documents the original publication-boundary `SessionPreparation` decision. -## `SessionLocation` — refusal-diagnostics artifact target +## `SessionLocation` — per-Session artifact metadata -`SessionLocation` is not a consumer-facing query: log access goes through a session handle's `read`. It survives only as refusal diagnostics, letting a `SessionFormatUnsupportedError` name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. +`SessionLocation` is observational metadata for diagnostics and export; log access still goes through a session handle's `read`. Materialized persistence snapshots carry the selected artifact path, and a `SessionFormatUnsupportedError` can name the raw log a build refused to interpret. JSONL supplies the absolute transcript path inside its project/session directory; a backend without one artifact per session supplies nothing. ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index dba0685409..c88b884fdb 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -113,16 +113,16 @@ interface SessionHandle extends AsyncDisposable { 只读观察即 `open(id, 'read')`:句柄提供经过验证的连续前缀切片,绝不返回撕裂尾部,且同一句柄上的重复读取绝不会观察到比先前读取更旧的状态。持久化侧不存在已准备 Session 缓存:session-query 拥有自己的冷读缓存,按 `stat().revision` 变更令牌为每个 id 缓存一个已配平的冷 Session,仅在令牌变化时重新读取。该生命周期由[基于句柄的持久化 Agent Note](../../.agents/notes/implemented/architecture/2026-08-27-handle-based-session-persistence.zh.md)定义;已归档的 [Session 准备阶段记录](../../.agents/notes/archived/architecture/2026-08-05-session-preparation.md)记载了发布边界 `SessionPreparation` 最初的决策。 -## `SessionLocation`——拒绝诊断的产物目标 +## `SessionLocation`——每一 Session 的产物元数据 -`SessionLocation` 不是面向消费者的查询:日志访问走会话句柄的 `read`。它仅作为拒绝诊断存在,使 `SessionFormatUnsupportedError` 能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 +`SessionLocation` 用于诊断与导出的观察元数据;日志访问仍走会话句柄的 `read`。已物化的持久化快照携带选中产物路径,`SessionFormatUnsupportedError` 也能指出本构建拒绝解读的原始日志。JSONL 提供其项目/会话目录内 transcript(文本记录)的绝对路径;没有逐会话工件的后端则不提供。 ```ts type-equiv /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ @@ -319,6 +319,12 @@ interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } ``` diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index cca1649f96..286fc59954 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -7272,6 +7272,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionListValue', declaration: 'export interface SessionListValue {\n readonly items: readonly SessionSummary[];\n}', }, + { + name: 'SessionLocation', + declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', + }, { name: 'SessionLogOffset', declaration: 'export type SessionLogOffset = BrandedNumber<\'SessionLogOffset\'>;', @@ -7350,7 +7354,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionPersistenceSnapshot', - declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly formatStatus?: \'current\' | \'migration-required\';\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n}', + declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly formatStatus?: \'current\' | \'migration-required\';\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n readonly location?: SessionLocation;\n}', }, { name: 'SessionPersistenceStatOptions', diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 0a8e02bfea..5fe8787069 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -18,11 +18,14 @@ en: f37f81af3601784d zh: 89b47be0660c8b8b /deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: - en: ff52c54e4c6486b8 - zh: 88c52f4ed7b07308 + en: 59ba15abb59b6bd0 + zh: 1ea2b05fc05b9e61 /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: - en: a9e2aa65a0a903c5 - zh: 0f928d0130a4c452 + en: 4011f686afd24f09 + zh: 2710b9995c80ba1f +/deepseek-ai-dsh-session-log-export/use-this-package/backend-conversion: + en: 252bc78a7850557a + zh: e695658a94c8c2bf /deepseek-ai-dsh-session-log-export/use-this-package/composition: en: 4168a2d3e653ab73 zh: 8bf955cfab7b08e2 @@ -48,8 +51,8 @@ en: bd46d2fa1273a6a4 zh: 4d3eb2cf0a9bf12b /deepseek-ai-dsh-session-log-export/understand-the-implementation/markdown-copy-flow: - en: 0f71c07df6fe181e - zh: d1cadf6ac1b8a21f + en: d4e02b56ffb8ee8a + zh: 0a329039788a13ee /deepseek-ai-dsh-session-log-export/further-exploration: en: 8eef7e6c58c9f962 zh: 6c720c91c7f26299 @@ -69,8 +72,8 @@ en: 61e7dbf79010d071 zh: 29294355598c0476 /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work: - en: 9aad3e0c329709e4 - zh: 41f16123976897bf + en: 961c206403204080 + zh: c0cea2c965cc0b1b /deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note: en: 4ee4d24c26c127bf zh: 1facdfbfa3ccefbc diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 830a5ca74f..31416a2731 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -31,7 +31,7 @@ Use this package when the Web bundle should let users export a session log. It r `Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. -The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. +The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. The clipboard is one browser-wide destination, so this plugin instance runs one copy at a time. On hosts with `ClipboardItem` and `navigator.clipboard.write`, the write starts synchronously from the click with a promised `text/plain` Blob while the transcript is read; other hosts fall back to the shared clipboard helper after preparation. A request made while a copy is pending joins that operation and keeps the first click's Session. Every Session Header reflects copying in `aria-busy` and disables its copy row until the operation settles. Success, preparation-failure, or clipboard-failure feedback uses the root overlay toast. Disposal aborts the read and prevents later text from reaching the Blob; an operating-system clipboard write already accepted cannot be rolled back. @@ -39,7 +39,20 @@ When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, whi ### When to choose it -Choose it for a Web deployment that needs user-facing session export: a readable Markdown transcript for copying and a ZIP archive for downloading. Avoid it when a programmatic or Host-side export is needed: both operations keep the result in the browser, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. +Choose it for a Web deployment that needs user-facing session export: a readable Markdown transcript for copying and a ZIP archive for downloading, or for a backend caller that already holds a complete committed logical event snapshot and needs the same Markdown conversion. Avoid it when a Host path write is needed: the browser operations keep their result in the browser, and the conversion utility performs no IO. The logs are serialized from persistence read handles, so any mounted backend is supported. + +### Backend conversion + +The package entry also exports `formatSessionMarkdown(events, sourcePath?)`: a pure backend utility for callers that already hold a complete committed logical event snapshot in seq order, including any inherited prefix. It reads the latest `session/title` event, returns Markdown ending in one newline, performs no IO, starts no plugin or Session, and does not mutate the events. An optional backend-resolved `sourcePath` is emitted verbatim in a dynamic fenced `Session file:` block after the title; omitted or empty paths add no block. Missing or blank titles produce `Untitled Session`. Only the supplied events are converted; descendant Session logs are not read. + +```ts +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { formatSessionMarkdown } from '@deepseek-ai/dsh-session-log-export' + +export function convertTranscript(events: readonly SessionEvent[], sourcePath?: string): string { + return formatSessionMarkdown(events, sourcePath) +} +``` ### Composition @@ -97,7 +110,7 @@ The Host route is a feature-owned exact Fetch contribution. Connection applies i ### Markdown copy flow -The Header asks the Markdown copy controller for the clicked Session. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=` route; other hosts fall back to the shared clipboard helper after preparation. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession` and takes the latest `session/title` event's title field from that same snapshot before passing the events to the pure formatter. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. +The Header asks the Markdown copy controller for the clicked Session. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=` route; other hosts fall back to the shared clipboard helper after preparation. The Host route reads the complete live-preferred logical log through `ctx.sessionQuery.readSession` and passes those events to the pure formatter. It separately asks `ctx.get('sessionPersistence')?.stat` for the selected materialized artifact path and passes that optional path to the formatter; a metadata-read failure is a sanitized preparation failure. The read is a detached export observation: it creates no Agent, retains no Session, and writes no Session data. It answers 404 for an absent Session and a sanitized 500 for every other read failure. The controller holds one pending operation and one `shell.overlay` toast entry. A second Header request joins the pending operation, so the clipboard and its notice always describe the first click's Session. Both the preparation promise and the admitted clipboard write are observed; every rejection is contained. Disposal aborts the read, prevents later text from reaching the promised Blob, suppresses notices, and awaits admitted clipboard work. @@ -147,6 +160,7 @@ These limits define when this package is a poor fit or needs special operational - **Markdown copy is the selected Session only** — descendant Session logs are exported by the ZIP download, not by the Markdown copy. - **Transcript text is buffered in memory** — the complete Markdown string and its Blob are held before the clipboard accepts them. Browser clipboard limits can reject a large export; this package does not add a byte cap or truncate the requested complete transcript. - **Accepted clipboard writes cannot be rolled back** — disposal stops pending preparation and feedback, but an operating-system write that already succeeded remains on the clipboard. +- **The source path is observed metadata, not an atomic log claim** — `stat` identifies the selected materialized artifact at export time. Live committed events can be ahead of that file, and an unmaterialized Session has no source-path block. ### Dev Note diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 484d3b8328..67b94237e2 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" `复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 -格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 +格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。 @@ -39,7 +39,20 @@ kind: "package-reference" ### 何时选择 -为需要面向用户的会话导出——复制可读的 Markdown 记录与下载 ZIP 归档——的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:两个操作都把结果留在浏览器中,而非写入 Host 路径。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 +为需要面向用户的会话导出——复制可读的 Markdown 记录与下载 ZIP 归档——的 Web 部署选择它,也适用于已经持有完整已提交逻辑事件快照并需要相同 Markdown 转换的后端调用方。仅需 Host 路径写入时避免使用:浏览器操作把结果留在浏览器中,转换工具不执行 IO。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 + +### 后端转换 + +包入口还导出 `formatSessionMarkdown(events, sourcePath?)`:面向已经持有完整已提交逻辑事件快照(按 seq 排序,包含任何继承前缀)的调用方的纯后端工具。它读取最新的 `session/title` 事件,返回以恰好一个换行结尾的 Markdown,不执行 IO、不启动插件或 Session,也不修改事件。可选的、由后端解析的 `sourcePath` 会原样写入标题后的动态围栏 `Session file:` 文本块;省略或空路径不添加该块。缺失或空白标题生成 `Untitled Session`。它只转换传入的事件;不读取子会话日志。 + +```ts +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { formatSessionMarkdown } from '@deepseek-ai/dsh-session-log-export' + +export function convertTranscript(events: readonly SessionEvent[], sourcePath?: string): string { + return formatSessionMarkdown(events, sourcePath) +} +``` ### 组合 @@ -97,7 +110,7 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 ### Markdown 复制流程 -Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并从同一快照取出最新 `session/title` 事件的 title 字段,再把事件交给纯格式化器。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 +Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并把事件交给纯格式化器。它另外通过 `ctx.get('sessionPersistence')?.stat` 获取选中已物化产物的路径,并把可选路径传给格式化器;元数据读取失败属于经过清理的准备失败。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。 控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入由 Promise 提供的 Blob、抑制提示,并等待已接受的剪贴板工作完成。 @@ -147,6 +160,7 @@ Header 向 Markdown 复制控制器请求被点击的 Session。点击期间, - **Markdown 复制仅限选中的 Session**——子会话日志由 ZIP 下载导出,而不是 Markdown 复制。 - **记录文本缓冲在内存中**——完整的 Markdown 字符串及其 Blob 在剪贴板接受前都会保留。浏览器剪贴板限制可能拒绝大型导出;本包不增加字节上限,也不截断请求的完整记录。 - **已接受的剪贴板写入无法回滚**——释放会停止待处理的准备与反馈,但操作系统已经成功的写入会留在剪贴板中。 +- **来源路径是观察到的元数据,不是原子日志声明**——`stat` 标识导出时选中的已物化产物。实时已提交事件可能领先于该文件,未物化的 Session 没有来源路径块。 ### 开发备注 diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 57c2a850b7..5fe99dce5f 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -6,7 +6,6 @@ import Schema from '@deepseek-ai/schemastery' import { brandString } from '@deepseek-ai/dsh-brand' import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' -import type {} from '@deepseek-ai/dsh-session-title' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -38,6 +37,7 @@ export type { SessionLogExportReady, SessionLogZipEntry, } from './archive.ts' +export { formatSessionMarkdown } from './markdown.ts' export const name = 'session-log-download' export const inject = ['commands', 'connection'] @@ -122,9 +122,9 @@ function connectionOf(ctx: Context): SessionLogConnection { * Produce one complete Session transcript as Markdown from one Session * observation. The read is a detached export observation through * session-query's live-preferred corpus, so it creates no Agent, retains no - * Session, and writes no Session data. The title is the latest logged title - * value from the same event snapshot as the body. Abort is observed before and - * after the read; the corpus read itself has no cancellation parameter. + * Session, and writes no Session data. The formatter reads the latest title from + * the same event snapshot as the body. Abort is observed before and after the + * read; the corpus read itself has no cancellation parameter. * @param ctx - host context carrying Connection and session-query. * @param request - authenticated Fetch request with a required `sessionId` query. * @returns the Markdown response, or a sanitized status for invalid, missing, or unavailable reads. @@ -143,8 +143,9 @@ async function sessionMarkdownResponse(ctx: Context, request: Request): Promise< request.signal.throwIfAborted() const snapshot = await sessionQuery.readSession(sessionId) request.signal.throwIfAborted() - const title = snapshot.events.findLast(event => event.type === 'session/title')?.data.title - return new Response(formatSessionMarkdown({ title, events: snapshot.events }), { + const metadata = await ctx.get('sessionPersistence')?.stat(sessionId, { signal: request.signal }) + request.signal.throwIfAborted() + return new Response(formatSessionMarkdown(snapshot.events, metadata?.location?.path), { headers: { 'content-type': 'text/markdown; charset=utf-8' }, }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts index 715ebbd6f0..84261bee39 100644 --- a/packages/session-query/session-log-export/src/markdown.ts +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -1,9 +1,11 @@ /** - * Renders one complete committed Session log as Markdown for the "Copy as - * Markdown" action. The raw log is authoritative: compaction surface - * replacements never remove earlier messages, and fork-inherited history - * appears once. Reasoning, system and developer messages, injected non-human - * context, request headers, and runtime records are omitted. + * Pure backend converter for one complete committed Session log. The "Copy as + * Markdown" Host route uses it, and the package entry exports it for callers + * that already hold the logical events. The raw log is authoritative: + * compaction surface replacements never remove earlier messages, and + * fork-inherited history appears once. Reasoning, system and developer + * messages, injected non-human context, request headers, and runtime records + * are omitted. * @module */ @@ -13,18 +15,11 @@ import type { ToolResultMessage, UserMessage, } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' /** Heading used when the log carries no title and the Session has no name. */ -export const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session' - -/** Input to {@link formatSessionMarkdown}. */ -export interface SessionMarkdownInput { - /** Latest log-backed title, or `undefined` when the log has no title event. */ - readonly title: string | undefined - /** Complete committed raw event log in seq order, including inherited history. */ - readonly events: readonly SessionEvent[] -} +const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session' interface AssistantEntry { readonly kind: 'assistant' @@ -59,13 +54,23 @@ interface Turn { } /** - * Format one complete Session log as Markdown. - * @param input - the latest title and the complete committed event log. - * @returns Markdown ending in exactly one newline; an empty Session returns its title heading only. + * Render one complete committed Session log as Markdown. This is a pure backend + * utility: it needs no mounted plugin, reads no storage, and does not mutate the + * supplied events. The latest `session/title` event supplies the heading; the + * rest of the transcript is derived only from the supplied logical events. + * @param events - complete committed logical event snapshot in seq order, including any inherited prefix. + * @param sourcePath - optional backend-resolved path to the selected Session artifact; emitted verbatim in a fenced text block. + * @returns Markdown ending in exactly one newline; an empty log returns the heading and optional source metadata only. */ -export function formatSessionMarkdown(input: SessionMarkdownInput): string { - const sections = [`# ${normalizedTitle(input.title)}`] - for (const turn of collectTurns(input.events)) { +export function formatSessionMarkdown(events: readonly SessionEvent[], sourcePath?: string): string { + const title = events.findLast(event => event.type === 'session/title')?.data.title + const sections = [`# ${normalizedTitle(title)}`] + if (sourcePath !== undefined && sourcePath !== '') { + sections.push(`Session file: + +${fenced(sourcePath, 'text', false)}`) + } + for (const turn of collectTurns(events)) { const finalIndex = finalReplyIndex(turn.segments) const end = finalIndex < 0 ? turn.segments.length : finalIndex let pending: TurnEntry[] = [] @@ -260,8 +265,8 @@ function jsonArguments(argumentsText: string): string { return JSON.stringify(parsed, null, 2) } -function fenced(text: string, language = ''): string { - const content = text.replace(/\n+$/u, '') +function fenced(text: string, language = '', trimTrailing = true): string { + const content = trimTrailing ? text.replace(/\n+$/u, '') : text let longest = 0 for (const match of content.matchAll(/`+/gu)) longest = Math.max(longest, match[0].length) const fence = '`'.repeat(Math.max(3, longest + 1)) diff --git a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts index 6483dcff52..2b00a04e9a 100644 --- a/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown-route.host.spec.ts @@ -4,9 +4,11 @@ import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser import type { MessageId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionHeader, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { apply, inject } from '../src/index.ts' import { SESSION_MARKDOWN_EXPORT_PATH } from '../src/routes.ts' @@ -74,14 +76,19 @@ function query(overrides: { } } -async function mounted(sessionQuery: unknown): Promise<{ +async function mounted( + sessionQuery: unknown, + persistence?: Pick, +): Promise<{ readonly shared: ReturnType readonly connection: HostConnectionService readonly dispose: () => Promise }> { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) ctx.provide('commands', { register: () => () => {} } as never) if (sessionQuery !== undefined) ctx.provide('sessionQuery', sessionQuery as never) + if (persistence !== undefined) ctx.provide('sessionPersistence', persistence as SessionPersistence) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber @@ -109,6 +116,7 @@ describe('Session Markdown Fetch route', () => { expect(markdown).toContain('# Route title from events') expect(markdown).toContain('> Route question') expect(markdown).toContain('Route answer') + expect(markdown).not.toContain('Session file:') expect(markdown.endsWith('\n')).toBe(true) const head = await shared.fetch( @@ -126,6 +134,59 @@ describe('Session Markdown Fetch route', () => { )).status).toBe(404) }) + + it('emits the actual selected artifact path from persistence metadata', async () => { + const sourcePath = '/tmp/project/session.v4.jsonl' + const stat = vi.fn(async (_id: SessionId, _options?: { readonly signal?: AbortSignal }) => ({ + header: HEADER, + revision: SessionPersistenceRevision('test:location'), + location: { kind: 'jsonl', path: sourcePath }, + })) + const { shared, connection, dispose } = await mounted(query(), { stat }) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(200) + const markdown = await response.text() + expect(markdown).toContain(`Session file:\n\n\`\`\`text\n${sourcePath}\n\`\`\``) + const [requestedId, statOptions] = stat.mock.calls[0]! + expect(requestedId).toBe(SESSION_ID) + expect(statOptions?.signal).toBeInstanceOf(AbortSignal) + await dispose() + }) + + it('omits the source block when metadata has no materialized location', async () => { + const stat = vi.fn(async () => ({ + header: HEADER, + revision: SessionPersistenceRevision('test:no-location'), + })) + const { shared, connection, dispose } = await mounted(query(), { stat }) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(200) + expect(await response.text()).not.toContain('Session file:') + await dispose() + }) + + it('sanitizes a metadata read failure as a preparation failure', async () => { + const stat = vi.fn(async () => { + throw new Error('metadata failed at /private/host/session.v4.jsonl') + }) + const { shared, connection, dispose } = await mounted(query(), { stat }) + const response = await shared.fetch( + request(`${SESSION_MARKDOWN_EXPORT_PATH}?sessionId=${SESSION_ID}`), + connection.browserPeer, + ) + expect(response.status).toBe(500) + const detail = await response.text() + expect(detail).toBe('session markdown export failed to read the stored log') + expect(detail).not.toContain('/private/host') + await dispose() + }) + it('validates the query before reporting missing Markdown services', async () => { const { shared, connection, dispose } = await mounted(undefined) expect((await shared.fetch(request(SESSION_MARKDOWN_EXPORT_PATH), connection.browserPeer)).status).toBe(400) diff --git a/packages/session-query/session-log-export/tests/markdown.host.spec.ts b/packages/session-query/session-log-export/tests/markdown.host.spec.ts index 80fa21c6a6..9b5a0dec74 100644 --- a/packages/session-query/session-log-export/tests/markdown.host.spec.ts +++ b/packages/session-query/session-log-export/tests/markdown.host.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock, MessageId, ToolCallId } from '@deepseek-ai/dsh-llm' import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' import { brandNumber, brandString } from '@deepseek-ai/dsh-brand' import { formatSessionMarkdown } from '../src/markdown.ts' @@ -10,6 +11,24 @@ const callId = (value: string): ToolCallId => brandString(value) const attachmentId = (value: string): AttachmentId => brandString(value) const sequence = (value: number): SessionSeq => brandNumber(value) +function titleEvent(seq: number, title: string): SessionEvent<'session/title'> { + return { + type: 'session/title', + seq: sequence(seq), + time: seq, + data: { title, messageSeqs: [], source: { kind: 'fallback' } }, + } +} + +function renderWithTitle(input: { + readonly title: string | undefined + readonly events: readonly SessionEvent[] +}): string { + return formatSessionMarkdown(input.title === undefined + ? input.events + : [titleEvent(0, input.title), ...input.events]) +} + function start(seq: number, turn: number): SessionEvent<'turn/start'> { return { type: 'turn/start', seq: sequence(seq), time: seq, data: { turn } } } @@ -134,8 +153,66 @@ function text(value: string): ContentBlock { } describe('formatSessionMarkdown', () => { + + it('selects the latest real session/title event and treats missing or blank titles as untitled', () => { + const events = [ + titleEvent(0, 'Old title'), + start(1, 1), + user(2, 'Question'), + titleEvent(3, 'Latest title'), + assistant(4, [text('Answer')]), + end(5, 1), + ] + expect(formatSessionMarkdown(events)).toContain('# Latest title') + expect(formatSessionMarkdown(events)).not.toContain('# Old title') + + const blank = [titleEvent(0, ' '), start(1, 1), user(2, 'Question'), end(3, 1)] + expect(formatSessionMarkdown(blank)).toBe('# Untitled Session\n\n> Question\n') + expect(formatSessionMarkdown([])).toBe('# Untitled Session\n') + }) + + + it('emits an exact fenced source path when supplied and omits it otherwise', () => { + const events = [ + titleEvent(0, 'Source path'), + start(1, 1), + user(2, 'Question'), + assistant(3, [text('Answer')]), + end(4, 1), + ] + const sourcePath = '/tmp/Session files/```\nsecond' + expect(formatSessionMarkdown(events, sourcePath)).toBe([ + '# Source path', + '', + 'Session file:', + '', + '````text', + sourcePath, + '````', + '', + '> Question', + '', + 'Answer', + '', + ].join('\n')) + expect(formatSessionMarkdown(events, '')).toBe('# Source path\n\n> Question\n\nAnswer\n') + expect(formatSessionMarkdown(events)).toBe('# Source path\n\n> Question\n\nAnswer\n') + }) + + it('does not mutate a frozen logical event snapshot', () => { + const events = Object.freeze([ + Object.freeze(titleEvent(0, 'Frozen')), + Object.freeze(start(1, 1)), + Object.freeze(user(2, 'Question')), + Object.freeze(assistant(3, [text('Answer')])), + Object.freeze(end(4, 1)), + ]) + expect(formatSessionMarkdown(events)).toBe('# Frozen\n\n> Question\n\nAnswer\n') + expect(events).toHaveLength(5) + }) + it('keeps complete chronology across inherited history and a compaction surface replacement', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Chronology', events: [ start(1, 1), @@ -170,7 +247,7 @@ describe('formatSessionMarkdown', () => { }) it('renders a turn with a collapsible commentary/tool section before the final reply', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Turn layout', events: [ start(1, 1), @@ -216,7 +293,7 @@ describe('formatSessionMarkdown', () => { }) it('keeps a mid-turn steering message after the activity that preceded it', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Steering order', events: [ start(1, 1), @@ -264,7 +341,7 @@ describe('formatSessionMarkdown', () => { '', 'After', ].join('\n') - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Authored final', events: [start(1, 1), user(2, 'Show details'), assistant(3, [text(authored)]), end(4, 1)], }) @@ -275,7 +352,7 @@ describe('formatSessionMarkdown', () => { }) it('ignores replacement surface events and keeps original append chronology', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Replacements', events: [ start(1, 1), @@ -313,7 +390,7 @@ describe('formatSessionMarkdown', () => { }) it('emits no empty assistant section and exactly one trailing newline', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Whitespace', events: [ start(1, 1), @@ -329,7 +406,7 @@ describe('formatSessionMarkdown', () => { it('preserves four-space and tab indentation in final and commentary prose', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Indented code', events: [ start(1, 1), @@ -347,7 +424,7 @@ describe('formatSessionMarkdown', () => { }) it('closes unfinished commentary fences before the generated details closer', () => { - const backtick = formatSessionMarkdown({ + const backtick = renderWithTitle({ title: 'Backtick fence', events: [ start(1, 1), @@ -359,7 +436,7 @@ describe('formatSessionMarkdown', () => { }) expect(backtick).toContain('const x = 1\n```\n\n
\n\nAfter backtick') - const tilde = formatSessionMarkdown({ + const tilde = renderWithTitle({ title: 'Tilde fence', events: [ start(1, 1), @@ -371,7 +448,7 @@ describe('formatSessionMarkdown', () => { }) expect(tilde).toContain('const y = 2\n~~~\n\n
\n\nAfter tilde') - const longer = formatSessionMarkdown({ + const longer = renderWithTitle({ title: 'Longer fence', events: [ start(1, 1), @@ -385,7 +462,7 @@ describe('formatSessionMarkdown', () => { }) it('does not treat an invalid backtick info string as an open fence', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Invalid fence', events: [ start(1, 1), @@ -402,7 +479,7 @@ describe('formatSessionMarkdown', () => { }) it('closes an unfinished final fence before the next turn', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Final fence', events: [ start(1, 1), @@ -421,7 +498,7 @@ describe('formatSessionMarkdown', () => { it('strips blank edge lines without removing indented code blank lines', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Edge blanks', events: [ start(1, 1), @@ -435,7 +512,7 @@ describe('formatSessionMarkdown', () => { }) it('escapes wrapper tags outside fences and grows fences past tool backtick runs', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Escaping', events: [ start(1, 1), @@ -457,7 +534,7 @@ describe('formatSessionMarkdown', () => { }) it('labels attachments and omits reasoning, system prompts, and injected context', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Hidden input', events: [ start(1, 1), @@ -499,13 +576,13 @@ describe('formatSessionMarkdown', () => { }) it('exports only the heading for a blank Session and escapes title newlines', () => { - expect(formatSessionMarkdown({ title: 'One\nTwo', events: [] })).toBe('# One Two\n') - expect(formatSessionMarkdown({ title: ' ', events: [] })).toBe('# Untitled Session\n') - expect(formatSessionMarkdown({ title: undefined, events: [] })).toBe('# Untitled Session\n') + expect(renderWithTitle({ title: 'One\nTwo', events: [] })).toBe('# One Two\n') + expect(renderWithTitle({ title: ' ', events: [] })).toBe('# Untitled Session\n') + expect(renderWithTitle({ title: undefined, events: [] })).toBe('# Untitled Session\n') }) it('renders tool-only and incomplete turns predictably', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Incomplete', events: [ start(1, 1), @@ -537,7 +614,7 @@ describe('formatSessionMarkdown', () => { }) it('renders assistant-only, user-only, empty, and malformed content predictably', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Edge content', events: [ // A committed assistant message before the first turn marker still @@ -597,7 +674,7 @@ describe('formatSessionMarkdown', () => { }) it('renders an orphan tool result without inventing a call name', () => { - const markdown = formatSessionMarkdown({ + const markdown = renderWithTitle({ title: 'Orphan', events: [ start(1, 1), diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index e134bef8d2..d8f2314ebd 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -30,8 +30,8 @@ en: 96498792672f6d50 zh: d767a9c69dced889 /deepseek-ai-dsh-session-persistence-jsonl/use-this-package/reading-the-logs: - en: 3e53fc90a486d373 - zh: 00e6c6e65c56629e + en: 4cec8a6c9f6e488b + zh: 4757440a552db5e3 /deepseek-ai-dsh-session-persistence-jsonl/understand-the-implementation: en: a35407bb25671e7e zh: 6a00f9510c85adcb diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 5782c61468..2de63df367 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -82,7 +82,7 @@ Unloading the backend waits for the consumer unloads it triggers through `sessio ### Reading the logs -Materialized `stat` and `list` snapshots include the format catalog's header classification as `formatStatus`, alongside the translated logical `header`. A read-only preparation leaves the selected historical version unchanged; the status becomes `current` after publication of the successor. Unmaterialized pending Sessions omit `formatStatus`. +Materialized `stat` and `list` snapshots include the format catalog's header classification as `formatStatus`, alongside the translated logical `header`. They also include `location: { kind: 'jsonl', path }` for the selected physical generation. A read-only preparation leaves the selected historical version unchanged; the status becomes `current` after publication of the successor. Unmaterialized pending Sessions omit `formatStatus` and `location`. `open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index dbd602afa3..e5494de7d6 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -82,7 +82,7 @@ kind: "package-reference" ### 读取日志 -已实体化的 `stat` 与 `list` 快照在转换后的逻辑 `header` 之外,通过 `formatStatus` 保留格式 catalog 的头部分类。只读准备不会改变所选历史版本;发布后继之后,状态才变为 `current`。尚未实体化的待定 Session 省略 `formatStatus`。 +已实体化的 `stat` 与 `list` 快照在转换后的逻辑 `header` 之外,通过 `formatStatus` 保留格式 catalog 的头部分类。它们还包含选中物理 generation 的 `location: { kind: 'jsonl', path }`。只读准备不会改变所选历史版本;发布后继之后,状态才变为 `current`。尚未实体化的待定 Session 省略 `formatStatus` 与 `location`。 `open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 3c3c13589f..f8351fa9ee 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -504,6 +504,7 @@ class JsonlSessionPersistence extends SessionPersistence { ? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`) : fileRevision(identity), sizeBytes: Number(identity.size), + location: { kind: 'jsonl', path: selected.sourcePath }, } } catch (error: unknown) { options?.signal?.throwIfAborted() @@ -552,6 +553,7 @@ class JsonlSessionPersistence extends SessionPersistence { ? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`) : fileRevision(identity), sizeBytes: Number(identity.size), + location: { kind: 'jsonl', path: artifact.path }, }) } for (const [id, entry] of pending) { diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 760aaa46c3..82f9f5b6c8 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1223,14 +1223,38 @@ describe('JsonlSessionPersistence: immutable format generations', () => { await writeFile(path, `${JSON.stringify(physical)}\n`) const formatStatus = version === SESSION_FORMAT_VERSION ? 'current' : 'migration-required' expect(await ctx.sessionPersistence.stat(header.id)).toMatchObject({ - header: { version: SESSION_FORMAT_VERSION }, formatStatus, + header: { version: SESSION_FORMAT_VERSION }, + formatStatus, + location: { kind: 'jsonl', path }, }) const [listed] = await ctx.sessionPersistence.list() expect(listed?.formatStatus).toBe(formatStatus) + expect(listed?.location).toEqual({ kind: 'jsonl', path }) expect(readTally.bySuffix.size).toBe(0) } }) + + it('reports the selected compressed artifact path in stat and list metadata', async () => { + const compressedRoot = await freshRoot() + const compressed = new Context() + try { + await compressed.plugin(JsonlSessionPersistence, { root: compressedRoot, compression: 'zstd' }) + const header = meta('zstd-location', '/work') + const handle = await compressed.sessionPersistence.create(header) + await handle.append(oneTurnLog()) + await handle.flush() + await handle.close() + const path = logPath(compressedRoot, header.cwd, header.id, 'zstd') + expect((await compressed.sessionPersistence.stat(header.id))?.location) + .toEqual({ kind: 'jsonl', path }) + const [listed] = await compressed.sessionPersistence.list() + expect(listed?.location).toEqual({ kind: 'jsonl', path }) + } finally { + await compressed.fiber.dispose() + } + }) + it('selects a retained future generation above readable historical bytes', async () => { const header = meta('future-wins', '/work') const sourcePath = historicalLogPath(root, header.cwd, header.id) @@ -1421,13 +1445,21 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect(pendingRows.map(s => s.header.id)).toContain(m.id) expect(pending?.sizeBytes).toBeUndefined() expect(pending?.formatStatus).toBeUndefined() + expect(pending?.location).toBeUndefined() expect(pendingRows.find(s => s.header.id === m.id)?.formatStatus).toBeUndefined() + expect(pendingRows.find(s => s.header.id === m.id)?.location).toBeUndefined() await handle.append(oneTurnLog()) expect((await stat(dir)).isDirectory()).toBe(true) expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true) + const materializedPath = rawLogPath(root, '/work', m.id) expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id) - expect((await ctx.sessionPersistence.stat(m.id))?.formatStatus).toBe('current') + expect(await ctx.sessionPersistence.stat(m.id)).toMatchObject({ + formatStatus: 'current', + location: { kind: 'jsonl', path: materializedPath }, + }) + expect((await ctx.sessionPersistence.list()).find(s => s.header.id === m.id)?.location) + .toEqual({ kind: 'jsonl', path: materializedPath }) await handle.close() }) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 8dd313345e..bd02807cf7 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -33,8 +33,8 @@ en: 08b09dce0bcf47c8 zh: b6c5926d6e440a7b /deepseek-ai-dsh-session-persistence/use-this-package/failures-and-recovery: - en: 18a5cfd4cfa07751 - zh: 0c9bf65c4a40d599 + en: 3b536d66137e8f2a + zh: e1f0fbbad61f515e /deepseek-ai-dsh-session-persistence/understand-the-implementation: en: 9e303bd09a71fef1 zh: 1f631925081ae101 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 80f3ec7126..9e9446b81b 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -62,7 +62,7 @@ Persistence returns the physically valid log; semantic repair belongs to the rea ### Failures and recovery -For versioned artifacts, `stat` and `list` expose `formatStatus` as `current` or `migration-required`. The separate `header` remains current logical metadata. This status describes storage, not an in-memory preparation or migration task; providers without this information omit it. +For versioned artifacts, `stat` and `list` expose `formatStatus` as `current` or `migration-required`. The separate `header` remains current logical metadata. This status describes storage, not an in-memory preparation or migration task; providers without this information omit it. A materialized snapshot also carries `location` with the backend-resolved selected per-Session artifact path when the backend keeps one; this observational metadata supports diagnostics and export, is not persisted Session content, and is omitted for an unmaterialized pending Session. A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SessionHandle` exposes only current logical records identified by `SESSION_FORMAT_VERSION`; a provider must convert any supported historical storage before returning a handle, and the shipped JSONL provider migrates supported historical generations through its static catalog. A newer format instructs the operator to upgrade the harness. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`. diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index ae8547aa4e..cc1f2edb82 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -62,7 +62,7 @@ await ctx.sessionPersistence.flush() // backend-wide d ### 失败与恢复 -对于版本化产物,`stat` 与 `list` 通过 `formatStatus` 暴露 `current` 或 `migration-required`。独立的 `header` 仍是当前逻辑元数据。该状态描述存储,不描述内存准备或迁移任务;没有这项信息的提供方省略该字段。 +对于版本化产物,`stat` 与 `list` 通过 `formatStatus` 暴露 `current` 或 `migration-required`。独立的 `header` 仍是当前逻辑元数据。该状态描述存储,不描述内存准备或迁移任务;没有这项信息的提供方省略该字段。已物化快照还包含 `location`,在后端保留独立产物时给出后端解析的选中每 Session 产物路径;这项观察元数据用于诊断与导出,不是持久化 Session 内容,未物化的待定 Session 省略它。 当前构建无法忠实解读的存储日志会被拒绝,并返回指明拒绝方向的错误,绝不会被误读。`SessionHandle` 只暴露由 `SESSION_FORMAT_VERSION` 标识的当前逻辑记录;提供方必须在返回句柄前转换任何受支持的历史存储,随产品交付的 JSONL 提供方会通过静态 catalog 迁移受支持的历史代际。更新的格式会要求操作者升级 harness。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。 diff --git a/packages/session/session-persistence/src/errors.ts b/packages/session/session-persistence/src/errors.ts index 62e232741e..cf4acd60cc 100644 --- a/packages/session/session-persistence/src/errors.ts +++ b/packages/session/session-persistence/src/errors.ts @@ -77,10 +77,10 @@ export class SessionHandleClosedError extends Error { } /** - * A backend-resolved, per-session local artifact location. Carried only by - * refusal diagnostics ({@link SessionFormatUnsupportedError}) so a user can - * find the raw log a build refused to interpret; it is not a consumer-facing - * query — log access goes through a session handle's `read`. + * A backend-resolved, per-Session local artifact path used by observational + * snapshots and refusal diagnostics. It names a backend target for the Session + * and does not authorize reads; log access still goes through a session + * handle's `read`. It is not persisted Session content. */ export interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index f257ca0e1e..951c60b31f 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -9,6 +9,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { SessionEvent, SessionHeader, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionHandle, SessionAccess } from './handle.ts' +import type { SessionLocation } from './errors.ts' import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. @@ -58,6 +59,12 @@ export interface SessionPersistenceSnapshot { readonly eventCount?: number /** Physical artifact byte size, when the backend can provide it cheaply (JSONL); otherwise absent. */ readonly sizeBytes?: number + /** + * Backend-resolved location of the selected materialized per-Session + * artifact, for diagnostics and export. Absent when the backend has no + * materialized per-Session file, including an unmaterialized pending Session. + */ + readonly location?: SessionLocation } /** Options for {@link SessionPersistence.create}. */ From 455686626d2411387d903b526af9730d62b955a7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 14:49:55 +0800 Subject: [PATCH 09/73] refactor(tools): separate scoped executors from tool registration --- apps/cli/package.json | 1 + apps/cli/tests/fixtures/dsh-badge/snapshot.ts | 3 +- .../tests/optional-bundle-transitions.e2e.ts | 3 +- .../headless/tests/profile-resolution.ts | 10 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 3 +- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 2 +- apps/cli/tests/web-agent-presets.e2e.ts | 19 +- apps/desktop-host/package.json | 3 +- apps/desktop-host/tests/office.spec.ts | 3 +- apps/web/package.json | 1 + apps/web/tests/background-job-list.e2e.ts | 3 +- apps/web/tests/jobs-menu-layering.e2e.ts | 3 +- apps/web/tests/live-job-stream.e2e.ts | 3 +- apps/web/tests/minimal-preset.snapshot.ts | 5 +- apps/web/tests/replay-round-trip.e2e.ts | 3 +- apps/web/tests/schedule-after.e2e.ts | 7 +- apps/web/tests/shipped-composition.e2e.ts | 9 +- .../agent-continuation.worker.ts | 2 +- benchmarks/package.json | 1 + packages/acp/acp/package.json | 1 + packages/acp/acp/tests/bridge.spec.ts | 2 +- packages/acp/acp/tests/edges.spec.ts | 2 +- packages/acp/acp/tests/turns.spec.ts | 2 +- packages/boot/plugin-manager/package.json | 2 + .../boot/plugin-manager/tests/tools.spec.ts | 9 +- packages/boot/plugin-market/package.json | 2 + .../boot/plugin-market/tests/tools.spec.ts | 6 +- packages/bundle/base/cordis.patch.yml | 10 +- packages/bundle/base/package.json | 2 + packages/bundle/headless/cordis.patch.yml | 15 +- packages/bundle/headless/package.json | 2 + packages/bundle/sdk-minimal/cordis.patch.yml | 3 + packages/bundle/sdk-minimal/package.json | 1 + packages/bundle/web-app/cordis.patch.yml | 18 +- packages/bundle/web-app/package.json | 4 +- packages/bundle/web-app/presets/ptc.patch.yml | 6 +- packages/client/ui-chat/package.json | 2 +- .../src/client/conversation-nodes/tool.ts | 2 +- .../client/conversation-nodes/turn-process.ts | 2 +- .../src/client/model/tool-call-tree.ts | 2 +- packages/client/ui-chat/tsconfig.client.json | 6 +- packages/client/ui-plan/package.json | 2 +- .../ui-plan/src/client/plan-definition.ts | 2 +- packages/client/ui-plan/src/client/plan.ts | 1 + packages/client/ui-plan/tsconfig.json | 4 +- packages/client/ui-tool/package.json | 3 + .../src/client/tool/models/todo-history.ts | 2 +- .../spill-policy-terminal.client.spec.ts | 11 +- packages/client/ui-tool/tsconfig.json | 3 + packages/client/ui-trajectory/package.json | 2 +- .../src/client/trajectory-tool-definition.ts | 2 +- packages/client/ui-trajectory/tsconfig.json | 6 +- .../client/ui-user-questions/package.json | 1 + .../tests/node-plugin.client.spec.ts | 2 + .../compaction/compaction-basic/package.json | 1 + .../tests/compaction-loop-repro.spec.ts | 2 +- .../tests/runtime-context-admission.spec.ts | 2 +- .../context/agent-instructions/package.json | 2 + .../tests/agent-instructions.spec.ts | 149 +- .../context/file-reference-local/package.json | 2 + .../tests/service.spec.ts | 6 +- .../context/session-reference/package.json | 2 + .../tests/fixtures/cordis.yml | 1 + .../tests/loader-composition.spec.ts | 5 +- packages/context/time-context/package.json | 1 + .../time-context/tests/time-context.spec.ts | 2 +- packages/core/agent-loop/package.json | 3 + packages/core/agent-loop/src/agent.ts | 24 +- packages/core/agent-loop/src/tool-calls.ts | 290 --- .../agent-loop/tests/agent-initiator.spec.ts | 10 +- packages/core/agent-loop/tests/agent.spec.ts | 2 + packages/core/agent-loop/tests/cancel.spec.ts | 6 +- .../tests/config-session-id.spec.ts | 8 + .../tests/contract-regressions.spec.ts | 11 +- .../agent-loop/tests/coverage-edges.spec.ts | 5 +- .../agent-loop/tests/interception.spec.ts | 5 +- packages/core/agent-loop/tests/loop.spec.ts | 7 +- .../core/agent-loop/tests/properties.spec.ts | 2 + .../agent-loop/tests/request-cache.e2e.ts | 5 +- .../agent-loop/tests/request-error.spec.ts | 2 + .../tests/request-reconstruction.spec.ts | 7 +- .../tests/resource-lifecycle.spec.ts | 3 +- packages/core/agent-loop/tests/resume.spec.ts | 21 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 5 +- .../tests/serial-listener-review.spec.ts | 2 +- .../core/agent-loop/tests/settings.spec.ts | 23 +- .../agent-loop/tests/shutdown-drain.spec.ts | 2 + .../tests/system-prompt-admission.spec.ts | 5 +- .../core/agent-loop/tests/tool-calls.spec.ts | 89 +- .../agent-loop/tests/tool-executors.spec.ts | 323 +++ .../core/agent-loop/tests/tool-order.spec.ts | 5 +- .../agent-loop/tests/tool-updates.spec.ts | 5 +- .../core/agent-tool-presentation/package.json | 4 +- .../core/agent-tool-presentation/src/index.ts | 51 +- .../tests/agent-tool-presentation.spec.ts | 12 +- .../agent-tool-presentation/tsconfig.json | 6 + .../core/tool-executor-native/package.json | 46 + .../core/tool-executor-native/src/index.ts | 49 + .../core/tool-executor-native/tsconfig.json | 30 + packages/core/tool-executor-ptc/package.json | 71 + packages/core/tool-executor-ptc/src/errors.ts | 16 + packages/core/tool-executor-ptc/src/index.ts | 104 + .../src/ptc-calls.ts | 0 .../{tools => tool-executor-ptc}/src/ptc.ts | 260 +-- .../src/py-types.ts | 6 +- .../src/session-types.ts} | 4 +- .../src/ts-types.ts | 10 +- packages/core/tool-executor-ptc/src/types.ts | 55 + .../tests/ptc-calls.spec.ts | 0 .../tests/ptc.spec.ts | 194 +- .../tests/py-types.spec.ts | 4 +- .../tests/ts-types.spec.ts | 4 +- packages/core/tool-executor-ptc/tsconfig.json | 57 + packages/core/tools/package.json | 24 +- packages/core/tools/src/batch.ts | 280 +++ packages/core/tools/src/contracts.ts | 341 +++ packages/core/tools/src/events.ts | 58 + packages/core/tools/src/executor.ts | 97 + packages/core/tools/src/index.ts | 1883 +---------------- packages/core/tools/src/pipeline.ts | 753 +++++++ packages/core/tools/src/schema.ts | 20 +- packages/core/tools/src/testing.ts | 42 - .../core/tools/tests/execution-mode.spec.ts | 31 +- .../core/tools/tests/exports.built.e2e.ts | 45 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 + packages/core/tools/tests/scoped.spec.ts | 27 +- packages/core/tools/tests/tools.spec.ts | 253 ++- packages/core/tools/tsconfig.json | 12 - packages/core/tools/tsdown.config.ts | 13 + .../deliverables/tool-present/package.json | 2 + .../tool-present/tests/built-errors.e2e.ts | 2 +- .../tool-present/tests/present.spec.ts | 9 +- .../experimental/auto-review/package.json | 3 + .../experimental/auto-review/src/index.ts | 1 + .../auto-review/tests/auto-review.e2e.ts | 5 +- .../auto-review/tests/auto-review.spec.ts | 75 +- .../experimental/auto-review/tsconfig.json | 3 + .../browser-use-runtime/package.json | 3 + .../browser-use-runtime/tests/mcp-upstream.ts | 6 +- .../browser-use-runtime/tests/mcp.spec.ts | 13 +- .../browser-use-stagehand-native/package.json | 2 + .../tests/loader-composition.spec.ts | 2 + .../tests/native.e2e.ts | 9 +- .../tests/provider.spec.ts | 7 +- .../claude-code-mods/package.json | 2 + .../claude-code-mods/src/host-ops.ts | 15 +- .../claude-code-mods/tests/bridge.spec.ts | 7 +- .../tests/loader-composition.spec.ts | 5 +- .../computer-use-cua-driver-mcp/package.json | 2 + .../tests/composition.spec.ts | 7 +- .../tests/installed-driver.e2e.ts | 5 +- .../tests/lifecycle.spec.ts | 2 + .../package.json | 2 + .../tests/cancellation.spec.ts | 3 +- .../tests/loader-composition.spec.ts | 5 +- .../tests/native.e2e.ts | 5 +- .../tests/provider.spec.ts | 7 +- .../examples/persistent-ptc.cordis.yml | 5 +- packages/experimental/cua-macos/package.json | 3 + .../cua-macos/tests/large-image-ptc.spec.ts | 5 +- .../tests/loader-composition.spec.ts | 14 +- .../tests/persistent-bridge.built.e2e.ts | 3 +- .../tests/provider-lifecycle.spec.ts | 7 +- .../hooks-claude-code/package.json | 1 + .../hooks-claude-code/tests/bridge.spec.ts | 5 +- .../hooks-claude-code/tests/coverage-cases.ts | 5 +- .../experimental/hooks-codex/package.json | 1 + .../hooks-codex/tests/bridge.spec.ts | 5 +- .../hooks-codex/tests/coverage-cases.ts | 7 +- .../experimental/tool-agent-team/package.json | 1 + .../tool-agent-team/tests/tool-team.spec.ts | 5 +- packages/experimental/tool-ralph/package.json | 2 + .../tool-ralph/tests/integration.spec.ts | 9 +- .../tool-ralph/tests/tool-ralph.spec.ts | 5 +- .../tool-session-query/package.json | 2 + .../tests/sqlite-integration.spec.ts | 8 +- .../tests/tool-session-query.spec.ts | 16 +- .../experimental/tool-terminal/package.json | 3 + .../tests/loader-composition.spec.ts | 10 +- .../tool-terminal/tests/tools.spec.ts | 12 +- .../experimental/tool-worktree/package.json | 2 + .../tests/loader-composition.spec.ts | 7 +- .../cordis-host-runner/package.json | 2 + .../cordis-host-runner/src/guard.ts | 2 +- .../cordis-host-runner/tests/helpers.ts | 5 +- .../tests/sandbox-context.spec.ts | 8 +- packages/extensions/tool-cordis/package.json | 1 + .../extensions/tool-cordis/src/api-catalog.ts | 112 +- .../extensions/tool-cordis/tests/host.spec.ts | 2 + packages/fs/tool-fs-search/package.json | 2 + .../tool-fs-search/tests/integration.spec.ts | 9 +- .../fs/tool-fs-search/tests/load-path.spec.ts | 2 + .../fs/tool-fs-search/tests/tools.spec.ts | 10 +- packages/fs/tool-fs/package.json | 3 + packages/fs/tool-fs/tests/integration.spec.ts | 27 +- packages/fs/tool-fs/tests/read-image.spec.ts | 18 +- packages/fs/tool-fs/tests/tools.spec.ts | 24 +- .../fs/tool-str-replace-editor/package.json | 2 + .../tests/tools.spec.ts | 6 +- packages/goal/tool-goal/package.json | 2 + .../goal/tool-goal/tests/tool-goal.spec.ts | 12 +- .../guard/repeat-tool-reminder/package.json | 1 + .../tests/repeat-tool-reminder.spec.ts | 5 +- packages/guard/timeout-policy/package.json | 2 + .../tests/timeout-policy.spec.ts | 30 +- .../interaction/tool-ask-user/package.json | 2 + .../tool-ask-user/tests/tool-ask-user.spec.ts | 40 +- .../interaction/user-questions/package.json | 4 +- .../user-questions/src/projection.ts | 2 +- .../user-questions/tests/projection.spec.ts | 2 +- .../interaction/user-questions/tsconfig.json | 6 +- packages/jobs/tool-jobs/package.json | 2 + .../jobs/tool-jobs/tests/tool-jobs.spec.ts | 14 +- packages/llm/llm-deepseek/package.json | 2 + .../tests/account-routing.spec.ts | 6 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 2 + .../llm-deepseek/tests/fixtures/cordis.yml | 1 + packages/llm/llm-retry/package.json | 2 + .../tests/loader-composition.spec.ts | 3 + packages/llm/llm-retry/tests/retry.spec.ts | 5 +- packages/lsp/tool-lsp/package.json | 4 +- .../lsp/tool-lsp/tests/integration.spec.ts | 5 +- packages/lsp/tool-lsp/tests/tool-lsp.spec.ts | 5 +- packages/mcp/mcp-client/package.json | 2 + packages/mcp/mcp-client/tests/apply.spec.ts | 4 + .../mcp/mcp-client/tests/mcp-client.e2e.ts | 37 +- .../mcp/mcp-client/tests/mcp-client.spec.ts | 89 +- .../tests/negotiation-lifecycle.spec.ts | 2 + .../mcp/mcp-client/tests/protocol.spec.ts | 15 +- .../mcp/mcp-client/tests/reconnect.spec.ts | 7 +- .../mcp-client/tests/server-context.spec.ts | 5 +- .../mcp-client/tests/tool-definition.spec.ts | 8 +- packages/mcp/mcp-resources/package.json | 3 + .../mcp/mcp-resources/tests/resources.spec.ts | 13 +- packages/plan/plan-mode/package.json | 3 + .../plan/plan-mode/tests/integration.spec.ts | 5 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 34 +- .../plan/plan-mode/tests/projection.spec.ts | 2 + .../preset/agent-preset-registry/package.json | 6 + .../agent-preset-registry/tests/harness.ts | 2 + .../tests/tool-executors.spec.ts | 142 ++ packages/schedule/schedule/package.json | 1 + packages/schedule/schedule/tests/harness.ts | 2 + packages/schedule/tool-schedule/package.json | 1 + .../tool-schedule/tests/tool-schedule.spec.ts | 7 +- .../session-checkpoint-policy/package.json | 2 + .../tests/session-checkpoint-policy.spec.ts | 12 +- .../session/working-directory/package.json | 1 + .../tests/working-directory.spec.ts | 3 +- .../shell/tool-bash-persistent/package.json | 2 + .../tests/loader-composition.spec.ts | 6 +- .../tool-bash-persistent/tests/tools.spec.ts | 6 +- packages/shell/tool-bash/package.json | 3 + .../shell/tool-bash/tests/background.spec.ts | 16 +- .../shell/tool-bash/tests/integration.spec.ts | 6 +- packages/shell/tool-bash/tests/tools.spec.ts | 37 +- .../shell/tool-pwsh-persistent/package.json | 2 + .../tests/loader-composition.spec.ts | 6 +- .../tool-pwsh-persistent/tests/tools.spec.ts | 6 +- packages/shell/tool-pwsh/package.json | 2 + .../shell/tool-pwsh/tests/background.spec.ts | 15 +- .../tool-pwsh/tests/fixtures/loader/driver.ts | 7 +- .../shell/tool-pwsh/tests/integration.spec.ts | 7 +- packages/shell/tool-pwsh/tests/tools.spec.ts | 23 +- packages/skill/skill-office/package.json | 2 + .../skill-office/tests/docx-validation.e2e.ts | 2 +- .../tests/progressive-loading.spec.ts | 5 +- packages/skill/tool-skill/package.json | 2 + .../skill/tool-skill/tests/tool-skill.spec.ts | 38 +- .../tool-workspace-dependencies/package.json | 2 + .../tests/tool-workspace-dependencies.spec.ts | 11 +- packages/spill/spill-policy/package.json | 4 + packages/spill/spill-policy/src/index.ts | 1 + .../tests/multimodal-recovery.spec.ts | 8 +- .../spill-policy/tests/multimodal.spec.ts | 13 +- .../spill-policy/tests/spill-policy.spec.ts | 68 +- packages/spill/spill-policy/tsconfig.json | 3 + packages/subagent/subagent/package.json | 1 + .../subagent/tests/structured.spec.ts | 32 +- .../tool-subagent-control/package.json | 1 + .../tests/list-agents.spec.ts | 3 +- .../tests/tool-subagent-control.spec.ts | 3 +- packages/subagent/tool-subagent/package.json | 2 + .../subagent/tool-subagent/tests/harness.ts | 3 +- .../tool-subagent/tests/list-models.spec.ts | 8 +- .../tests/model-selection-settings.spec.ts | 3 +- .../tests/model-selection.spec.ts | 2 + .../tool-subagent/tests/tool-subagent.spec.ts | 14 +- .../agent-loop-testkit/package.json | 5 +- .../agent-loop-testkit/src/index.ts | 9 +- .../agent-loop-testkit/tsconfig.json | 6 + .../session-snapshot/package.json | 1 + .../tests/fixtures/dynamic-tool-updates.ts | 2 +- .../fixtures/subagent-structured-parent.ts | 2 +- .../test-support/tool-testkit/package.json | 39 + .../test-support/tool-testkit/src/index.ts | 91 + .../test-support/tool-testkit/tsconfig.json | 21 + packages/todo/tool-todo/package.json | 2 + .../tests/loader-composition.spec.ts | 8 +- .../todo/tool-todo/tests/projection.spec.ts | 2 + .../todo/tool-todo/tests/tool-todo.spec.ts | 6 +- packages/web/tool-web/package.json | 2 + .../web/tool-web/tests/integration.spec.ts | 10 +- packages/web/tool-web/tests/load-path.spec.ts | 2 + packages/web/tool-web/tests/spill.spec.ts | 7 +- packages/web/tool-web/tests/tool-web.spec.ts | 22 +- packages/workflow/tool-workflow/package.json | 2 + .../tool-workflow/tests/tool-workflow.spec.ts | 11 +- pnpm-lock.yaml | 535 ++++- scripts/check-workspace-constraints.spec.ts | 30 + scripts/check-workspace-constraints.ts | 12 + scripts/doc-standard.spec.ts | 1 + scripts/gen-cordis-catalog.ts | 7 + scripts/gen-doc-graphs.ts | 17 +- scripts/gen-tool-catalog.ts | 56 +- scripts/no-unknown-casts.baseline.json | 45 +- scripts/product-package-policy.ts | 1 + scripts/type-equiv.manifest.json | 61 +- .../verify-package-readme-model-experience.ts | 1 + snapshots/sdk/sdk.snapshot.ts | 3 +- .../sdk/tool-error-details/runtime.cordis.yml | 8 +- .../advanced-toolchain/cordis.snapshot.yml | 9 +- .../session/advanced-toolchain/cordis.yml | 9 +- .../both-mode-turn/cordis.snapshot.yml | 9 +- snapshots/session/both-mode-turn/cordis.yml | 9 +- .../cordis-inspect-jsdoc/cordis.snapshot.yml | 9 +- .../session/cordis-inspect-jsdoc/cordis.yml | 9 +- .../cordis.snapshot.yml | 9 +- .../cordis-inspect-liveness/cordis.yml | 9 +- .../cordis.snapshot.yml | 9 +- .../session/cordis-inspect-timeout/cordis.yml | 9 +- .../session/cua-native/cordis.snapshot.yml | 9 +- snapshots/session/cua-native/cordis.yml | 9 +- snapshots/session/cua-ptc/cordis.snapshot.yml | 9 +- snapshots/session/cua-ptc/cordis.yml | 9 +- .../session/mcp-empty-ptc/cordis.snapshot.yml | 8 +- snapshots/session/mcp-empty-ptc/cordis.yml | 8 +- .../mcp-resources-ptc/cordis.snapshot.yml | 9 +- .../session/mcp-resources-ptc/cordis.yml | 9 +- .../cordis.snapshot.yml | 8 +- .../office-render-tools-ptc/cordis.yml | 8 +- .../ptc-node-read-only/cordis.snapshot.yml | 9 +- .../session/ptc-node-read-only/cordis.yml | 9 +- .../ptc-node-workspace/cordis.snapshot.yml | 9 +- .../session/ptc-node-workspace/cordis.yml | 9 +- .../ptc-persistent/cordis.snapshot.yml | 9 +- snapshots/session/ptc-persistent/cordis.yml | 9 +- .../ptc-policy-change/cordis.snapshot.yml | 9 +- .../session/ptc-policy-change/cordis.yml | 9 +- .../ptc-python-turn/cordis.snapshot.yml | 9 +- snapshots/session/ptc-python-turn/cordis.yml | 9 +- .../ptc-read-image/cordis.snapshot.yml | 9 +- snapshots/session/ptc-read-image/cordis.yml | 9 +- .../session/ptc-turn/cordis.snapshot.yml | 9 +- snapshots/session/ptc-turn/cordis.yml | 9 +- .../ptc-workspace-context/cordis.snapshot.yml | 9 +- .../session/ptc-workspace-context/cordis.yml | 9 +- tsconfig.base.json | 6 +- tsconfig.client.json | 3 + tsconfig.host.json | 3 + 360 files changed, 5532 insertions(+), 3491 deletions(-) delete mode 100644 packages/core/agent-loop/src/tool-calls.ts create mode 100644 packages/core/agent-loop/tests/tool-executors.spec.ts create mode 100644 packages/core/tool-executor-native/package.json create mode 100644 packages/core/tool-executor-native/src/index.ts create mode 100644 packages/core/tool-executor-native/tsconfig.json create mode 100644 packages/core/tool-executor-ptc/package.json create mode 100644 packages/core/tool-executor-ptc/src/errors.ts create mode 100644 packages/core/tool-executor-ptc/src/index.ts rename packages/core/{tools => tool-executor-ptc}/src/ptc-calls.ts (100%) rename packages/core/{tools => tool-executor-ptc}/src/ptc.ts (77%) rename packages/core/{tools => tool-executor-ptc}/src/py-types.ts (99%) rename packages/core/{tools/src/types.ts => tool-executor-ptc/src/session-types.ts} (96%) rename packages/core/{tools => tool-executor-ptc}/src/ts-types.ts (97%) create mode 100644 packages/core/tool-executor-ptc/src/types.ts rename packages/core/{tools => tool-executor-ptc}/tests/ptc-calls.spec.ts (100%) rename packages/core/{tools => tool-executor-ptc}/tests/ptc.spec.ts (94%) rename packages/core/{tools => tool-executor-ptc}/tests/py-types.spec.ts (99%) rename packages/core/{tools => tool-executor-ptc}/tests/ts-types.spec.ts (98%) create mode 100644 packages/core/tool-executor-ptc/tsconfig.json create mode 100644 packages/core/tools/src/batch.ts create mode 100644 packages/core/tools/src/contracts.ts create mode 100644 packages/core/tools/src/events.ts create mode 100644 packages/core/tools/src/executor.ts create mode 100644 packages/core/tools/src/pipeline.ts delete mode 100644 packages/core/tools/src/testing.ts create mode 100644 packages/core/tools/tests/exports.built.e2e.ts create mode 100644 packages/core/tools/tsdown.config.ts create mode 100644 packages/preset/agent-preset-registry/tests/tool-executors.spec.ts create mode 100644 packages/test-support/tool-testkit/package.json create mode 100644 packages/test-support/tool-testkit/src/index.ts create mode 100644 packages/test-support/tool-testkit/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 1c1e6dc362..45ff084454 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -154,6 +154,7 @@ "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@types/js-yaml": "^4.0.9", diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index c84912a9f1..287c1aff82 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { fileURLToPath } from 'node:url' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -51,7 +52,7 @@ try { && message.source.kind === 'skill-catalog')?.content : undefined const summary = (await ctx.skills.list()).find(skill => skill.name === 'dsh-badge') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('dsh-badge-snapshot'), name: 'skill', arguments: { name: 'dsh-badge' }, diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index f084cb29cb..17863719d2 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */ import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' @@ -232,7 +233,7 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option it('loads the actual badge asset only while its bundle is selected', async () => { const owner = await agent() - const load = (callId: string) => ctx.tools.execute({ + const load = (callId: string) => executeToolFixture(ctx, { callId: ToolCallId(callId), name: 'skill', arguments: { name: 'dsh-badge' }, agent: owner.agent, signal: new AbortController().signal, }) diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 9978557402..2a6ff851fb 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -40,7 +40,7 @@ interface ResolutionEvidence { sourceToolsCjs: string | null sourceLeafCjs: string sourceLeafExplicitCjs: string - scheduler: boolean + executor: boolean modules: string[] } @@ -139,7 +139,7 @@ export function testProfileResolution(mode: ExampleMode): void { const staleTools = join(root, 'stale-tools') const staleToolsLink = join(sharedModules, '@deepseek-ai', 'dsh-tools') await writePackage(staleTools, { name: '@deepseek-ai/dsh-tools', version: '0.0.0', exports }, { - 'index.mjs': "export default class Tools {}\nexport const TOOL_RUNTIME_SCHEDULER = Symbol()\nthrow new Error('STALE_DSH_TOOLS')", + 'index.mjs': "export default class Tools {}\nthrow new Error('STALE_DSH_TOOLS')", 'index.cjs': "throw new Error('STALE_DSH_TOOLS')", }) const sourceDir = join(sourcePackageDir, 'src') @@ -183,7 +183,7 @@ export function testProfileResolution(mode: ExampleMode): void { 'probe.mjs': [ "import { createRequire } from 'node:module'", "import { getEnvironmentData } from 'node:worker_threads'", - "import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'", + "import Tools from '@deepseek-ai/dsh-tools'", `import { leaf } from '${leafName}'`, `import { leaf as bridgeLeaf } from '${bridgeName}'`, `import { external } from '${externalName}'`, @@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void { ' sourceToolsInstance: ctx.tools instanceof SourceTools,', ` profileToolsCjs: ${toolsCjsExpression},`, ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', - " scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',", + " executor: typeof ctx.tools.prepare().execute === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', ' }', @@ -291,7 +291,7 @@ export function testProfileResolution(mode: ExampleMode): void { expect(evidence.sourceLeafCjs).toBe(evidence.cjs.filename) expect(evidence.sourceLeafExplicitCjs).toBe(realpathSync.native(join(sourceDevLeaf, 'index.cjs'))) expect(evidence.sourceLeafCjs).not.toBe(evidence.sourceLeafExplicitCjs) - expect(evidence.scheduler).toBe(true) + expect(evidence.executor).toBe(true) expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : []) for (const name of ['tools', 'agent-loop']) { const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js' diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index cce567d62e..d10bf5e532 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -1,3 +1,4 @@ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -96,7 +97,7 @@ function runCode( signal: AbortSignal = testToolSignal, agent?: Agent, ): Promise { - return harness.tools.execute({ + return executeModelToolFixture(harness, { callId: ToolCallId(`keyless-code-${++keylessCall}`), name: RUN_CODE_NAME, arguments: { code, description: 'Run the e2e program' }, diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 5c51bd36ae..4d3bb617b8 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -399,7 +399,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const patch = join(root, 'failure.yml') await mkdir(home) if (blocked) await writeFile(join(home, 'logs'), 'blocked') - await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n') + await writeFile(patch, '- id: tool-executor-native\n config:\n maxParallelToolCalls: 0\n') const child = execa(launch.command, [ ...launch.args, '--profile', 'sdk', '--patch', patch, ], { diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4d1f9210a2..9f03ae0f79 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises' @@ -619,7 +620,7 @@ describe('the shipped Web composition', () => { expect(scoped).toContain('project-proof') // The preset's own loader tool resolves the global-layer skill. - const loaded = await ctx.tools.execute({ + const loaded = await executeToolFixture(ctx, { callId: ToolCallId('preset-skills-load'), name: 'skill', arguments: { name: 'dsh-badge' }, @@ -777,7 +778,7 @@ describe('a user preset declared from the shipped cordis rows', () => { ])) } const signal = new AbortController().signal - const listed = await copyCtx.tools.execute({ + const listed = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-list'), name: 'cordis_inspect_list', arguments: {}, @@ -792,7 +793,7 @@ describe('a user preset declared from the shipped cordis rows', () => { // The `Tool` provider is the one built over the host context: it // answers with the shared registry's view of the REQUESTING agent, // so the copy sees its own preset's tools, not the host's empty set. - const queried = await copyCtx.tools.execute({ + const queried = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-tools'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Tool', method: 'listTools' }, @@ -809,12 +810,12 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(queriedText).toContain(`"name": "${toolName}"`) } - // The `Config` provider reads the booted profile tree: the shipped `tools` row declares a Config, + // The `Config` provider reads the booted profile tree: the executor selector declares a Config, // and the bootstrap include row is a carrier. The name filter keeps each page small. type ConfigRow = { id: string; patchId: string; name: string; status: string } type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } } const listConfigs = async (input: Record, callId: string): Promise => { - const page = await copyCtx.tools.execute({ + const page = await executeToolFixture(copyCtx, { callId: ToolCallId(callId), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input }, @@ -824,16 +825,16 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(page.isError, resultText(page)).toBe(false) return (JSON.parse(resultText(page)) as ConfigPage).data } - const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tools' }, 'copied-preset-inspect-configs') + const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-tool-presentation' }, 'copied-preset-inspect-configs') const toolsRow = toolsRows.entries[0] - expect(toolsRow).toMatchObject({ patchId: 'tools', status: 'schema' }) + expect(toolsRow).toMatchObject({ patchId: 'tool-executor-native', status: 'schema' }) expect(toolsRows).toMatchObject({ total: 1, nextOffset: null }) const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes') expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' }) const firstPage = await listConfigs({ limit: 5 }, 'copied-preset-inspect-page') expect(firstPage.entries).toHaveLength(5) expect(firstPage.nextOffset).toBe(5) - const projected = await copyCtx.tools.execute({ + const projected = await executeToolFixture(copyCtx, { callId: ToolCallId('copied-preset-inspect-config'), name: 'cordis_inspect_query', arguments: { platform: 'host', provider: 'Config', method: 'listConfigs', input: { entry: toolsRow!.id } }, @@ -943,7 +944,7 @@ describe('a delegated child', () => { ] as const for (const [name, args] of calls) { expect(toolNames(ctx, child.agent)).toContain(name) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId(`child-${name}`), signal: new AbortController().signal, name, arguments: args, agent: child.agent, }) diff --git a/apps/desktop-host/package.json b/apps/desktop-host/package.json index 570d4fc856..c46d2ba972 100644 --- a/apps/desktop-host/package.json +++ b/apps/desktop-host/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cordis-plugin-include": "workspace:~", - "@deepseek-ai/dsh-skill": "workspace:*" + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*" } } diff --git a/apps/desktop-host/tests/office.spec.ts b/apps/desktop-host/tests/office.spec.ts index 02e741d4ce..33adbf68e7 100644 --- a/apps/desktop-host/tests/office.spec.ts +++ b/apps/desktop-host/tests/office.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' @@ -29,7 +30,7 @@ it('loads Desktop Office CLI paths and removes them on disposal', async () => { expect('default' in desktopOffice).toBe(false) expect(ctx.loader.unwrapExports(desktopOffice)).toBe(desktopOffice) const modules = new Map([ - ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], + ['agents', AgentRegistry], ['systemPrompt', SystemPrompt], ['tools', ToolRuntime], ['toolExecutor', NativeToolExecutor], ['skills', SkillRegistry], ['office', desktopOffice], ]) ctx.loader.internal = { diff --git a/apps/web/package.json b/apps/web/package.json index e07f7b89dc..b113c3cf48 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", "@deepseek-ai/dsh-session-projection-cache": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@types/node": "^22.20.1", "@types/ws": "8.18.1", diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index f407bdb53e..fab978232b 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Session-header background jobs driven by a real `ctx.jobs` entry. No model // call is involved. import { readFile, writeFile } from 'node:fs/promises' @@ -87,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) - const started = await scaffold.ctx.tools.execute({ + const started = await executeToolFixture(scaffold.ctx, { signal: new AbortController().signal, callId: ToolCallId('background-job-list-e2e'), name: 'bash', diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index 040531ba95..f8be48f444 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' @@ -116,7 +117,7 @@ describe.skipIf(MODE === 'record')('web e2e: jobs menu layering', () => { const opened = scaffold.ctx.agents.get(SEED_ID) if (opened === undefined) throw new Error('opened session has no live Agent') agent = opened - const started = await scaffold.ctx.tools.execute({ + const started = await executeToolFixture(scaffold.ctx, { signal: new AbortController().signal, callId: ToolCallId('jobs-menu-layering-e2e'), name: 'bash', diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts index 2b4fb64d68..4bffdb8a62 100644 --- a/apps/web/tests/live-job-stream.e2e.ts +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Session-header job list driven by a real background bash run: the job row // arrives over the job roster stream, expanding it opens the output // observation stream, and the panel shows the process's real output while it @@ -87,7 +88,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) - const started = await scaffold.ctx.tools.execute({ + const started = await executeToolFixture(scaffold.ctx, { signal: new AbortController().signal, callId: ToolCallId('live-job-stream-e2e'), name: 'bash', diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts index 16eebb7273..bec53293a7 100644 --- a/apps/web/tests/minimal-preset.snapshot.ts +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -97,14 +98,14 @@ describe('minimal agent preset', () => { const stateDir = join(scaffold.workspaceCwd, 'persistent-state') await mkdir(stateDir) const signal = new AbortController().signal - await scaffold.ctx.tools.execute({ + await executeToolFixture(scaffold.ctx, { signal, callId: ToolCallId('minimal-bash-state-setup'), name: 'bash', arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, agent: agentHandle.agent, }) - const bash = await scaffold.ctx.tools.execute({ + const bash = await executeToolFixture(scaffold.ctx, { signal, callId: ToolCallId('minimal-bash-state-read'), name: 'bash', diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index c5a382a55e..6e7725cde6 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Web e2e scenario: fresh round trip. A real chromium types a prompt into the // real composer; the wire, Remote gateway, agent loop, and the REAL bash tool (echo // in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless) @@ -249,7 +250,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { setup: agentCtx => probe.ctx.agentPresets.mount(agentCtx).then(() => undefined), }) try { - const result = await probe.ctx.tools.execute({ + const result = await executeToolFixture(probe.ctx, { signal: AbortSignal.timeout(5_000), callId: ToolCallId('web-url-probe'), name: 'bash', diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 89318180b1..a4978a6b98 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Keyless assembled-Web evidence for conversational Schedule delivery. */ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -354,7 +355,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { source: { kind: 'user' }, }) await workspace.attachSession(afterHandle.agent.id) - const afterCreated = await scaffold.ctx.tools.execute({ + const afterCreated = await executeToolFixture(scaffold.ctx, { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-after-create'), name: 'schedule_create', @@ -420,7 +421,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { }) await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id }) await workspace.attachSession(everyHandle.agent.id) - const everyListed = await scaffold.ctx.tools.execute({ + const everyListed = await executeToolFixture(scaffold.ctx, { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-every-list'), name: 'schedule_list', @@ -1181,7 +1182,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { }) const time = clock.format(new Date(Date.now() + 3_600_000)) const prompt = 'Inspect the daily calendar rule' - const result = await scaffold.ctx.tools.execute({ + const result = await executeToolFixture(scaffold.ctx, { signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'), name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent, }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 9c7d827aba..ef88a47621 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Boots the shipped Web composition over the built dist this lane already uses // and asserts its catalog, defaults, Loader lifecycle, and one complete Auto // producer-to-tool path. Browser scenarios in this lane own visual behavior. @@ -678,7 +679,7 @@ it('lets a preset producer reach the background-job registry', async () => { // `tool-bash` is a preset row and `tasks` is a host registry; the producer // resolves it with `ctx.get`, so a registry hidden behind a preset realm // fails here — with every task control still listed in the catalog above. - const started = await ctx.tools.execute({ + const started = await executeToolFixture(ctx, { signal, callId: ToolCallId('shipped-bash-background'), name: 'bash', @@ -700,7 +701,7 @@ it('lets a preset producer reach the background-job registry', async () => { // The controller reads what the producer started: same registry, one // owner. A per-preset registry would list nothing here even on success. - const listed = await ctx.tools.execute({ + const listed = await executeToolFixture(ctx, { signal, callId: ToolCallId('shipped-task-list'), name: 'job_list', @@ -713,7 +714,7 @@ it('lets a preset producer reach the background-job registry', async () => { // The full round trip: the output a host-plane producer wrote is collected // through a preset-plane control, which is the linkage the realm severed. - const collected = await ctx.tools.execute({ + const collected = await executeToolFixture(ctx, { signal, callId: ToolCallId('shipped-task-output'), name: 'job_output', @@ -1034,7 +1035,7 @@ it('rolls back a failed shipped Auto initialization before publishing or interce // contribution performs no review, so success proves the failed shipped // integration left no pre-execute listener behind. It is not supported // Auto execution behavior. - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('shipped-auto-init-rollback-write'), name: 'write', diff --git a/benchmarks/agent-continuation/agent-continuation.worker.ts b/benchmarks/agent-continuation/agent-continuation.worker.ts index b19cc2c43c..775ca0ccd3 100644 --- a/benchmarks/agent-continuation/agent-continuation.worker.ts +++ b/benchmarks/agent-continuation/agent-continuation.worker.ts @@ -10,7 +10,7 @@ import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts' diff --git a/benchmarks/package.json b/benchmarks/package.json index d36d79bfdb..4601a6e972 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:*", "@deepseek-ai/dsh-storage-json": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 936f60429a..2cb861e4f3 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*" } diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index c0f5d73f89..1e35f39e92 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -9,7 +9,7 @@ import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture.ts' diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 8df1a98ad5..e0885db03d 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -3,7 +3,7 @@ import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { createUserMessage, ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' declare module '@deepseek-ai/dsh-llm' { diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 1ef2bc94ce..735e3cee67 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -6,7 +6,7 @@ import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' import { setImmediate } from 'node:timers/promises' import SubagentRuntime, { NO_START_CAPABILITIES } from '@deepseek-ai/dsh-subagent' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { errorResponse, diff --git a/packages/boot/plugin-manager/package.json b/packages/boot/plugin-manager/package.json index 387a324956..3f06504c1b 100644 --- a/packages/boot/plugin-manager/package.json +++ b/packages/boot/plugin-manager/package.json @@ -97,6 +97,8 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-hmr": "workspace:*", diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index c7981e2296..64257882b1 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -1,8 +1,10 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' /** Agent controls use the same manager methods as the Web and bound inventory reads. */ import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import SandboxPolicy from '@deepseek-ai/dsh-sandbox-policy' @@ -14,7 +16,7 @@ import type PluginManager from '../src/index.ts' import type { BundleInfo } from '../src/types.ts' import * as tool from '../src/tools.ts' -function resultText(result: Awaited>): string { +function resultText(result: ToolExecutionResult): string { if (typeof result.value !== 'string') throw new Error('Expected a serialized manager result') return result.value } @@ -35,11 +37,12 @@ async function fixture(mode: 'read-only' | 'workspace-write' | 'danger-full-acce ctx.provide('pluginManager', manager as unknown as PluginManager) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjections) await ctx.plugin(SandboxPolicy, { mode }) if (approval !== undefined) await ctx.plugin(ApprovalService, { policy: approval }) const fiber = await ctx.plugin(tool) - const call = (args: unknown, agent?: Agent, signal = new AbortController().signal) => ctx.tools.execute({ name: 'plugin_manager', arguments: args, + const call = (args: unknown, agent?: Agent, signal = new AbortController().signal) => executeToolFixture(ctx, { name: 'plugin_manager', arguments: args, ...agent === undefined ? {} : { agent }, callId: ToolCallId('manager-call'), signal }) return { ctx, manager, call, fiber } diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 1ed7cb7b4d..9032c4d9ae 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -82,6 +82,8 @@ "@deepseek-ai/dsh-plugin-manager": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/semver": "^7.8.0" diff --git a/packages/boot/plugin-market/tests/tools.spec.ts b/packages/boot/plugin-market/tests/tools.spec.ts index ae1b929b15..98eb0fe16c 100644 --- a/packages/boot/plugin-market/tests/tools.spec.ts +++ b/packages/boot/plugin-market/tests/tools.spec.ts @@ -1,4 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** The read-only market listing tool over the same service the Discover page uses. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context, Service } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -29,9 +31,10 @@ async function fixture() { await ctx.plugin(PluginMarket, { indexes: [URL_A] }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const fiber = await ctx.plugin(tool) const call = async (args: unknown, signal = new AbortController().signal) => { - const result = await ctx.tools.execute({ name: 'plugin_market', arguments: args, callId: ToolCallId('market-call'), signal }) + const result = await executeToolFixture(ctx, { name: 'plugin_market', arguments: args, callId: ToolCallId('market-call'), signal }) return { ...result, json: typeof result.value === 'string' ? JSON.parse(result.value) as Record : undefined } } return { ctx, call, fiber } @@ -165,6 +168,7 @@ it('registers the tool only while the pluginMarket service is mounted', async () await ctx.plugin(Manager) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(tool) expect(ctx.tools.get('plugin_market')).toBeUndefined() const market = await ctx.plugin(PluginMarket, { indexes: [] }) diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index ee2f8f280b..7581bfa8d0 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -487,11 +487,17 @@ # ── rows every mode mounts, whose values each overlay may state ────────────── - # The tool registry. Presentation mode is a deployment choice; omitting it here - # keeps the schema default (native). + # Shared tool definitions and the deployment’s native executor. - id: tools name: '@deepseek-ai/dsh-tools' + - id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + + - id: tool-executor-ptc + name: '@deepseek-ai/dsh-tool-executor-ptc' + disabled: true + # The deployment persona is a deployment choice; plan-mode and tool plugins own # their own prompt sections. - id: system-prompt diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index bad07101bb..bd5098115f 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -98,6 +98,8 @@ "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-present": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index cf224539eb..181406b4f8 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -11,10 +11,17 @@ personaPrefix: >- You are a coding agent powered by the {{model}} model. -- id: tools - config: - # Keep the same temporary process-wide PTC mode opt-in as the Web surface. - mode: !!js process.env.DSH_TOOLS_MODE +- id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + disabled: !!js | + (() => { + const mode = process.env.DSH_TOOLS_MODE ?? 'native' + if (mode !== 'native' && mode !== 'ptc') throw new Error('DSH_TOOLS_MODE must be native or ptc') + return mode === 'ptc' + })() + +- id: tool-executor-ptc + disabled: !!js process.env.DSH_TOOLS_MODE !== 'ptc' - insert: - id: headless-startup diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 3ed8f43f6b..497038b8e9 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -42,6 +42,8 @@ "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0" diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml index 79922e7113..a2fda9dc0e 100644 --- a/packages/bundle/sdk-minimal/cordis.patch.yml +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -120,6 +120,9 @@ - id: tools name: '@deepseek-ai/dsh-tools' + - id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + - id: mcp-resources name: '@deepseek-ai/dsh-mcp-resources' diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 954731516d..560d6bda2c 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-terminal-bash": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index a1ca0b5483..b76c2309a9 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -29,13 +29,17 @@ path: ':memory:' openAt: never -- id: tools - config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|ptc) opts a whole dsh - # process into PTC mode while per-session tool-presentation selection is being - # designed; unset keeps the schema default (native). Remove the env seam - # once the web UI owns the choice per session. - mode: !!js process.env.DSH_TOOLS_MODE +- id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + disabled: !!js | + (() => { + const mode = process.env.DSH_TOOLS_MODE ?? 'native' + if (mode !== 'native' && mode !== 'ptc') throw new Error('DSH_TOOLS_MODE must be native or ptc') + return mode === 'ptc' + })() + +- id: tool-executor-ptc + disabled: !!js process.env.DSH_TOOLS_MODE !== 'ptc' # ── web-only host rows, the transport layer, and the browser roster ───────── diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 9949c36fea..5b77785fb1 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -58,7 +58,6 @@ "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", - "@deepseek-ai/dsh-agent-tool-presentation": "workspace:*", "@deepseek-ai/dsh-api-job-controller": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", @@ -159,6 +158,8 @@ "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", @@ -173,6 +174,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-web-frontend": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", diff --git a/packages/bundle/web-app/presets/ptc.patch.yml b/packages/bundle/web-app/presets/ptc.patch.yml index e89cc60b85..a8fb615885 100644 --- a/packages/bundle/web-app/presets/ptc.patch.yml +++ b/packages/bundle/web-app/presets/ptc.patch.yml @@ -120,10 +120,8 @@ config: fetch: true searchTimeoutMs: 60000 - - id: tool-presentation - name: '@deepseek-ai/dsh-agent-tool-presentation' - config: - mode: ptc + - id: tool-executor-ptc + name: '@deepseek-ai/dsh-tool-executor-ptc' - id: present name: '@deepseek-ai/dsh-tool-present' - id: tool-plugin-manager diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 600891f0ec..c0ef37362f 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -85,8 +85,8 @@ "@deepseek-ai/dsh-session-turn-outline": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-workspace-path": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@tanstack/react-virtual": "3.14.10", "react": "^18.2.0", diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index 0252539059..a94131b8e2 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -4,7 +4,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index b38b937fea..13aa334d73 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts' import { diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 2be2121414..0d1a76cc0e 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index e03a2b0a90..646bec228d 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -42,9 +42,6 @@ { "path": "../../core/session" }, - { - "path": "../../core/tools" - }, { "path": "../../interaction/commands" }, @@ -116,6 +113,9 @@ }, { "path": "../product-analytics/tsconfig.client.json" + }, + { + "path": "../../core/tool-executor-ptc" } ], "exclude": [ diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index f17c979ef2..4f9bf567b5 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -68,9 +68,9 @@ "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-user-questions": "workspace:*", "@deepseek-ai/dsh-client-resources": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-util-crypto": "workspace:*" }, "files": [ diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts index 8c742f71fa..634f285910 100644 --- a/packages/client/ui-plan/src/client/plan-definition.ts +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -1,5 +1,5 @@ /** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */ -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import { submittedPlan, type SubmittedPlan } from './plan.ts' diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index d44b8f2dd9..494b441dda 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -1,4 +1,5 @@ /** Plan text and resource identities derived from logged native or PTC calls. */ +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types' diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 5e7d0fb6df..13d4930dd1 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -60,10 +60,10 @@ "path": "../../llm/llm" }, { - "path": "../../core/tools" + "path": "../../util/crypto" }, { - "path": "../../util/crypto" + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 9db958772f..4f2b6e35da 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -51,7 +51,10 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", diff --git a/packages/client/ui-tool/src/client/tool/models/todo-history.ts b/packages/client/ui-tool/src/client/tool/models/todo-history.ts index 591bc05ca5..c552e75577 100644 --- a/packages/client/ui-tool/src/client/tool/models/todo-history.ts +++ b/packages/client/ui-tool/src/client/tool/models/todo-history.ts @@ -1,5 +1,5 @@ /** Indexed recorded todo predecessors for root and nested Tool calls. */ -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, TodoItem, diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 6aed393a37..4e68ff9997 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -1,4 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Producer-to-UI regression for shell results bounded by the real spill policy. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -11,7 +14,8 @@ import { SpillLocator, SpillStore, type SaveTextSpill, type SpillRef } from '@de import * as SpillPolicy from '@deepseek-ai/dsh-spill-policy' import { formatSpillNotice } from '@deepseek-ai/dsh-spill-policy/notice' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { describe, expect, it } from 'vitest' import { isSpilledShellCall, terminalCardModel } from '../src/client/tool/models/terminal-card-model.ts' @@ -40,7 +44,8 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl const ctx = new Context() try { await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: nested ? 'ptc' : 'native' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((nested ? 'ptc' : 'native') === 'ptc' ? PtcToolExecutor : NativeToolExecutor) ctx.provide('workingDirectory', { ensure: async () => process.cwd() }) await ctx.plugin(MemorySpillStore) await ctx.plugin(SpillPolicy, { maxInlineTokens }) @@ -75,7 +80,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl // Nested PTC registers retained-environment cleanup on the owning context. const agent = { ctx, session } as Agent const callId = ToolCallId('shell-call') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId, agent, signal: new AbortController().signal, diff --git a/packages/client/ui-tool/tsconfig.json b/packages/client/ui-tool/tsconfig.json index 966cb32e1a..b6ff14828a 100644 --- a/packages/client/ui-tool/tsconfig.json +++ b/packages/client/ui-tool/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../util/workspace-path" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index a46d92dd8d..a1aed60236 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -52,10 +52,10 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", "@deepseek-ai/dsh-compaction": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index aa0f4489b1..79b17178f9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -3,7 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, StartedToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { trajectoryNode } from './trajectory-definition-common.ts' diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 744091c364..fb862eeaec 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -41,9 +41,6 @@ { "path": "../../core/session" }, - { - "path": "../../core/tools" - }, { "path": "../../compaction/compaction" }, @@ -52,6 +49,9 @@ }, { "path": "../../attachment/attachment" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 130f136298..aa028828f8 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", diff --git a/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts b/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts index 1ffb0a9caf..0ba71458f9 100644 --- a/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts +++ b/packages/client/ui-user-questions/tests/node-plugin.client.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import ToolRuntime from '@deepseek-ai/dsh-tools' @@ -17,6 +18,7 @@ describe('ui-user-questions node plugin', () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) await ctx.plugin({ apply }).await() diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index b361f747cf..de942df3fb 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-compaction-image-offload": "workspace:*" } diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 8d762591eb..c495164962 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -4,7 +4,7 @@ import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-a import { createUserMessage, createSystemMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts b/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts index 54d5603bfc..2c3bdbb95f 100644 --- a/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts +++ b/packages/compaction/compaction-basic/tests/runtime-context-admission.spec.ts @@ -8,7 +8,7 @@ import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepse import { SessionId } from '@deepseek-ai/dsh-session' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import TokenMeter from '@deepseek-ai/dsh-token-meter' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import BasicCompaction from '@deepseek-ai/dsh-compaction-basic' import { expect, it } from 'vitest' diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index c451b67521..529a1c5123 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -58,7 +58,9 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" } diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 721866df73..81e0ae7d35 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' @@ -25,7 +27,8 @@ import type { } from '@deepseek-ai/dsh-fs' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { ToolExecution, ToolExecutionToken, @@ -251,6 +254,7 @@ async function mountAgentInstructions(ctx: Context, config: AgentInstructions.Co async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) return mountAgentInstructionsPlugin(ctx, config) @@ -2589,7 +2593,7 @@ describe('workspace context request injection', () => { expect(baselineEvents(agent)).toHaveLength(1) await write(join(root, 'AGENTS.md'), 'updated repo rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-remount'), name: 'read', @@ -2805,7 +2809,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await write(join(root, 'AGENTS.md'), 'new root rule with more detail') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-baseline-change'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -2837,7 +2841,7 @@ describe('workspace context request injection', () => { await composeBaselinePrefix(ctx, agent) await rm(join(root, 'AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-baseline-remove'), name: 'read', arguments: { file_path: 'file.txt' }, agent, }) @@ -3635,6 +3639,7 @@ describe('dynamic nested workspace context injection', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) @@ -3761,7 +3766,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested'), name: 'read', @@ -3845,7 +3850,7 @@ describe('dynamic nested workspace context injection', () => { }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-configured-nested-candidate'), name: 'read', @@ -3881,7 +3886,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-overlay'), name: 'read', @@ -3926,7 +3931,7 @@ describe('dynamic nested workspace context injection', () => { }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-overlay-disabled'), name: 'read', @@ -3957,7 +3962,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-1'), name: 'read', @@ -3965,7 +3970,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) await appendAdditionalContexts(ctx, agent) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-2'), name: 'read', @@ -3991,6 +3996,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -4003,12 +4009,12 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-version-fast-path'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4032,6 +4038,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -4043,18 +4050,18 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) fs.entries.set(instructionPath, { type: 'file', content: 'same package rule', version: FsVersion('revision-2') }) - const afterVersionChange = await ctx.tools.execute({ + const afterVersionChange = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-same-digest-version-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await syncAgentInstructions(ctx, agent) - const afterRefresh = await ctx.tools.execute({ + const afterRefresh = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-version-cache-refresh'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4080,6 +4087,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -4092,11 +4100,11 @@ describe('dynamic nested workspace context injection', () => { const firstAgent = await stubAgent(root) const secondAgent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-from-first-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: firstAgent, }) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-from-second-session'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: secondAgent, }) @@ -4127,13 +4135,13 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/AGENTS.md'), 'new package rule with more detail') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4173,7 +4181,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-both-siblings'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4182,7 +4190,7 @@ describe('dynamic nested workspace context injection', () => { expect(firstText).toContain('sibling package rule') await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-one-sibling-removed'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4214,7 +4222,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-nested-dup-siblings'), name: 'read', arguments: { file_path: join('pkg', 'deep', 'file.txt') }, agent, }) @@ -4452,7 +4460,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4461,7 +4469,7 @@ describe('dynamic nested workspace context injection', () => { expect(firstText).toContain('initial divergent nested rule') await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/CLAUDE.md'), 'canonical nested rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-dup-convergence'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4492,14 +4500,14 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) // Only the earlier candidate changes; the sibling stays byte-identical but now duplicates it. await write(join(root, 'pkg/AGENTS.md'), 'secondary nested rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-earlier-converges'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4533,13 +4541,13 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-remove'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4576,7 +4584,7 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4590,7 +4598,7 @@ describe('dynamic nested workspace context injection', () => { await rm(join(root, 'pkg/AGENTS.md')) await mkdir(join(root, 'pkg/elsewhere'), { recursive: true }) await symlink(join(root, 'pkg/elsewhere'), join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-symlink-dir'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4619,20 +4627,20 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-to-create-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) await write(join(root, 'pkg/AGENTS.md'), 'restored package rule') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-tombstone'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4657,6 +4665,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) @@ -4667,13 +4676,13 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await appendAdditionalContexts(ctx, agent) fs.throwOnStat.add(join(root, 'pkg/AGENTS.md')) - const duringFailure = await ctx.tools.execute({ + const duringFailure = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-during-provider-failure'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -4700,7 +4709,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-resume'), name: 'read', @@ -4710,7 +4719,7 @@ describe('dynamic nested workspace context injection', () => { await appendAdditionalContexts(ctx, agent) const resumed = await stubAgent(root, agent.session.snapshotEvents()) - const afterResume = await ctx.tools.execute({ + const afterResume = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-resume'), name: 'read', @@ -4739,7 +4748,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const original = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-offline-change'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent: original, }) @@ -4773,7 +4782,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-before-compact'), name: 'read', @@ -4781,7 +4790,7 @@ describe('dynamic nested workspace context injection', () => { agent, }) const contextSeq = (await appendAdditionalContexts(ctx, agent))! - const visibleBeforeCompact = await ctx.tools.execute({ + const visibleBeforeCompact = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-while-visible'), name: 'read', @@ -4797,7 +4806,7 @@ describe('dynamic nested workspace context injection', () => { sourceEventSeqs: [SessionSeq(contextSeq)], }) - const afterCompact = await ctx.tools.execute({ + const afterCompact = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-compact'), name: 'read', @@ -4832,7 +4841,7 @@ describe('dynamic nested workspace context injection', () => { const baseline = baselineEvents(agent)[0] expect(baseline).toBeDefined() - const whileVisible = await ctx.tools.execute({ + const whileVisible = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-visible-baseline'), name: 'read', @@ -4847,7 +4856,7 @@ describe('dynamic nested workspace context injection', () => { sourceEventSeqs: [baseline!.seq], }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-compacted-baseline'), name: 'read', @@ -4856,7 +4865,7 @@ describe('dynamic nested workspace context injection', () => { }) const rearmedContext = (await syncedAgentInstructions(ctx, agent)) await appendAdditionalContexts(ctx, agent) - const afterRearm = await ctx.tools.execute({ + const afterRearm = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-rearmed-baseline'), name: 'read', @@ -4891,7 +4900,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-package'), name: 'read', @@ -4901,7 +4910,7 @@ describe('dynamic nested workspace context injection', () => { const firstText = blocksText(((await syncedAgentInstructions(ctx, agent))).content) await appendAdditionalContexts(ctx, agent) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-subtree'), name: 'read', @@ -4932,7 +4941,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 700 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-subtree-omitting-parent'), name: 'read', @@ -4942,7 +4951,7 @@ describe('dynamic nested workspace context injection', () => { const firstText = blocksText(((await syncedAgentInstructions(ctx, agent))).content) await appendAdditionalContexts(ctx, agent) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-parent-after-omit'), name: 'read', @@ -4997,7 +5006,7 @@ describe('dynamic nested workspace context injection', () => { source: { kind: 'other' }, }), { surfaceOp: 'append' }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-spoofed-state'), name: 'read', @@ -5027,14 +5036,14 @@ describe('dynamic nested workspace context injection', () => { await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const rootResult = await ctx.tools.execute({ + const rootResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-root-file'), name: 'read', arguments: { file_path: 'root.txt' }, agent, }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-absolute-nested-file'), name: 'read', @@ -5062,6 +5071,7 @@ describe('dynamic nested workspace context injection', () => { const nested = join(root, 'pkg/AGENTS.md') await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem fs.entries.set(join(root, '.git'), { type: 'directory' }) @@ -5073,7 +5083,7 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-unreadable-nested-instruction'), name: 'read', @@ -5121,7 +5131,7 @@ describe('dynamic nested workspace context injection', () => { })], })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-downstream'), name: 'read', @@ -5177,7 +5187,7 @@ describe('dynamic nested workspace context injection', () => { feedback: [{ type: 'text' as const, text: 'blocked downstream' }], })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-blocked-downstream'), name: 'read', @@ -5210,6 +5220,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) let shouldBlock = true @@ -5223,7 +5234,7 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const blocked = await ctx.tools.execute({ + const blocked = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('outer-block-first'), name: 'read', @@ -5234,7 +5245,7 @@ describe('dynamic nested workspace context injection', () => { expect(agent.inbox.nextStep).toEqual([]) shouldBlock = false - const accepted = await ctx.tools.execute({ + const accepted = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('outer-block-retry'), name: 'read', @@ -5265,6 +5276,7 @@ describe('dynamic nested workspace context injection', () => { await write(join(root, 'pkg/deep/file.txt'), 'hello') await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: '/' }) await ctx.plugin(ToolFs) ctx.tools.register(defineContentToolFixture({ @@ -5272,7 +5284,7 @@ describe('dynamic nested workspace context injection', () => { description: 'read through a nested dispatch', parameters: {}, async execute(_args, exec) { - const nested = await ctx.tools.execute({ + const nested = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`${exec.callId}:nested`), name: 'read', @@ -5295,7 +5307,7 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - const blocked = await ctx.tools.execute({ + const blocked = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite-first'), name: 'composite-read', arguments: {}, agent, }) @@ -5510,7 +5522,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 0 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-with-disabled-budget'), name: 'read', @@ -5535,6 +5547,7 @@ describe('dynamic nested workspace context injection', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingFileSystem) const fs = ctx.fs as RecordingFileSystem const instructionPath = join(root, 'pkg/AGENTS.md') @@ -5546,12 +5559,12 @@ describe('dynamic nested workspace context injection', () => { await mountAgentInstructionsPlugin(ctx, { maxBytes: 20 }) const agent = await stubAgent(root) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-tiny-budget-1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) await syncAgentInstructions(ctx, agent) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-tiny-budget-2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5582,7 +5595,7 @@ describe('dynamic nested workspace context injection', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-missing'), name: 'read', @@ -5613,7 +5626,7 @@ describe('dynamic nested workspace context injection', () => { await fiber.dispose() const agent = await stubAgent(root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-after-dispose'), name: 'read', @@ -5710,7 +5723,7 @@ describe('workspace context inbox synchronization', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const agent = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pending-v1'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5723,7 +5736,7 @@ describe('workspace context inbox synchronization', () => { agent.inbox.append('next-step', duplicate) await write(join(root, 'pkg/AGENTS.md'), 'pending version two with more detail') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pending-v2'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5734,7 +5747,7 @@ describe('workspace context inbox synchronization', () => { expect(blocksText(agent.inbox.nextStep[0]?.content)).not.toContain('pending version one') await rm(join(root, 'pkg/AGENTS.md')) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pending-delete'), name: 'read', arguments: { file_path: join('pkg', 'file.txt') }, agent, }) @@ -5845,14 +5858,14 @@ describe('workspace context inbox synchronization', () => { pinHarnessHome(home) await mountFileToolsAndAgentInstructions(ctx, { maxBytes: 65536 }) const original = await stubAgent(root) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('recover-pending-a'), name: 'read', arguments: { file_path: join('a', 'file.txt') }, agent: original, }) await syncAgentInstructions(ctx, original) const resumed = await stubAgent(root, original.session.snapshotEvents()) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('recover-pending-b'), name: 'read', arguments: { file_path: join('b', 'file.txt') }, agent: resumed, }) diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index e0a0cd0843..9b83f23959 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-file-reference": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-working-directory": "workspace:*", diff --git a/packages/context/file-reference-local/tests/service.spec.ts b/packages/context/file-reference-local/tests/service.spec.ts index 19ec85c3f6..5e3aa77cce 100644 --- a/packages/context/file-reference-local/tests/service.spec.ts +++ b/packages/context/file-reference-local/tests/service.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -10,7 +11,8 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { FILE_REFERENCE_PROMPT } from '@deepseek-ai/dsh-file-reference' import LocalFileReferenceService, { WorkspaceFileSearch } from '../src/index.ts' @@ -26,6 +28,7 @@ async function harness(): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem) await ctx.plugin(SessionProjectionRegistry) @@ -73,6 +76,7 @@ describe('LocalFileReferenceService', () => { expect(ctx.agents.get(agent.id)).toBe(agent) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) ctx.tools.register(defineContentToolFixture({ name: 'read', description: 'read a file', parameters: {}, execute: () => Promise.resolve([]), })) diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index a63ae81927..4c88afb1f4 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -47,6 +47,7 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "zod": "4.4.3" @@ -95,6 +96,7 @@ "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*" diff --git a/packages/context/session-reference/tests/fixtures/cordis.yml b/packages/context/session-reference/tests/fixtures/cordis.yml index 2be7c197bb..61cb99ae4d 100644 --- a/packages/context/session-reference/tests/fixtures/cordis.yml +++ b/packages/context/session-reference/tests/fixtures/cordis.yml @@ -1,6 +1,7 @@ - name: '@deepseek-ai/dsh-session' - name: '@deepseek-ai/dsh-system-prompt' - name: '@deepseek-ai/dsh-tools' +- name: '@deepseek-ai/dsh-tool-executor-native' - name: '@deepseek-ai/dsh-fs-local' - name: '@deepseek-ai/dsh-session-projection' - name: '@deepseek-ai/dsh-working-directory' diff --git a/packages/context/session-reference/tests/loader-composition.spec.ts b/packages/context/session-reference/tests/loader-composition.spec.ts index 3c8cfa6e15..113bccccc8 100644 --- a/packages/context/session-reference/tests/loader-composition.spec.ts +++ b/packages/context/session-reference/tests/loader-composition.spec.ts @@ -1,5 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real Loader composition preserves retrievable source text outside the bounded preview. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -51,6 +53,7 @@ describe('session-reference real Loader composition', () => { ['@deepseek-ai/dsh-working-directory', workingDirectoryPlugin], ['@deepseek-ai/dsh-system-prompt', systemPromptPlugin], ['@deepseek-ai/dsh-tools', toolsPlugin], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-fs-local', fsPlugin], ['@deepseek-ai/dsh-tool-fs', toolFsPlugin], ['@deepseek-ai/dsh-session-query-sqlite', queryPlugin], @@ -110,7 +113,7 @@ describe('session-reference real Loader composition', () => { const readLines: string[] = [] let totalLines = Infinity for (let offset = 1; offset <= totalLines; offset += 7) { - const read = await ctx.tools.execute({ + const read = await executeToolFixture(ctx, { name: 'read', callId: ToolCallId(`read-${offset}`), arguments: { file_path: notice.fullSnapshot.locator, offset, limit: 7 }, signal: new AbortController().signal, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index a0411f7569..294f07e017 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -55,6 +55,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 92499007d1..9e342606cf 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -6,7 +6,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { unsupportedInbox, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 6ddc5130ca..a828ed85b7 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -56,6 +56,9 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index adf2e33ddc..92d0e05aff 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,13 +31,13 @@ import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnE import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import { ToolBatchExecutionError } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' import { AssistantStreamAttempt } from './assistant-stream.ts' import { SystemPromptProjection } from './runtime-context.ts' -import { executeToolCalls } from './tool-calls.ts' type Phase = | { kind: 'idle'; lastTurn: number } @@ -548,11 +548,23 @@ export class ReactLoopAgent implements Agent { const toolCalls = message.content.filter(block => block.type === 'tool-call') if (toolCalls.length === 0) return { kind: 'completed' } - const { concluded } = await executeToolCalls( - this.loopCtx, turn, step, toolCalls, signal, - context => this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]), - ) - return concluded ? { kind: 'completed' } : null + const maxParallelToolCalls = this.loopCtx.agentLoop.config.maxParallelToolCalls.get() + try { + const result = await this.loopCtx.tools.execute({ + calls: toolCalls, signal, agent: this, log: { turn, step }, + maxParallelToolCalls, + }) + for (const context of result.additionalContexts) { + this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]) + } + return result.concluded ? { kind: 'completed' } : null + } catch (error: unknown) { + if (!(error instanceof ToolBatchExecutionError)) throw error + for (const context of error.partial.additionalContexts) { + this.inbox.splice('next-step', this.inbox.nextStep.length, 0, [context]) + } + throw error.cause + } } catch (error: unknown) { if (!live.ended) live.abandon() throw error diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts deleted file mode 100644 index c12de68d19..0000000000 --- a/packages/core/agent-loop/src/tool-calls.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Schedules one assistant step's tool calls. Exclusive calls form barriers; - * parallel calls use a bounded rolling pool and are reclassified before start. - * Dispatch may overlap, while policy, results, and result context remain - * model-ordered. Abort or an internal scheduler failure stops replenishment - * and drains started calls. - * - * Abort records synthetic error results for skipped calls so replay stays - * valid. A terminal scheduler failure rejects after draining; the owning step - * records conservative recovery results before closing. - * @module dsh-agent-loop/tool-calls - */ - -import type { Context } from '@deepseek-ai/cordis' -import { createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' -import type { Session, SessionSeq, UserMessage } from '@deepseek-ai/dsh-session' -import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' -import { assertNever } from '@deepseek-ai/dsh-util-values' - -/** One tool call after argument parsing, ready to schedule. */ -interface PlannedCall { - block: ToolCallBlock - exec: ToolExecutionInput -} - -/** Settled dispatch awaiting model-order finalization. */ -interface Slot { - exec: ToolRunContext - result: ToolExecutionResult - needsPost: boolean -} - -/** One scheduler group outcome, including a drained cancellation. */ -interface GroupOutcome { - consumed: number - aborted: boolean - /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ - concluded: boolean -} - -/** - * Schedule one assistant step's tool calls by their live concurrency mode. - * Ordinary completion and abort commit started-call results in order. Abort - * drains them, records synthetic results for unstarted calls, and returns with - * the signal still aborted after accepting started-call context through the - * caller-supplied acceptor (the machine stages it in its next-step inbox for the - * step boundary). An internal scheduler failure stops new dispatches, drains - * already-started dispatches, and rejects with the first failure. The owning - * step supplies error results for requests without a committed outcome. - * The committed step's AgentLoop driver boundary supplies the initiating Agent - * that becomes each explicit {@link ToolExecutionInput.agent}. - * - * @param ctx - loop context that owns the tool registry and carries the initiating Agent. - * @param turn - current turn number. - * @param step - current step number. - * @param toolCalls - assistant calls in model order. - * @param signal - abort signal shared by the step. - * @param acceptContext - accepts committed result context for the next step boundary. - */ -export async function executeToolCalls( - ctx: Context, - turn: number, - step: number, - toolCalls: ToolCallBlock[], - signal: AbortSignal, - acceptContext: (context: UserMessage) => void, -): Promise<{ concluded: boolean }> { - const agent = ctx.agents.requireInitiator() - const { session } = agent - - // Inputs are distinct because tools/execute wrappers may replace `exec.signal`. - const planned: PlannedCall[] = toolCalls.map(block => ({ - block, - exec: { - callId: block.id, - name: block.name, - arguments: parseArguments(block.arguments), - agent, - signal, - }, - })) - - let next = 0 - let concluded = false - while (next < planned.length) { - // Commit before classifying again so registry changes affect unstarted calls. - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - const first = planned[next]! - const mode = ctx.tools.executionMode(first.exec).kind - const group = mode === 'parallel' ? planned.slice(next) : [first] - const outcome = await runGroup( - ctx, turn, step, group, mode, signal, acceptContext, - ) - next += outcome.consumed - concluded ||= outcome.concluded - if (outcome.aborted) { - for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block) - return { concluded } - } - } - return { concluded } -} - -/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ -function parseArguments(raw: string): unknown { - try { - return raw ? JSON.parse(raw) : {} - } catch { - return raw - } -} - -/** - * Run one exclusive barrier or parallel pool. Later calls are reclassified - * before start; an exclusive reclassification waits for the current pool to - * drain and remains for the caller's next barrier. Results and contexts commit - * in model order. Abort stops starts, drains and commits started calls, accepts - * their contexts into the owning batch, records results for skipped calls, and - * returns an aborted outcome. Scheduler failure drains dispatches and rejects - * for the owning step to record recovery results. - */ -async function runGroup( - ctx: Context, - turn: number, - step: number, - group: PlannedCall[], - mode: ToolExecutionMode['kind'], - signal: AbortSignal, - acceptContext: (context: UserMessage) => void, -): Promise { - const { session } = ctx.agents.requireInitiator() - const maxParallelToolCalls = ctx.agentLoop.config.maxParallelToolCalls.get() - const slots: (Slot | undefined)[] = group.map(() => undefined) - // Started slots retain their `tool/call` seq so the result can cite it. - const callSeqs: Array = group.map(() => undefined) - let nextToStart = 0 - let committed = 0 - let started = 0 - let aborted: boolean = signal.aborted - let concluded = false - let schedulerFailure: { error: unknown } | undefined - const throwSchedulerFailure = (): void => { - if (schedulerFailure !== undefined) throw schedulerFailure.error - } - - // `committed` advances only across contiguous model-order slots. - const commitReady = async (): Promise => { - while (committed < group.length) { - const slot = slots[committed] - if (slot === undefined) break - const call = group[committed] - const result = slot.needsPost - ? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result) - : ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result) - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index - appendToolResult(session, turn, step, call!.block, result, callSeqs[committed]!) - for (const context of result.additionalContexts ?? []) acceptContext(context) - concluded ||= result.concludesTurn === true - committed++ - } - } - - const inFlight = new Map>() - - const startCall = async (index: number): Promise => { - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index - const call = group[index]! - callSeqs[index] = appendToolCall(session, turn, step, call.block) - started++ - const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec) - throwSchedulerFailure() - switch (prepared.kind) { - case 'dispatch': { - const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then( - (outcome) => { - slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' } - return index - }, - (error: unknown) => { - schedulerFailure ??= { error } - return index - }, - ) - inFlight.set(index, promise) - break - } - case 'post-result': - slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true } - break - case 'final-result': - slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false } - break - /* v8 ignore next -- closed-union exhaustiveness guard */ - default: - assertNever(prepared, 'tool-call scheduler prepare result') - } - } - - const fillPool = async (): Promise => { - while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { - // Re-read later modes after ordered commits so registry changes can create a barrier. - // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition - const nextCall = group[nextToStart]! - if (nextToStart > 0 && mode === 'parallel' - && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break - await startCall(nextToStart) - nextToStart++ - throwSchedulerFailure() - await commitReady() - throwSchedulerFailure() - // Abort may arrive while pre-execute awaits. - if (signal.aborted) aborted = true - } - } - - // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler - // failure stops new dispatches and reaches the turn boundary after every - // already-started dispatch settles. - try { - await fillPool() - while (inFlight.size > 0) { - const settledIndex = await Promise.race(inFlight.values()) - inFlight.delete(settledIndex) - throwSchedulerFailure() - await commitReady() - throwSchedulerFailure() - // Abort may arrive while a tool or ordered commit awaits. - - if (signal.aborted) aborted = true - await fillPool() - } - } catch (error: unknown) { - schedulerFailure ??= { error } - await Promise.allSettled(inFlight.values()) - throw schedulerFailure.error - } - - if (aborted) { - // Started calls and accepted context settle first; every remaining model - // call then receives an ordered synthetic result before the turn aborts. - for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block) - return { consumed: group.length, aborted: true, concluded } - } - /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ - if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') - return { consumed: started, aborted: false, concluded } -} - -/** Append the durable call/result pair for a model call skipped after cancellation. */ -function appendSkippedToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): void { - const callSeq = appendToolCall(session, turn, step, block) - appendToolResult(session, turn, step, block, { - content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], - isError: true, - error: { - message: 'tool call aborted before dispatch', - info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, - }, - }, callSeq) -} - -/** Append a started call and return the event seq that its result must cite. */ -function appendToolCall(session: Session, turn: number, step: number, block: ToolCallBlock): SessionSeq { - const event = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }) - return event.seq -} - -/** Append a model-ordered result linked to its call event. */ -function appendToolResult( - session: Session, - turn: number, - step: number, - block: ToolCallBlock, - result: ToolExecutionResult, - callSeq: SessionSeq, -): void { - const message = createToolResultMessage({ - callId: block.id, - content: result.content, - isError: result.isError, - }) - session.append('tool/result', { - turn, step, - message, - ...result.error?.info ? { error: result.error.info } : {}, - // The tool's private presentation payload (e.g. a result-time diff), - // persisted so a UI bridge reproduces the card on replay. - ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', sourceEventSeqs: [callSeq] }) -} diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 0b9d32ec06..15c2967fdb 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -7,7 +9,8 @@ import LlmRuntime, { createUserMessage, ToolCallId, LlmAdapter } from '@deepsee import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' const testToolSignal = new AbortController().signal @@ -25,6 +28,7 @@ async function harness(adapter: LlmAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const agentsFiber = await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -126,6 +130,7 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -312,7 +317,7 @@ describe('AgentLoop initiator scope', () => { }, })) - const direct = await ctx.tools.execute({ + const direct = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('direct'), name: 'agentless-probe', @@ -387,6 +392,7 @@ describe('AgentLoop initiator scope', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 180edf291d..133103d12d 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -26,6 +27,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index add362835b..8c2b935edb 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears @@ -13,7 +14,8 @@ import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SessionLogOffset, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent, type AgentCancelCause } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -30,6 +32,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -828,6 +831,7 @@ describe('Agent.cancel()', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 0d73ab4ba7..6231a2e60a 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -42,6 +43,7 @@ async function makeCoreContext(): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) return ctx } @@ -373,6 +375,7 @@ describe('config-driven session id', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('deferred') }], @@ -396,6 +399,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) + await ctx1.plugin(NativeToolExecutor) await ctx1.plugin(AgentRegistry) await ctx1.plugin(JsonlSessionPersistence, { root }) await ctx1.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) @@ -419,6 +423,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) + await ctx2.plugin(NativeToolExecutor) await ctx2.plugin(AgentRegistry) await ctx2.plugin(JsonlSessionPersistence, { root }) await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('cfg'), provider: 'mock', model: 'mock' }] }) @@ -445,6 +450,7 @@ describe('config-driven session id', () => { await ctx1.plugin(SessionProjectionRegistry) await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRuntime) + await ctx1.plugin(NativeToolExecutor) await ctx1.plugin(AgentRegistry) await ctx1.plugin(JsonlSessionPersistence, { root }) await ctx1.plugin(AgentLoop, { agents: [] }) @@ -463,6 +469,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) + await ctx2.plugin(NativeToolExecutor) await ctx2.plugin(AgentRegistry) await ctx2.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(JsonlSessionPersistence, { root }) @@ -489,6 +496,7 @@ describe('config-driven session id', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('main'), provider: 'mock', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 0213c38060..c3e3c74f4b 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { type RequestMessage, createUserMessage, ToolCallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type PostToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -29,6 +31,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -578,6 +581,7 @@ describe('turn numbering continues across seeded sessions', () => { await ctx2.plugin(SessionProjectionRegistry) await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRuntime) + await ctx2.plugin(NativeToolExecutor) await ctx2.plugin(AgentRegistry) await ctx2.plugin(AgentLoop, { agents: [] }) ctx2.llm.registerAdapter(['mock'], second) @@ -1294,6 +1298,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1344,6 +1349,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1394,6 +1400,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1440,6 +1447,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1488,6 +1496,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 38387dc3ad..57a9887b3a 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm' @@ -5,7 +6,8 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -29,6 +31,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 352583df36..0f0efc14b0 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -9,7 +10,8 @@ import SessionStore, { type UserMessage, } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent, type PreStepDecision, @@ -47,6 +49,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 9ae652df61..59ee116fdc 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@deepseek-ai/dsh-llm' @@ -5,7 +6,8 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent, type AssistantStreamFrame } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -35,6 +37,7 @@ async function harness(adapter: MockAdapter, persona = '') { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: persona }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1917,6 +1920,7 @@ describe('agent loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', reasoningEffort: effort }], @@ -1945,6 +1949,7 @@ describe('agent loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index daea36f629..2ed219491f 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -9,6 +9,7 @@ * idle→running→idle, while teardown is a registry lifecycle. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' @@ -43,6 +44,7 @@ async function harness() { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], new EchoAdapter()) diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index e36281607d..9e68d64401 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -45,6 +47,7 @@ async function loopHarness(): Promise { await created.plugin(SessionProjectionRegistry) await created.plugin(SystemPrompt, { personaPrefix: SYSTEM }) await created.plugin(ToolRuntime) + await created.plugin(NativeToolExecutor) await created.plugin(AgentRegistry) await created.plugin(AgentLoop, { agents: [] }) await created.plugin(LlmDeepSeek) diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index 616a817082..5287cb4deb 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -17,6 +18,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 69014d400d..55244dec40 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -5,6 +5,7 @@ * replacement or header change explains the difference. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' @@ -12,7 +13,8 @@ import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, Stre import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -41,6 +43,7 @@ async function harnessRoutes( await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: persona }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) for (const [provider, adapter] of adapters) ctx.llm.registerAdapter([provider], adapter) @@ -354,6 +357,7 @@ describe('request stability across the loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'stable base' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) const started = Promise.withResolvers() @@ -473,6 +477,7 @@ describe('request stability across the loop', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'stable base' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) let observed: GenerateOptions | undefined diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index 8a0a66df12..029a810ae4 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { setImmediate } from 'node:timers/promises' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -117,7 +118,7 @@ it.each(['loop', 'root'] as const)('unloads %s while a background child is stopp await release.promise return next() }) - const started = await ctx.tools.execute({ + const started = await executeToolFixture(ctx, { agent: parent, signal: new AbortController().signal, callId: ToolCallId('background-start'), name: 'subagent', arguments: { description: 'child', prompt: 'wait' }, diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 287593936e..cc1b8492e8 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { ToolCallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -9,7 +10,8 @@ import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionLogOffset, SessionSeq, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence' @@ -40,6 +42,7 @@ async function mountPersistentHarness(root: string, adapter: MockAdapter, compre await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) // The backend mounts BEFORE the loop so root teardown unwinds the loop // first: live agents drain their writers into still-open handles. @@ -872,6 +875,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(JsonlSessionPersistence, { root }) @@ -991,9 +995,17 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', name: 'probe', description: 'probe', parameters: {}, async execute() { return [{ type: 'text', text: 'unexpected execution' }] }, })) - first.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw new Error('scheduler prepare failed') } const sessionId = SessionId('failed-tool-history') const initial = await first.agents.create({ sessionId, agentOptions: { provider: 'mock', model: 'mock' } }) + const executor = new NativeToolExecutor.NativeToolExecutor(initial.agent.ctx) + executor.execute = async (input) => { + if (input.log === undefined || input.agent === undefined) throw new Error('test expects a logged Agent step') + const { agent, log: { turn, step } } = input + const call = input.calls[0]! + agent.session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments }) + throw new Error('executor failed after accepting its first call') + } + initial.agent.ctx.tools.setExecutor(executor) const failed = waitForIdle(first, initial.agent) initial.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'try tools' }], source: { kind: 'user' } })) await failed @@ -1007,7 +1019,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', event.data.message.toolCallId, event.data.error?.code, ])).toEqual([[ToolCallId('started'), TOOL_OUTCOME_UNKNOWN], [ToolCallId('pending'), TOOL_NOT_STARTED]]) expect(stored.at(-1)).toMatchObject({ - type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'scheduler prepare failed' } } }, + type: 'turn/end', data: { reason: { kind: 'error', error: { code: 'UNKNOWN', message: 'executor failed after accepting its first call' } } }, }) const continued = mode === 'resume' @@ -1087,6 +1099,7 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) @@ -1344,6 +1357,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) + await configured.plugin(NativeToolExecutor) await configured.plugin(AgentRegistry) await configured.plugin(JsonlSessionPersistence, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) @@ -1378,6 +1392,7 @@ describe('configured-start failure edges', () => { await configured.plugin(SessionProjectionRegistry) await configured.plugin(SystemPrompt) await configured.plugin(ToolRuntime) + await configured.plugin(NativeToolExecutor) await configured.plugin(AgentRegistry) await configured.plugin(JsonlSessionPersistence, { root }) configured.llm.registerAdapter(['mock'], new MockAdapter([])) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2addf9cba2..b28222031b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, onTestFinished } from 'vitest' import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -21,6 +23,7 @@ async function harnessWithLoop(adapter: MockAdapter = new MockAdapter([textRespo await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'You are the deployment.' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/serial-listener-review.spec.ts b/packages/core/agent-loop/tests/serial-listener-review.spec.ts index ad08e9802d..7eecb9cee6 100644 --- a/packages/core/agent-loop/tests/serial-listener-review.spec.ts +++ b/packages/core/agent-loop/tests/serial-listener-review.spec.ts @@ -8,7 +8,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import LocalFileReference from '@deepseek-ai/dsh-file-reference-local' import SubagentRuntime from '@deepseek-ai/dsh-subagent' diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts index 4a0fd1c154..daa715a3f9 100644 --- a/packages/core/agent-loop/tests/settings.spec.ts +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -1,5 +1,6 @@ /** The `agent-loop` settings section layered over the composition entry. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' @@ -11,7 +12,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { liveConfig } from '../../../settings/settings/tests/live-config.ts' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -async function boot() { +async function boot(maxParallelToolCalls?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) @@ -20,13 +21,14 @@ async function boot() { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) onTestFinished(() => ctx.fiber.dispose()) - const live = await liveConfig(ctx, AgentLoop, { agents: [], maxParallelToolCalls: 4 }) + await ctx.plugin(NativeToolExecutor) + const live = await liveConfig(ctx, AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls } }) return { ctx, live } } it('updates future scheduler budgets while preserving composed agents and the running loop', async () => { - const { ctx, live } = await boot() + const { ctx, live } = await boot(4) const before = live.fiber await live.update({ maxParallelToolCalls: 1 }) expect(live.entry.fiber === before).toBe(true) @@ -35,3 +37,18 @@ it('updates future scheduler budgets while preserving composed agents and the ru await expect(live.update({ maxParallelToolCalls: 0 })).rejects.toThrow() expect(ctx.agentLoop.config.maxParallelToolCalls.get()).toBe(1) }) + +it('defaults to ten and restores that default without remounting the loop', async () => { + const { ctx, live } = await boot() + const loop = ctx.agentLoop + expect(loop.config.maxParallelToolCalls.get()).toBe(10) + await live.update({ maxParallelToolCalls: 2 }) + expect(loop.config.maxParallelToolCalls.get()).toBe(2) + await expect(live.update({ maxParallelToolCalls: 0 })).rejects.toThrow() + await expect(live.update({ maxParallelToolCalls: 1.5 })).rejects.toThrow() + expect(loop.config.maxParallelToolCalls.get()).toBe(2) + await live.replace({ agents: [] }) + expect(loop.config.maxParallelToolCalls.get()).toBe(10) + expect(ctx.agentLoop.config.maxParallelToolCalls.get()).toBe(10) + expect(live.entry.fiber === live.fiber).toBe(true) +}) diff --git a/packages/core/agent-loop/tests/shutdown-drain.spec.ts b/packages/core/agent-loop/tests/shutdown-drain.spec.ts index d89928395b..553cc05bba 100644 --- a/packages/core/agent-loop/tests/shutdown-drain.spec.ts +++ b/packages/core/agent-loop/tests/shutdown-drain.spec.ts @@ -1,5 +1,6 @@ /** Root-fiber shutdown drains buffered session events durably (both mount orders). */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, afterEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' @@ -36,6 +37,7 @@ async function mount(order: 'backend-first' | 'loop-first'): Promise<{ ctx: Cont await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) if (order === 'backend-first') { await ctx.plugin(JsonlSessionPersistence, { root }) diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index 01543b64df..77fd8a1dc3 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { installModelSelection, type Agent, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' @@ -7,7 +8,8 @@ import { toPiContext } from '@deepseek-ai/dsh-llm-pi-ai/src/context.ts' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse } from './mock-adapter.ts' declare module '@deepseek-ai/dsh-llm' { @@ -29,6 +31,7 @@ async function harness(capable = new MockAdapter(Array.from({ length: 8 }, () => await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) capable.systemPromptUpdate = 'in-history' diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 8aa74cec2d..0d0b1168b1 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -1,8 +1,11 @@ +import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' /** * Exercises scheduler ordering and cancellation with deterministic gated tools. * ACP expected outputs own transcript-facing coverage. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -10,7 +13,8 @@ import SessionStore, { SessionEvent, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_U import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import LlmRuntime from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' -import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -24,20 +28,29 @@ declare module '@deepseek-ai/dsh-llm' { } } -async function harness(adapter: MockAdapter, maxParallelToolCalls?: number) { +async function harness(adapter: MockAdapter, maxParallelToolCalls?: number, executorLimit?: number) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) + let toolPipeline!: ToolExecutionPipeline + await ctx.plugin({ + inject: ['tools'], Config: NativeToolExecutor.Config, + apply(owner: Context, config: NativeToolExecutor.Config) { + const executor = new NativeToolExecutor.NativeToolExecutor(owner, () => config.maxParallelToolCalls.get()) + toolPipeline = executor['pipeline'] + owner.tools.setExecutor(executor) + }, + }, { ...executorLimit === undefined ? {} : { maxParallelToolCalls: executorLimit } }) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls }, }) ctx.llm.registerAdapter(['mock'], adapter) - return ctx + return Object.assign(ctx, { toolPipeline }) } function waitForIdle(ctx: Context, agent: Agent): Promise { @@ -271,6 +284,46 @@ describe('tool-call scheduler: model-order results despite out-of-order settleme }) describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => { + it.each([ + { executorLimit: 1, loopLimit: 2, expected: 2 }, + { executorLimit: 3, loopLimit: 1, expected: 1 }, + { executorLimit: 2, loopLimit: undefined, expected: 10 }, + ])('uses $expected parallel calls with executor $executorLimit and loop limit $loopLimit', async ({ executorLimit, loopLimit, expected }) => { + const adapter = new MockAdapter([ + multiCall(Array.from({ length: 12 }, (_, n) => ({ id: `c${n}`, name: 'overlap', args: {} }))), + textResponse('done'), + ]) + const ctx = await harness(adapter, loopLimit, executorLimit) + onTestFinished(() => ctx.fiber.dispose()) + let active = 0 + let peak = 0 + ctx.tools.register(defineContentToolFixture({ + name: 'overlap', description: 'Observe simultaneous tool bodies.', parameters: {}, + isConcurrencySafe: () => true, + async execute() { + peak = Math.max(peak, ++active) + // Let the scheduler start every available sibling before any body settles. + await new Promise(resolve => setImmediate(resolve)) + active-- + return [{ type: 'text', text: 'done' }] + }, + })) + const agent = await ctx.agentLoop.create(SessionId('override'), { provider: 'mock', model: 'mock' }) + const idle = waitForIdle(ctx, agent) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await idle + expect(peak).toBe(expected) + expect(active).toBe(0) + expect(events(agent).filter(event => event.type === 'tool/result')).toHaveLength(12) + }) + + it.each([0, 1.5])('rejects an invalid per-call concurrency override %s', async (maxParallelToolCalls) => { + const ctx = await harness(new MockAdapter([])) + onTestFinished(() => ctx.fiber.dispose()) + await expect(ctx.tools.execute({ calls: [], signal: new AbortController().signal, maxParallelToolCalls })) + .rejects.toThrow('maxParallelToolCalls must be a positive integer') + }) + it('rejects invalid global maxParallelToolCalls config at plugin load', async () => { await expect(harness(new MockAdapter([]), 0)).rejects.toThrow() await expect(harness(new MockAdapter([]), 1.5)).rejects.toThrow() @@ -338,6 +391,7 @@ describe('tool-call scheduler: rolling pool honors maxParallelToolCalls', () => await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 1 }) ctx.llm.registerAdapter(['mock'], adapter) @@ -631,7 +685,7 @@ describe('tool-call scheduler: abort handling', () => { }) describe('tool-call scheduler: failure quiescence', () => { - it.each(['execution-mode', 'prepare', 'finalize'] as const)('pairs outstanding requests after %s fails and preserves committed results', async (phase) => { + it.each(['execution-mode', 'prepare', 'complete'] as const)('pairs outstanding requests after %s fails and preserves committed results', async (phase) => { const adapter = new MockAdapter([ multiCall([ { id: 'c1', name: 'p', args: { id: '1' } }, @@ -650,13 +704,13 @@ describe('tool-call scheduler: failure quiescence', () => { return [{ type: 'text', text: `done-${args.id}` }] }, })) - const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER] - const executionMode = ctx.tools.executionMode.bind(ctx.tools) + const scheduler = ctx.toolPipeline + const executionMode = ctx.toolPipeline.executionMode.bind(ctx.toolPipeline) const prepare = scheduler.prepare.bind(scheduler) - const finalize = scheduler.finalize.bind(scheduler) + const complete = scheduler.complete.bind(scheduler) const failure = new Error(`${phase} failed`) if (phase === 'execution-mode') { - ctx.tools.executionMode = (exec) => { + ctx.toolPipeline.executionMode = (exec) => { if (exec.callId === ToolCallId('c2')) throw failure return executionMode(exec) } @@ -666,9 +720,9 @@ describe('tool-call scheduler: failure quiescence', () => { return prepare(exec) } } else { - scheduler.finalize = (exec, result) => { + scheduler.complete = (exec, outcome) => { if (exec.callId === ToolCallId('c2')) throw failure - return finalize(exec, result) + return complete(exec, outcome) } } const agent = await ctx.agentLoop.create(SessionId(`failure-${phase}`), { provider: 'mock', model: 'mock' }) @@ -681,7 +735,7 @@ describe('tool-call scheduler: failure quiescence', () => { const failedEvents = events(agent) const calls = failedEvents.filter(event => event.type === 'tool/call') const results = failedEvents.filter(event => event.type === 'tool/result') - expect(executed).toEqual(phase === 'finalize' ? ['1', '2'] : ['1']) + expect(executed).toEqual(phase === 'complete' ? ['1', '2'] : ['1']) expect(results.map(event => [event.data.message.toolCallId, event.data.error?.code])).toEqual([ [ToolCallId('c1'), undefined], [ToolCallId('c2'), phase === 'execution-mode' ? TOOL_NOT_STARTED : TOOL_OUTCOME_UNKNOWN], @@ -702,9 +756,9 @@ describe('tool-call scheduler: failure quiescence', () => { }) expect(listenerCount()).toBe(idleListenerCount) - ctx.tools.executionMode = executionMode + ctx.toolPipeline.executionMode = executionMode scheduler.prepare = prepare - scheduler.finalize = finalize + scheduler.complete = complete const nextTurn = waitForIdle(ctx, agent) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) await nextTurn @@ -728,7 +782,7 @@ describe('tool-call scheduler: failure quiescence', () => { onTestFinished(() => ctx.fiber.dispose()) const failure = new Error('prepare failed') const recoveryFailure = new Error('recovery result rejected') - ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare = () => { throw failure } + ctx.toolPipeline.prepare = () => { throw failure } ctx.on('internal/dispatch', (_mode, name, args) => { if (name === 'session/event' && (args[1] as SessionEvent).type === 'tool/result') throw recoveryFailure }) @@ -763,7 +817,7 @@ describe('tool-call scheduler: failure quiescence', () => { name: 'p', description: 'probe', parameters: {}, async execute() { return [{ type: 'text', text: 'completed' }] }, })) - const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER] + const scheduler = ctx.toolPipeline const dispatch = scheduler.dispatch.bind(scheduler) scheduler.dispatch = async (exec) => { if (exec.agent?.id === SessionId('first-agent')) { @@ -818,7 +872,7 @@ describe('tool-call scheduler: failure quiescence', () => { ctx.tools.register(gated.tool) // The registry contains expected failures as results; replace its internal // view only to inject the invariant violation this boundary must contain. - const scheduler = ctx.tools[TOOL_RUNTIME_SCHEDULER] + const scheduler = ctx.toolPipeline const prepare = scheduler.prepare.bind(scheduler) const dispatch = scheduler.dispatch.bind(scheduler) const prepareGate = Promise.withResolvers() @@ -897,7 +951,8 @@ describe('PTC mode native-tool denial through the agent loop', () => { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) // eslint-disable-next-line @typescript-eslint/no-explicit-any -- FakePtcRuntime is an internal test helper with an opaque type shape await ctx.plugin(FakePtcRuntime as any) await ctx.plugin(AgentRegistry) diff --git a/packages/core/agent-loop/tests/tool-executors.spec.ts b/packages/core/agent-loop/tests/tool-executors.spec.ts new file mode 100644 index 0000000000..900eba6031 --- /dev/null +++ b/packages/core/agent-loop/tests/tool-executors.spec.ts @@ -0,0 +1,323 @@ +/** Scoped executor selection and lifetime through the real Agent loop. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { expect, it, onTestFinished } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm' +import { bindScopeParent, createScope, type Scope, type ScopeKey } from '@deepseek-ai/dsh-scope' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionProjections from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import type { AssembleContext, PromptAssembly } from '@deepseek-ai/dsh-system-prompt' +import Tools, { defineTool, ToolsService } from '@deepseek-ai/dsh-tools' +import type { ToolExecutor, ToolBatchInput, ToolExecutionToken, ToolRunContext } from '@deepseek-ai/dsh-tools' +import AgentLoop from '../src/index.ts' +import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' + +/** An independent execution implementation: no PTC executor inheritance or delegation. */ +function independent(ctx: Context, name: string, trace: string[]): ToolExecutor { + const transport = defineTool({ + name, description: `Execute through ${name}.`, parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute(_args, exec) { + const tool = ctx.tools.get('echo', exec.agent) + if (tool === undefined) throw new Error('echo is not visible') + return `${name}:${String(await tool.execute({}, exec))}` + }, + }) + const executor: ToolExecutor = { + sections: [ + { name: 'tools:transport', order: 800, text: `Call ${name}.`, interpolate: false }, + { name: 'tools:sdk', order: 5000, text: tools => tools.map(tool => tool.name).join(', '), interpolate: false }, + ], + describe: () => ({ schemas: [transport], knownNames: [name] }), + async execute(input) { + trace.push(`${name}:execute`) + const results = [] + for (const call of input.calls) { + if (call.name !== name) throw new Error(`unexpected transport ${call.name}`) + const callSeq = input.log === undefined ? undefined : input.agent!.session.append('tool/call', { + turn: input.log.turn, step: input.log.step, + callId: call.id, name: call.name, arguments: call.arguments, + }).seq + const exec: ToolRunContext = { + callId: call.id, name: call.name, arguments: JSON.parse(call.arguments), signal: input.signal, + ...input.agent === undefined ? {} : { agent: input.agent }, + rootCallId: call.id, token: Symbol() as ToolExecutionToken, + deferContext() {}, concludeTurn() {}, + } + const value = String(await transport.execute(exec.arguments, exec)) + const result = { isError: false as const, value, content: [{ type: 'text' as const, text: value }] } + if (input.log !== undefined) { + const { agent, log: { turn, step } } = input + agent!.session.append('tool/result', { + turn, step, message: createToolResultMessage({ callId: call.id, content: result.content, isError: false }), + }, { surfaceOp: 'append', ...callSeq === undefined ? {} : { sourceEventSeqs: [callSeq] } }) + } + results.push({ callId: call.id, result }) + } + return { results, additionalContexts: [], concluded: false } + }, + } + return executor +} + +async function setup(adapter = new MockAdapter([]), maxParallelToolCalls?: number) { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjections) + await ctx.plugin(SystemPrompt) + await ctx.plugin(Tools) + await ctx.plugin(NativeToolExecutor) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [], ...maxParallelToolCalls === undefined ? {} : { maxParallelToolCalls } }) + ctx.llm.registerAdapter(['mock'], adapter) + ctx.tools.register(defineTool({ + name: 'echo', description: 'Shared capability.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'shared', + })) + return ctx +} + +async function scoped(ctx: Context, key: ScopeKey): Promise { + let scope!: Scope + await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, key) }, + { inject: ['tools', 'systemPrompt'] })) + return scope +} + +async function run(ctx: Context, agent: Agent): Promise { + const idle = new Promise((resolve) => { + const dispose = ctx.on('agent/status', (event) => { + if (event.agent === agent && event.status === 'idle') { dispose(); resolve() } + }) + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await idle +} + +it('shares the registry and selects an independent executor through the preset scope chain', async () => { + const trace: string[] = [] + const adapter = new MockAdapter([ + toolCallResponse('sdk-call', 'sdk_eval', {}), textResponse('sdk done'), + toolCallResponse('native-call', 'echo', {}), textResponse('native done'), + ]) + const ctx = await setup(adapter) + const presetKey = { preset: 'sdk' } + const preset = await scoped(ctx, presetKey) + preset.ctx.tools.setExecutor(independent(preset.ctx, 'sdk_eval', trace)) + const sdk = await ctx.agentLoop.create(SessionId('sdk'), { provider: 'mock', model: 'mock' }) + bindScopeParent(sdk, presetKey) + const native = await ctx.agentLoop.create(SessionId('native'), { provider: 'mock', model: 'mock' }) + expect(ctx.tools).toBeInstanceOf(ToolsService) + expect('registerExecutor' in ctx.tools).toBe(false) + expect(sdk.ctx.tools.get('echo', sdk)).toBe(ctx.tools.get('echo')) + expect(native.ctx.tools.get('echo', native)).toBe(ctx.tools.get('echo')) + await run(ctx, sdk) + await run(ctx, native) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['sdk_eval']) + expect(adapter.requests[2]?.tools?.map(tool => tool.name)).toEqual(['echo']) + expect(trace).toEqual(['sdk_eval:execute']) + expect(sdk.session.snapshotEvents().find(event => event.type === 'tool/result')).toMatchObject({ + data: { message: { content: [{ type: 'text', text: 'sdk_eval:shared' }], isError: false } }, + }) +}) + +it('dispatches through the current Agent scope after prompt listeners rebuild the assembly', async () => { + const trace: string[] = [] + const adapter = new MockAdapter([ + () => { selectNearer(); return toolCallResponse('held', 'first_eval', {}) }, + textResponse('done'), + ]) + const ctx = await setup(adapter) + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + const { sections, contexts, tools, variables } = await next() + return { sections, contexts, tools, variables } + }) + const presetKey = { preset: 'first' } + const preset = await scoped(ctx, presetKey) + preset.ctx.tools.setExecutor(independent(preset.ctx, 'first_eval', trace)) + const agent = await ctx.agentLoop.create(SessionId('changing'), { provider: 'mock', model: 'mock' }) + bindScopeParent(agent, presetKey) + const selectNearer = () => { + const nearer = independent(agent.ctx, 'first_eval', trace) + const execute = nearer.execute.bind(nearer) + nearer.execute = (input) => { trace.push('nearer'); return execute(input) } + agent.ctx.tools.setExecutor(nearer) + } + await run(ctx, agent) + expect(adapter.requests[0]?.tools?.map(tool => tool.name)).toEqual(['first_eval']) + expect(adapter.requests[1]?.tools?.map(tool => tool.name)).toEqual(['first_eval']) + expect(trace).toEqual(['nearer', 'first_eval:execute']) +}) + +it('rejects new batches after unload while an accepted batch settles on its original executor', async () => { + const trace: string[] = [] + const ctx = await setup() + const key = { preset: 'temporary' } + const scope = await scoped(ctx, key) + const agent = await ctx.agentLoop.create(SessionId('temporary'), { provider: 'mock', model: 'mock' }) + bindScopeParent(agent, key) + const provider = independent(scope.ctx, 'old_eval', trace) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(undefined) }) + const execute = provider.execute.bind(provider) + provider.execute = async (input) => { entered.resolve(undefined); await release.promise; return execute(input) } + const dispose = scope.ctx.tools.setExecutor(provider) + const input = { + calls: [{ type: 'tool-call', name: 'old_eval', id: ToolCallId('accepted'), arguments: '{}' }], + signal: new AbortController().signal, + agent, + } satisfies ToolBatchInput + const pending = ctx.tools.execute(input) + await entered.promise + dispose() + await expect(ctx.systemPrompt.assemble({ scope: key })).rejects.toThrow('selected tool executor is unavailable') + await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') + scope.ctx.tools.setExecutor(independent(scope.ctx, 'replacement_eval', trace)) + release.resolve(undefined) + const result = await pending + expect(result.results[0]?.result).toMatchObject({ isError: false, value: 'old_eval:shared' }) + const replacement = await ctx.tools.execute({ + ...input, + calls: [{ type: 'tool-call', name: 'replacement_eval', id: ToolCallId('replacement'), arguments: '{}' }], + }) + expect(replacement.results[0]?.result).toMatchObject({ isError: false, value: 'replacement_eval:shared' }) + expect(trace).toEqual(['old_eval:execute', 'replacement_eval:execute']) +}) + +it('refreshes the selected projection when the same assembly context is reused', async () => { + const ctx = await setup() + const key = { preset: 'refresh' } + const scope = await scoped(ctx, key) + const context: AssembleContext = { scope: key } + expect((await ctx.systemPrompt.assemble(context)).tools.map(tool => tool.name)).toEqual(['echo']) + scope.ctx.tools.setExecutor(independent(scope.ctx, 'new_eval', [])) + expect((await ctx.systemPrompt.assemble(context)).tools.map(tool => tool.name)).toEqual(['new_eval']) +}) + +it.each([ + ['occupied', 1, 'already registered'], + ['invalid-order', Number.NaN, 'order must be a finite number'], +] as const)('rolls back executor section registrations when %s is rejected', async (name, order, message) => { + const ctx = await setup() + const key = { preset: name } + const scope = await scoped(ctx, key) + scope.ctx.systemPrompt.section({ name: 'occupied', order: 1, text: 'Existing guidance.' }) + const executor: ToolExecutor = { + ...independent(scope.ctx, 'rejected_eval', []), + sections: [ + { name: 'transient', order: 0, text: 'Must be rolled back.' }, + { name, order, text: 'Invalid contribution.' }, + ], + } + expect(() => scope.ctx.tools.setExecutor(executor)).toThrow(message) + const assembly = await ctx.systemPrompt.assemble({ scope: key }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.map(section => section.name)).not.toContain('transient') +}) + +it('assembles independent tools and section text for overlapping prompt assemblies', async () => { + const ctx = await setup() + const key = { preset: 'overlapping' } + const scope = await scoped(ctx, key) + const context: AssembleContext = { scope: key } + let entered!: () => void + const reached = new Promise((resolve) => { entered = resolve }) + let release!: () => void + const resume = new Promise((resolve) => { release = resolve }) + let calls = 0 + ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { + if (calls++ === 0) { entered(); await resume } + return { ...await next() } + }) + const pending = ctx.systemPrompt.assemble(context) + await reached + const replacement = independent(scope.ctx, 'next_eval', []) + const describe = replacement.describe.bind(replacement) + let projections = 0 + replacement.describe = (tools) => { projections++; return describe(tools) } + let second: PromptAssembly + try { + scope.ctx.tools.setExecutor(replacement) + second = await ctx.systemPrompt.assemble(context) + } finally { + release() + } + const first = await pending + expect(first.tools.map(tool => tool.name)).toEqual(['echo']) + expect(first).not.toHaveProperty('toolExecutor') + expect(second.tools.map(tool => tool.name)).toEqual(['next_eval']) + expect(second.sections).toContainEqual({ name: 'tools:transport', text: 'Call next_eval.', interpolate: false }) + expect(second.sections).toContainEqual({ name: 'tools:sdk', text: 'echo', interpolate: false }) + expect(projections).toBe(1) +}) + +it('keeps the foundation usable for registration and requires an explicit executor for calls', async () => { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SystemPrompt) + await ctx.plugin(Tools) + ctx.tools.register(defineTool({ + name: 'echo', description: 'Registered without an executor.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'shared', + })) + expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(['echo']) + await expect(ctx.systemPrompt.assemble()).rejects.toThrow('selected tool executor is unavailable') + await expect(ctx.tools.execute({ + calls: [{ type: 'tool-call', name: 'echo', id: ToolCallId('unconfigured'), arguments: '{}' }], signal: new AbortController().signal, + })).rejects.toThrow('selected tool executor is unavailable') +}) + +it.each([undefined, 1, 6])('hands the executor raw calls and the resolved loop limit %s', async (maxParallelToolCalls) => { + const raw = ['{ "value": 1 }', '[2, 3]'] + const adapter = new MockAdapter([[ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: ToolCallId('one'), name: 'custom', arguments: raw[0]! } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('two'), name: 'custom', arguments: raw[1]! } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ], textResponse('done')]) + const ctx = await setup(adapter, maxParallelToolCalls) + const agent = await ctx.agentLoop.create(SessionId('raw-batch'), { provider: 'mock', model: 'mock' }) + const executor = independent(agent.ctx, 'custom', []) + const batches: ToolBatchInput[] = [] + const execute = executor.execute.bind(executor) + executor.execute = (input) => { batches.push(input); return execute(input) } + agent.ctx.tools.setExecutor(executor) + await run(ctx, agent) + expect(batches).toHaveLength(1) + expect(batches[0]?.calls.map(call => call.arguments)).toEqual(raw) + expect(batches[0]?.agent).toBe(agent) + expect(batches[0]?.log).toMatchObject({ turn: 1, step: 1 }) + expect(batches[0]?.maxParallelToolCalls).toBe(maxParallelToolCalls ?? 10) + expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/result').map(event => event.data.message.toolCallId)) + .toEqual(['one', 'two']) +}) + +it('keeps Agent scope without a model step and logs raw calls only with a logging step', async () => { + const ctx = await setup() + const agent = await ctx.agentLoop.create(SessionId('batch-scope'), { provider: 'mock', model: 'mock' }) + agent.ctx.tools.restrict({ deny: ['echo'] }) + const input = { + calls: [{ type: 'tool-call', id: ToolCallId('scoped'), name: 'echo', arguments: '{ }' }], + signal: new AbortController().signal, + } satisfies ToolBatchInput + const scoped = await ctx.tools.execute({ ...input, agent }) + expect(scoped.results[0]?.result.error?.info?.code).toBe('UNKNOWN_TOOL') + expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/call')).toHaveLength(0) + const owned = await ctx.tools.execute({ ...input, agent, log: { turn: 1, step: 1 } }) + expect(owned.results[0]?.result.error?.info?.code).toBe('UNKNOWN_TOOL') + const calls = agent.session.snapshotEvents().filter(event => event.type === 'tool/call') + expect(calls).toHaveLength(1) + expect(calls[0]?.data.arguments).toBe('{ }') + const unowned = await ctx.tools.execute(input) + expect(unowned.results[0]?.result).toMatchObject({ isError: false, value: 'shared' }) + expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/call')).toHaveLength(1) +}) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 287f5fe761..6e267d03d1 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createUserMessage } from '@deepseek-ai/dsh-llm' /** * Loop-level tool-order determinism: the request/header event — and therefore the frozen @@ -13,7 +14,8 @@ import LlmRuntime from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -27,6 +29,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: 'stable base', ...toolOrder !== undefined ? { toolOrder } : {} }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-loop/tests/tool-updates.spec.ts b/packages/core/agent-loop/tests/tool-updates.spec.ts index 6eca806085..bc192e179e 100644 --- a/packages/core/agent-loop/tests/tool-updates.spec.ts +++ b/packages/core/agent-loop/tests/tool-updates.spec.ts @@ -1,4 +1,5 @@ /** The loop logs tool changes as developer messages and carries session tool history to the runtime. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -8,7 +9,8 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse } from './mock-adapter.ts' const contexts: Context[] = [] @@ -24,6 +26,7 @@ async function harness(adapter: MockAdapter) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index ddcd12bf32..ae8fce2993 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", - "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode or native tool calls", + "description": "Deprecated tool presentation selector; use dsh-tool-executor-native or dsh-tool-executor-ptc directly", "version": "0.2.1-alpha.4", "publishConfig": { "access": "public" @@ -29,6 +29,8 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/schemastery": "workspace:~" }, "peerDependencies": { diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index 1470fe8f26..7531ba183e 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -1,28 +1,13 @@ /** - * Agent-plane presentation selector: the row an agent preset carries to say - * which form of its tools the model sees. - * - * The tool registry itself stays on the host plane — the agent loop's - * scheduler, the API proxy's presenters, and every tool plugin are all its - * consumers, so it cannot move into a preset. What a preset CAN own is the - * presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE, - * which is the preset's standing mount, so the declaration covers every agent - * joined to that preset and a PTC mode preset runs beside native ones in one - * process. One row per composition, not one per session. - * - * A PTC mode needs a TypeScript PTC runtime, which is a host-plane service - * ([`dsh-ptc-runtime-node`](../../../ptc-runtime/ptc-runtime-node/README.md)). - * This row therefore waits for it rather than assuming it: a preset selecting - * PTC mode against a deployment that composes no runtime fails at mount, named - * in the preset's own activation audit, instead of at the first prompt. - * @module @deepseek-ai/dsh-agent-tool-presentation + * Select built-in tool execution for every Agent in the mounting scope. + * @module + * @deprecated Mount @deepseek-ai/dsh-tool-executor-native or @deepseek-ai/dsh-tool-executor-ptc directly. */ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' -// Type-only: brings the `ctx.tools` Context merge into this program. -import type {} from '@deepseek-ai/dsh-tools' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' /** Cordis plugin name. */ export const name = 'tool-presentation' @@ -34,7 +19,10 @@ export const name = 'tool-presentation' */ export const inject = ['tools'] -/** Plugin config. */ +/** + * Plugin config. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, @@ -43,10 +31,13 @@ export interface Config { * without this row already gets, so an omitted value would mean the row was * composed for nothing. */ - mode: ToolPresentationMode + mode: 'native' | 'ptc' } -/** Runtime schema. */ +/** + * Runtime schema. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export const Config: z = z.object({ mode: z.union(['native', 'ptc'] as const).required(), }) @@ -55,18 +46,8 @@ export const Config: z = z.object({ * Declare the tool presentation for every agent this composition covers. * @param ctx - the mounting composition's scope context (a preset's standing scope). * @param config - the selected presentation. + * @deprecated Mount @deepseek-ai/dsh-tool-executor-native or @deepseek-ai/dsh-tool-executor-ptc directly. */ export function apply(ctx: Context, config: Config): void { - // `presentAs` is itself the effect — it registers through the calling - // context and hands back that exact disposer — so the declaration unwinds - // with this row without a second wrapper owning it. - if (config.mode === 'native') { - ctx.tools.presentAs('native') - return - } - // The wait is the loud failure: an entry still pending on `ptcRuntime` is - // what `dsh-agent-preset-registry` reports as an unusable row, naming this id. - ctx.inject(['ptcRuntime'], (runtimeCtx: Context) => { - runtimeCtx.tools.presentAs(config.mode) - }) + ctx.plugin(config.mode === 'native' ? NativeToolExecutor : PtcToolExecutor) } diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index 1d65d968c9..c301280d1c 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -6,6 +6,7 @@ * than at the first prompt assembly. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' @@ -33,7 +34,8 @@ class StubRuntime extends PtcRuntime { async function host(options: { runtime?: boolean } = {}) { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) - await ctx.plugin(ToolRuntime, {}) + await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) if (options.runtime !== false) await ctx.plugin(StubRuntime) ctx.tools.register(defineTool({ name: 'echo', @@ -82,17 +84,13 @@ describe('the tool-presentation row', () => { expect(plainAssembly.tools.map(tool => tool.name)).toEqual(['echo']) }) - it('restores the deployment default when the agent unloads', async () => { + it('rejects the selected scope after its provider unloads', async () => { const ctx = await host() const { agent, row } = await mount(ctx, { mode: 'ptc' }) await row.dispose() - // HMR safety: the preset subtree is torn down with its agent, and the - // presentation must go with it rather than outliving the composition. - const assembly = await ctx.systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) - expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + await expect(ctx.systemPrompt.assemble({ scope: agent })).rejects.toThrow('selected tool executor is unavailable') }) it('waits for a PTC runtime the deployment does not compose', async () => { diff --git a/packages/core/agent-tool-presentation/tsconfig.json b/packages/core/agent-tool-presentation/tsconfig.json index a3e6344b9d..7d77c04f8b 100644 --- a/packages/core/agent-tool-presentation/tsconfig.json +++ b/packages/core/agent-tool-presentation/tsconfig.json @@ -19,6 +19,12 @@ }, { "path": "../../core/tools" + }, + { + "path": "../tool-executor-native" + }, + { + "path": "../tool-executor-ptc" } ] } diff --git a/packages/core/tool-executor-native/package.json b/packages/core/tool-executor-native/package.json new file mode 100644 index 0000000000..6ad7c03e89 --- /dev/null +++ b/packages/core/tool-executor-native/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-tool-executor-native", + "description": "Native tool execution plugin", + "version": "0.2.1-alpha.4", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/tool-executor-native" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cosmokit": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cosmokit": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" + } +} diff --git a/packages/core/tool-executor-native/src/index.ts b/packages/core/tool-executor-native/src/index.ts new file mode 100644 index 0000000000..24685a696b --- /dev/null +++ b/packages/core/tool-executor-native/src/index.ts @@ -0,0 +1,49 @@ +/** Native execution plugin for the mounting Agent, preset, or deployment scope. @module */ +import type { Context } from '@deepseek-ai/cordis' +import { projectToolSchema } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools' +import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' +import type { Volatile } from '@deepseek-ai/cosmokit' +import z from '@deepseek-ai/schemastery' + +/** Executes visible capabilities directly and owns their complete batch settlement. */ +export class NativeToolExecutor implements ToolExecutor { + private readonly pipeline: ToolExecutionPipeline + + constructor(ctx: Context, private readonly maxParallel: () => number = () => 10) { + this.pipeline = new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }) + } + + describe(tools: readonly ToolDefinition[]): ToolModelInput { + return { schemas: tools.map(definition => projectToolSchema(definition, false)) } + } + + execute(input: ToolBatchInput): Promise { + return executeToolBatch(input, this.pipeline, this.maxParallel) + } +} + +/** Cordis plugin name. */ +export const name = 'native-tool-executor' +/** Tool registration and scoped dispatch. */ +export const inject = ['tools'] + +/** Native batch concurrency configuration. */ +export interface Config { + /** Maximum overlapping concurrency-safe calls in one batch. */ + maxParallelToolCalls: Volatile +} + +/** Validated live concurrency limit; one makes each batch serial. */ +export const Config: z<{ maxParallelToolCalls?: number }, Config> = z.object({ + maxParallelToolCalls: z.number().step(1).min(1).default(10).volatile(), +}) + +/** + * Select native execution in the mounting scope. + * @param ctx - context owning this executor's selection and lifetime. + * @param config - live batch concurrency configuration. + */ +export function apply(ctx: Context, config: Config): void { + ctx.tools.setExecutor(new NativeToolExecutor(ctx, () => config.maxParallelToolCalls.get())) +} diff --git a/packages/core/tool-executor-native/tsconfig.json b/packages/core/tool-executor-native/tsconfig.json new file mode 100644 index 0000000000..4ba2702b24 --- /dev/null +++ b/packages/core/tool-executor-native/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../scope" + }, + { + "path": "../tools" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/schemastery" + } + ] +} diff --git a/packages/core/tool-executor-ptc/package.json b/packages/core/tool-executor-ptc/package.json new file mode 100644 index 0000000000..9fc193942b --- /dev/null +++ b/packages/core/tool-executor-ptc/package.json @@ -0,0 +1,71 @@ +{ + "name": "@deepseek-ai/dsh-tool-executor-ptc", + "description": "PTC program execution, tool bindings, and generated SDK prompts", + "version": "0.2.1-alpha.4", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/tool-executor-ptc" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "license": "MIT", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/session-types.d.ts", + "default": "./lib/types/session-types.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts" + ], + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cosmokit": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/schemastery": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/cosmokit": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + } +} diff --git a/packages/core/tool-executor-ptc/src/errors.ts b/packages/core/tool-executor-ptc/src/errors.ts new file mode 100644 index 0000000000..67d468e9d0 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/errors.ts @@ -0,0 +1,16 @@ +/** Program execution failures reported by PTC. @module */ +import { HarnessError } from '@deepseek-ai/dsh-llm' + +/** + * Thrown by `run_code` when the program run itself failed — a program + * exception, a budget expiry, an abort, or substrate death. Extends + * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution + * pipeline converts it into a structured `isError` result whose text carries + * the failure kind plus the captured logs, so the model can self-correct. + */ +export class CodeRunFailedError extends HarnessError { + constructor(message: string) { + super(message, 'CODE_RUN_FAILED') + this.name = 'CodeRunFailedError' + } +} diff --git a/packages/core/tool-executor-ptc/src/index.ts b/packages/core/tool-executor-ptc/src/index.ts new file mode 100644 index 0000000000..74de8722b0 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/index.ts @@ -0,0 +1,104 @@ +/** PTC execution plugin for the mounting Agent, preset, or deployment scope. @module */ +import type { Context } from '@deepseek-ai/cordis' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { RUN_CODE_NAME, ToolNotFoundError, projectToolSchema } from '@deepseek-ai/dsh-tools' +import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolPromptSection, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools' +import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' +import { createRunCodeTool, requirePtcRuntime, type PtcSdkLanguage } from './ptc.ts' +import { renderToolsSdk, type ToolSdkSchema } from './ts-types.ts' +import { renderToolsSdkPy } from './py-types.ts' +import type { Volatile } from '@deepseek-ai/cosmokit' +import z from '@deepseek-ai/schemastery' + +const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` + +const SDK_RENDERERS = { + typescript: renderToolsSdk, + python: renderToolsSdkPy, +} satisfies Record string> + +/** Runs programs whose nested tool calls use the shared guarded invocation pipeline. */ +export class PtcExecutor implements ToolExecutor { + readonly sections: readonly ToolPromptSection[] + private readonly pipeline: ToolExecutionPipeline + private readonly maxParallelSubCalls: number + private ptcTransport: ToolDefinition | undefined + + constructor(private readonly ctx: Context, config: Pick = {}, private readonly maxParallel: () => number = () => 10) { + this.sections = [ + { name: 'tools:ptc-only', order: ctx.systemPrompt.getSectionOrder('PTC_ONLY'), text: PTC_ONLY_INSTRUCTION, interpolate: false }, + { name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: tools => this.renderSdk(tools), interpolate: false }, + ] + this.maxParallelSubCalls = config.maxParallelSubCalls ?? 10 + if (!Number.isInteger(this.maxParallelSubCalls) || this.maxParallelSubCalls < 1) { + throw new Error('maxParallelSubCalls must be a positive integer') + } + this.pipeline = new ToolExecutionPipeline(ctx, { + get: (name, scope) => this.get(name, scope), + reject: (name, nested) => !nested && name !== RUN_CODE_NAME + ? new ToolNotFoundError(name, `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`) + : undefined, + }) + } + + describe(_tools: readonly ToolDefinition[]): ToolModelInput { + return { schemas: [projectToolSchema(this.requirePtcTransport(), false)], knownNames: [RUN_CODE_NAME] } + } + + private renderSdk(tools: readonly ToolDefinition[]): string { + const runtime = requirePtcRuntime(this.ctx) + const render = SDK_RENDERERS[runtime.language] + return render(tools.map(definition => ({ + ...projectToolSchema(definition, false), + output: definition.output.schema, + })), runtime.programFormat) + } + + private get(name: string, scope?: ScopeKey): ToolDefinition | undefined { + return name === RUN_CODE_NAME ? this.requirePtcTransport() : this.ctx.tools.get(name, scope) + } + + execute(input: ToolBatchInput): Promise { + return executeToolBatch(input, this.pipeline, this.maxParallel) + } + + /** Keep the reserved transport outside registered capabilities and their restrictions. */ + private requirePtcTransport(): ToolDefinition { + this.ptcTransport ??= createRunCodeTool(this.ctx, this.pipeline, this.maxParallelSubCalls) + return this.ptcTransport + } +} + +export { CodeRunFailedError } from './errors.ts' +export type { PtcDispatchLog } from './types.ts' +export type { PtcDispatchStartEventData, PtcDispatchEventData } from './session-types.ts' +export { jsonSchemaToTs, renderToolsSdk, type ToolSdkSchema } from './ts-types.ts' +export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' + +/** Cordis plugin name. */ +export const name = 'ptc-tool-executor' +/** The program provider activates with its runtime. */ +export const inject = ['tools', 'systemPrompt', 'ptcRuntime'] + +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe calls in one model batch. */ + maxParallelToolCalls: Volatile + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls?: number +} + +/** Validated program execution configuration. */ +export const Config: z<{ maxParallelToolCalls?: number; maxParallelSubCalls?: number }, Config> = z.object({ + maxParallelToolCalls: z.number().step(1).min(1).default(10).volatile(), + maxParallelSubCalls: z.natural().min(1).default(10), +}) + +/** + * Select PTC execution in the mounting scope. + * @param ctx - context owning this executor's selection and lifetime. + * @param config - program subcall concurrency limits. + */ +export function apply(ctx: Context, config: Config): void { + ctx.tools.setExecutor(new PtcExecutor(ctx, config, () => config.maxParallelToolCalls.get())) +} diff --git a/packages/core/tools/src/ptc-calls.ts b/packages/core/tool-executor-ptc/src/ptc-calls.ts similarity index 100% rename from packages/core/tools/src/ptc-calls.ts rename to packages/core/tool-executor-ptc/src/ptc-calls.ts diff --git a/packages/core/tools/src/ptc.ts b/packages/core/tool-executor-ptc/src/ptc.ts similarity index 77% rename from packages/core/tools/src/ptc.ts rename to packages/core/tool-executor-ptc/src/ptc.ts index b3d85428c1..be45a469f8 100644 --- a/packages/core/tools/src/ptc.ts +++ b/packages/core/tool-executor-ptc/src/ptc.ts @@ -3,42 +3,36 @@ * tools through nested executions scheduled under the native concurrency * contract; each sub-dispatch is logged for reconstruction, while only the * outer curated result enters model history. - * @module @deepseek-ai/dsh-tools/src/ptc + * @module */ +import type { PtcDispatchLog } from './types.ts' import { symbols, type Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' -declare module '@deepseek-ai/dsh-llm' { - interface MessageSourceMap { - /** Images deferred from a successful PTC subcall's final result. */ - 'ptc-mode': { kind: 'ptc-mode' } - } -} +import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolCallId, ToolSchema } from '@deepseek-ai/dsh-llm' import type { PtcBindingFunction, PtcRunResult, PtcRunSandbox, PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import { approveEscalation, ESCALATION_TARGETS, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' -import type { ApprovalService } from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-working-directory' +import type {} from '@deepseek-ai/dsh-sandbox-policy' import { deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' +import { defineTool, parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' +import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { toolErrorMessage } from '@deepseek-ai/dsh-tools/pipeline' +import { scopeTarget } from '@deepseek-ai/dsh-scope' import { PtcCalls } from './ptc-calls.ts' -import { TOOL_RUNTIME_SCHEDULER } from './index.ts' -import type { PtcDispatchLog, ToolDefinition, ToolExecutionResult, ToolRuntime, ToolRunContext } from './index.ts' -import type {} from './types.ts' +import { CodeRunFailedError } from './errors.ts' +import type { ToolDefinition, ToolExecutionResult, ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from './session-types.ts' /** The model-facing name of the PTC mode tool. */ -export const RUN_CODE_NAME = 'run_code' +export { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -/** - * The language-specific `run_code` schema text: the tool `description` and its - * `code` parameter description, kept together so a language's two model-facing - * strings share one source of truth. Keyed by `PtcRuntime.language`, mirroring - * `SDK_RENDERERS` in {@link ./index.ts}. The emitted flavor MUST match the - * semantics the same language's SDK instructions promise, so the model never - * receives a TypeScript schema beside a Python SDK (or vice versa). - */ +/** Language-specific tool and code-parameter descriptions. */ interface RunCodeFlavor { /** The tool `description` the model sees for this language. */ readonly description: string @@ -69,23 +63,33 @@ const TYPESCRIPT_FUNCTION_FLAVOR = runCodeFlavor('TypeScript', { }) const PYTHON_FLAVOR = runCodeFlavor('Python') -/** - * The languages PTC mode ships a presentation for. Both per-language tables — - * {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are - * checked against this union with `satisfies`, so a language added to one and - * not the other fails `typecheck` instead of waiting for a runtime that reports - * it. The tables stay declared `Record` because `PtcRuntime.language` - * is an unconstrained `string`: this union pins what the harness ships, while the - * `Object.hasOwn` guards reject what a mounted runtime may report. - */ +/** Languages with both a transport schema and an SDK renderer. */ export type PtcSdkLanguage = 'typescript' | 'python' -/** Per-language `run_code` schema flavors (see {@link RunCodeFlavor}); one entry per {@link PtcSdkLanguage}. */ -const RUN_CODE_FLAVORS: Record = { +type SupportedPtcRuntime = PtcRuntime & { readonly language: PtcSdkLanguage } + +const RUN_CODE_FLAVORS = { typescript: TYPESCRIPT_FLAVOR, python: PYTHON_FLAVOR, } satisfies Record +/** + * Read the active runtime, rejecting missing providers and unsupported languages. + * @param ctx - executor context containing the runtime provider. + * @returns the runtime with a supported SDK language. + */ +export function requirePtcRuntime(ctx: Context): SupportedPtcRuntime { + const runtime = ctx.get('ptcRuntime') + if (runtime === undefined) { + throw new Error('dsh-tools: mode "ptc" requires a PTC runtime — load a ctx.ptcRuntime implementation (e.g. @deepseek-ai/dsh-ptc-runtime-node) or load @deepseek-ai/dsh-tool-executor-native') + } + if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language)) { + const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ') + throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) + } + return runtime as SupportedPtcRuntime +} + /** * The `description` parameter's model-facing description: language-independent * (the UI label contract is the same for every runtime), shared between the @@ -104,9 +108,7 @@ const RUN_CODE_CONTROLS = { justification: { type: 'string', description: 'Reason this complete program needs wider access, shown to the user for approval. Use the language of the user’s current request.' }, } as const -function controlParameters(runtime: PtcRuntime | undefined) { - // Catalog readers have no mounted runtime; real model assembly requires one. - if (runtime === undefined) return RUN_CODE_CONTROLS +function controlParameters(runtime: PtcRuntime) { return { ...runtime.timeout === undefined ? {} : { timeoutMs: { ...RUN_CODE_CONTROLS.timeoutMs, @@ -119,58 +121,17 @@ function controlParameters(runtime: PtcRuntime | undefined) { } } -function escalationGuidance(runtime: PtcRuntime | undefined): string { - return runtime?.sandboxMode === undefined ? '' +function escalationGuidance(mode: PtcRuntime['sandboxMode']): string { + return mode === undefined ? '' : ' A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically.' } -/** - * Resolve the {@link RunCodeFlavor} for the loaded runtime's language, read at - * schema-emission time so the model-visible `run_code` schema always matches - * the SDK section's language. `peekRuntime` returns `undefined` only when no - * runtime is mounted, which reaches this function through definition readers - * and `schemas()` — the doc-catalog harvest is the only shipped one, and none - * of them feeds a model, because `wireSchemas` calls `requirePtcRuntime` - * before projecting — so that path degrades to {@link TYPESCRIPT_FLAVOR}. A - * mounted runtime whose language has no flavor entry fails loud, exactly as - * `requirePtcRuntime` rejects it at assembly. Keeping this table in step with - * `SDK_RENDERERS` is the compiler's job ({@link PtcSdkLanguage}); what this - * guard owns is the runtime-supplied language neither table knows, which never - * yields a wrong-language schema for a real runtime. - */ -function resolveFlavor(peekRuntime: () => PtcRuntime | undefined): RunCodeFlavor { - const runtime = peekRuntime() - if (runtime === undefined) { - // No runtime mounted: reached by definition readers and `schemas()`, of - // which the doc-catalog harvest is the only shipped one. None feeds a - // model — `wireSchemas` calls `requirePtcRuntime` before projecting, so - // the assembly path never arrives here. Degrade to the TS default. - return TYPESCRIPT_FLAVOR - } - // Own-property read: a language like `toString`/`constructor` would otherwise - // resolve an inherited Object.prototype member as a flavor. - const flavor = RUN_CODE_FLAVORS[runtime.language] - if (!Object.hasOwn(RUN_CODE_FLAVORS, runtime.language) || flavor === undefined) { - const known = Object.keys(RUN_CODE_FLAVORS).map(name => JSON.stringify(name)).join(', ') - throw new Error(`dsh-tools: no run_code schema flavor registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) - } +/** Match schema text to the runtime language and program syntax. */ +function resolveFlavor(runtime: SupportedPtcRuntime): RunCodeFlavor { return runtime.language === 'typescript' && runtime.programFormat === 'function-body' - ? TYPESCRIPT_FUNCTION_FLAVOR : flavor + ? TYPESCRIPT_FUNCTION_FLAVOR : RUN_CODE_FLAVORS[runtime.language] } -/** - * Thrown by `run_code` when the program run itself failed — a program - * exception, a budget expiry, an abort, or substrate death. Extends - * {@link HarnessError} (`code: 'CODE_RUN_FAILED'`); the registry's execution - * pipeline converts it into a structured `isError` result whose text carries - * the failure kind plus the captured logs, so the model can self-correct. - */ -export class CodeRunFailedError extends HarnessError { - constructor(message: string) { - super(message, 'CODE_RUN_FAILED') - this.name = 'CodeRunFailedError' - } -} /** * Snapshot one binding call's argument as lossless JSON, then snapshot that @@ -288,47 +249,46 @@ function renderValue(value: JsonValue): string { /** Canonical value returned by the outer PTC mode transport. */ type RunCodeOutput = { logs: string[]; result?: JsonValue; sandbox?: PtcRunSandbox } -/** - * Registry-private capabilities the bridge receives at construction — the - * `requireRuntime` idiom: operations only the owning registry can mint stay - * off its public service API and flow here as closures instead. - */ -export interface RunCodeBridgeOptions { - /** Captures the calling Session current directory before program creation. */ - resolveWorkingDirectory: (exec: ToolRunContext) => Promise - /** Reads the approval channel when a program requests a wider sandbox mode. */ - peekApprover: () => ApprovalService | undefined - /** Resolves standing Session authority only for a runtime that enforces file policy. */ - resolveSandboxPolicy: (exec: ToolRunContext) => SandboxExecutionPolicy - /** Resolves `ctx.ptcRuntime` or throws the loud misconfiguration error (shared with the registry's assembly-time checks). */ - requireRuntime: () => PtcRuntime - /** - * Reads `ctx.ptcRuntime` without throwing: `undefined` when none is mounted. - * Lets schema emission tell "no runtime" (degrade to TS; the readers that - * reach it are {@link resolveFlavor}'s) apart from "unknown language" (fail - * loud). - */ - peekRuntime: () => PtcRuntime | undefined - /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ - maxParallel: number - /** Runs the contained `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ - shapeDispatchLog: (dispatch: PtcDispatchLog) => Promise +/** Unowned programs do not require Session directory state. */ +async function resolveWorkingDirectory(ctx: Context, exec: ToolRunContext): Promise { + if (exec.agent === undefined) return undefined + const directories = ctx.get('workingDirectory') + if (directories === undefined) throw new Error('dsh-tools: run_code with an Agent requires workingDirectory') + return directories.ensure(exec.agent, exec.signal) +} + +/** Resolve standing authority for a runtime that enforces file policy. */ +function resolveSandboxPolicy(ctx: Context, exec: ToolRunContext): SandboxExecutionPolicy { + const policy = ctx.get('sandboxPolicy') + if (policy === undefined) throw new Error('dsh-tools: confined PTC runtime requires sandboxPolicy') + return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) +} + +/** A failed log-content listener preserves the settled content and cannot fail the dispatch. */ +async function shapeDispatchLog(ctx: Context, dispatch: PtcDispatchLog): Promise { + try { + return await ctx.waterfall( + scopeTarget(ctx.tools, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, + () => Promise.resolve(dispatch.content), + ) + } catch (error: unknown) { + ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${toolErrorMessage(error)}; logging the original settled content`) + return dispatch.content + } } /** - * Build the `run_code` {@link ToolDefinition}: required `code` and - * `description` parameters, executed through the dispatch bridge described - * above. The - * registry reserves it as presentation infrastructure under non-native modes, - * outside the filterable global/scoped capability layers. - * @param registry - the owning registry (sub-calls go through its `execute`, - * bindings cover its registered tools). - * @param options - the registry-private capabilities described above. - * @param ctx - Owning registry context, independent of the calling Agent. - * @returns the registry-ready definition. + * Build the reserved `run_code` transport outside registered capabilities. + * @param ctx - executor context owning policy listeners and active program cleanup. + * @param executor - shared invocation pipeline for nested calls. + * @param maxParallel - maximum overlapping concurrency-safe subcalls. + * @returns the transport definition with runtime-dependent schema text. */ -export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeOptions, ctx: Context): ToolDefinition { - const { requireRuntime, peekRuntime, maxParallel, shapeDispatchLog } = options +export function createRunCodeTool( + ctx: Context, + executor: ToolExecutionPipeline, + maxParallel: number, +): ToolDefinition { const stateOwners = new WeakMap>() const calls = new PtcCalls(ctx) const definition = defineTool({ @@ -371,7 +331,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO const rendered = value.result === undefined ? '' : renderValue(value.result) const parts = [value.logs.join('\n'), rendered].filter(part => part.length > 0) if (value.sandbox?.enforcement === 'partial') parts.push('File sandbox enforcement is partial on this host.') - if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.${escalationGuidance(peekRuntime())}`) + if (value.sandbox?.denied) parts.push(`The ${value.sandbox.mode} file sandbox denied an operation.${escalationGuidance(ctx.get('ptcRuntime')?.sandboxMode)}`) return [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }] }, }, @@ -379,7 +339,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO if (args.description.trim().length === 0) { throw new Error('invalid description: expected a non-empty string') } - const runtime = requireRuntime() + const runtime = requirePtcRuntime(ctx) const run = async (callSignal: AbortSignal): Promise => { validateEscalationArgs(args.sandbox_permissions, args.justification) if (args.timeoutMs !== undefined && runtime.timeout === undefined) { @@ -388,7 +348,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) { throw new Error('invalid timeoutMs: expected a positive finite number') } - const standingPolicy = runtime.sandboxMode === undefined ? undefined : options.resolveSandboxPolicy(exec) + const standingPolicy = runtime.sandboxMode === undefined ? undefined : resolveSandboxPolicy(ctx, exec) let policy = standingPolicy if (args.sandbox_permissions !== undefined && args.justification !== undefined) { if (standingPolicy === undefined) throw new Error('sandbox_permissions is not available for this PTC runtime') @@ -398,12 +358,12 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO effectiveMode: standingPolicy.mode, subject: 'program', }, { - approver: options.peekApprover(), agent: exec.agent, callId: exec.callId, + approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId, toolName: RUN_CODE_NAME, signal: callSignal, }) policy = { ...standingPolicy, mode: approvedMode } } - const cwd = await options.resolveWorkingDirectory({ ...exec, signal: callSignal }) + const cwd = await resolveWorkingDirectory(ctx, { ...exec, signal: callSignal }) callSignal.throwIfAborted() // The run-scoped abort: follows the outer signal in, and fires when the @@ -418,7 +378,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // The per-run scheduler uses the registry's staged interface and follows // the same concurrency rules as the native loop. It also follows the // native loop's SEQUENCING: every ordered stage (the dispatch-start - // append, prepare = pre-execute/guards, finalize/finish = post-execute, + // append, prepare = pre-execute/guards, complete = post-execute, // context deferral, the settle append) runs inside ONE driver lane, so // ordered policy stages never overlap each other and only the // around-dispatch/body stage runs concurrently. Starts are strictly @@ -555,7 +515,6 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO signal: runController.signal, } type DispatchOutcome = { isError: true; message: string } | { isError: false; value: JsonValue } - const scheduler = registry[TOOL_RUNTIME_SCHEDULER] const outcome = await new Promise((resolve, reject) => { // Set by the dispatch stage (or start() for a pre-settled result): what commit() finalizes in submission order. let parked: @@ -577,7 +536,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // The listener may replace the durable copy with a preview and // locator; the program's value and model-visible result are // untouched. - const logged = await shapeDispatchLog({ + const logged = await shapeDispatchLog(ctx, { exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result // finalization; append snapshots the final copy again, so @@ -606,7 +565,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO settled: false, // Re-read per driver pass against the same agent view the SDK // declared; fail-closed exclusive when undeclared/invalid. - classify: () => registry.executionMode(input).kind, + classify: () => executor.executionMode(input).kind, abandon: () => { reject(new Error(`run_code run is over (${String(runController.signal.reason)}); ${name} tool call abandoned`)) }, @@ -621,9 +580,9 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // Ordered prepare runs INSIDE the driver lane: the next entry's // pre-execute waits for this resolution, as under the native // scheduler. Only the launched body below overlaps. - const prepared = await scheduler.prepare(input) + const prepared = await executor.prepare(input) if (prepared.kind === 'dispatch') { - this.flight = scheduler.dispatch(prepared.exec).then((dispatchOutcome) => { + this.flight = executor.dispatch(prepared.exec).then((dispatchOutcome) => { parked = { kind: dispatchOutcome.kind, exec: prepared.exec, result: dispatchOutcome.result } this.settled = true }) @@ -635,9 +594,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO async commit(): Promise { /* v8 ignore next -- commit() runs only after `settled` flipped, which set parked. */ if (parked === undefined) return - const result = parked.kind === 'post-result' - ? await scheduler.finalize(parked.exec, parked.result) - : scheduler.finish(parked.exec, parked.result) + const result = await executor.complete(parked.exec, parked) if (!result.isError && result.content.some(block => block.type === 'image')) { exec.deferContext(createUserMessage({ content: result.content, @@ -688,8 +645,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // restricted globals vanish) — the same view the SDK section declared, // so a program can bind exactly what its prompt promised; sub-dispatch // re-resolves per call through the same view (exec.agent threads down). - for (const schema of registry.schemas(exec.agent)) { - if (schema.name === RUN_CODE_NAME) continue + for (const schema of ctx.tools.schemas(exec.agent)) { Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(deepFreeze(schema)) }) } @@ -701,7 +657,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO let runtimes = stateOwners.get(agent) if (runtimes === undefined) stateOwners.set(agent, runtimes = new WeakSet()) // Cordis can wrap service returns repeatedly; cleanup belongs to the original provider. - let provider = runtime + let provider: PtcRuntime = runtime for (;;) { const original = (provider as PtcRuntime & { [symbols.original]?: PtcRuntime })[symbols.original] if (original === undefined) break @@ -737,7 +693,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : '' const sandboxText = result.sandbox === undefined ? '' : `\nFile sandbox: ${result.sandbox.mode}${result.sandbox.enforcement === undefined ? '' : `; enforcement: ${result.sandbox.enforcement}`}${result.sandbox.denied ? '; operation denied' : ''}.` - throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}${result.sandbox?.denied ? escalationGuidance(runtime) : ''}`) + throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}${sandboxText}${result.sandbox?.denied ? escalationGuidance(runtime.sandboxMode) : ''}`) } return { logs: result.logs, @@ -749,7 +705,7 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO } } return runtime.sandboxMode === undefined || exec.agent === undefined ? run(exec.signal) - : calls.run(exec.agent.session.id, exec.signal, () => options.resolveSandboxPolicy(exec).mode, run) + : calls.run(exec.agent.session.id, exec.signal, () => resolveSandboxPolicy(ctx, exec).mode, run) }, // The model-authored description is the call's always-visible UI label // (the bash `description` precedent); the program itself rides rawInput. @@ -763,30 +719,30 @@ export function createRunCodeTool(registry: ToolRuntime, options: RunCodeBridgeO // title and reads durable result content without duplicating a large raw // result into the host view payload. }) - // Resolve the language flavor lazily, at the moment the registry projects the - // schema (`schemaOf` destructures `description`/`parameters`). The definition - // is minted once at registration, before a runtime is known; deferring here - // is the least invasive point that still emits the loaded runtime's language. + // Read current runtime instructions and controls whenever the schema is projected. Object.defineProperty(definition, 'description', { enumerable: true, get: () => { - const runtime = peekRuntime() - const instructions = runtime?.executionInstructions - return resolveFlavor(peekRuntime).description + const runtime = requirePtcRuntime(ctx) + const instructions = runtime.executionInstructions + return resolveFlavor(runtime).description + (instructions ? ` ${instructions}` : '') - + (runtime === undefined ? '' : " Each program starts in the Session's current directory. Running programs keep their initial directory.") - + escalationGuidance(runtime) + + " Each program starts in the Session's current directory. Running programs keep their initial directory." + + escalationGuidance(runtime.sandboxMode) }, }) Object.defineProperty(definition, 'parameters', { enumerable: true, // Recompile through the same spec→schema projection defineTool used, so // the emitted schema always matches the validated specification. - get: () => parameterSchemaSpecToJsonSchema({ - code: { type: 'string', required: true, description: resolveFlavor(peekRuntime).codeDescription }, - description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, - ...controlParameters(peekRuntime()), - }), + get: () => { + const runtime = requirePtcRuntime(ctx) + return parameterSchemaSpecToJsonSchema({ + code: { type: 'string', required: true, description: resolveFlavor(runtime).codeDescription }, + description: { type: 'string', required: true, description: RUN_CODE_DESCRIPTION_PARAM_DESCRIPTION }, + ...controlParameters(runtime), + }) + }, }) return definition } diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tool-executor-ptc/src/py-types.ts similarity index 99% rename from packages/core/tools/src/py-types.ts rename to packages/core/tool-executor-ptc/src/py-types.ts index 65110f6b51..9634099bd3 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tool-executor-ptc/src/py-types.ts @@ -13,8 +13,8 @@ * @module @deepseek-ai/dsh-tools/src/py-types */ -import { assertSupportedJsonSchema } from './json-schema.ts' -import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode, JsonSchemaScalar } from '@deepseek-ai/dsh-tools' import type { ToolSdkSchema } from './ts-types.ts' /** @@ -759,7 +759,7 @@ The available tools:` * (the caller excludes `run_code` itself). * @returns the complete section text. */ -export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { +export function renderToolsSdkPy(schemas: readonly ToolSdkSchema[]): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) const state: RenderState = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) } // ONE ordered member stream, matching the documented lexicographic contract diff --git a/packages/core/tools/src/types.ts b/packages/core/tool-executor-ptc/src/session-types.ts similarity index 96% rename from packages/core/tools/src/types.ts rename to packages/core/tool-executor-ptc/src/session-types.ts index dc41c299a0..8047a127db 100644 --- a/packages/core/tools/src/types.ts +++ b/packages/core/tool-executor-ptc/src/session-types.ts @@ -1,7 +1,7 @@ /** - * Durable Tool event vocabulary shared with type-only consumers. + * Durable PTC dispatch events shared with type-only readers. * - * @module @deepseek-ai/dsh-tools/types + * @module @deepseek-ai/dsh-tool-executor-ptc/types */ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tool-executor-ptc/src/ts-types.ts similarity index 97% rename from packages/core/tools/src/ts-types.ts rename to packages/core/tool-executor-ptc/src/ts-types.ts index 1c1444e0b1..007c95c16d 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tool-executor-ptc/src/ts-types.ts @@ -7,12 +7,12 @@ */ import type { ToolSchema } from '@deepseek-ai/dsh-llm' -import { assertSupportedJsonSchema } from './json-schema.ts' -import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' +import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' +import type { JsonSchemaNode, JsonSchemaScalar } from '@deepseek-ai/dsh-tools' /** Internal PTC mode projection: the model-facing schema plus the canonical output schema. */ -export interface ToolSdkSchema extends ToolSchema { +export interface ToolSdkSchema extends Readonly { /** Validated canonical value returned by the tool binding. */ - output: JsonSchemaNode + readonly output: JsonSchemaNode } /** Property names that are valid bare TS identifiers; anything else is quoted. */ @@ -309,7 +309,7 @@ function renderBashExample(schemas: ToolSdkSchema[], programFormat: 'function-bo * @param programFormat - Runtime syntax; function bodies support returning a result, REPLs print it. * @returns the complete section text. */ -export function renderToolsSdk(schemas: ToolSdkSchema[], programFormat: 'function-body' | 'repl' = 'function-body'): string { +export function renderToolsSdk(schemas: readonly ToolSdkSchema[], programFormat: 'function-body' | 'repl' = 'function-body'): string { const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) const argsMembers: string[] = [] const outputMembers: string[] = [] diff --git a/packages/core/tool-executor-ptc/src/types.ts b/packages/core/tool-executor-ptc/src/types.ts new file mode 100644 index 0000000000..07d2c960c5 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/types.ts @@ -0,0 +1,55 @@ +/** Live PTC dispatch policy declarations; importing types does not mount the executor. @module */ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { ToolExecution, ToolsService } from '@deepseek-ai/dsh-tools' + +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. `content` is the RENDERED result projection + * (what a native `tool/result` would carry) — the program itself received + * the structured `value` (or just the error message on failure); only the + * `tool/ptc-dispatch` event's copy changes. + */ +export interface PtcDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Opaque sub-call id; new calls use `:ptc:`. */ + readonly subCallId: ToolCallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/ptc-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ + 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise + } +} + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** Images deferred from a successful PTC subcall's final result. */ + 'ptc-mode': { kind: 'ptc-mode' } + } +} diff --git a/packages/core/tools/tests/ptc-calls.spec.ts b/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts similarity index 100% rename from packages/core/tools/tests/ptc-calls.spec.ts rename to packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts similarity index 94% rename from packages/core/tools/tests/ptc.spec.ts rename to packages/core/tool-executor-ptc/tests/ptc.spec.ts index c1e4475140..c42b502e2a 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -1,3 +1,8 @@ +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import type { ToolExecutionInput } from '@deepseek-ai/dsh-tools' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' +import { PtcExecutor, CodeRunFailedError } from '../src/index.ts' +import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -10,10 +15,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import type { PtcRunRequest, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' -import ToolRuntime, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools' -import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineTool } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import type { JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import type { SessionEventMap } from '@deepseek-ai/dsh-session' import type { JsonValue } from '@deepseek-ai/dsh-util-values' @@ -58,7 +64,7 @@ class FakeRuntime extends PtcRuntime { } interface SetupOptions { - mode?: Config['mode'] + mode?: 'native' | 'ptc' maxParallelSubCalls?: number runtime?: false | { language?: string } toolOrder?: string[] @@ -68,13 +74,18 @@ async function setup(options: SetupOptions = {}) { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} }) - await ctx.plugin(ToolRuntime, { mode: options.mode ?? 'ptc', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} }) + await ctx.plugin(ToolRuntime) + const executor = options.mode === 'native' ? new NativeToolExecutor(ctx) : new PtcExecutor(ctx, { ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} }) + ctx.tools.setExecutor(executor) let runtime: FakeRuntime | undefined if (options.runtime !== false) { await ctx.plugin(FakeRuntime, options.runtime ?? {}) runtime = ctx.ptcRuntime as FakeRuntime } - return { ctx, tools: ctx.tools, systemPrompt: ctx.systemPrompt, runtime: runtime! } + return { ctx, tools: { + get: (name: string, scope?: ScopeKey) => executor instanceof PtcExecutor ? executor['get'](name, scope) : ctx.tools.get(name, scope), + execute: (input: ToolExecutionInput) => executeCall(ctx, input), + }, systemPrompt: ctx.systemPrompt, runtime: runtime! } } /** Mint an agent scope configured like production that can register scoped tool policy. */ @@ -110,23 +121,37 @@ function fakeAgent(): { agent: Agent; events: { type: string; data: unknown }[] const events: { type: string; data: unknown }[] = [] const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) + let seq = 0 const agent = { ctx, session: { header: { cwd: '/workspace' }, - append: (type: string, data: unknown) => { events.push({ type, data }) }, + append: (type: string, data: unknown) => { + if (type.startsWith('tool/ptc-')) events.push({ type, data }) + return { seq: SessionSeq(++seq) } + }, }, } as Agent return { agent, events } } -/** Dispatch run_code through the registry pipeline, as the loop would. */ +/** Invoke one raw call through the selected model executor. */ +async function executeCall(ctx: Context, input: ToolExecutionInput): Promise { + const batch = await ctx.tools.execute({ + calls: [{ type: 'tool-call', id: input.callId, name: input.name, arguments: JSON.stringify(input.arguments) }], + signal: input.signal, + ...input.agent === undefined ? {} : { agent: input.agent }, + }) + return batch.results[0]!.result +} + +/** Dispatch run_code through a single-call batch. */ async function runCode( ctx: Context, code: string, extras: { agent?: Agent; signal?: AbortSignal; description?: string } = {}, ): Promise { - return ctx.tools.execute({ + return executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('call-1'), name: RUN_CODE_NAME, @@ -173,8 +198,8 @@ it('logs detached frozen nested metadata without changing the binding value or l }) describe('mode-aware wire contribution', () => { - it('rejects an unsupported presentation mode in configuration', () => { - expect(() => ToolRuntime.Config({ mode: 'both' } as never)).toThrow() + it('rejects a non-positive PTC concurrency limit in configuration', () => { + expect(() => PtcToolExecutor.Config({ maxParallelSubCalls: 0 })).toThrow() }) it.each([ @@ -389,7 +414,7 @@ describe('mode-aware wire contribution', () => { expect(transports[0]?.description).toContain('Execute a TypeScript program') expect(assembly.sections.find(section => section.name === 'scoped-note')?.text).toBe('safe note') expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('scoped_safe:') - expect(ctx.tools.get(RUN_CODE_NAME, agent)).toBe(ctx.tools.get(RUN_CODE_NAME)) + expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) const result = await runCode(ctx, 'return 1', { agent }) expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }]) }) @@ -465,7 +490,7 @@ describe('mode-aware wire contribution', () => { it.each(['typescript', 'python'])('lists code before description for a %s runtime', async (language) => { const { ctx, systemPrompt } = await setup({ mode: 'ptc', runtime: { language } }) try { - const parameters = ctx.tools.get(RUN_CODE_NAME)!.parameters + const parameters = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)!.parameters expect(Object.keys(parameters.properties as Record).slice(0, 2)) .toEqual(['code', 'description']) expect(parameters).toHaveProperty('required', ['code', 'description']) @@ -477,7 +502,7 @@ describe('mode-aware wire contribution', () => { .toContain('arguments: `code`') // defineTool validates its captured static schema, independently of the runtime parameters getter. - const rejected = await ctx.tools.execute({ + const rejected = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('argument-order'), name: RUN_CODE_NAME, arguments: {}, }) expect(rejected).toHaveProperty('error.message', @@ -531,35 +556,19 @@ describe('mode-aware wire contribution', () => { expect(codeParam.description).toBe("The Python program to execute, following this tool's execution instructions.") }) - it('resolves the run_code schema flavor lazily and fails loud on a language absent from the flavor table', async () => { - // The flavor getter reads the runtime directly (peekRuntime), so it — not - // requirePtcRuntime — owns the flavor-table guard. Keeping - // RUN_CODE_FLAVORS in step with SDK_RENDERERS is the compiler's job (both - // are `satisfies`-checked against PtcSdkLanguage), so what the guard - // covers is a mounted runtime naming a language absent from both tables, - // which throws when the schema is projected. Assembly's - // requirePtcRuntime rejects such a language earlier; this reaches the - // guard on its own. - const { ctx } = await setup({ mode: 'ptc', runtime: { language: 'ruby' } }) - const definition = ctx.tools.get(RUN_CODE_NAME) - // Names the known languages, symmetric with the SDK_RENDERERS guard: this - // is the reachable rejection, so it must be at least as diagnosable. - expect(() => definition?.description) - .toThrow(/no run_code schema flavor registered for runtime language "ruby" \(known: "typescript", "python"\)/) + it.each(['ruby', 'constructor'])('rejects transport schema reads for unsupported runtime language %s', async (language) => { + const { ctx } = await setup({ mode: 'ptc', runtime: { language } }) + const definition = new PtcExecutor(ctx)['get'](RUN_CODE_NAME) + const message = `no SDK renderer registered for runtime language "${language}" (known: "typescript", "python")` + expect(() => definition?.description).toThrow(message) + expect(() => definition?.parameters).toThrow(message) }) - it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { - // Any reader of the definition without a mounted runtime uses this fallback; the - // shipped one is the tool-catalog generator, which boots the registry under - // `mode: ptc` and reads run_code's schema WITHOUT a runtime. peekRuntime - // returns undefined there, so the flavor getter degrades to the TS default - // rather than throwing. None of those readers feeds a model: assembly goes - // through wireSchemas, which requires a runtime first. + it('rejects transport schema reads when no runtime is mounted', async () => { const { ctx } = await setup({ mode: 'ptc', runtime: false }) - const definition = ctx.tools.get(RUN_CODE_NAME) - expect(definition?.description).toContain('Execute a TypeScript program') - const params = definition?.parameters as { properties: { code: { description: string } } } - expect(params.properties.code.description).toBe("The TypeScript program to execute, following this tool's execution instructions.") + const definition = new PtcExecutor(ctx)['get'](RUN_CODE_NAME) + expect(() => definition?.description).toThrow('requires a PTC runtime') + expect(() => definition?.parameters).toThrow('requires a PTC runtime') }) it("rejects the assembly when toolOrder names a native tool that mode 'ptc' no longer contributes", async () => { @@ -573,8 +582,9 @@ describe('mode-aware wire contribution', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(FakeRuntime, {}) - const fiber = await ctx.plugin(ToolRuntime, { mode: 'ptc' }) - expect(ctx.tools.get(RUN_CODE_NAME)).toBeDefined() + const fiber = await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) + expect(new PtcExecutor(ctx)['get'](RUN_CODE_NAME)).toBeDefined() await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() expect(assembly.tools).toEqual([]) @@ -1611,7 +1621,8 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + ctx.tools.setExecutor(new PtcExecutor(ctx)) const result = await runCode(ctx, 'program') expect(result.isError).toBe(true) expect((result.content[0] as { text: string }).text).toContain('requires a PTC runtime') @@ -1621,7 +1632,8 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() try { await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + ctx.tools.setExecutor(new PtcExecutor(ctx)) await ctx.plugin(FakeRuntime) const runtime = ctx.ptcRuntime as FakeRuntime const calls = registerEcho(ctx) @@ -1647,7 +1659,7 @@ describe('the run_code dispatch bridge', () => { it('presents the model-authored description as the execute-card title over the program input', async () => { const { ctx } = await setup({ mode: 'ptc' }) - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! // The description labels the card (the bash description precedent); the // program itself remains the expanded raw input. expect(tool.presentCall?.({ code: 'return 1', description: 'Return the constant one' })).toEqual({ @@ -1675,7 +1687,7 @@ describe('the run_code dispatch bridge', () => { runtime.behavior = () => Promise.resolve(output) const result = await runCode(ctx, 'return 1') - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! expect(result.content).toEqual([{ type: 'text', text }]) // Presenters keep the pending program title and render this durable content @@ -1694,7 +1706,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'return 1') - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! expect(result.content).toEqual([{ type: 'text', text: preview }]) expect('presentResult' in tool).toBe(false) @@ -1708,7 +1720,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'return 1') - const tool = ctx.tools.get(RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! expect(result.isError).toBe(true) expect(result.content).toEqual([{ @@ -1992,7 +2004,7 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - expect(() => new ToolRuntime(ctx, { mode: 'ptc', maxParallelSubCalls: 0 })) + expect(() => new PtcExecutor(ctx, { maxParallelSubCalls: 0 })) .toThrow('maxParallelSubCalls must be a positive integer') }) @@ -2000,16 +2012,19 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'ptc' }) - expect(registry.get(RUN_CODE_NAME)).toBeDefined() + await ctx.plugin(ToolRuntime) + const registry = new PtcExecutor(ctx) + expect(registry['get'](RUN_CODE_NAME)).toBeDefined() }) it('defaults to native mode under direct construction with no config', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx) - expect(registry.get(RUN_CODE_NAME)).toBeUndefined() + await ctx.plugin(ToolRuntime) + const registry = new NativeToolExecutor(ctx) + ctx.tools.setExecutor(registry) + expect(registry.describe(ctx.tools.definitions()).schemas.some(tool => tool.name === RUN_CODE_NAME)).toBe(false) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) @@ -2017,9 +2032,11 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + const registry = new PtcExecutor(ctx) registerEcho(ctx, 'write') - const result = await registry.execute({ + ctx.tools.setExecutor(registry) + const result = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('call-1'), name: 'write', @@ -2038,11 +2055,13 @@ describe('the run_code dispatch bridge', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + const registry = new PtcExecutor(ctx) registerEcho(ctx, 'write') const aborted = new AbortController() aborted.abort() - const result = await registry.execute({ + ctx.tools.setExecutor(registry) + const result = await executeCall(ctx, { signal: aborted.signal, callId: ToolCallId('call-1'), name: 'write', @@ -2066,7 +2085,7 @@ describe('per-agent presentation', () => { const calls = registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('ptc') + scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) const coded = await systemPrompt.assemble({ scope: agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) @@ -2074,7 +2093,7 @@ describe('per-agent presentation', () => { .toContain('echo') // Announced surface and callable surface must agree for THIS agent, whose // mode is its own rather than the deployment's. - const denied = await ctx.tools.execute({ + const denied = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('coded-direct'), name: 'echo', @@ -2097,12 +2116,12 @@ describe('per-agent presentation', () => { // The preset's standing scope declares once; the agent only PARENTS to it // (the per-preset standing mount configuration has no per-agent declaration). const standing = await mintAgentScope(ctx, 'preset:ptc-like') - standing.scope.ctx.tools.presentAs('ptc') + standing.scope.ctx.tools.setExecutor(new PtcExecutor(standing.scope.ctx)) const joined = await mintAgentScope(ctx, 'joined-agent') bindScopeParent(joined.agent, standing.agent) const loner = await mintAgentScope(ctx, 'loner-agent') - expect(ctx.tools.get(RUN_CODE_NAME, joined.agent)).toBeDefined() + expect((await ctx.systemPrompt.assemble({ scope: joined.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeDefined() const coded = await systemPrompt.assemble({ scope: joined.agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) // Through the EXECUTOR, not just the wire: the deployment default is @@ -2110,14 +2129,14 @@ describe('per-agent presentation', () => { // scope's effective mode would announce [run_code] and still execute the // native call — the bypass, reopened for exactly the preset composition // `dsh-agent-tool-presentation` produces. - expect(ctx.tools.executionMode({ + expect(new PtcExecutor(ctx)['pipeline'].executionMode({ signal: testToolSignal, callId: ToolCallId('preset-coded-schedule'), name: 'echo', arguments: { value: 'joined' }, agent: joined.agent, })).toEqual({ kind: 'exclusive' }) - const denied = await ctx.tools.execute({ + const denied = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('preset-coded-direct'), name: 'echo', @@ -2127,10 +2146,10 @@ describe('per-agent presentation', () => { expect(denied.error?.info).toEqual({ name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }) expect(calls).toEqual([]) // A sibling that never parented stays native, as does the global view. - expect(ctx.tools.get(RUN_CODE_NAME, loner.agent)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: loner.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeUndefined() const native = await systemPrompt.assemble({ scope: loner.agent }) expect(native.tools.map(tool => tool.name)).toEqual(['echo']) - const allowed = await ctx.tools.execute({ + const allowed = await executeCall(ctx, { signal: testToolSignal, callId: ToolCallId('native-sibling-direct'), name: 'echo', @@ -2146,13 +2165,13 @@ describe('per-agent presentation', () => { registerEcho(ctx) const coded = await mintAgentScope(ctx, 'coded') const plain = await mintAgentScope(ctx, 'plain') - coded.scope.ctx.tools.presentAs('ptc') + coded.scope.ctx.tools.setExecutor(new PtcExecutor(coded.scope.ctx)) // Not merely hidden from the prompt: the transport one agent presents must // not be dispatchable by another that never presented it. - expect(ctx.tools.get(RUN_CODE_NAME, coded.agent)).toBeDefined() - expect(ctx.tools.get(RUN_CODE_NAME, plain.agent)).toBeUndefined() - expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined() + expect((await ctx.systemPrompt.assemble({ scope: coded.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeDefined() + expect((await ctx.systemPrompt.assemble({ scope: plain.agent })).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeUndefined() + expect((await ctx.systemPrompt.assemble()).tools.find(tool => tool.name === RUN_CODE_NAME)).toBeUndefined() }) it('lets an agent opt out of a PTC mode deployment', async () => { @@ -2160,44 +2179,41 @@ describe('per-agent presentation', () => { registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('native') + scope.ctx.tools.setExecutor(new NativeToolExecutor(scope.ctx)) const assembly = await systemPrompt.assemble({ scope: agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) - // The deployment's global section still reaches this scope; rendering it - // empty is what keeps the opted-out agent's prompt free of an SDK. expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toBe('') + expect(assembly.sections.find(section => section.name === 'tools:ptc-only')?.text).toBe('') }) - it('restores the deployment default when the agent unloads', async () => { + it('rejects an unloaded executor without falling back to the deployment default', async () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - const dispose = scope.ctx.tools.presentAs('ptc') + const dispose = scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) dispose() - const assembly = await systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) - expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) + await expect(systemPrompt.assemble({ scope: agent })).rejects.toThrow('selected tool executor is unavailable') }) it('refuses a second declaration for the same agent', async () => { const { ctx } = await setup({ mode: 'native' }) const { scope } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('ptc') + scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) // Two answers to "which form does the model see" is a contradiction, and // silently keeping either one would make the composition unreadable. - expect(() => scope.ctx.tools.presentAs('native')) - .toThrow('conflicts with "ptc" already declared') + expect(() => scope.ctx.tools.setExecutor(new NativeToolExecutor(scope.ctx))) + .toThrow('Only one tool executor may be registered in each scope') }) - it('refuses an unscoped declaration', async () => { + it('refuses a second deployment default', async () => { const { ctx } = await setup({ mode: 'native' }) - expect(() => ctx.tools.presentAs('ptc')) - .toThrow('requires a scoped context') + expect(() => ctx.tools.setExecutor(new PtcExecutor(ctx))) + .toThrow('Only one tool executor may be registered in each scope') }) it('reserves run_code even where no agent presents it', async () => { @@ -2212,7 +2228,7 @@ describe('per-agent presentation', () => { const { ctx, systemPrompt } = await setup({ mode: 'native', runtime: false }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('ptc') + scope.ctx.tools.setExecutor(new PtcExecutor(scope.ctx)) await expect(systemPrompt.assemble({ scope: agent })) .rejects.toThrow('mode "ptc" requires a PTC runtime') @@ -2318,9 +2334,9 @@ describe('per-program execution controls', () => { }) it('advertises configured timeout values and one-execution scope only when supported', async () => { - const { ctx, tools } = await controlledSetup() + const { ctx, systemPrompt } = await controlledSetup() try { - const schema = tools.schemas().find(tool => tool.name === RUN_CODE_NAME)! + const schema = (await systemPrompt.assemble()).tools.find(tool => tool.name === RUN_CODE_NAME)! expect(JSON.stringify(schema.parameters)).toContain('Default 120000; capped at 600000') expect(JSON.stringify(schema.parameters)).toContain('sandbox_permissions') expect(schema.description).toContain('Nested tools retain their own policies') @@ -2329,7 +2345,7 @@ describe('per-program execution controls', () => { } finally { await ctx.fiber.dispose() } const python = await setup({ runtime: { language: 'python' } }) try { - const schema = python.tools.schemas().find(tool => tool.name === RUN_CODE_NAME)! + const schema = (await python.systemPrompt.assemble()).tools.find(tool => tool.name === RUN_CODE_NAME)! expect(JSON.stringify(schema.parameters)).not.toContain('timeoutMs') expect(JSON.stringify(schema.parameters)).not.toContain('sandbox_permissions') expect(schema.description).not.toContain('Programs start with an empty environment.') @@ -2503,7 +2519,7 @@ describe('PTC state ownership', () => { const format = vi.spyOn(runtime, 'programFormat', 'get').mockReturnValue(programFormat) const release = vi.spyOn(runtime, 'release') try { - expect(ctx.tools.schemas().find(tool => tool.name === 'run_code')?.description).not.toContain('persistent-in-memory') + expect(new PtcExecutor(ctx).describe([]).schemas.find(tool => tool.name === 'run_code')?.description).not.toContain('persistent-in-memory') expect((await runCode(ctx, 'let value = 1', { agent })).isError).toBe(false) expect((await runCode(ctx, 'value', { agent })).isError).toBe(false) expect(runtime.lastRequest?.stateOwner).toBe(agent) @@ -2519,7 +2535,7 @@ describe('PTC state ownership', () => { const format = vi.spyOn(runtime, 'programFormat', 'get').mockReturnValue('repl') const instructions = vi.spyOn(runtime, 'executionInstructions', 'get').mockReturnValue('Each call evaluates Python expressions in a fresh environment.') try { - const description = ctx.tools.schemas().find(tool => tool.name === 'run_code')?.description + const description = new PtcExecutor(ctx).describe([]).schemas.find(tool => tool.name === 'run_code')?.description expect(description).toContain('Each call evaluates Python expressions in a fresh environment.') expect(description).not.toContain('persistent-in-memory') expect(description).not.toContain('TypeScript') diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tool-executor-ptc/tests/py-types.spec.ts similarity index 99% rename from packages/core/tools/tests/py-types.spec.ts rename to packages/core/tool-executor-ptc/tests/py-types.spec.ts index 2b988194fa..14aa722300 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tool-executor-ptc/tests/py-types.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tools/src/py-types.ts' +import { jsonSchemaToPy, renderToolsSdkPy } from '@deepseek-ai/dsh-tool-executor-ptc/src/py-types.ts' import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' -import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' describe('jsonSchemaToPy', () => { it('maps the defineTool DSL subset', () => { diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tool-executor-ptc/tests/ts-types.spec.ts similarity index 98% rename from packages/core/tools/tests/ts-types.spec.ts rename to packages/core/tool-executor-ptc/tests/ts-types.spec.ts index 818e1fa423..2ec79e974c 100644 --- a/packages/core/tools/tests/ts-types.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ts-types.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts' -import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools/src/json-schema.ts' import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' diff --git a/packages/core/tool-executor-ptc/tsconfig.json b/packages/core/tool-executor-ptc/tsconfig.json new file mode 100644 index 0000000000..307c0de803 --- /dev/null +++ b/packages/core/tool-executor-ptc/tsconfig.json @@ -0,0 +1,57 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../ptc-runtime/ptc-runtime" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, + { + "path": "../scope" + }, + { + "path": "../session" + }, + { + "path": "../tools" + }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../util/values" + }, + { + "path": "../../session/working-directory" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../agent" + } + ] +} diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index c896879886..27c8c392a3 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -18,21 +18,23 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, - "./types": { - "types": "./lib/types/types.d.ts", - "default": "./lib/types/types.js" - }, "./presentation": { "types": "./lib/types/presentation.d.ts", "default": "./lib/types/presentation.js" }, "./src/*": "./src/*", "./package.json": "./package.json", - "./locale/*.json": "./locale/*.json" + "./locale/*.json": "./locale/*.json", + "./pipeline": { + "types": "./lib/types/pipeline.d.ts", + "default": "./lib/pipeline.js" + } }, "files": [ "locale/*.json", "lib/index.js", + "lib/pipeline.js", + "lib/shared-*.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" ], @@ -40,15 +42,11 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-sandbox-policy": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*" + "@deepseek-ai/dsh-user-approval": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", @@ -58,16 +56,14 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", - "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-sandbox-policy": "workspace:*", - "@deepseek-ai/dsh-working-directory": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*" } } diff --git a/packages/core/tools/src/batch.ts b/packages/core/tools/src/batch.ts new file mode 100644 index 0000000000..ea744a9d8b --- /dev/null +++ b/packages/core/tools/src/batch.ts @@ -0,0 +1,280 @@ +/** + * Schedules one assistant step's tool calls. Exclusive calls form barriers; + * parallel calls use a bounded rolling pool and are reclassified before start. + * Dispatch may overlap, while policy, results, and result context remain + * model-ordered. Abort or an internal scheduler failure stops replenishment + * and drains started calls. + * + * Abort records synthetic error results for skipped calls so replay stays + * valid. A terminal scheduler failure rejects after draining; the owning step + * records conservative recovery results before closing. + * @module dsh-tools/batch + */ + +import { ToolBatchExecutionError } from './executor.ts' +import { TOOL_ABORTED_BEFORE_DISPATCH } from './contracts.ts' +import type { ToolBatchInput, ToolBatchResult, ToolCallResult } from './executor.ts' +import { createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' +import type { SessionSeq, UserMessage } from '@deepseek-ai/dsh-session' +import type { ToolExecutionInput, ToolExecutionMode, ToolExecutionResult, ToolRunContext } from './contracts.ts' +import type { ScheduledToolDispatch, ToolExecutionPipeline } from './pipeline.ts' +import { assertNever } from '@deepseek-ai/dsh-util-values' + +/** One tool call after argument parsing, ready to schedule. */ +interface PlannedCall { + block: ToolCallBlock + exec: ToolExecutionInput +} + +/** Settled dispatch awaiting model-order finalization. */ +type Slot = ScheduledToolDispatch & { exec: ToolRunContext } + +/** One scheduler group outcome, including a drained cancellation. */ +interface GroupOutcome { + consumed: number + aborted: boolean + /** Whether any committed result carried {@link ToolExecutionResult.concludesTurn}. */ + concluded: boolean +} + +/** + * Execute raw calls with ordered policy and results around overlapping tool bodies. + * Abort drains started calls and synthesizes results for skipped calls. Internal + * failures drain before rejecting with the already committed results and context. + * @param input - raw calls, optional concurrency override, and optional Session owner. + * @param pipeline - executor-owned invocation implementation. + * @param maxParallel - reads the executor's current validated limit when input has no override. + * @returns model-ordered outcomes after every started body has settled. + * @throws RangeError when the explicit concurrency override is not a positive integer. + */ +export async function executeToolBatch( + input: ToolBatchInput, + pipeline: ToolExecutionPipeline, + maxParallel: () => number, +): Promise { + const { calls: toolCalls, signal, agent, maxParallelToolCalls } = input + if (maxParallelToolCalls !== undefined && (!Number.isInteger(maxParallelToolCalls) || maxParallelToolCalls < 1)) { + throw new RangeError('maxParallelToolCalls must be a positive integer') + } + const settlement = new BatchSettlement(input) + try { + // Inputs are distinct because tools/execute wrappers may replace `exec.signal`. + const planned: PlannedCall[] = toolCalls.map(block => ({ + block, + exec: { + callId: block.id, + name: block.name, + arguments: parseArguments(block.arguments), + ...agent === undefined ? {} : { agent }, + signal, + }, + })) + + let next = 0 + let concluded = false + while (next < planned.length) { + // Commit before classifying again so registry changes affect unstarted calls. + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition + const first = planned[next]! + const mode = pipeline.executionMode(first.exec).kind + const group = mode === 'parallel' ? planned.slice(next) : [first] + const outcome = await runGroup( + pipeline, settlement, maxParallelToolCalls ?? maxParallel(), group, mode, signal, + ) + next += outcome.consumed + concluded ||= outcome.concluded + if (outcome.aborted) { + for (const call of planned.slice(next)) settlement.skip(call.block) + return settlement.result(concluded) + } + } + return settlement.result(concluded) + } catch (error: unknown) { + throw new ToolBatchExecutionError(error, settlement.result()) + } +} + +/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */ +function parseArguments(raw: string): unknown { + try { + return raw ? JSON.parse(raw) : {} + } catch (_error: unknown) { + // Preserve malformed model input for tool-owned argument diagnostics. + return raw + } +} + +/** + * Run one exclusive barrier or parallel pool. Later calls are reclassified + * before start; an exclusive reclassification waits for the current pool to + * drain and remains for the caller's next barrier. Results and contexts commit + * in model order. Abort stops starts, drains and commits started calls, accepts + * their contexts into the owning batch, records results for skipped calls, and + * returns an aborted outcome. Scheduler failure drains dispatches and rejects + * for the owning step to record recovery results. + */ +async function runGroup( + pipeline: ToolExecutionPipeline, + settlement: BatchSettlement, + maxParallelToolCalls: number, + group: PlannedCall[], + mode: ToolExecutionMode['kind'], + signal: AbortSignal, +): Promise { + const slots: (Slot | undefined)[] = group.map(() => undefined) + // Started slots retain their `tool/call` seq so the result can cite it. + const callSeqs: Array = group.map(() => undefined) + let nextToStart = 0 + let committed = 0 + let started = 0 + let aborted: boolean = signal.aborted + let concluded = false + let schedulerFailure: { error: unknown } | undefined + const throwSchedulerFailure = (): void => { + if (schedulerFailure !== undefined) throw schedulerFailure.error + } + + // `committed` advances only across contiguous model-order slots. + const commitReady = async (): Promise => { + while (committed < group.length) { + const slot = slots[committed] + if (slot === undefined) break + const call = group[committed] + const result = await pipeline.complete(slot.exec, slot) + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index + settlement.commit(call!.block, result, callSeqs[committed]) + concluded ||= result.concludesTurn === true + committed++ + } + } + + const inFlight = new Map>() + + const startCall = async (index: number): Promise => { + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded index + const call = group[index]! + callSeqs[index] = settlement.start(call.block) + started++ + const prepared = await pipeline.prepare(call.exec) + throwSchedulerFailure() + switch (prepared.kind) { + case 'dispatch': { + const promise = pipeline.dispatch(prepared.exec).then( + (outcome) => { + slots[index] = { ...outcome, exec: prepared.exec } + return index + }, + (error: unknown) => { + schedulerFailure ??= { error } + return index + }, + ) + inFlight.set(index, promise) + break + } + case 'post-result': + case 'final-result': + slots[index] = prepared + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + assertNever(prepared, 'tool-call scheduler prepare result') + } + } + + const fillPool = async (): Promise => { + while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) { + // Re-read later modes after ordered commits so registry changes can create a barrier. + // oxlint-disable-next-line typescript/no-non-null-assertion -- bounded by the loop condition + const nextCall = group[nextToStart]! + if (nextToStart > 0 && mode === 'parallel' + && pipeline.executionMode(nextCall.exec).kind !== 'parallel') break + await startCall(nextToStart) + nextToStart++ + throwSchedulerFailure() + await commitReady() + throwSchedulerFailure() + // Abort may arrive while pre-execute awaits. + if (signal.aborted) aborted = true + } + } + + // Ordered pre-execute may await; only dispatch/body overlaps. A scheduler + // failure stops new dispatches and reaches the turn boundary after every + // already-started dispatch settles. + try { + await fillPool() + while (inFlight.size > 0) { + const settledIndex = await Promise.race(inFlight.values()) + inFlight.delete(settledIndex) + throwSchedulerFailure() + await commitReady() + throwSchedulerFailure() + // Abort may arrive while a tool or ordered commit awaits. + + if (signal.aborted) aborted = true + await fillPool() + } + } catch (error: unknown) { + schedulerFailure ??= { error } + await Promise.allSettled(inFlight.values()) + throw schedulerFailure.error + } + + if (aborted) { + // Started calls and accepted context settle first; every remaining model + // call then receives an ordered synthetic result before the turn aborts. + for (const call of group.slice(started)) settlement.skip(call.block) + return { consumed: group.length, aborted: true, concluded } + } + /* v8 ignore next -- unreachable: a non-aborted group commits every started call */ + if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls') + return { consumed: started, aborted: false, concluded } +} + +/** Collect committed results and optionally append their durable Session events. */ +class BatchSettlement { + private readonly results: ToolCallResult[] = [] + private readonly contexts: UserMessage[] = [] + + constructor(private readonly input: ToolBatchInput) {} + + start(block: ToolCallBlock): SessionSeq | undefined { + if (this.input.log === undefined) return undefined + const { agent, log: { turn, step } } = this.input + // oxlint-disable-next-line typescript/no-non-null-assertion -- ToolBatchInput requires agent whenever log is provided + return agent!.session.append('tool/call', { + turn, step, callId: block.id, name: block.name, arguments: block.arguments, + }).seq + } + + commit(block: ToolCallBlock, result: ToolExecutionResult, callSeq: SessionSeq | undefined): void { + if (this.input.log !== undefined) { + const { agent, log: { turn, step } } = this.input + // oxlint-disable-next-line typescript/no-non-null-assertion -- ToolBatchInput requires agent whenever log is provided + agent!.session.append('tool/result', { + turn, step, + message: createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError }), + ...result.error?.info ? { error: result.error.info } : {}, + ...result.meta !== undefined ? { meta: result.meta } : {}, + }, { surfaceOp: 'append', ...callSeq === undefined ? {} : { sourceEventSeqs: [callSeq] } }) + } + this.results.push({ callId: block.id, result }) + this.contexts.push(...result.additionalContexts ?? []) + } + + skip(block: ToolCallBlock): void { + this.commit(block, { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, + }, this.start(block)) + } + + result(concluded = this.results.some(({ result }) => result.concludesTurn === true)): ToolBatchResult { + return { results: this.results, additionalContexts: this.contexts, concluded } + } +} diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts new file mode 100644 index 0000000000..abf652fd9e --- /dev/null +++ b/packages/core/tools/src/contracts.ts @@ -0,0 +1,341 @@ +/** Tool definitions and invocation data shared by registries and executors. @module */ +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import type { JsonSchemaNode } from './json-schema.ts' +import type { ToolCallView, ToolResultView } from './presentation.ts' + +/** Reserved model-facing program transport name. */ +export const RUN_CODE_NAME = 'run_code' +declare const toolExecutionTokenBrand: unique symbol + +/** Tool-owned canonical output contract used after the body returns a JSON value. */ +export interface ToolOutputDefinition { + /** Raw supported JSON Schema enforced against every successful canonical value. */ + readonly schema: JsonSchemaNode + /** Pure projection from validated arguments and value to Native/model content. */ + render(args: unknown, value: JsonValue): ContentBlock[] + /** Pure replayable presentation projection for native and nested calls. */ + presentationMeta?(args: unknown, value: JsonValue): JsonValue +} + +/** A registered tool: its schema plus the execution function. */ +export interface ToolDefinition extends ToolSchema { + /** Mandatory canonical output declaration. */ + readonly output: ToolOutputDefinition + /** + * Run one accepted call and return only its canonical lossless-JSON value. + * Async work must observe or forward `exec.signal` and settle only after its + * owned work reaches quiescence. The executor preserves caller cancellation + * through around-dispatch signal replacement and does not abandon this + * promise, but it cannot hard-kill same-process code. + * @param args - losslessly snapshotted, frozen model arguments. + * @param exec - execution identity, cancellation signal, and context deferral. + * @returns the canonical value declared by `output.schema`. + */ + execute(args: unknown, exec: ToolRunContext): Promise + /** + * Install execution-prepared content before `tools/post-execute` policies. + * The callback is captured when the call starts and runs once for a + * normalized outcome entering post-execute. Policy replacements remain + * authoritative; pipeline failures that bypass post-execute skip projection. + * @param exec - immutable execution identity and arguments. + * @param result - normalized result before post-execute policy. + * @returns replacement content, or undefined to preserve the renderer output. + */ + projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined + /** + * Synchronous last-mile transform for model-facing content. The executor + * snapshots this callback when execution starts and invokes it exactly once + * for every normalized outcome, including pipeline failures that bypass + * `tools/post-execute`, immediately before lossless materialization. + * Returning `undefined` preserves the content; every other result field + * remains executor-owned. The callback must be total and must not throw. + * @param exec - immutable execution identity and arguments. + * @param result - complete normalized outcome before materialization. + * @returns replacement content, or `undefined` to preserve it. + */ + finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined + /** + * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. + * Enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` (a `tools/execute` wrapper); it + * is NEVER sent to the model — `schemas()` whitelists only name/description/ + * parameters. Declaring it asserts this tool forwards `exec.signal` to a + * cooperative implementation that can reach quiescence when the signal aborts. + */ + timeoutMs?: number + /** + * Pure synchronous classifier for overlap with sibling tool calls. Only + * `true` opts in; omission, exceptions, non-`true` returns, and invalid + * `defineTool` arguments are exclusive. This metadata is never model-visible. + * + * Opted-in executions must not mutate parent-owned state. Shared state must + * tolerate concurrent dispatch; recorder races are permitted only when they + * commute or fail closed. See the + * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full contract. + * @param args - parsed arguments; `defineTool` validates before calling. + * @returns Whether this call may join a parallel group. + */ + isConcurrencySafe?(args: unknown): boolean + /** + * Optional: how to present the PENDING state of one call in a UI, derived from + * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows + * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), + * or `undefined` (or omit the method) to fall back to a generic presentation + * (title = tool name, raw args as input). Pure and side-effect-free: a UI may + * call it during live streaming AND a session-log replay, so it must depend + * only on `args`. + */ + presentCall?(args: unknown): ToolCallView | undefined + /** + * Optional: how to present the COMPLETED state, given the same `args` and the + * durable result projection (`content`, failure state, and optional `meta`). Returns a + * {@link ToolResultView}, or `undefined` (or omit the method) to keep the + * pending title and render the raw result content. Pure and side-effect-free + * for the same replay reason. + */ + presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined +} + +/** The completed outcome handed to {@link ToolDefinition.presentResult}. */ +export interface ToolResult { + /** The final model-facing content (or the rendered error text on failure). */ + content: ContentBlock[] + /** Whether the call failed. */ + isError: boolean + /** + * The tool-private presentation payload projected by its output declaration. + * It is persisted on `tool/result` or `tool/ptc-dispatch` for Host presenters + * and Client renderers to narrow independently. Absent when the tool + * declared no projector. + */ + meta?: JsonValue +} + +/** Opaque call identity that permits correlation without exposing mutable execution state. */ +export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } + +/** + * Parsed input to the shared invocation pipeline. The pipeline + * adds its own token to form a {@link ToolExecution}; + * callers do not choose that token. + */ +export interface ToolExecutionInput { + readonly callId: ToolCallId + /** + * Root model-requested call owning this execution tree. Callers omit it for + * a root execution; nested dispatchers propagate the enclosing value. + */ + readonly rootCallId?: ToolCallId + readonly name: string + /** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */ + readonly schema?: ToolSchema + /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ + readonly arguments: unknown + /** The agent on whose behalf the call runs (set by the agent loop). */ + readonly agent?: Agent + /** + * Opaque token of the enclosing transport execution, when one exists. PTC + * mode sets this on SDK sub-dispatches so commit-style observers can wait for + * the outer `run_code` outcome without receiving its live mutable execution. + * The token also marks the call as a transport sub-dispatch rather than a + * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may + * execute a native tool name — a model-direct call (no parent) is denied as + * `UNKNOWN_TOOL` before the policy pipeline. + */ + readonly parent?: ToolExecutionToken + /** Required caller-owned cancellation for this invocation. */ + readonly signal: AbortSignal +} + +/** + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. + */ +export type ToolExecutionMode = + | { kind: 'parallel' } + | { kind: 'exclusive' } + +/** + * One pending tool call inside the executor pipeline. Parsed arguments cross + * one lossless-JSON materialization boundary before policy and are deep-frozen; + * call identity, the caller signal, and the executor-assigned {@link token} are + * readonly. The executor freezes the complete object before `tools/result` + * observers run. + */ +export interface ToolExecution extends ToolExecutionInput { + /** Root model-requested call, resolved for every root and nested execution. */ + readonly rootCallId: ToolCallId + /** Executor-assigned identity shared with nested calls only as their opaque `parent` token. */ + readonly token: ToolExecutionToken +} + +/** + * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper + * may replace the signal for its delegated lifetime, but it cannot remove it. + * The executor fuses every replacement with the captured caller signal. + */ +export interface ToolDispatchExecution extends Omit { + /** Cancellation signal visible to the next wrapper or tool body. */ + signal: AbortSignal +} + +/** + * Runtime context handed to a tool implementation after the executor has + * accepted a {@link ToolExecution}. {@link deferContext} attaches context to + * this execution's own result — a composite tool ferries nested-dispatch + * context back to the outer result, and a leaf tool may mint a fresh + * plugin-sourced instruction; the loop appends it only after the + * `tool/result`. + */ +export interface ToolRunContext extends ToolExecution { + /** + * Defer one context — typically a nested-dispatch context ferried by a + * composite tool, or a fresh plugin-sourced instruction — until this tool's + * final result reaches the agent loop. Contexts retain their individual + * source and metadata and are emitted in call order. + */ + deferContext(context: UserMessage): void + /** + * Mark a successful final result as terminal for the current agent turn. + * The marker rides this execution's own result (`concludesTurn` exists only + * on {@link ToolExecutionSuccess}); a composite that dispatches nested + * calls forwards it from the nested result, exactly like + * `additionalContexts`, so only an authoritative nested success can + * conclude the enclosing run. + */ + concludeTurn(): void +} + +/** Canonical error code for cancellation after a tool body was invoked. */ +export const TOOL_ABORTED = 'ABORTED' + +/** Canonical error code for cancellation before a tool body was invoked. */ +export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' + +/** Structured error metadata for a failed tool call (alongside the model-facing text). */ +export interface ToolErrorInfo { + name: string + code: string + /** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */ + reason?: string +} + +/** Canonical failure detail; internal routing information remains optional. */ +export interface ToolFailure { + /** Human-readable failure message without the Native `Error: ` envelope. */ + message: string + /** Internal error class/code used by policy and durable diagnostics. */ + info?: ToolErrorInfo +} + +/** + * Thrown (internally) when the model requests a tool that isn't registered. + * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool + * failure is as routable as a tool-thrown one — retry/sandbox/replay code can + * distinguish it from a tool body's own error. + */ +export class ToolNotFoundError extends HarnessError { + /** + * @param toolName - the name the caller asked for. + * @param reachableFrom - how the model reaches this tool instead, when the + * name IS visible and only the presentation denies calling it directly. + * Omitted for a name that is registered nowhere. + */ + constructor(toolName: string, reachableFrom?: string) { + super( + reachableFrom === undefined + ? `unknown tool "${toolName}"` + : `unknown tool "${toolName}": ${reachableFrom}`, + 'UNKNOWN_TOOL', + ) + this.name = 'ToolNotFoundError' + } +} + +/** Thrown when a tool body or post-policy value violates its declared output. */ +export class ToolOutputError extends HarnessError { + /** Schema/value violations in validation order. */ + readonly violations: string[] + + constructor(toolName: string, violations: string[]) { + super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') + this.name = 'ToolOutputError' + this.violations = violations + } +} + +/** Successful canonical tool execution, including its Native/model projection. */ +export interface ToolExecutionSuccess { + readonly isError: false + /** Execution-local canonical value; deliberately omitted from durable events. */ + readonly value: JsonValue + readonly content: ContentBlock[] + readonly error?: never + readonly meta?: JsonValue + readonly additionalContexts?: UserMessage[] + /** The agent loop stops after committing this successful result batch. */ + readonly concludesTurn?: true +} + +/** Failed canonical tool execution; failures never carry a successful value. */ +export interface ToolExecutionFailure { + readonly isError: true + readonly error: ToolFailure + readonly value?: never + readonly content: ContentBlock[] + readonly meta?: JsonValue + readonly additionalContexts?: UserMessage[] + readonly concludesTurn?: never +} + +/** The discriminated, execution-local outcome of one tool call. */ +export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure + +/** + * Pre-dispatch decision. `allow` runs the call; `deny` materializes its + * model-facing reason and optional structured error identity; `cancel` selects + * the canonical cancellation result without presenting a policy denial; `ask` + * runs only after an approval service returns `allowed-once` and otherwise + * denies; its `reason` is the audited approval reason and its optional + * `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and + * presented. + */ +export type PreToolDecision = + | { kind: 'allow' } + | { kind: 'deny'; reason: string; info?: ToolErrorInfo } + | { kind: 'cancel' } + | { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } } + +/** + * Post-dispatch decision: accept, replace one projection, attach context for the + * next request, or block by turning corrective feedback into an error result. + */ +export type PostToolDecision = + | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } + | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } + | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } + +/** + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved PTC mode transport. + */ +export interface ToolRestriction { + /** Global tool names that stay visible; everything else is removed. */ + readonly allow?: readonly string[] + /** Global tool names removed from visibility. */ + readonly deny?: readonly string[] +} + +/** + * A monotonic execution guard evaluated after every `tools/pre-execute` + * listener and before the tool body. Returning a reason denies the call; + * returning `undefined` leaves it unchanged. Because guards have no allow + * result, listener ordering cannot turn a denial back into permission. + * @param execution - the identity-protected call after extensible pre-execute policy completed. + * @returns a final denial reason, or `undefined` to leave the call allowed. + */ +export type ToolGuard = (execution: Readonly) => string | undefined diff --git a/packages/core/tools/src/events.ts b/packages/core/tools/src/events.ts new file mode 100644 index 0000000000..cb890d2df7 --- /dev/null +++ b/packages/core/tools/src/events.ts @@ -0,0 +1,58 @@ +/** Tool contribution changes and shared execution policy events. @module */ +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { ToolExecution, ToolDispatchExecution, ToolExecutionResult, PreToolDecision, PostToolDecision } from './contracts.ts' +import type { ToolsService } from './index.ts' + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; + * `cancel` selects the canonical pre-dispatch cancellation result, and missing + * approval support turns `ask` into denial. Async gates must observe + * `exec.signal`; the registry rechecks cancellation after they settle but + * never abandons their promise. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the pending call (name, parsed arguments, caller agent). + * @mode waterfall + */ + 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise + /** + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. The registry re-fuses the original caller + * signal before the body, so replacement cannot detach caller cancellation; + * wrappers must still restore their signal and reach quiescence. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + * @mode waterfall + */ + 'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise + /** + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this waterfall as errors. Async + * listeners must observe `exec.signal`; after they settle, caller + * cancellation replaces only a successful accepted outcome with the code + * selected by whether the tool body was invoked. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ + 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise + /** + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode emit + */ + 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined + /** + * A tool or executor was registered or unregistered, or a scoped restriction changed. + * Unfiltered registry notification: scoped listeners receive changes from every + * scope because a global contribution can affect every agent's next assembly. + * @mode emit + */ + 'tools/change'(): void + } +} diff --git a/packages/core/tools/src/executor.ts b/packages/core/tools/src/executor.ts new file mode 100644 index 0000000000..c7a508870b --- /dev/null +++ b/packages/core/tools/src/executor.ts @@ -0,0 +1,97 @@ +/** Model tool descriptions and complete batch execution owned by plugins. @module */ +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { ToolCallBlock, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' +import type { ToolDefinition, ToolExecutionResult } from './contracts.ts' + +/** Model-call schemas produced by an executor. */ +export type ToolModelInput = Pick + +/** Executor-owned guidance registered through the system-prompt section API. */ +export interface ToolPromptSection extends Pick { + /** Static guidance or text generated from this step's visible capability definitions. */ + readonly text: string | ((tools: readonly ToolDefinition[]) => string) +} + +/** Raw model calls, with optional Agent ownership and model-step logging. */ +export interface ToolBatchInput { + readonly calls: readonly ToolCallBlock[] + readonly signal: AbortSignal + /** + * Positive integer overriding the executor's concurrency limit for this batch. + * Applies to concurrency-safe root calls; program subcall limits remain separate. + * Undefined uses the executor's configured limit. + */ + readonly maxParallelToolCalls?: number + /** + * Owning Agent supplies scoped tools and execution context, including outside a turn. + * Undefined for standalone calls outside an Agent, such as a service invocation. + */ + readonly agent?: Agent + /** + * Committed model step receiving root call/result events; requires `agent`. + * Undefined when `agent` is absent or for calls outside a model response, such + * as session initialization hooks. Without `log`, root call/result events are + * omitted while `agent`, when provided, still supplies scope and execution context. + */ + readonly log?: { + readonly turn: number + readonly step: number + } +} + +/** One settled result correlated with its original model call. */ +export interface ToolCallResult { + readonly callId: ToolCallId + readonly result: ToolExecutionResult +} + +/** Model-ordered outcomes and context accepted for the next step. */ +export interface ToolBatchResult { + readonly results: readonly ToolCallResult[] + readonly additionalContexts: readonly UserMessage[] + readonly concluded: boolean +} + +/** A plugin owns argument parsing, dispatch, scheduling, cancellation, and logging. */ +export interface ToolExecutor { + /** Guidance registered and disposed with this executor; absent means no executor-owned sections. */ + readonly sections?: readonly ToolPromptSection[] + /** + * Describe the model's callable tools. + * @param tools - currently visible capability definitions, after scoped restrictions. + * @returns schemas and names used to validate tool ordering. + */ + describe(tools: readonly ToolDefinition[]): ToolModelInput + /** + * Execute a complete batch. With log, commit root call/result events to the + * Agent's Session; without log, omit those events. Tool-owned events may still + * use the owning Agent's Session. Return + * one outcome per input call in input order, including skipped calls on abort. + * Never settle while started work is still running. Internal failures reject + * with ToolBatchExecutionError after draining, preserving committed outcomes + * and context; callers must stop dependent work and repair missing log results. + * An explicit maxParallelToolCalls takes precedence over executor configuration. + * @param input - original calls, cancellation, optional concurrency override, Agent and logging step. + * @returns settled results, deferred context, and whether a success concludes the turn. + */ + execute(input: ToolBatchInput): Promise +} + +/** A drained batch failed internally; only the included results are committed. */ +export class ToolBatchExecutionError extends Error { + constructor(cause: unknown, readonly partial: ToolBatchResult) { + super('Tool batch execution failed', { cause }) + this.name = 'ToolBatchExecutionError' + } +} + +/** The selected provider is missing or unloaded; another provider must not take its calls. */ +export class ToolExecutorUnavailableError extends HarnessError { + constructor() { + super('The selected tool executor is unavailable. Load an executor in this composition before running the agent.', 'TOOL_EXECUTOR_UNAVAILABLE') + this.name = 'ToolExecutorUnavailableError' + } +} diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index ca939d4dfb..ab301264c0 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,33 +1,15 @@ -/** - * Tool registry, model presentation modes, and pre/guard/around/post/result - * execution pipeline. - * @module @deepseek-ai/dsh-tools - */ - -import type {} from '@deepseek-ai/dsh-working-directory' +/** Tool definitions and scoped dispatch to plugin-owned executors. @module */ import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' -import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' -import type { ToolCallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' -import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { UserMessage } from '@deepseek-ai/dsh-session' -import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import type { PromptSection, ToolProviderResult } from '@deepseek-ai/dsh-system-prompt' -import type { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type {} from '@deepseek-ai/dsh-sandbox-policy' -// Type-only: makes `ctx.get('approval')` resolve to the ApprovalService -// augmentation. The seam stays optional at runtime — see `serviceAsk`. -import type {} from '@deepseek-ai/dsh-user-approval' -import type { ToolCallView, ToolResultView } from './presentation.ts' -import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' -import type { JsonSchemaNode } from './json-schema.ts' -import { createRunCodeTool, RUN_CODE_NAME } from './ptc.ts' -import type { PtcSdkLanguage } from './ptc.ts' -import { renderToolsSdk } from './ts-types.ts' -import type { ToolSdkSchema } from './ts-types.ts' -import { renderToolsSdkPy } from './py-types.ts' +import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeChainOf } from '@deepseek-ai/dsh-scope' +import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' +import { ToolExecutorUnavailableError } from './executor.ts' +import type { ToolExecutor, ToolBatchInput, ToolBatchResult } from './executor.ts' +import './events.ts' +import type { ToolSchema } from '@deepseek-ai/dsh-llm' +import { projectToolSchema } from './schema.ts' +import { assertSupportedJsonSchema } from './json-schema.ts' +import { RUN_CODE_NAME } from './contracts.ts' +import type { ToolDefinition, ToolRestriction, ToolExecution, ToolGuard } from './contracts.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -36,673 +18,10 @@ declare module '@deepseek-ai/dsh-llm' { } } -/** - * Language → SDK-section renderer. The registry looks up the loaded - * `ctx.ptcRuntime.language` in this table when assembling the `tools:sdk` - * section under a non-native mode; a runtime whose language is not a key - * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a - * new backend language is three parallel edits — a {@link PtcSdkLanguage} - * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `ptc.ts` for - * its `run_code` schema strings — plus the renderer function this table points - * at. The `satisfies` clause pins this table's key set to that union, which - * the flavor table is checked against too, so any of the three left out is a - * typecheck failure. What no check reaches is the prose that names the values - * instead of deriving them: the seam's `dsh-ptc-runtime` README pair, its - * `PtcRuntime.language` JSDoc, and `docs/subsystems/ptc-runtime.md` - * with its zh pair, plus this package's own README pair and the - * {@link Config.mode} JSDoc. - */ -/** - * The model-facing statement of the `ptc` collapse. Names the consequence - * (the call fails) and the route (inside the program), because a rule the - * model can only discover by being denied is one it corrects too late. - */ -const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` - -const SDK_RENDERERS: Record string> = { - typescript: renderToolsSdk, - python: renderToolsSdkPy, -} satisfies Record string> - -export { - defineTool, - valueSchemaSpecToJsonSchema, - parameterSchemaSpecToJsonSchema, - validateArgs, - ToolArgsError, - type ValueSchemaAnnotations, - type StringValueSchemaSpec, - type NumberValueSchemaSpec, - type IntegerValueSchemaSpec, - type BooleanValueSchemaSpec, - type NullValueSchemaSpec, - type ArrayValueSchemaSpec, - type ObjectValueSchemaSpec, - type JsonValueSchemaSpec, - type OneOfValueSchemaSpec, - type ValueSchemaSpec, - type ParameterPropertySpec, - type ParameterSchemaSpec, - type ParameterJsonSchema, - type InferValue, - type InferArgs, - type DefineToolOptions, -} from './schema.ts' - -export { - assertSupportedJsonSchema, - assertObjectJsonSchema, - validateJsonSchemaValue, - JsonSchemaError, - type JsonSchemaNode, - type ObjectJsonSchema, - type JsonSchemaType, - type JsonSchemaScalar, -} from './json-schema.ts' - -export type { PtcDispatchEventData, PtcDispatchStartEventData } from './types.ts' - -export { CodeRunFailedError, RUN_CODE_NAME } from './ptc.ts' -export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' -export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' -export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' - -// The render-intent vocabulary a tool declares via `presentCall`/`presentResult` -// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools` -// stays the single public API for tool producers and UI adapters. -export type { - ToolCallKind, - FileLocation, - FileDiff, - ReadFileLine, - ToolCallView, - GenericCallView, - TerminalCallView, - DiffCallView, - ToolResultView, - GenericResultView, - TerminalResultView, - DiffResultView, - SearchResultView, - SearchMatchesResultView, - SearchPathsResultView, - SearchFileMatches, - SearchLineMatch, - ReadResultView, - WebResultView, - WebSearchResultView, - WebFetchResultView, - WebSource, -} from './presentation.ts' - declare module '@deepseek-ai/cordis' { interface Context { - tools: ToolRuntime + tools: ToolsService } - - interface Events { - /** - * Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; - * `cancel` selects the canonical pre-dispatch cancellation result, and missing - * approval support turns `ask` into denial. Async gates must observe - * `exec.signal`; the registry rechecks cancellation after they settle but - * never abandons their promise. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - * @param exec - the pending call (name, parsed arguments, caller agent). - * @mode waterfall - */ - 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise - /** - * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns - * a normalized result; wrappers may change only `exec.signal`, while call - * identity remains immutable. The registry re-fuses the original caller - * signal before the body, so replacement cannot detach caller cancellation; - * wrappers must still restore their signal and reach quiescence. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). - * @mode waterfall - */ - 'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise - /** - * Accept, replace, enrich, or block a normalized dispatch result. `next()` - * accepts it unchanged; thrown tools still reach this waterfall as errors. Async - * listeners must observe `exec.signal`; after they settle, caller - * cancellation replaces only a successful accepted outcome with the code - * selected by whether the tool body was invoked. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. - * @param exec - the call that just ran (name, parsed arguments, caller agent). - * @param result - the dispatch outcome a listener may accept, replace, or block. - * @mode waterfall - */ - 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise - /** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/ptc-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall - */ - 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise - /** - * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. - * @param exec - the execution object that traversed the pipeline. - * @param result - a deep-frozen snapshot of the final returned result. - * @mode emit - */ - 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined - /** - * A tool was registered or unregistered, or a scoped restriction changed - * (the available tool set changed — possibly for one scope only). An - * UNFILTERED registry-subject notification, deliberately not scope-filtered - * dispatch: a global change concerns every agent's next assembly, so a - * scoped listener subscribing here sees every change, not just its own - * scope's. - * @mode emit - */ - 'tools/change'(): void - } -} - -/** Tool-owned canonical output contract used after the body returns a JSON value. */ -export interface ToolOutputDefinition { - /** Raw supported JSON Schema enforced against every successful canonical value. */ - readonly schema: JsonSchemaNode - /** Pure projection from validated arguments and value to Native/model content. */ - render(args: unknown, value: JsonValue): ContentBlock[] - /** Pure replayable presentation projection for native and nested calls. */ - presentationMeta?(args: unknown, value: JsonValue): JsonValue -} - -/** A registered tool: its schema plus the execution function. */ -export interface ToolDefinition extends ToolSchema { - /** Mandatory canonical output declaration. */ - readonly output: ToolOutputDefinition - /** - * Run one accepted call and return only its canonical lossless-JSON value. - * Async work must observe or forward `exec.signal` and settle only after its - * owned work reaches quiescence. The registry preserves caller cancellation - * through around-dispatch signal replacement and does not abandon this - * promise, but it cannot hard-kill same-process code. - * @param args - losslessly snapshotted, frozen model arguments. - * @param exec - execution identity, cancellation signal, and context deferral. - * @returns the canonical value declared by `output.schema`. - */ - execute(args: unknown, exec: ToolRunContext): Promise - /** - * Install execution-prepared content before `tools/post-execute` policies. - * The callback is captured when the call starts and runs once for a - * normalized outcome entering post-execute. Policy replacements remain - * authoritative; pipeline failures that bypass post-execute skip projection. - * @param exec - immutable execution identity and arguments. - * @param result - normalized result before post-execute policy. - * @returns replacement content, or undefined to preserve the renderer output. - */ - projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined - /** - * Synchronous last-mile transform for model-facing content. The registry - * snapshots this callback when execution starts and invokes it exactly once - * for every normalized outcome, including pipeline failures that bypass - * `tools/post-execute`, immediately before lossless materialization. - * Returning `undefined` preserves the content; every other result field - * remains registry-owned. The callback must be total and must not throw. - * @param exec - immutable execution identity and arguments. - * @param result - complete normalized outcome before materialization. - * @returns replacement content, or `undefined` to preserve it. - */ - finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined - /** - * Cooperative tool-call timeout budget in milliseconds. Omit for no deadline. - * Enforced by `@deepseek-ai/dsh-tool-call-timeout-policy` (a `tools/execute` wrapper); it - * is NEVER sent to the model — `schemas()` whitelists only name/description/ - * parameters. Declaring it asserts this tool forwards `exec.signal` to a - * cooperative implementation that can reach quiescence when the signal aborts. - */ - timeoutMs?: number - /** - * Pure synchronous classifier for overlap with sibling tool calls. Only - * `true` opts in; omission, exceptions, non-`true` returns, and invalid - * `defineTool` arguments are exclusive. This metadata is never model-visible. - * - * Opted-in executions must not mutate parent-owned state. Shared state must - * tolerate concurrent dispatch; recorder races are permitted only when they - * commute or fail closed. See the - * [parallel-tool-call Agent Note](../../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) - * for the full contract. - * @param args - parsed arguments; `defineTool` validates before calling. - * @returns Whether this call may join a parallel group. - */ - isConcurrencySafe?(args: unknown): boolean - /** - * Optional: how to present the PENDING state of one call in a UI, derived from - * the call's `args` (parsed arguments, `unknown` — the tool validates/narrows - * its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent), - * or `undefined` (or omit the method) to fall back to a generic presentation - * (title = tool name, raw args as input). Pure and side-effect-free: a UI may - * call it during live streaming AND a session-log replay, so it must depend - * only on `args`. - */ - presentCall?(args: unknown): ToolCallView | undefined - /** - * Optional: how to present the COMPLETED state, given the same `args` and the - * durable result projection (`content`, failure state, and optional `meta`). Returns a - * {@link ToolResultView}, or `undefined` (or omit the method) to keep the - * pending title and render the raw result content. Pure and side-effect-free - * for the same replay reason. - */ - presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined -} - -/** The completed outcome handed to {@link ToolDefinition.presentResult}. */ -export interface ToolResult { - /** The final model-facing content (or the rendered error text on failure). */ - content: ContentBlock[] - /** Whether the call failed. */ - isError: boolean - /** - * The tool-private presentation payload projected by its output declaration. - * It is persisted on `tool/result` or `tool/ptc-dispatch` for Host presenters - * and Client renderers to narrow independently. Absent when the tool - * declared no projector. - */ - meta?: JsonValue -} - -declare const toolExecutionTokenBrand: unique symbol - -/** Opaque call identity that permits correlation without exposing mutable execution state. */ -export type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } - -/** - * Caller-supplied description of one tool call. {@link ToolRuntime.execute} - * adds the registry-owned token to form a pipeline {@link ToolExecution}; - * callers do not choose that token. - */ -export interface ToolExecutionInput { - readonly callId: ToolCallId - /** - * Root model-requested call owning this execution tree. Callers omit it for - * a root execution; nested dispatchers propagate the enclosing value. - */ - readonly rootCallId?: ToolCallId - readonly name: string - /** Binding-time tool schema for a PTC inner call; frozen by its producer and never logged. */ - readonly schema?: ToolSchema - /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ - readonly arguments: unknown - /** The agent on whose behalf the call runs (set by the agent loop). */ - readonly agent?: Agent - /** - * Opaque token of the enclosing transport execution, when one exists. PTC - * mode sets this on SDK sub-dispatches so commit-style observers can wait for - * the outer `run_code` outcome without receiving its live mutable execution. - * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may - * execute a native tool name — a model-direct call (no parent) is denied as - * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. - */ - readonly parent?: ToolExecutionToken - /** Required caller-owned cancellation for this invocation. */ - readonly signal: AbortSignal -} - -/** - * Scheduling mode for one pending call. `parallel` may overlap with siblings; - * `exclusive` runs alone and forms an ordering barrier. - */ -export type ToolExecutionMode = - | { kind: 'parallel' } - | { kind: 'exclusive' } - -/** - * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, - * outer call identity), the sub-call identity, and the outcome whose durable - * copy a listener may reshape. `content` is the RENDERED result projection - * (what a native `tool/result` would carry) — the program itself received - * the structured `value` (or just the error message on failure); only the - * `tool/ptc-dispatch` event's copy changes. - */ -export interface PtcDispatchLog { - /** The outer `run_code` execution. */ - readonly exec: ToolExecution - /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ - readonly agent?: Agent - /** Opaque sub-call id; new calls use `:ptc:`. */ - readonly subCallId: ToolCallId - /** The dispatched sub-tool name. */ - readonly name: string - /** Whether the sub-call settled as an error. */ - readonly isError: boolean - /** The sub-call's complete model-facing content (the settle event's default payload). */ - readonly content: ContentBlock[] -} - -/** - * One pending tool call inside the registry pipeline. Parsed arguments cross - * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity, the caller signal, and the registry-assigned {@link token} are - * readonly. The registry freezes the complete object before `tools/result` - * observers run. - */ -export interface ToolExecution extends ToolExecutionInput { - /** Root model-requested call, resolved for every root and nested execution. */ - readonly rootCallId: ToolCallId - /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ - readonly token: ToolExecutionToken -} - -/** - * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper - * may replace the signal for its delegated lifetime, but it cannot remove it. - * The registry fuses every replacement with the captured caller signal. - */ -export interface ToolDispatchExecution extends Omit { - /** Cancellation signal visible to the next wrapper or tool body. */ - signal: AbortSignal -} - -/** - * Runtime context handed to a tool implementation after the registry has - * accepted a {@link ToolExecution}. {@link deferContext} attaches context to - * this execution's own result — a composite tool ferries nested-dispatch - * context back to the outer result, and a leaf tool may mint a fresh - * plugin-sourced instruction; the loop appends it only after the - * `tool/result`. - */ -export interface ToolRunContext extends ToolExecution { - /** - * Defer one context — typically a nested-dispatch context ferried by a - * composite tool, or a fresh plugin-sourced instruction — until this tool's - * final result reaches the agent loop. Contexts retain their individual - * source and metadata and are emitted in call order. - */ - deferContext(context: UserMessage): void - /** - * Mark a successful final result as terminal for the current agent turn. - * The marker rides this execution's own result (`concludesTurn` exists only - * on {@link ToolExecutionSuccess}); a composite that dispatches nested - * calls forwards it from the nested result, exactly like - * `additionalContexts`, so only an authoritative nested success can - * conclude the enclosing run. - */ - concludeTurn(): void -} - -/** Registry-owned live execution object; public pipeline views stay readonly. */ -type MutableToolRunContext = Omit & { signal: AbortSignal } - -/** - * Scheduler-only result after ordered pre-execute and guards. A `post-result` - * still receives post-execute; a `final-result` bypasses it. - * @internal - */ -export type ScheduledToolPreparation = - | { kind: 'dispatch'; exec: ToolRunContext } - | { kind: 'post-result'; exec: ToolRunContext; result: ToolExecutionResult } - | { kind: 'final-result'; exec: ToolRunContext; result: ToolExecutionResult } - -/** - * Scheduler-only dispatch result. A `post-result` still receives post-execute; - * a `final-result` already matches {@link ToolRuntime.execute} failure semantics. - * @internal - */ -export type ScheduledToolDispatch = - | { kind: 'post-result'; result: ToolExecutionResult } - | { kind: 'final-result'; result: ToolExecutionResult } - -/** - * Symbol-keyed scheduler view that keeps pre/post policy ordered while - * overlapping dispatch. Ordinary callers use {@link ToolRuntime.execute}; - * this is not a plugin extension point. - * @internal - */ -export interface ToolRuntimeScheduler { - /** Materialize input, run the ordered pre-execute/guard gate, and decide what stage follows. */ - prepare(exec: ToolExecutionInput): Promise - /** Run only the around-dispatch/body stage. */ - dispatch(exec: ToolRunContext): Promise - /** Run post-execute and definition-owned content finalization, then materialize and notify. */ - finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise - /** Run definition-owned content finalization, then materialize and notify without post-execute. */ - finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult -} - -/** - * Scheduler entry point omitted from the generated named service API. - * @internal - */ -export const TOOL_RUNTIME_SCHEDULER: unique symbol = Symbol('@deepseek-ai/dsh-tools.scheduler') - -/** Canonical error code for cancellation after a tool body was invoked. */ -export const TOOL_ABORTED = 'ABORTED' - -/** Canonical error code for cancellation before a tool body was invoked. */ -export const TOOL_ABORTED_BEFORE_DISPATCH = 'ABORTED_BEFORE_DISPATCH' - -/** Structured error metadata for a failed tool call (alongside the model-facing text). */ -export interface ToolErrorInfo { - name: string - code: string - /** Optional raw user-facing detail; durable projections preserve it but model-facing content does not include it. */ - reason?: string -} - -/** Canonical failure detail; internal routing information remains optional. */ -export interface ToolFailure { - /** Human-readable failure message without the Native `Error: ` envelope. */ - message: string - /** Internal error class/code used by policy and durable diagnostics. */ - info?: ToolErrorInfo -} - -/** - * Thrown (internally) when the model requests a tool that isn't registered. - * Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool - * failure is as routable as a tool-thrown one — retry/sandbox/replay code can - * distinguish it from a tool body's own error. - */ -export class ToolNotFoundError extends HarnessError { - /** - * @param toolName - the name the caller asked for. - * @param reachableFrom - how the model reaches this tool instead, when the - * name IS visible and only the presentation denies calling it directly. - * Omitted for a name that is registered nowhere. - */ - constructor(toolName: string, reachableFrom?: string) { - super( - reachableFrom === undefined - ? `unknown tool "${toolName}"` - : `unknown tool "${toolName}": ${reachableFrom}`, - 'UNKNOWN_TOOL', - ) - this.name = 'ToolNotFoundError' - } -} - -/** Thrown when a tool body or post-policy value violates its declared output. */ -export class ToolOutputError extends HarnessError { - /** Schema/value violations in validation order. */ - readonly violations: string[] - - constructor(toolName: string, violations: string[]) { - super(`tool "${toolName}" returned invalid output: ${violations.join('; ')}`, 'INVALID_TOOL_OUTPUT') - this.name = 'ToolOutputError' - this.violations = violations - } -} - -/** Convert one projector exception into the canonical invalid-output failure. */ -function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError { - return new ToolOutputError(toolName, [`output.${projector} failed: ${errorMessage(error)}`]) -} - -/** Snapshot one projector result before later durable-result materialization. */ -function snapshotProjection(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T { - try { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) { - throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`]) - } - return detached - } catch (error: unknown) { - if (error instanceof ToolOutputError) throw error - throw projectionError(toolName, projector, error) - } -} - -/** Snapshot one body or policy value into the canonical invalid-output failure class. */ -function snapshotToolValue(toolName: string, candidate: unknown): JsonValue { - try { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON']) - return detached as JsonValue - } catch (error: unknown) { - if (error instanceof ToolOutputError) throw error - throw new ToolOutputError(toolName, [`value snapshot failed: ${errorMessage(error)}`]) - } -} - -/** Successful canonical tool execution, including its Native/model projection. */ -export interface ToolExecutionSuccess { - readonly isError: false - /** Execution-local canonical value; deliberately omitted from durable events. */ - readonly value: JsonValue - readonly content: ContentBlock[] - readonly error?: never - readonly meta?: JsonValue - readonly additionalContexts?: UserMessage[] - /** The agent loop stops after committing this successful result batch. */ - readonly concludesTurn?: true -} - -/** Failed canonical tool execution; failures never carry a successful value. */ -export interface ToolExecutionFailure { - readonly isError: true - readonly error: ToolFailure - readonly value?: never - readonly content: ContentBlock[] - readonly meta?: JsonValue - readonly additionalContexts?: UserMessage[] - readonly concludesTurn?: never -} - -/** The discriminated, execution-local outcome of one tool call. */ -export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure - -/** - * Pre-dispatch decision. `allow` runs the call; `deny` materializes its - * model-facing reason and optional structured error identity; `cancel` selects - * the canonical cancellation result without presenting a policy denial; `ask` - * runs only after an approval service returns `allowed-once` and otherwise - * denies; its `reason` is the audited approval reason and its optional - * `displayReason` is the localized prompt text. Input rewriting is excluded because arguments are already logged and - * presented. - */ -export type PreToolDecision = - | { kind: 'allow' } - | { kind: 'deny'; reason: string; info?: ToolErrorInfo } - | { kind: 'cancel' } - | { kind: 'ask'; reason?: string; displayReason?: { readonly en: string; readonly [locale: string]: string } } - -/** - * Post-dispatch decision: accept, replace one projection, attach context for the - * next request, or block by turning corrective feedback into an error result. - */ -export type PostToolDecision = - | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] } - | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] } - | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] } - -/** - * Best-effort human-readable message from an arbitrary thrown value: Error - * instances use `.message`; non-Error objects with a string `message` - * property (e.g. `throw { message: 'denied' }`) use it too; everything else - * is stringified. - */ -function errorMessage(error: unknown): string { - try { - if (error instanceof Error) return error.message - if (typeof error === 'object' && error !== null - && 'message' in error && typeof error.message === 'string') { - return error.message - } - return String(error) - } catch { - // A hostile thrown value can trap `instanceof`, property access, or string - // coercion. Error normalization is the outermost safety boundary, so its - // fallback must itself be total. - return '' - } -} - -/** Derive one failure message from policy feedback without changing its rendered blocks. */ -function failureMessageFromContent(content: ContentBlock[]): string { - const text = content - .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) - .join('\n') - return text.length > 0 ? text : 'tool result blocked by post-execute policy' -} - -/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ -function materializePresentation(candidate: T): T { - const detached = snapshotJsonValue(candidate) - if (detached === undefined) { - throw new TypeError('tool result must be losslessly JSON-serializable') - } - return deepFreeze(detached) -} - -/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ -function errorInfo(error: unknown): ToolErrorInfo | undefined { - try { - return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined - } catch { - return undefined - } -} - -/** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'ptc' - -/** Plugin config: how the registered tools are presented to the model. */ -export interface Config { - /** - * Model presentation. `native` (default) sends every visible schema; `ptc` - * sends only `run_code` plus a generated SDK prompt and collapses the - * executor to the same surface (a model-direct call may only name - * `run_code`; `run_code` SDK sub-dispatches keep every visible tool). - * PTC mode requires a `ctx.ptcRuntime` whose `language` - * has a registered SDK renderer (TypeScript or Python) and fails prompt - * assembly when it is absent or has no renderer. Under `ptc`, native names - * in `toolOrder` are invalid. - */ - mode?: ToolPresentationMode - /** - * Concurrency cap for a `run_code` program's overlapping sub-calls - * (default 10, the loop scheduler's own default). Sub-calls follow the - * native scheduling contract — only calls whose tools classify - * concurrency-safe overlap; exclusive calls form barriers — so `1` - * restores strictly serial dispatch. Must be a positive integer. - */ - maxParallelSubCalls?: number -} - -/** - * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved PTC mode transport. - */ -export interface ToolRestriction { - /** Global tool names that stay visible; everything else is removed. */ - readonly allow?: readonly string[] - /** Global tool names removed from visibility. */ - readonly deny?: readonly string[] } /** One restriction compiled at registration for repeated live-global lookup. */ @@ -713,7 +32,7 @@ interface CompiledToolRestriction { /** One scope's complete registry view, derived in a single layer traversal. */ interface ToolView { - /** Visible definitions after restrictions, scoped shadowing, and transport insertion. */ + /** Visible definitions after restrictions, scoped shadowing, and inherited restrictions. */ readonly visible: ReadonlyMap /** Pre-restriction capability names used by prompt-order validation. */ readonly knownNames: ReadonlySet @@ -721,27 +40,10 @@ interface ToolView { readonly restrictableNames: ReadonlySet } -/** - * A monotonic execution guard evaluated after every `tools/pre-execute` - * listener and before the tool body. Returning a reason denies the call; - * returning `undefined` leaves it unchanged. Because guards have no allow - * result, listener ordering cannot turn a denial back into permission. - * @param execution - the identity-protected call after extensible pre-execute policy completed. - * @returns a final denial reason, or `undefined` to leave the call allowed. - */ -export type ToolGuard = (execution: Readonly) => string | undefined - /** One scope's complete tool-registry contribution. */ class ToolLayer implements ScopeLayer { readonly tools: NamedEntries readonly restrictions = new AnonymousEntries() - readonly guards = new AnonymousEntries() - /** - * Presentation this scope's agent declared for itself, shadowing the - * deployment default. One cell rather than an entry table: two answers to - * "which form does the model see" is a contradiction, not a merge. - */ - mode: ToolPresentationMode | undefined constructor(scope: ScopeKey | undefined) { this.tools = new NamedEntries(name => new Error(scope === undefined @@ -751,8 +53,7 @@ class ToolLayer implements ScopeLayer { /** Whether every contribution table in this aggregate layer is empty. */ isEmpty(): boolean { - return this.tools.isEmpty() && this.restrictions.isEmpty() && this.guards.isEmpty() - && this.mode === undefined + return this.tools.isEmpty() && this.restrictions.isEmpty() } /** Whether every compiled restriction in this layer admits a global tool name. */ @@ -764,303 +65,36 @@ class ToolLayer implements ScopeLayer { return true } - /** First monotonic denial from this layer's live guard registrations. */ - guardReason(exec: ToolExecution): string | undefined { - for (const guard of this.guards.values()) { - const reason = guard(exec) - if (reason !== undefined) return reason - } - return undefined - } } -/** Approval decision plus whether the approval channel reported cancellation. */ -interface ToolAskResolution { - readonly decision: Extract - readonly approvalCancelled: boolean +interface Registration { + executor: ToolExecutor | undefined } -/** Caller cancellation and dispatch state kept outside the around-wrapper view. */ -interface ToolCancellationState { - readonly callerSignal: AbortSignal - bodyInvoked: boolean +class GuardLayer { + readonly guards = new AnonymousEntries() + isEmpty(): boolean { return this.guards.isEmpty() } } -/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ -interface FusedToolSignal { - readonly signal: AbortSignal - dispose(): void -} - -/** Resolve the run_code overlap cap at the owning config boundary (direct construction bypasses the Loader schema). */ -function resolveMaxParallelSubCalls(value: number | undefined): number { - const maxParallelSubCalls = value ?? 10 - if (!Number.isInteger(maxParallelSubCalls) || maxParallelSubCalls < 1) { - throw new Error('maxParallelSubCalls must be a positive integer') - } - return maxParallelSubCalls -} - -/** - * Tool registry and execution pipeline. Scoped registrations shadow globals; - * one visibility resolver feeds presentation, lookup, and dispatch. - */ -export class ToolRuntime extends Service { +/** Concrete tool registry and scoped executor dispatcher. */ +export class ToolsService extends Service { static inject = ['systemPrompt'] - static Config: z = z.object({ - mode: z.union(['native', 'ptc'] as const).default('native'), - maxParallelSubCalls: z.natural().min(1).default(10), - }) + private readonly layers = new ScopedLayers(scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }) + private readonly scoped = new WeakMap() + private root: Registration | undefined + private readonly guards = new ScopedLayers(() => new GuardLayer(), () => {}) - /** Internal staged view consumed by `dsh-agent-loop`'s parallel scheduler. */ - readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler = { - prepare: exec => this.prepareScheduledExecution(exec), - dispatch: exec => this.dispatchScheduledExecution(exec), - finalize: (exec, result) => this.finalizeScheduledExecution(exec, result), - finish: (exec, result) => this.finishScheduledExecution(exec, result), - } - - /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ - private deferredContexts = new WeakMap() - /** Executions whose tool body declared the current turn complete. */ - private concludingExecutions = new WeakSet() - /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ - private cancellationStates = new WeakMap() - /** Definition-owned final content transform snapshotted before policy begins. */ - private contentFinalizers = new WeakMap() - /** Execution-prepared content installed before post-execute policy. */ - private contentProjectors = new WeakMap() - private readonly layers = new ScopedLayers( - scope => new ToolLayer(scope), - () => { this.ctx.emit('tools/change') }, - ) - /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */ - private readonly defaultMode: ToolPresentationMode - private readonly maxParallelSubCalls: number - /** - * Reserved presentation transport, kept outside the filterable registration - * layers. Built on first need rather than at construction: which agents run - * a PTC mode is no longer known when the service is constructed, and the - * transport owns its PTC call tracking and state-owner registrations. - */ - private ptcTransport: ToolDefinition | undefined - private readonly ptcContext: Context - - constructor(ctx: Context, config: Config = {}) { + constructor(ctx: Context) { super(ctx, 'tools') - this.ptcContext = ctx - // The schema already defaulted an omitted mode; the ?? narrows the - // optional-input type for direct (non-Loader) construction in tests. - this.defaultMode = config.mode ?? 'native' - this.maxParallelSubCalls = resolveMaxParallelSubCalls(config.maxParallelSubCalls) - ctx.systemPrompt.tools(context => this.wireSchemas(context.scope)) - if (this.defaultMode !== 'native') { - ctx.systemPrompt.section(this.collapseSection()) - ctx.systemPrompt.section(this.sdkSection()) - } + ctx.systemPrompt.tools(({ scope }) => { + const executor = this.selectExecutor(scope) + const view = this.view(scope) + const description = executor.describe([...view.visible.values()]) + return { ...description, knownNames: description.knownNames ?? [...view.knownNames] } + }) } - /** - * The prompt statement of the `ptc` executor collapse, registered wherever - * {@link sdkSection} is and rendering empty outside an effective `ptc`. - * - * Every tool contributes its own guidance section naming its tool, none of - * them qualify how that tool is reached, and they all render before the SDK. - * Without this the model reads a catalog of tools it is told to use and no - * statement that only `run_code` may be called, so it emits a native call, - * receives `UNKNOWN_TOOL` for a tool the prompt just declared, and concludes - * the deployment is inconsistent. Its order places the rule before that - * guidance rather than after it. - * - * Native scopes render no instruction. - * @returns the section registration. - */ - private collapseSection(): PromptSection { - return { - name: 'tools:ptc-only', - order: this.ctx.systemPrompt.getSectionOrder('PTC_ONLY'), - // The SAME predicate the executor denies by, so the prompt cannot state - // a rule the registry does not enforce (see `collapses`). - text: context => this.modeFor(context.scope) === 'ptc' ? PTC_ONLY_INSTRUCTION : '', - } - } - - /** - * The generated-SDK prompt section, registered globally by a PTC mode - * deployment and per scope by {@link presentAs}. - * - * The body regenerates from the CALLING scope, and renders empty for an - * agent presenting natively — an agent that opted out under a PTC mode - * deployment still sees the global registration, and an empty section is - * dropped from the rendered prompt. - * @returns the section registration. - */ - private sdkSection(): PromptSection { - return { - name: 'tools:sdk', - order: this.ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), - interpolate: false, - // Regenerate from the calling scope's visible tools in stable order. - text: (context) => { - const mode = this.modeFor(context.scope) - if (mode === 'native') return '' - const runtime = this.requirePtcRuntime(mode) - // Own-property read: a language like `toString`/`constructor` would - // otherwise resolve an inherited Object.prototype member as a renderer. - const render = SDK_RENDERERS[runtime.language] - /* v8 ignore next -- requirePtcRuntime rejects an unknown language before this runs. */ - if (render === undefined) throw new Error(`dsh-tools: no SDK renderer for ${runtime.language}`) - return render(this.sdkSchemas(context.scope), runtime.programFormat) - }, - } - } - - /** - * The presentation one scope's agent sees: its own declaration, else the - * deployment default. - * @param scope - the calling agent, or undefined for the global view. - * @returns the resolved presentation mode. - */ - private modeFor(scope?: ScopeKey): ToolPresentationMode { - // Nearest scope wins along the chain: a preset's standing declaration - // covers every agent parented under it, and an agent's own (were one ever - // declared) would override its preset's. The mode decides what the model - // SEES, which is exactly the class of fact the chain inherits. - const layers = this.layers.chainLayers(scope) - for (let index = layers.length - 1; index >= 0; index -= 1) { - const mode = layers[index]?.mode - if (mode !== undefined) return mode - } - return this.defaultMode - } - - /** - * The reserved `run_code` transport, built on first need. - * - * It never enters the global layer: per-agent restrictions must not remove - * it, and a scoped registration must not shadow it. The visibility resolver - * appends it after resolving the filterable global/scoped capability layers, - * and only for scopes whose mode actually presents it. - * @returns the shared transport definition. - */ - private requirePtcTransport(): ToolDefinition { - this.ptcTransport ??= createRunCodeTool(this, { - resolveWorkingDirectory: async (exec) => { - // Only Agent-owned PTC requires directory state; native registries - // and unowned programs can run without this service. - if (exec.agent === undefined) return undefined - const directories = this.ctx.get('workingDirectory') - if (directories === undefined) throw new Error('dsh-tools: run_code with an Agent requires workingDirectory') - return directories.ensure(exec.agent, exec.signal) - }, - requireRuntime: () => this.requirePtcRuntime(this.defaultMode), - peekApprover: () => this.ctx.get('approval'), - resolveSandboxPolicy: (exec) => { - const policy = this.ctx.get('sandboxPolicy') - if (policy === undefined) throw new Error('dsh-tools: confined PTC runtime requires sandboxPolicy') - return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) - }, - // The language-aware description/parameters getters read the runtime - // without demanding one, so a native-default process can still project - // the transport for an agent that chose code. - peekRuntime: () => this.ctx.get('ptcRuntime'), - maxParallel: this.maxParallelSubCalls, - shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), - }, this.ptcContext) - return this.ptcTransport - } - - /** - * Present the calling scope's tools in `mode` instead of the deployment - * default. Nearest scope on the chain wins, so a preset's standing - * declaration covers every agent joined under it. - * - * Scoped only, and one declaration per scope: this is how an agent preset - * composes PTC mode agents beside native ones in the same process, and a - * process-global override would be the `mode` config field instead. - * @param mode - the presentation the covered agents' models see. - * @returns the exact disposer that restores the deployment default. - */ - presentAs(mode: ToolPresentationMode): () => void { - const ctx = this.ctx - if (scopeOf(ctx) === undefined) { - throw new Error('tools.presentAs() requires a scoped context (agent.ctx): a context-global presentation is the `mode` config field on the tools row') - } - const dispose = ctx.effect(function* (this: ToolRuntime) { - yield this.layers.effect( - ctx, - (layer) => { - if (layer.mode !== undefined) { - throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`) - } - layer.mode = mode - return () => { layer.mode = undefined } - }, - { label: 'tools.presentAs()' }, - ) - // The SDK and collapse sections are per scope for the same reason the - // mode is. Under a deployment that already defaults to PTC mode this - // shadows the global registration with an identical body, which costs - // nothing and keeps one rule instead of a case analysis. - if (mode !== 'native') { - yield ctx.systemPrompt.section(this.collapseSection()) - yield ctx.systemPrompt.section(this.sdkSection()) - } - }.bind(this), 'tools.presentAs()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous composite teardown - return dispose - } - - /** - * Build one scope's wire schemas and names for prompt-order validation. - * Restrictions do not make known tools invalid, but a mode collapse does. - */ - private wireSchemas(scope?: ScopeKey): ToolProviderResult { - const view = this.view(scope) - const mode = this.modeFor(scope) - if (mode === 'native') { - const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - return { schemas, knownNames: [...view.knownNames] } - } - // Validate the runtime language BEFORE projecting schemas: schemaOf reads - // run_code's language-aware description/parameters getters, whose own - // flavor-table guard would otherwise surface first. This keeps the - // renderer-table rejection the canonical assembly-time error for a - // language with no SDK renderer. - this.requirePtcRuntime(mode) - const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - return { - schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), - knownNames: [RUN_CODE_NAME], - } - } - - /** - * Resolve the PTC runtime or throw the actionable misconfiguration error. - * Read at use time (assembly / run_code execution), NOT via static - * `inject`: an inject entry would hold `ctx.tools` — and every tool plugin - * behind it — hostage to a PTC runtime existing even under `mode: - * 'native'`. - * - * Assembly and `run_code` execution read separately, so the language is not - * bound to a request. Harmless while one published backend exists — both - * reads return the same flavor — but a reload that swapped in a second - * language between them would hand a program written against one SDK to the - * other. Binding it is deferred until a second backend ships (the first - * point it is testable). - */ - private requirePtcRuntime(mode: ToolPresentationMode): PtcRuntime { - const runtime = this.ctx.get('ptcRuntime') - if (!runtime) { - throw new Error(`dsh-tools: mode "${mode}" requires a PTC runtime — load a ctx.ptcRuntime implementation (e.g. @deepseek-ai/dsh-ptc-runtime-node) or set tools mode to "native"`) - } - if (!Object.hasOwn(SDK_RENDERERS, runtime.language)) { - const known = Object.keys(SDK_RENDERERS).map(name => JSON.stringify(name)).join(', ') - throw new Error(`dsh-tools: no SDK renderer registered for runtime language ${JSON.stringify(runtime.language)} (known: ${known})`) - } - return runtime - } /** * Register globally or in the calling agent scope. Scoped tools shadow @@ -1132,66 +166,16 @@ export class ToolRuntime extends Service { } /** - * Register a monotonic guard after the extensible `tools/pre-execute` - * waterfall. A plain-context guard applies globally; one registered through - * `agent.ctx` applies only to that agent. Any matching guard may deny by - * returning a reason, while no guard can force-allow a call another guard - * denied. The exact effect disposer is returned for ordered ownership and - * HMR cleanup. - * @param guard - synchronous check; a returned string denies the execution. - * @returns the exact disposer that unregisters the guard. - */ - guard(guard: ToolGuard): () => void { - return this.layers.effect( - this.ctx, - layer => layer.guards.append(guard), - { label: 'tools.guard()', notify: false }, - ) - } - - /** First monotonic denial from the global then the scope chain's guard layers, farthest first. */ - private guardReason(exec: ToolExecution): string | undefined { - const globalReason = this.layers.global.guardReason(exec) - if (globalReason !== undefined) return globalReason - if (exec.agent === undefined) return undefined - for (const layer of this.layers.chainLayers(exec.agent)) { - const reason = layer.guardReason(exec) - if (reason !== undefined) return reason - } - return undefined - } - - /** - * Resolve every registry fact one scope needs in one layer traversal. The - * visible map applies restrictions to the INHERITED surface, then the - * scope's own registrations and the reserved presentation transport; the - * other sets retain the pre-restriction facts needed by restriction and - * prompt-order validation. - * - * A restriction filters what a scope inherits — the global layer and every - * ancestor layer on its chain — and never what its OWN layer registers. - * That exemption is what a per-child capability filter has to keep intact: - * the delegation runtime registers a child's structured-output tool into the - * child's own layer, and a filter naming the capabilities the child may use - * must not strip the machinery it answers through. - * - * Reading the exempt set as "the global layer" instead of "not mine" held - * only while every model-facing tool sat in the host composition. Once - * presets moved them onto the agent plane they became an ANCESTOR - * contribution, so a child's filter silently stopped constraining anything - * it was given. + * Resolve visible tools and pre-restriction names. Restrictions across the + * scope chain intersect over inherited global and ancestor tools; the viewing + * scope's own registrations remain exempt. Nearer registrations shadow + * farther ones. Pre-restriction names support restriction and prompt-order validation. * @param scope - the viewing scope (the agent), or undefined for the global view. * @returns the complete derived view for that scope. */ private view(scope?: ScopeKey): ToolView { - // Scope-chain layers, farthest ancestor first, the exact scope last. const layers = this.layers.chainLayers(scope) - // Chain-blind on purpose: this is the ONE layer whose registrations the - // scope owns rather than inherits, and it is absent until the scope - // contributes something. const own = this.layers.peek(scope) - // Inherited surface, nearest ancestor last: a nearer scope's same-name - // entry shadows a farther one, and the global layer is the farthest. const inherited = new Map(this.layers.global.tools.entries()) for (const layer of layers) { if (layer === own) continue @@ -1203,26 +187,14 @@ export class ToolRuntime extends Service { for (const [name, definition] of inherited) { knownNames.add(name) restrictableNames.add(name) - // Restrictions intersect across the whole chain: any scope on it may - // mask an inherited name for everything nested inside it. if (layers.every(layer => layer.admits(name))) visible.set(name, definition) } - // The scope's own registrations last, shadowing an inherited name and - // outside the filter above. if (own !== undefined) { for (const [name, definition] of own.tools.entries()) { knownNames.add(name) visible.set(name, definition) } } - // Presentation infrastructure is resolved last and outside capability - // filtering. Registration rejects this reserved name, so the insertion is - // an invariant assertion as well as protection against future layer - // changes. Per scope: a native agent must not find `run_code` in its - // dispatch table because some other agent in the process presents it. - if (this.modeFor(scope) !== 'native') { - visible.set(RUN_CODE_NAME, this.requirePtcTransport()) - } return { visible, knownNames, restrictableNames } } @@ -1240,23 +212,12 @@ export class ToolRuntime extends Service { } /** - * Resolve the definition that MAY EXECUTE for a call, applying the mode - * collapse at the operation boundary that owns it. The registry view - * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc` - * may only name the reserved `run_code` transport, while a nested - * sub-dispatch (a `parent` token set — the `run_code` SDK calling a tool - * it bound) may call any visible tool. Denial surfaces as `UNKNOWN_TOOL` - * through the executor, matching an absent definition. - * @param name - the tool name as registered. - * @param scope - the viewing scope (the agent); omitted = the global view. - * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. - * @returns the definition that may run, or undefined when the call must be rejected. + * Read the visible capability definitions for descriptions and program bindings. + * @param scope - viewing Agent or preset; omitted selects the global view. + * @returns definitions after scoped shadowing and restrictions. */ - private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { - const tool = this.get(name, scope) - if (tool === undefined) return undefined - if (this.collapses(name, scope, nested)) return undefined - return tool + definitions(scope?: ScopeKey): readonly ToolDefinition[] { + return [...this.view(scope).visible.values()] } /** @@ -1266,728 +227,100 @@ export class ToolRuntime extends Service { * @returns one deep-cloned schema per visible tool. */ schemas(scope?: ScopeKey): ToolSchema[] { - return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true)) + return [...this.view(scope).visible.values()].map(definition => projectToolSchema(definition, true)) } - - /** Project visible callable tools onto the generated PTC mode SDK contract. */ - private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] { - return [...this.view(scope).visible.values()] - .filter(definition => definition.name !== RUN_CODE_NAME) - .map((definition): ToolSdkSchema => { - const output = snapshotJsonValue(definition.output.schema) - /* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */ - if (output === undefined) { - throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`) - } - return { - ...this.schemaOf(definition, true), - output, - } - }) - } - - /** Project one definition onto the model-facing schema fields. */ - private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema { - const { name, description, parameters, deferLoading } = definition - const detached = detachParameters ? snapshotJsonValue(parameters) : parameters - if (detached === undefined) { - throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`) - } - return { - name, - description, - parameters: detached, - ...deferLoading === true ? { deferLoading } : {}, - } - } - /** - * Classify a pending call through the caller's visible tool definition. Only - * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or - * throwing classifiers are exclusive. - * @param exec - call name, parsed arguments, and optional agent scope. - * @returns the fail-closed scheduling mode. + * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * @param executor - initialized provider owned by the contributing plugin. + * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ - executionMode(exec: ToolExecutionInput): ToolExecutionMode { - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } - try { - const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) - return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } - } catch { - return { kind: 'exclusive' } - } - } - - /** - * Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch - * and return the content the bridge should log on `tool/ptc-dispatch`. - * Contained: when a listener throws, the method logs the original settled - * content; that failure must not fail the dispatch or omit the settle event. Private: - * the ONE consumer is the `run_code` bridge this registry constructs, which - * receives it as a capability parameter (the `requireRuntime` idiom) — the - * waterfall, not this invoker, is the public extension point. - */ - private async shapeDispatchLog(dispatch: PtcDispatchLog): Promise { - try { - return await this.ctx.waterfall( - scopeTarget(this, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, - () => Promise.resolve(dispatch.content), - ) - } catch (error: unknown) { - this.ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) - return dispatch.content - } - } - - /** - * Whether the `ptc` mode collapse denies a model-direct call: only the - * reserved `run_code` transport may be named. Nested sub-dispatches (a - * `parent` token set) bypass the collapse. One home for the - * security-relevant predicate, shared by {@link resolveExecution} and - * {@link createExecution} so the two can never drift apart. - * - * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `ptc` - * by an agent preset under a native deployment is the composition - * `dsh-agent-tool-presentation` exists for, and reading the deployment default would - * leave exactly that agent uncollapsed — announcing one surface while - * executing another, which is the bypass this collapse closes. - * @param name - the tool name as registered. - * @param scope - the viewing scope whose effective presentation mode applies. - * @param nested - whether the call is a transport sub-dispatch, not a model-direct call. - */ - private collapses(name: string, scope: ScopeKey | undefined, nested: boolean): boolean { - return !nested && this.modeFor(scope) === 'ptc' && name !== RUN_CODE_NAME - } - - /** - * Execute through pre-policy, guards, around-dispatch, post-policy, - * definition-owned content finalization, and final notification. Tool and - * listener failures resolve as materialized error results; an invisible tool - * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen - * snapshot final observers receive. Cancellation - * arriving after entry and before final result materialization skips a - * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a - * successful started outcome with `ABORTED`; already-started work is still - * drained and may retain a tool-owned structured error. - * @param exec - the typed same-process call input. The registry assigns its - * correlation token before policy begins. - * @returns the materialized final result. - */ - async execute(exec: ToolExecutionInput): Promise { - return this.prepareExecution(exec, prepared => this.completeScheduledExecution(prepared)) - } - - private async completeScheduledExecution(prepared: ScheduledToolPreparation): Promise { - switch (prepared.kind) { - case 'dispatch': { - const dispatched = await this.dispatchScheduledExecution(prepared.exec) - return dispatched.kind === 'post-result' - ? await this.finalizeScheduledExecution(prepared.exec, dispatched.result) - : this.finishScheduledExecution(prepared.exec, dispatched.result) - } - case 'post-result': - return await this.finalizeScheduledExecution(prepared.exec, prepared.result) - case 'final-result': - return this.finishScheduledExecution(prepared.exec, prepared.result) - /* v8 ignore next -- closed-union exhaustiveness guard */ - default: - return assertNever(prepared, 'scheduled tool preparation') - } - } - - private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { - const deferredContexts: UserMessage[] = [] - const token = createExecutionToken() - const callId = exec.callId - const rootCallId = exec.rootCallId ?? callId - const name = exec.name - const agent = exec.agent - const parent = exec.parent - const signal = exec.signal - // Distinguish a mode-collapsed call (visible in the scope, denied only by - // the `ptc` collapse) from a genuinely unknown tool. A collapsed call is - // deterministically denied, so it terminates BEFORE the extensible policy - // pipeline: pre-execute listeners, approval `ask`, and guards must never - // observe — or worse, approve — a call that can only fail. An unknown tool - // keeps the historical dispatch-stage `UNKNOWN_TOOL` path so policy - // listeners still see every name that reaches the registry. - const visible = this.get(name, agent) - const collapsed = visible !== undefined && this.collapses(name, agent, parent !== undefined) - const concludingExecutions = this.concludingExecutions - const base = { - token, - callId, - rootCallId, - name, - signal, - ...agent !== undefined ? { agent } : {}, - ...parent !== undefined ? { parent } : {}, - ...exec.schema !== undefined ? { schema: exec.schema } : {}, - deferContext(context: UserMessage): void { - deferredContexts.push(context) - }, - concludeTurn(): void { - concludingExecutions.add(this as unknown as ToolExecution) - }, - } - // Capture the finalizer BEFORE argument materialization: the - // `finalizeContent` contract snapshots the callback when the call starts, - // and an arguments getter can replace or clear the registered callback - // during `snapshotJsonValue`. The collapse only decides whether the - // CAPTURED callback is retained: the pre-dispatch abort path keeps it - // (the cancellation contract routes aborted results through it — a getter - // that aborts mid-materialization before an invalid-args failure lands in - // the same retained path), while the `UNKNOWN_TOOL` denial and the - // invalid-args failure of a NON-ABORTED collapsed call drop it (the call - // could never execute). - const capturedFinalizer = visible?.finalizeContent?.bind(visible) - const capturedProjector = visible?.projectContent?.bind(visible) - const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined => - collapsed && !signal.aborted ? undefined : capturedFinalizer - try { - const detached = snapshotJsonValue(exec.arguments) - if (detached === undefined) { - throw new TypeError('tool execution arguments must be losslessly JSON-serializable') - } - const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } - this.deferredContexts.set(execution, deferredContexts) - this.contentFinalizers.set(execution, finalizerFor()) - if (!collapsed) this.contentProjectors.set(execution, capturedProjector) - this.cancellationStates.set(execution, { - callerSignal: signal, - bodyInvoked: false, - }) - if (collapsed) { - // The collapse denies the call before the policy pipeline, but a - // pre-dispatch abort still keeps the established cancellation - // contract: `prepare`'s caller-cancellation check is skipped for - // final-results, so honor the abort here instead of surfacing - // `UNKNOWN_TOOL` on an already-cancelled call. - if (signal.aborted) { - return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } - } - // The name IS visible here, so the denial carries the route the model - // must take instead. Without it the model reads a bare `unknown tool` - // for a tool the prompt just declared and concludes the deployment is - // broken rather than correcting itself. - return { - kind: 'final-result', - exec: execution, - result: toolErrorResult(new ToolNotFoundError( - name, - `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`, - )), - } - } - return { kind: 'ready', exec: execution } - } catch (error: unknown) { - const execution: MutableToolRunContext = { ...base, arguments: undefined } - this.contentFinalizers.set(execution, finalizerFor()) - return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } - } - } - - /** - * Run the ordered pre-execute and monotonic guard stages for the scheduler. - * @param input - the caller-supplied execution input. - * @returns the prepared execution plus the next scheduler stage. - * @internal - */ - private async prepareScheduledExecution(input: ToolExecutionInput): Promise { - return this.prepareExecution(input, prepared => prepared) - } - - private async prepareExecution( - input: ToolExecutionInput, - next: (prepared: ScheduledToolPreparation) => T | PromiseLike, - ): Promise { - const created = this.createExecution(input) - if (created.kind !== 'ready') return next(created) - const exec = created.exec - if (this.callerCancelled(exec)) { - return next({ kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() }) - } - try { - const carrier = scopeTarget(this, exec.agent) - const gate = await this.ctx.waterfall( - carrier, 'tools/pre-execute', exec, - () => Promise.resolve({ kind: 'allow' }), - ) - const askResolution = gate.kind === 'ask' - ? await this.serviceAsk(exec, gate) - : { decision: gate, approvalCancelled: false } - const { decision } = askResolution - if (this.callerCancelled(exec) && askResolution.approvalCancelled) { - return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) - } - if (decision.kind === 'cancel') { - return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) - } - const denialReason = decision.kind === 'allow' ? this.guardReason(exec) : decision.reason - const denialInfo = decision.kind === 'deny' ? decision.info : undefined - if (denialReason !== undefined) { - return await next({ - kind: 'post-result', - exec, - result: this.materializeFinalResult({ - content: [{ type: 'text', text: `Error: ${denialReason}` }], - isError: true, - error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } }, - }), + setExecutor(executor: ToolExecutor): () => void { + const scope = scopeOf(this.ctx) + const existing = scope === undefined ? this.root : this.scoped.get(scope) + if (existing?.executor !== undefined) throw new Error('Only one tool executor may be registered in each scope') + const record: Registration = { executor } + // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; preserve the Cordis disposer identity + return this.ctx.effect(function* (this: ToolsService) { + for (const section of executor.sections ?? []) { + yield this.ctx.systemPrompt.section({ + name: section.name, + order: section.order, + ...section.interpolate === undefined ? {} : { interpolate: section.interpolate }, + text: ({ scope }) => { + if (this.selectExecutor(scope) !== executor) return '' + return typeof section.text === 'function' ? section.text(this.definitions(scope)) : section.text + }, }) } - if (this.callerCancelled(exec)) { - return await next({ kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() }) + if (scope === undefined) this.root = record + else this.scoped.set(scope, record) + this.ctx.emit('tools/change') + yield () => { + // Keep an unavailable selection so a program cannot fall through to a different executor. + record.executor = undefined + this.ctx.emit('tools/change') } - return await next({ kind: 'dispatch', exec }) - } catch (error: unknown) { - return next({ kind: 'final-result', exec, result: toolErrorResult(error) }) - } - } - - /** Whether the original caller signal is currently aborted. */ - private callerCancelled(exec: ToolRunContext): boolean { - const state = this.cancellationStates.get(exec) - /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ - if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') - return state.callerSignal.aborted - } - - /** Canonical cancellation outcome selected by whether the tool body started. */ - private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult { - const state = this.cancellationStates.get(exec) - /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ - if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') - return state.bodyInvoked - ? toolAbortedResult(prior) - : toolAbortedBeforeDispatchResult(prior) + }.bind(this), 'tools.setExecutor()') } /** - * Dispatch the registered body with the original caller signal fused back - * into any around-wrapper replacement. Cancellation never abandons the body: - * a started promise reaches quiescence before its outcome becomes `ABORTED`. + * Resolve the nearest scoped provider, or the deployment default. + * @param scope - Agent or preset scope; omitted selects only the root contribution. + * @returns the selected executor; throws when it is missing or unloaded. */ - private async dispatchToolBody(exec: MutableToolRunContext): Promise { - const state = this.cancellationStates.get(exec) - /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ - if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') - const wrapperSignal = exec.signal - const fused = fuseToolSignals(state.callerSignal, wrapperSignal) - const signal = fused.signal - - if (isAborted(signal)) { - fused.dispose() - return toolAbortedBeforeDispatchResult() - } - exec.signal = signal - try { - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (!tool) throw new ToolNotFoundError(exec.name) - state.bodyInvoked = true - const returned = await tool.execute(exec.arguments, exec) - const result = this.createSuccessResult(exec, tool, returned) - return isAborted(signal) - ? toolAbortedResult(result) - : result - } catch (error: unknown) { - return toolErrorResult(error) - } finally { - fused.dispose() - exec.signal = wrapperSignal + private selectExecutor(scope?: ScopeKey): ToolExecutor { + let record: Registration | undefined + for (const key of scopeChainOf(scope)) { + record = this.scoped.get(key) + if (record !== undefined) break } + record ??= this.root + const executor = record?.executor + if (executor === undefined) throw new ToolExecutorUnavailableError() + return executor } /** - * Run around-dispatch and the tool body. Tool and unknown-tool failures still - * receive post-execute; pipeline failures are already final. - * @param exec - the prepared execution. - * @returns whether the result still needs post-execute. - * @internal + * Delegate a raw call batch to its Agent's provider or the deployment default. + * Select at dispatch; accepted batches settle through that provider even if it unloads. + * @param input - original calls, optional owning Agent, and optional logging step. + * @returns the selected executor's settled batch; unavailable selections reject. */ - private async dispatchScheduledExecution(exec: ToolRunContext): Promise { - try { - const mutableExec = exec as MutableToolRunContext - const carrier = scopeTarget(this, exec.agent) - const result = await this.ctx.waterfall( - carrier, 'tools/execute', mutableExec, - () => this.dispatchToolBody(mutableExec), - ) - const normalized = this.normalizeDispatchResult(exec, result) - const deferredContexts = this.deferredContexts.get(exec) - /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ - if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') - const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 - ? normalized - : this.markCanonical(exec, { - ...normalized, - additionalContexts: [ - ...deferredContexts, - ...normalized.additionalContexts ?? [], - ], - }) - return { - kind: 'post-result', - result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError - ? this.cancellationResult(exec, resultWithDeferredContexts) - : resultWithDeferredContexts, - } - } catch (error: unknown) { - return { kind: 'final-result', result: toolErrorResult(error) } - } + async execute(input: ToolBatchInput): Promise { + return this.selectExecutor(input.agent).execute(input) } /** - * Run ordered post-execute, then apply definition-owned content finalization, - * materialize, and notify the final outcome. - * @param exec - the prepared execution. - * @param result - dispatch/pre result that still needs post-execute. - * @returns the materialized final result. - * @internal + * Register a monotonic denial in the calling scope. + * @param guard - evaluated after extensible pre-execute policy by the shared pipeline. + * @returns the effect disposer that removes this guard. */ - private async finalizeScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): Promise { - try { - const project = this.contentProjectors.get(exec) - this.contentProjectors.delete(exec) - const content = project?.(exec, result) - const projected = content === undefined - ? result - : this.markCanonical(exec, this.materializeFinalResult({ ...result, content })) - const postResult = await this.postExecute(exec, projected) - return this.finishScheduledExecution( - exec, - this.callerCancelled(exec) && !postResult.isError - ? this.cancellationResult(exec, postResult) - : postResult, - ) - } catch (error: unknown) { - return this.finishScheduledExecution(exec, toolErrorResult(error)) - } + guard(guard: ToolGuard): () => void { + return this.guards.effect(this.ctx, layer => layer.guards.append(guard), { label: 'tools.guard()' }) } /** - * Materialize the candidate, apply definition-owned content finalization, - * then materialize and notify the authoritative result. - * @param exec - the prepared execution. - * @param result - final result. - * @returns the materialized final result. - * @internal + * Check global and inherited guards after pre-execute policy and approval resolve. + * @param exec - invocation whose guards are checked before dispatch. + * @returns the first denial reason, or undefined when no guard denies. */ - private finishScheduledExecution(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { - let materializedResult: ToolExecutionResult - try { - materializedResult = this.materializeFinalResult(result) - } catch (error: unknown) { - materializedResult = this.materializeFinalResult(toolErrorResult(error)) - } - let finalResult: ToolExecutionResult - try { - finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult)) - } catch (error: unknown) { - finalResult = this.materializeFinalResult(toolErrorResult(error)) - } - this.notifyResult(exec, finalResult) - return finalResult - } - - /** Apply the snapshotted tool-owned content transform without exposing other result fields. */ - private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { - const finalizeContent = this.contentFinalizers.get(exec) - if (finalizeContent === undefined) return result - const content = finalizeContent(exec, result) - return content === undefined ? result : { ...result, content } - } - - /** Notify observers without exposing a mutation or error channel into the outcome. */ - private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { - // Freeze the registry's live object before observers receive its readonly - // WeakMap-keyable view. - Object.freeze(exec) - const { name: toolName, callId } = exec - const reportFailure = (error: unknown): void => { - this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${errorMessage(error)}`) - } - const callbacks = this.ctx.events.dispatch('emit', [ - scopeTarget(this, exec.agent), 'tools/result', exec, result, - ]) - for (const callback of callbacks) { - try { - const returned: unknown = callback(exec, result) - void Promise.resolve(returned).catch(reportFailure) - } catch (error: unknown) { - reportFailure(error) + checkGuards(exec: ToolExecution): string | undefined { + for (const layer of [this.guards.global, ...this.guards.chainLayers(exec.agent)]) { + for (const guard of layer.guards.values()) { + const reason = guard(exec) + if (reason !== undefined) return reason } } + return undefined } - /** - * Resolve an `ask` decision to allow/deny through the approval seam. The - * seam is consumed opportunistically with `ctx.get('approval')` — a - * deployment that composes no ApprovalService keeps the historical degrade - * to deny, and an unmount mid-session degrades the same way on the next ask. - * An agent-less execution also degrades: without an agent there is no - * session to audit to and no UI to route to. Otherwise the outcome maps - * one-to-one — `allowed-once` proceeds; the three non-grants deny with - * distinct reasons so the model can tell a human "no" from an absent - * approval channel. - */ - private async serviceAsk( - exec: ToolExecution, - ask: Extract, - ): Promise { - const approval = this.ctx.get('approval') - if (approval === undefined) { - return { - decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, - approvalCancelled: false, - } - } - if (exec.agent === undefined) { - return { - decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, - approvalCancelled: false, - } - } - const outcome = await approval.request({ - agent: exec.agent, - toolName: exec.name, - callId: exec.callId, - ...ask.reason !== undefined ? { reason: ask.reason } : {}, - ...ask.displayReason !== undefined ? { displayReason: ask.displayReason } : {}, - signal: exec.signal, - }) - switch (outcome) { - case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } - case 'rejected': return { - decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, - approvalCancelled: false, - } - case 'cancelled': return { - decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, - approvalCancelled: true, - } - case 'unavailable': return { - decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, - approvalCancelled: false, - } - default: return assertNever(outcome, 'ApprovalOutcome') - } - } - - /** - * Run the `tools/post-execute` waterfall over a dispatched `result` and apply - * its {@link PostToolDecision}: `accept` keeps the call successful (replacing - * `content` when given), `block` turns it into an `isError` whose content is - * the corrective `feedback`. Either decision may attach `additionalContexts`, - * which are ferried on the returned result for the loop's active-batch FIFO. - * Context deferred by the tool body survives an accepted result but is - * discarded when the outer call is blocked; a block exposes only context the - * blocking decision explicitly supplied. - * Runs inside `execute`'s outer try/catch (a throwing listener → isError). - */ - private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { - const decision = await this.ctx.waterfall( - scopeTarget(this, exec.agent), 'tools/post-execute', exec, result, - () => Promise.resolve({ kind: 'accept' }), - ) - const decisionContexts = decision.additionalContexts ?? [] - if (decision.kind === 'block') { - const message = failureMessageFromContent(decision.feedback) - return this.markCanonical(exec, { - content: decision.feedback, - isError: true, - error: { message }, - ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, - }) - } - if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { - throw new TypeError('tools/post-execute accept decision cannot replace both value and content') - } - const additionalContexts = [ - ...result.additionalContexts ?? [], - ...decisionContexts, - ] - if (Object.hasOwn(decision, 'value')) { - if (result.isError) { - throw new TypeError('tools/post-execute cannot replace the value of a failed result') - } - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (tool === undefined) throw new ToolNotFoundError(exec.name) - const replaced = this.createSuccessResult(exec, tool, decision.value) - return this.markCanonical(exec, { - ...replaced, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - }) - } - return this.markCanonical(exec, { - ...result, - ...decision.content !== undefined ? { content: decision.content } : {}, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - }) - } - - /** Registry-normalized results and the exact dispatch that validated each value. */ - private readonly canonicalResults = new WeakMap() - - /** Mark one registry-normalized result as canonical only for its owning dispatch. */ - private markCanonical(exec: ToolExecution, result: T): T { - this.canonicalResults.set(result, exec.token) - return result - } - - /** Snapshot, validate, render, and optionally project one successful body value. */ - private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { - const detached = snapshotToolValue(tool.name, candidate) - const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') - if (violations.length > 0) throw new ToolOutputError(tool.name, violations) - const value = deepFreeze(detached) - let rendered: ContentBlock[] - try { - rendered = tool.output.render(exec.arguments, value) - } catch (error: unknown) { - throw projectionError(tool.name, 'render', error) - } - const content = snapshotProjection(tool.name, 'render', rendered) - let meta: JsonValue | undefined - if (tool.output.presentationMeta !== undefined) { - let projected: JsonValue - try { - projected = tool.output.presentationMeta(exec.arguments, value) - } catch (error: unknown) { - throw projectionError(tool.name, 'presentationMeta', error) - } - meta = snapshotProjection(tool.name, 'presentationMeta', projected) - } - const concludesTurn = this.concludingExecutions.has(exec) - return this.markCanonical(exec, this.materializeFinalResult({ - isError: false, - value, - content, - ...meta !== undefined ? { meta } : {}, - ...concludesTurn ? { concludesTurn: true as const } : {}, - }) as ToolExecutionSuccess) - } - - /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ - private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { - if (this.canonicalResults.get(result) === exec.token) return result - if (result.isError) { - return this.markCanonical(exec, { - isError: true, - error: result.error, - content: result.content, - ...result.meta !== undefined ? { meta: result.meta } : {}, - ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - }) - } - const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) - if (tool === undefined) throw new ToolNotFoundError(exec.name) - const normalized = this.createSuccessResult(exec, tool, result.value) - return this.markCanonical(exec, { - ...normalized, - ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - }) - } - - /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ - private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { - const presentation = { - content: result.content, - ...result.meta !== undefined ? { meta: result.meta } : {}, - ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, - } - if (result.isError) { - return materializePresentation({ isError: true as const, error: result.error, ...presentation }) - } - const detached = materializePresentation({ - isError: false as const, - ...presentation, - ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, - }) - return deepFreeze({ ...detached, value: result.value }) - } } -/** Mint a same-process correlation token whose identity is its value. */ -function createExecutionToken(): ToolExecutionToken { - return Symbol('dsh.tool.execution') as ToolExecutionToken -} +export default ToolsService -function toolErrorResult(error: unknown): ToolExecutionResult { - const info = errorInfo(error) - const message = errorMessage(error) - return { - content: [{ type: 'text', text: `Error: ${message}` }], - isError: true, - error: { message, ...info ? { info } : {} }, - } -} - -/** Read live abort state across an await without treating it as synchronously immutable. */ -function isAborted(signal: AbortSignal): boolean { - return signal.aborted -} - -/** - * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`. - * Keeping the relay dispatch-scoped also removes listeners when work settles. - */ -function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal { - if (caller === wrapper) return { signal: caller, dispose() {} } - - const controller = new AbortController() - let listening = false - const dispose = (): void => { - if (!listening) return - listening = false - caller.removeEventListener('abort', abortFromCaller) - wrapper.removeEventListener('abort', abortFromWrapper) - } - const abortFrom = (source: AbortSignal): void => { - const reason: unknown = source.reason - controller.abort(reason) - dispose() - } - const abortFromCaller = (): void => { abortFrom(caller) } - const abortFromWrapper = (): void => { abortFrom(wrapper) } - - if (wrapper.aborted) abortFromWrapper() - else if (caller.aborted) abortFromCaller() - else { - listening = true - caller.addEventListener('abort', abortFromCaller, { once: true }) - wrapper.addEventListener('abort', abortFromWrapper, { once: true }) - } - return { signal: controller.signal, dispose } -} - -/** Canonical result when cancellation supersedes success after body invocation. */ -function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { - const additionalContexts = prior?.additionalContexts ?? [] - return { - content: [{ type: 'text', text: 'Error: tool call aborted' }], - isError: true, - error: { - message: 'tool call aborted', - info: { name: 'AbortError', code: TOOL_ABORTED }, - }, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - } -} - -/** Canonical result when cancellation prevents tool body invocation. */ -function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult { - const additionalContexts = prior?.additionalContexts ?? [] - return { - content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], - isError: true, - error: { - message: 'tool call aborted before dispatch', - info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, - }, - ...additionalContexts.length > 0 ? { additionalContexts } : {}, - } -} - -export default ToolRuntime +export * from './contracts.ts' +export * from './executor.ts' +export * from './schema.ts' +export * from './json-schema.ts' +export * from './presentation.ts' +export { ToolsService as ToolRuntime } diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts new file mode 100644 index 0000000000..fad3b19f02 --- /dev/null +++ b/packages/core/tools/src/pipeline.ts @@ -0,0 +1,753 @@ +/** Shared tool invocation policy, cancellation, and result settlement. @module */ +import type { Context } from '@deepseek-ai/cordis' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { UserMessage } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-user-approval' +import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' +import { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' +import { validateJsonSchemaValue } from './json-schema.ts' +import type { ToolDefinition, ToolExecutionInput, ToolExecutionMode, ToolExecution, ToolRunContext, ToolExecutionResult, ToolExecutionSuccess, ToolErrorInfo, ToolExecutionToken, PreToolDecision, PostToolDecision } from './contracts.ts' + +/** Executor-owned lookup and restrictions applied before extensible execution policy. */ +export interface ToolExecutionSource { + /** Read the live definition visible to the calling scope. */ + get(name: string, scope?: ScopeKey): ToolDefinition | undefined + /** Reject an otherwise visible invocation before policy; omitted permits it. */ + reject?(name: string, nested: boolean): Error | undefined +} + +/** Prepared invocation; immediate results carry their post-execute requirement. */ +export type ScheduledToolPreparation = + | { kind: 'dispatch'; exec: ToolRunContext } + | ({ exec: ToolRunContext } & ScheduledToolDispatch) + +/** A `post-result` receives post-execute; a `final-result` bypasses it. */ +export type ScheduledToolDispatch = + | { kind: 'post-result'; result: ToolExecutionResult } + | { kind: 'final-result'; result: ToolExecutionResult } + +/** Registry-owned live execution object; public pipeline views stay readonly. */ +type MutableToolRunContext = Omit & { signal: AbortSignal } + +/** Approval decision plus whether the approval channel reported cancellation. */ +interface ToolAskResolution { + readonly decision: Extract + readonly approvalCancelled: boolean +} + +/** Caller cancellation and dispatch state kept outside the around-wrapper view. */ +interface ToolCancellationState { + readonly callerSignal: AbortSignal + bodyInvoked: boolean +} + +/** One dispatch-scoped fused signal plus listener cleanup after the body settles. */ +interface FusedToolSignal { + readonly signal: AbortSignal + dispose(): void +} + +/** + * Shared invocation stages for batch and program schedulers. Schedulers order + * prepare and complete calls while dispatch may overlap. Use executeToolBatch + * for complete calls, including a single-call batch. + */ +export class ToolExecutionPipeline { + constructor(private readonly ctx: Context, private readonly source: ToolExecutionSource) {} + + private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { + const tool = this.source.get(name, scope) + return tool !== undefined && this.source.reject?.(name, nested) === undefined ? tool : undefined + } + + /** Context deferred by a running tool body, keyed by its scheduler-owned execution. */ + private deferredContexts = new WeakMap() + + /** Executions whose tool body declared the current turn complete. */ + private concludingExecutions = new WeakSet() + + /** Original caller cancellation, kept outside the wrapper-mutable execution object. */ + private cancellationStates = new WeakMap() + + /** Definition-owned final content transform snapshotted before policy begins. */ + private contentFinalizers = new WeakMap() + + /** Execution-prepared content installed before post-execute policy. */ + private contentProjectors = new WeakMap() + + /** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ + executionMode(exec: ToolExecutionInput): ToolExecutionMode { + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (!tool?.isConcurrencySafe) return { kind: 'exclusive' } + try { + const concurrencySafe: unknown = tool.isConcurrencySafe(exec.arguments) + return concurrencySafe === true ? { kind: 'parallel' } : { kind: 'exclusive' } + } catch { + return { kind: 'exclusive' } + } + } + + private createExecution(exec: ToolExecutionInput): ScheduledToolPreparation | { kind: 'ready'; exec: MutableToolRunContext } { + const deferredContexts: UserMessage[] = [] + const token = createExecutionToken() + const callId = exec.callId + const rootCallId = exec.rootCallId ?? callId + const name = exec.name + const agent = exec.agent + const parent = exec.parent + const signal = exec.signal + // Executor refusals precede extensible policy; unknown tools retain dispatch-stage handling. + const visible = this.source.get(name, agent) + const rejection = visible === undefined ? undefined : this.source.reject?.(name, parent !== undefined) + const rejected = rejection !== undefined + const concludingExecutions = this.concludingExecutions + const base = { + token, + callId, + rootCallId, + name, + signal, + ...agent !== undefined ? { agent } : {}, + ...parent !== undefined ? { parent } : {}, + ...exec.schema !== undefined ? { schema: exec.schema } : {}, + deferContext(context: UserMessage): void { + deferredContexts.push(context) + }, + concludeTurn(this: ToolExecution): void { + concludingExecutions.add(this) + }, + } + // Capture callbacks before argument getters run; rejected calls retain finalization only when aborted. + const capturedFinalizer = visible?.finalizeContent?.bind(visible) + const capturedProjector = visible?.projectContent?.bind(visible) + const finalizerFor = (): ToolDefinition['finalizeContent'] | undefined => + rejected && !signal.aborted ? undefined : capturedFinalizer + try { + const detached = snapshotJsonValue(exec.arguments) + if (detached === undefined) { + throw new TypeError('tool execution arguments must be losslessly JSON-serializable') + } + const execution: MutableToolRunContext = { ...base, arguments: deepFreeze(detached) } + this.deferredContexts.set(execution, deferredContexts) + this.contentFinalizers.set(execution, finalizerFor()) + if (!rejected) this.contentProjectors.set(execution, capturedProjector) + this.cancellationStates.set(execution, { + callerSignal: signal, + bodyInvoked: false, + }) + if (rejected) { + // Cancellation takes precedence over an executor refusal. + if (signal.aborted) { + return { kind: 'final-result', exec: execution, result: toolAbortedBeforeDispatchResult() } + } + return { + kind: 'final-result', + exec: execution, + result: toolErrorResult(rejection), + } + } + return { kind: 'ready', exec: execution } + } catch (error: unknown) { + const execution: MutableToolRunContext = { ...base, arguments: undefined } + this.contentFinalizers.set(execution, finalizerFor()) + return { kind: 'final-result', exec: execution, result: toolErrorResult(error) } + } + } + + /** + * Snapshot input, assign its correlation token, and run ordered pre-execute + * policy and guards. Cancellation skips a body that has not started. + * @param input - the caller-supplied execution input. + * @returns the prepared execution plus the next scheduler stage. + */ + async prepare(input: ToolExecutionInput): Promise { + const created = this.createExecution(input) + if (created.kind !== 'ready') return created + const exec = created.exec + if (this.callerCancelled(exec)) { + return { kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() } + } + try { + const carrier = scopeTarget(this.ctx.tools, exec.agent) + const gate = await this.ctx.waterfall( + carrier, 'tools/pre-execute', exec, + () => Promise.resolve({ kind: 'allow' }), + ) + const askResolution = gate.kind === 'ask' + ? await this.serviceAsk(exec, gate) + : { decision: gate, approvalCancelled: false } + const { decision } = askResolution + if (this.callerCancelled(exec) && askResolution.approvalCancelled) { + return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } + } + if (decision.kind === 'cancel') { + return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } + } + const denialReason = decision.kind === 'allow' ? this.ctx.tools.checkGuards(exec) : decision.reason + const denialInfo = decision.kind === 'deny' ? decision.info : undefined + if (denialReason !== undefined) { + return { + kind: 'post-result', + exec, + result: this.materializeFinalResult({ + content: [{ type: 'text', text: `Error: ${denialReason}` }], + isError: true, + error: { message: denialReason, ...denialInfo === undefined ? {} : { info: denialInfo } }, + }), + } + } + if (this.callerCancelled(exec)) { + return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } + } + return { kind: 'dispatch', exec } + } catch (error: unknown) { + return { kind: 'final-result', exec, result: toolErrorResult(error) } + } + } + + /** Whether the original caller signal is currently aborted. */ + private callerCancelled(exec: ToolRunContext): boolean { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.callerSignal.aborted + } + + /** Canonical cancellation outcome selected by whether the tool body started. */ + private cancellationResult(exec: ToolRunContext, prior?: ToolExecutionResult): ToolExecutionResult { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + return state.bodyInvoked + ? toolAbortedResult(prior) + : toolAbortedBeforeDispatchResult(prior) + } + + /** + * Dispatch the registered body with the original caller signal fused back + * into any around-wrapper replacement. Cancellation never abandons the body: + * a started promise reaches quiescence before its outcome becomes `ABORTED`. + */ + private async dispatchToolBody(exec: MutableToolRunContext): Promise { + const state = this.cancellationStates.get(exec) + /* v8 ignore next -- only registry-minted executions reach the staged scheduler methods */ + if (state === undefined) throw new Error('tool registry scheduler invariant violated: missing cancellation state') + const wrapperSignal = exec.signal + const fused = fuseToolSignals(state.callerSignal, wrapperSignal) + const signal = fused.signal + + if (isAborted(signal)) { + fused.dispose() + return toolAbortedBeforeDispatchResult() + } + exec.signal = signal + try { + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (!tool) throw new ToolNotFoundError(exec.name) + state.bodyInvoked = true + const returned = await tool.execute(exec.arguments, exec) + const result = this.createSuccessResult(exec, tool, returned) + return isAborted(signal) + ? toolAbortedResult(result) + : result + } catch (error: unknown) { + return toolErrorResult(error) + } finally { + fused.dispose() + exec.signal = wrapperSignal + } + } + + /** + * Run around-dispatch and the tool body. Tool and unknown-tool failures still + * receive post-execute; pipeline failures are already final. Started bodies + * drain on cancellation before a successful outcome becomes `ABORTED`. + * @param exec - the execution returned by this pipeline's prepare stage. + * @returns whether the result still needs post-execute. + */ + async dispatch(exec: ToolRunContext): Promise { + try { + const mutableExec = exec as MutableToolRunContext + const carrier = scopeTarget(this.ctx.tools, exec.agent) + const result = await this.ctx.waterfall( + carrier, 'tools/execute', mutableExec, + () => this.dispatchToolBody(mutableExec), + ) + const normalized = this.normalizeDispatchResult(exec, result) + const deferredContexts = this.deferredContexts.get(exec) + /* v8 ignore next -- dispatch only receives executions minted by this registry's prepare stage */ + if (deferredContexts === undefined) throw new Error('tool registry scheduler invariant violated: unprepared execution') + const resultWithDeferredContexts: ToolExecutionResult = deferredContexts.length === 0 + ? normalized + : this.markCanonical(exec, { + ...normalized, + additionalContexts: [ + ...deferredContexts, + ...normalized.additionalContexts ?? [], + ], + }) + return { + kind: 'post-result', + result: this.callerCancelled(exec) && !resultWithDeferredContexts.isError + ? this.cancellationResult(exec, resultWithDeferredContexts) + : resultWithDeferredContexts, + } + } catch (error: unknown) { + return { kind: 'final-result', result: toolErrorResult(error) } + } + } + + /** + * Run post-execute when required, apply definition-owned content finalization, + * and publish the lossless, frozen result. Policy and finalization failures + * become error results. Cancellation replaces a successful outcome before + * final materialization; tool-owned errors remain intact. + * @param exec - the execution returned by this pipeline's prepare stage. + * @param outcome - the preparation or dispatch result awaiting completion. + * @returns the same materialized result delivered to final observers. + */ + async complete(exec: ToolRunContext, outcome: ScheduledToolDispatch): Promise { + switch (outcome.kind) { + case 'final-result': + return this.finish(exec, outcome.result) + case 'post-result': + break + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + return assertNever(outcome, 'scheduled tool outcome') + } + const { result } = outcome + try { + const project = this.contentProjectors.get(exec) + this.contentProjectors.delete(exec) + const content = project?.(exec, result) + const projected = content === undefined + ? result + : this.markCanonical(exec, this.materializeFinalResult({ ...result, content })) + const postResult = await this.postExecute(exec, projected) + return this.finish( + exec, + this.callerCancelled(exec) && !postResult.isError + ? this.cancellationResult(exec, postResult) + : postResult, + ) + } catch (error: unknown) { + return this.finish(exec, toolErrorResult(error)) + } + } + + /** + * Materialize the candidate, apply definition-owned content finalization, + * then materialize and notify the authoritative result. + * @param exec - the prepared execution. + * @param result - final result. + * @returns the materialized final result. + * @internal + */ + private finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { + let materializedResult: ToolExecutionResult + try { + materializedResult = this.materializeFinalResult(result) + } catch (error: unknown) { + materializedResult = this.materializeFinalResult(toolErrorResult(error)) + } + let finalResult: ToolExecutionResult + try { + finalResult = this.materializeFinalResult(this.applyFinalContent(exec, materializedResult)) + } catch (error: unknown) { + finalResult = this.materializeFinalResult(toolErrorResult(error)) + } + this.notifyResult(exec, finalResult) + return finalResult + } + + /** Apply the snapshotted tool-owned content transform without exposing other result fields. */ + private applyFinalContent(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult { + const finalizeContent = this.contentFinalizers.get(exec) + if (finalizeContent === undefined) return result + const content = finalizeContent(exec, result) + return content === undefined ? result : { ...result, content } + } + + /** Notify observers without exposing a mutation or error channel into the outcome. */ + private notifyResult(exec: ToolExecution, result: ToolExecutionResult): void { + // Freeze the registry's live object before observers receive its readonly + // WeakMap-keyable view. + Object.freeze(exec) + const { name: toolName, callId } = exec + const reportFailure = (error: unknown): void => { + this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${toolErrorMessage(error)}`) + } + const callbacks = this.ctx.events.dispatch('emit', [ + scopeTarget(this.ctx.tools, exec.agent), 'tools/result', exec, result, + ]) + for (const callback of callbacks) { + try { + const returned: unknown = callback(exec, result) + void Promise.resolve(returned).catch(reportFailure) + } catch (error: unknown) { + reportFailure(error) + } + } + } + + /** + * Resolve an `ask` decision to allow/deny through the approval seam. The + * seam is consumed opportunistically with `ctx.get('approval')` — a + * deployment that composes no ApprovalService keeps the historical degrade + * to deny, and an unmount mid-session degrades the same way on the next ask. + * An agent-less execution also degrades: without an agent there is no + * session to audit to and no UI to route to. Otherwise the outcome maps + * one-to-one — `allowed-once` proceeds; the three non-grants deny with + * distinct reasons so the model can tell a human "no" from an absent + * approval channel. + */ + private async serviceAsk( + exec: ToolExecution, + ask: Extract, + ): Promise { + const approval = this.ctx.get('approval') + if (approval === undefined) { + return { + decision: { kind: 'deny', reason: ask.reason ?? `tool "${exec.name}" requires approval (not yet supported)` }, + approvalCancelled: false, + } + } + if (exec.agent === undefined) { + return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but the call has no agent to route it through` }, + approvalCancelled: false, + } + } + const outcome = await approval.request({ + agent: exec.agent, + toolName: exec.name, + callId: exec.callId, + ...ask.reason !== undefined ? { reason: ask.reason } : {}, + ...ask.displayReason !== undefined ? { displayReason: ask.displayReason } : {}, + signal: exec.signal, + }) + switch (outcome) { + case 'allowed-once': return { decision: { kind: 'allow' }, approvalCancelled: false } + case 'rejected': return { + decision: { kind: 'deny', reason: `the user rejected tool "${exec.name}"` }, + approvalCancelled: false, + } + case 'cancelled': return { + decision: { kind: 'deny', reason: `approval for tool "${exec.name}" was cancelled` }, + approvalCancelled: true, + } + case 'unavailable': return { + decision: { kind: 'deny', reason: `tool "${exec.name}" requires approval, but no approval channel is available` }, + approvalCancelled: false, + } + default: return assertNever(outcome, 'ApprovalOutcome') + } + } + + /** + * Run the `tools/post-execute` waterfall over a dispatched `result` and apply + * its {@link PostToolDecision}: `accept` keeps the call successful (replacing + * `content` when given), `block` turns it into an `isError` whose content is + * the corrective `feedback`. Either decision may attach `additionalContexts`, + * which are ferried on the returned result for the loop's active-batch FIFO. + * Context deferred by the tool body survives an accepted result but is + * discarded when the outer call is blocked; a block exposes only context the + * blocking decision explicitly supplied. + * Runs inside `execute`'s outer try/catch (a throwing listener → isError). + */ + private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { + const decision = await this.ctx.waterfall( + scopeTarget(this.ctx.tools, exec.agent), 'tools/post-execute', exec, result, + () => Promise.resolve({ kind: 'accept' }), + ) + const decisionContexts = decision.additionalContexts ?? [] + if (decision.kind === 'block') { + const message = failureMessageFromContent(decision.feedback) + return this.markCanonical(exec, { + content: decision.feedback, + isError: true, + error: { message }, + ...decisionContexts.length > 0 ? { additionalContexts: decisionContexts } : {}, + }) + } + if (Object.hasOwn(decision, 'content') && Object.hasOwn(decision, 'value')) { + throw new TypeError('tools/post-execute accept decision cannot replace both value and content') + } + const additionalContexts = [ + ...result.additionalContexts ?? [], + ...decisionContexts, + ] + if (Object.hasOwn(decision, 'value')) { + if (result.isError) { + throw new TypeError('tools/post-execute cannot replace the value of a failed result') + } + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const replaced = this.createSuccessResult(exec, tool, decision.value) + return this.markCanonical(exec, { + ...replaced, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + return this.markCanonical(exec, { + ...result, + ...decision.content !== undefined ? { content: decision.content } : {}, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + }) + } + + /** Registry-normalized results and the exact dispatch that validated each value. */ + private readonly canonicalResults = new WeakMap() + + /** Mark one registry-normalized result as canonical only for its owning dispatch. */ + private markCanonical(exec: ToolExecution, result: T): T { + this.canonicalResults.set(result, exec.token) + return result + } + + /** Snapshot, validate, render, and optionally project one successful body value. */ + private createSuccessResult(exec: ToolExecution, tool: ToolDefinition, candidate: unknown): ToolExecutionSuccess { + const detached = snapshotToolValue(tool.name, candidate) + const violations = validateJsonSchemaValue(tool.output.schema, detached, 'value') + if (violations.length > 0) throw new ToolOutputError(tool.name, violations) + const value = deepFreeze(detached) + let rendered: ContentBlock[] + try { + rendered = tool.output.render(exec.arguments, value) + } catch (error: unknown) { + throw projectionError(tool.name, 'render', error) + } + const content = snapshotProjection(tool.name, 'render', rendered) + let meta: JsonValue | undefined + if (tool.output.presentationMeta !== undefined) { + let projected: JsonValue + try { + projected = tool.output.presentationMeta(exec.arguments, value) + } catch (error: unknown) { + throw projectionError(tool.name, 'presentationMeta', error) + } + meta = snapshotProjection(tool.name, 'presentationMeta', projected) + } + const concludesTurn = this.concludingExecutions.has(exec) + return this.markCanonical(exec, this.materializeFinalResult({ + isError: false, + value, + content, + ...meta !== undefined ? { meta } : {}, + ...concludesTurn ? { concludesTurn: true as const } : {}, + }) as ToolExecutionSuccess) + } + + /** Normalize an around-dispatch wrapper's authored result through the owning output contract. */ + private normalizeDispatchResult(exec: ToolExecution, result: ToolExecutionResult): ToolExecutionResult { + if (this.canonicalResults.get(result) === exec.token) return result + if (result.isError) { + return this.markCanonical(exec, { + isError: true, + error: result.error, + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + const tool = this.resolveExecution(exec.name, exec.agent, exec.parent !== undefined) + if (tool === undefined) throw new ToolNotFoundError(exec.name) + const normalized = this.createSuccessResult(exec, tool, result.value) + return this.markCanonical(exec, { + ...normalized, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + }) + } + + /** Materialize the authoritative commit outcome once, immediately before `tools/result`. */ + private materializeFinalResult(result: ToolExecutionResult): ToolExecutionResult { + const presentation = { + content: result.content, + ...result.meta !== undefined ? { meta: result.meta } : {}, + ...result.additionalContexts !== undefined ? { additionalContexts: result.additionalContexts } : {}, + } + if (result.isError) { + return materializePresentation({ isError: true as const, error: result.error, ...presentation }) + } + const detached = materializePresentation({ + isError: false as const, + ...presentation, + ...result.concludesTurn === true ? { concludesTurn: true as const } : {}, + }) + return deepFreeze({ ...detached, value: result.value }) + } +} + +/** Convert one projector exception into the canonical invalid-output failure. */ +function projectionError(toolName: string, projector: 'render' | 'presentationMeta', error: unknown): ToolOutputError { + return new ToolOutputError(toolName, [`output.${projector} failed: ${toolErrorMessage(error)}`]) +} + +/** Snapshot one projector result before later durable-result materialization. */ +function snapshotProjection(toolName: string, projector: 'render' | 'presentationMeta', candidate: T): T { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new ToolOutputError(toolName, [`output.${projector} returned non-lossless JSON`]) + } + return detached + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw projectionError(toolName, projector, error) + } +} + +/** Snapshot one body or policy value into the canonical invalid-output failure class. */ +function snapshotToolValue(toolName: string, candidate: unknown): JsonValue { + try { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) throw new ToolOutputError(toolName, ['value is not lossless JSON']) + return detached as JsonValue + } catch (error: unknown) { + if (error instanceof ToolOutputError) throw error + throw new ToolOutputError(toolName, [`value snapshot failed: ${toolErrorMessage(error)}`]) + } +} + +/** + * Best-effort human-readable message from an arbitrary thrown value: Error + * instances use `.message`; non-Error objects with a string `message` + * property (e.g. `throw { message: 'denied' }`) use it too; everything else + * is stringified. + * @param error - thrown value from a tool or policy callback. + * @returns a printable failure message, including for hostile thrown values. + */ +export function toolErrorMessage(error: unknown): string { + try { + if (error instanceof Error) return error.message + if (typeof error === 'object' && error !== null + && 'message' in error && typeof error.message === 'string') { + return error.message + } + return String(error) + } catch { + // A hostile thrown value can trap `instanceof`, property access, or string + // coercion. Error normalization is the outermost safety boundary, so its + // fallback must itself be total. + return '' + } +} + +/** Derive one failure message from policy feedback without changing its rendered blocks. */ +function failureMessageFromContent(content: ContentBlock[]): string { + const text = content + .map(block => block.type === 'text' ? block.text : `[${block.type} content]`) + .join('\n') + return text.length > 0 ? text : 'tool result blocked by post-execute policy' +} + +/** Snapshot and freeze one durable tool-result projection or reject lossy data. */ +function materializePresentation(candidate: T): T { + const detached = snapshotJsonValue(candidate) + if (detached === undefined) { + throw new TypeError('tool result must be losslessly JSON-serializable') + } + return deepFreeze(detached) +} + +/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */ +function errorInfo(error: unknown): ToolErrorInfo | undefined { + try { + return error instanceof HarnessError ? { name: error.name, code: error.code } : undefined + } catch { + return undefined + } +} + +/** Mint a same-process correlation token whose identity is its value. */ +function createExecutionToken(): ToolExecutionToken { + return Symbol('dsh.tool.execution') as ToolExecutionToken +} + +function toolErrorResult(error: unknown): ToolExecutionResult { + const info = errorInfo(error) + const message = toolErrorMessage(error) + return { + content: [{ type: 'text', text: `Error: ${message}` }], + isError: true, + error: { message, ...info ? { info } : {} }, + } +} + +/** Read live abort state across an await without treating it as synchronously immutable. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +/** + * Fuse caller and wrapper cancellation without nesting `AbortSignal.any`. + * Keeping the relay dispatch-scoped also removes listeners when work settles. + */ +function fuseToolSignals(caller: AbortSignal, wrapper: AbortSignal): FusedToolSignal { + if (caller === wrapper) return { signal: caller, dispose() {} } + + const controller = new AbortController() + let listening = false + const dispose = (): void => { + if (!listening) return + listening = false + caller.removeEventListener('abort', abortFromCaller) + wrapper.removeEventListener('abort', abortFromWrapper) + } + const abortFrom = (source: AbortSignal): void => { + const reason: unknown = source.reason + controller.abort(reason) + dispose() + } + const abortFromCaller = (): void => { abortFrom(caller) } + const abortFromWrapper = (): void => { abortFrom(wrapper) } + + if (wrapper.aborted) abortFromWrapper() + else if (caller.aborted) abortFromCaller() + else { + listening = true + caller.addEventListener('abort', abortFromCaller, { once: true }) + wrapper.addEventListener('abort', abortFromWrapper, { once: true }) + } + return { signal: controller.signal, dispose } +} + +/** Canonical result when cancellation supersedes success after body invocation. */ +function toolAbortedResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted' }], + isError: true, + error: { + message: 'tool call aborted', + info: { name: 'AbortError', code: TOOL_ABORTED }, + }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + +/** Canonical result when cancellation prevents tool body invocation. */ +function toolAbortedBeforeDispatchResult(prior?: ToolExecutionResult): ToolExecutionResult { + const additionalContexts = prior?.additionalContexts ?? [] + return { + content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }], + isError: true, + error: { + message: 'tool call aborted before dispatch', + info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH }, + }, + ...additionalContexts.length > 0 ? { additionalContexts } : {}, + } +} + +export { executeToolBatch } from './batch.ts' diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 2cc6f0cb0b..eb6b6f30f9 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -1,9 +1,10 @@ -/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */ +/** Unified JSON-value schema DSL, inference, compilation, and tool definition helpers. @module dsh-tools/schema */ import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' +import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' import type { JsonValue } from '@deepseek-ai/dsh-util-values' -import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './index.ts' +import type { ToolDefinition, ToolExecution, ToolExecutionResult, ToolRunContext, ToolResult } from './contracts.ts' import { assertSupportedJsonSchema, isJsonSchemaRecord, isPlainJsonArray, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts' import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts' import type { ToolCallView, ToolResultView } from './presentation.ts' @@ -630,3 +631,16 @@ export function defineTool = Omit< - DefineToolOptions, - 'output' | 'execute' -> & { - /** Produce the fixture's content blocks as its canonical test value. */ - execute(args: import('./schema.ts').InferArgs, exec: ToolRunContext): Promise -} - -/** - * Define a test fixture that deliberately uses its content blocks as the - * canonical JSON value. Product tools must declare domain-owned DTOs instead. - * @param options - ordinary fixture fields plus a content-producing body. - * @returns a registry-ready tool with an explicit JSON-array output contract. - * @internal - */ -export function defineContentToolFixture( - options: ContentToolFixtureOptions, -): ToolDefinition { - // oxlint-disable-next-line typescript/unbound-method - const execute = options.execute - return defineTool({ - ...options, - output: { - schema: CONTENT_VALUE_SCHEMA, - render: (_args, value) => value as unknown as ContentBlock[], - }, - async execute(args, exec) { - return await execute(args, exec) as unknown as JsonValue[] - }, - }) -} diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 333909c24a..f8dbf21f9f 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -1,15 +1,13 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' /** Covers fail-closed per-call classification and model-schema isolation. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { - defineContentToolFixture, - type ToolDefinition, - type ToolExecutionInput, - type ToolExecutionMode, -} from '@deepseek-ai/dsh-tools' +import ToolRuntime, { type ToolDefinition, type ToolExecutionInput, type ToolExecutionMode } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' const testToolSignal = new AbortController().signal @@ -17,6 +15,7 @@ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -24,7 +23,7 @@ function exec(name: string, args: unknown): ToolExecutionInput { return { signal: testToolSignal, callId: ToolCallId('c1'), name, arguments: args } } -describe('ToolRuntime.executionMode', () => { +describe('ToolExecutionPipeline.executionMode', () => { it('returns parallel only for an explicit true classifier', async () => { const ctx = await setup() ctx.tools.register(defineContentToolFixture({ @@ -34,7 +33,7 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe: () => true, async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) }) it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { @@ -45,12 +44,12 @@ describe('ToolRuntime.executionMode', () => { parameters: {}, async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) }) it('returns exclusive for an unknown tool', async () => { const ctx = await setup() - expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) }) it('returns exclusive when the classifier returns false for these args', async () => { @@ -62,8 +61,8 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe: args => args.mode === 'read', async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => { @@ -75,7 +74,7 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe: () => true, async execute() { return [] }, })) - expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) }) it('treats a throwing raw classifier as exclusive', async () => { @@ -89,7 +88,7 @@ describe('ToolRuntime.executionMode', () => { async execute() { return null }, } ctx.tools.register(raw) - expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) }) it('treats a truthy non-boolean raw result as exclusive', async () => { @@ -103,7 +102,7 @@ describe('ToolRuntime.executionMode', () => { async execute() { return null }, } as unknown as ToolDefinition ctx.tools.register(raw) - expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) }) it('passes parsed arguments directly to a raw definition', async () => { @@ -117,7 +116,7 @@ describe('ToolRuntime.executionMode', () => { isConcurrencySafe(args) { seen = args; return true }, async execute() { return null }, }) - expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) expect(seen).toEqual({ anything: 1 }) }) diff --git a/packages/core/tools/tests/exports.built.e2e.ts b/packages/core/tools/tests/exports.built.e2e.ts new file mode 100644 index 0000000000..bac653511f --- /dev/null +++ b/packages/core/tools/tests/exports.built.e2e.ts @@ -0,0 +1,45 @@ +/** Public tool entries must share runtime identities without exporting test helpers from the plugin. */ +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 packageRoot = fileURLToPath(new URL('..', import.meta.url)) +const pipelineBundle = fileURLToPath(new URL('../lib/pipeline.js', import.meta.url)) +const execFileAsync = promisify(execFile) +const probe = String.raw` +import assert from 'node:assert/strict' +import { Context } from '@deepseek-ai/cordis' +import * as tools from '@deepseek-ai/dsh-tools' +import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' + +assert.equal(Object.hasOwn(tools, 'defineContentToolFixture'), false) +assert.equal(Object.hasOwn(tools, 'executeToolFixture'), false) +assert.equal(Object.hasOwn(tools, 'executeModelToolFixture'), false) +await assert.rejects(import('@deepseek-ai/dsh-tools/testing'), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }) +const ctx = new Context() +try { + const cause = new Error('registry lookup failed') + const pipeline = new ToolExecutionPipeline(ctx, { get() { throw cause } }) + assert.equal(typeof pipeline.prepare, 'function') + await assert.rejects(executeToolBatch({ + calls: [{ type: 'tool-call', id: 'probe', name: 'probe', arguments: '{}' }], + signal: new AbortController().signal, + }, pipeline, () => 1), error => error instanceof tools.ToolBatchExecutionError && error.cause === cause) + const definition = defineContentToolFixture({ + name: 'probe', description: 'probe', parameters: {}, + async execute() { return [{ type: 'text', text: 'ok' }] }, + }) + assert.equal(definition.name, 'probe') +} finally { + await ctx.fiber.dispose() +} +console.log('shared tool entries verified') +` + +it.skipIf(!existsSync(pipelineBundle))('shares batch failures and scheduler stages across built entries', { retry: 0 }, async ({ signal }) => { + const { stdout } = await execFileAsync(process.execPath, ['--input-type=module', '-e', probe], { cwd: packageRoot, signal }) + expect(stdout.trim()).toBe('shared tool entries verified') +}) diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index bc0b2ba97d..ccd1b2d8db 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -67,6 +67,8 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('attributes each harvested tool with its registering plugin source', async () => { const catalog = await collectToolCatalog() + const ptc = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-executor-ptc') + expect(ptc?.sources.run_code).toBe('packages/core/tool-executor-ptc/src/ptc.ts') const bash = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-bash') expect(bash?.sources.bash).toBe('packages/shell/tool-bash/src/index.ts') const control = catalog.find(entry => entry.pkg === '@deepseek-ai/dsh-tool-subagent-control') diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 7eac6489f7..2a45fccfb0 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Events } from '@deepseek-ai/cordis' @@ -18,6 +20,7 @@ async function mount(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -47,7 +50,7 @@ function tool(name: string, reply = `ran:${name}`): ToolDefinition { } async function run(ctx: Context, name: string, agent?: Agent): Promise { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name, @@ -416,7 +419,7 @@ describe('scoped execution dispatch', () => { expect(await run(ctx, 'danger', key)).toBe('Error: danger denied') const callerArguments = { source: true } - const safeResult = await ctx.tools.execute({ + const safeResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('safe-call'), name: 'safe', @@ -461,7 +464,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) stopCapture() policyCalls = 0 const signal = new AbortController().signal @@ -476,7 +479,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => { globalObserved += 1 }) const callerArguments = { invalid: () => undefined } - const scopedResult = await ctx.tools.execute({ + const scopedResult = await executeToolFixture(ctx, { callId: ToolCallId('non-cloneable'), name: 't', arguments: callerArguments, @@ -484,7 +487,7 @@ describe('scoped execution dispatch', () => { parent, signal, }) - const subjectlessResult = await ctx.tools.execute({ + const subjectlessResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('non-cloneable-subjectless'), name: 't', @@ -535,7 +538,7 @@ describe('scoped execution dispatch', () => { }, } as ToolExecutionInput - const result = await ctx.tools.execute(input) + const result = await executeToolFixture(ctx, input) expect(result.isError).toBe(false) expect(parentReads).toBe(1) @@ -553,7 +556,7 @@ describe('scoped execution dispatch', () => { if (exec.name === 'parent') parent = exec.token return next() }) - await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent'), name: 'parent', arguments: {} }) stopCapture() const acceptedSignal = new AbortController().signal const driftSignal = new AbortController().signal @@ -572,7 +575,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', (exec) => { observed = exec }) scope.ctx.on('tools/result', () => { scopedObserved += 1 }) - const result = await ctx.tools.execute(input) + const result = await executeToolFixture(ctx, input) expect(result.isError).toBe(true) expect(reads).toEqual({ callId: 1, name: 1, arguments: 1, agent: 1, parent: 1, signal: 1 }) @@ -607,7 +610,7 @@ describe('scoped execution dispatch', () => { }, } as ToolExecutionInput - const result = await ctx.tools.execute(input) + const result = await executeToolFixture(ctx, input) expect(result.isError).toBe(true) expect(result.content).toEqual([{ type: 'text', text: 'Error: getter exploded' }]) @@ -640,7 +643,7 @@ describe('scoped execution dispatch', () => { expect(result.isError).toBe(true) }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('bad-arguments'), name: 't', arguments: argumentsValue, }) @@ -661,7 +664,7 @@ describe('scoped execution dispatch', () => { get: () => ++reads === 1 ? 'safe' : new Map([['mutable', true]]), }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('unstable-arguments'), name: 't', arguments: argumentsValue, }) @@ -705,7 +708,7 @@ describe('scoped execution dispatch', () => { ctx.on('tools/result', () => Promise.reject(new Error('async observer failure')) as never) ctx.on('tools/result', (_exec, result) => { seen.push(result.isError) }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('final'), name: 't', arguments: {}, agent: key }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('final'), name: 't', arguments: {}, agent: key }) await Promise.resolve() expect(result).toMatchObject({ isError: true, content: [{ type: 'text', text: 'outer failure' }] }) expect(seen).toEqual([true, true]) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 870c72fe4c..ca90e1bd72 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' @@ -8,12 +10,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' -import ToolRuntime, { - defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, - TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, - type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, - type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken, -} from '@deepseek-ai/dsh-tools' +import ToolRuntime, { defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError, TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, type InferArgs, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision, type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { JsonValue } from '@deepseek-ai/dsh-util-values' declare module '@deepseek-ai/dsh-llm' { @@ -35,6 +33,7 @@ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -121,7 +120,7 @@ describe('ToolRuntime', () => { }) try { expect(Object.keys(ctx.tools.schemas()[0]!)).not.toContain('projectContent') - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('projection'), name: 'projected', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('projection'), name: 'projected', arguments: {} }) expect(result.content).toEqual([{ type: 'text', text: 'policy result' }]) if (result.isError) throw new Error('expected successful projection') expect(result.value).toBe('canonical') @@ -134,7 +133,7 @@ describe('ToolRuntime', () => { ctx.tools.register({ ...echoTool, projectContent() { throw new Error('projection failed') } }) ctx.on('tools/post-execute', async (_exec, _result, next) => { postCalls++; return next() }) try { - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('projection-error'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('projection-error'), name: 'echo', arguments: {} }) expect(result.isError).toBe(true) expect(JSON.stringify(result.content)).toContain('projection failed') expect(postCalls).toBe(0) @@ -157,7 +156,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) let observed: ToolExecutionResult | undefined ctx.on('tools/result', (_exec, result) => { observed = result }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: 'hi' }) expect(observed).toEqual(result) }) @@ -175,7 +174,7 @@ describe('ToolRuntime', () => { return 'ok' }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'meta-tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, @@ -193,7 +192,7 @@ describe('ToolRuntime', () => { return 'ok' }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'no-meta-tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'no-meta-tool', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, value: 'ok' }) expect('meta' in result).toBe(false) }) @@ -214,7 +213,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('bad-meta'), name: 'bad-meta', arguments: {}, }) @@ -252,7 +251,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('throwing-meta'), name: 'throwing-meta', arguments: {}, }) @@ -277,7 +276,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('throwing-finalizer'), name: 'throwing-finalizer', arguments: {}, }) @@ -320,8 +319,8 @@ describe('ToolRuntime', () => { execute: async () => 42 as unknown as string, })) - const lossy = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('lossy'), name: 'lossy-output', arguments: {} }) - const mismatch = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('mismatch'), name: 'wrong-output', arguments: {} }) + const lossy = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('lossy'), name: 'lossy-output', arguments: {} }) + const mismatch = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('mismatch'), name: 'wrong-output', arguments: {} }) expect(lossy.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) expect(lossy.content[0]?.type === 'text' ? lossy.content[0].text : '').toContain('not lossless JSON') expect(mismatch.error).toMatchObject({ info: { name: 'ToolOutputError', code: 'INVALID_TOOL_OUTPUT' } }) @@ -342,7 +341,7 @@ describe('ToolRuntime', () => { execute: async () => hostile as JsonValue, })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('hostile-body'), name: 'hostile-body', arguments: {}, }) @@ -370,7 +369,7 @@ describe('ToolRuntime', () => { execute: async () => 'ok', })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(projector), name: `throwing-${projector}`, arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(projector), name: `throwing-${projector}`, arguments: {} }) expect(result.isError).toBe(true) expect(result.error?.message) .toContain(projector === 'render' ? 'renderer exploded' : 'metadata exploded') @@ -400,7 +399,7 @@ describe('ToolRuntime', () => { execute: async () => 'ok', })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`hostile-${projector}`), name: `hostile-${projector}`, arguments: {}, }) @@ -439,9 +438,9 @@ describe('ToolRuntime', () => { } }) - const content = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('content'), name: 'projected', arguments: {} }) + const content = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('content'), name: 'projected', arguments: {} }) replacement = 'value' - const value = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('value'), name: 'projected', arguments: {} }) + const value = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('value'), name: 'projected', arguments: {} }) expect(content).toEqual({ isError: false, @@ -471,7 +470,7 @@ describe('ToolRuntime', () => { value: 'replacement', content: [{ type: 'text', text: 'also replacement' }], } as unknown as PostToolDecision)) - const bothResult = await both.tools.execute({ signal: testToolSignal, callId: ToolCallId('both'), name: 'echo', arguments: {} }) + const bothResult = await executeToolFixture(both, { signal: testToolSignal, callId: ToolCallId('both'), name: 'echo', arguments: {} }) expect(bothResult).toMatchObject({ isError: true, error: { message: 'tools/post-execute accept decision cannot replace both value and content' }, @@ -480,7 +479,7 @@ describe('ToolRuntime', () => { const invalid = await setup() invalid.tools.register(echoTool) invalid.on('tools/post-execute', async () => ({ kind: 'accept', value: 1 })) - const invalidResult = await invalid.tools.execute({ signal: testToolSignal, callId: ToolCallId('invalid'), name: 'echo', arguments: {} }) + const invalidResult = await executeToolFixture(invalid, { signal: testToolSignal, callId: ToolCallId('invalid'), name: 'echo', arguments: {} }) expect(invalidResult.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) expect('value' in invalidResult).toBe(false) }) @@ -493,7 +492,7 @@ describe('ToolRuntime', () => { feedback: [{ type: 'text', text: 'blocked by policy' }], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('block'), name: 'echo', arguments: { text: 'secret' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('block'), name: 'echo', arguments: { text: 'secret' } }) expect(result).toEqual({ isError: true, error: { message: 'blocked by policy' }, @@ -507,7 +506,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('replace-value'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('replace-value'), name: 'echo', arguments: {} }) expect(result).toEqual({ isError: false, value: 'replacement', @@ -523,7 +522,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) ctx.on('tools/post-execute', async () => ({ kind: 'block', feedback: [...feedback] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('block-message'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('block-message'), name: 'echo', arguments: {} }) expect(result.error?.message).toBe(message) }) @@ -535,7 +534,7 @@ describe('ToolRuntime', () => { feedback: [{ type: 'text', text: 'blocked', invalid: () => undefined } as never], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('invalid-block'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('invalid-block'), name: 'echo', arguments: {} }) expect(result).toMatchObject({ isError: true, error: { message: 'tool result must be losslessly JSON-serializable' }, @@ -551,7 +550,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/post-execute', async () => ({ kind: 'accept', value: 'replacement' })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('failed-replace'), name: 'throw-before-replace', arguments: {}, }) @@ -566,7 +565,7 @@ describe('ToolRuntime', () => { return { kind: 'accept', value: 'replacement' } }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('post-disposed'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('post-disposed'), name: 'echo', arguments: {} }) expect(result.error).toEqual({ message: 'unknown tool "echo"', info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, @@ -586,7 +585,7 @@ describe('ToolRuntime', () => { })], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('wrapper-failure'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('wrapper-failure'), name: 'echo', arguments: {} }) expect(result).toEqual({ isError: true, error: { message: 'wrapped failure' }, @@ -609,7 +608,7 @@ describe('ToolRuntime', () => { return { isError: false, value: 'replacement', content: [] } }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('wrapper-disposed'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('wrapper-disposed'), name: 'echo', arguments: {} }) expect(result.error).toEqual({ message: 'unknown tool "echo"', info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, @@ -623,8 +622,8 @@ describe('ToolRuntime', () => { name: 'meta-suppression', output: { ...echoTool.output, presentationMeta: () => ({ card: true }) }, }) - const direct = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('direct'), name: 'meta-suppression', arguments: {} }) - const nested = await ctx.tools.execute({ + const direct = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('direct'), name: 'meta-suppression', arguments: {} }) + const nested = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('nested'), name: 'meta-suppression', @@ -657,7 +656,7 @@ describe('ToolRuntime', () => { name: 'composite', async execute(_args, exec) { call += 1 - const nested = await ctx.tools.execute({ + const nested = await executeToolFixture(ctx, { signal: exec.signal, callId: ToolCallId(`nested-${call}`), name: 'terminal-nested', arguments: {}, parent: exec.token, }) if (nested.concludesTurn) exec.concludeTurn() @@ -671,7 +670,7 @@ describe('ToolRuntime', () => { if (exec.name !== 'terminal-nested') return next() return { kind: 'block', feedback: [{ type: 'text', text: 'nested success rejected' }] } }) - const recovered = await ctx.tools.execute({ + const recovered = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite-vetoed'), name: 'composite', arguments: {}, }) expect(recovered.isError).toBe(false) @@ -680,7 +679,7 @@ describe('ToolRuntime', () => { // The same nested call succeeding carries the marker; the composite // forwards it onto its own successful result. - const concluded = await ctx.tools.execute({ + const concluded = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite-ok'), name: 'composite', arguments: {}, }) expect(concluded.isError).toBe(false) @@ -697,7 +696,7 @@ describe('ToolRuntime', () => { }, }) - const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'nope', arguments: {} }) + const unknown = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'nope', arguments: {} }) expect(unknown.isError).toBe(true) expect(unknown.content[0]).toMatchObject({ text: 'Error: unknown tool "nope"' }) // An unknown tool is a routable failure class, same as a tool-thrown one. @@ -706,7 +705,7 @@ describe('ToolRuntime', () => { info: { name: 'ToolNotFoundError', code: 'UNKNOWN_TOOL' }, }) - const thrown = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c2'), name: 'boom', arguments: {} }) + const thrown = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c2'), name: 'boom', arguments: {} }) expect(thrown.isError).toBe(true) expect(thrown.content[0]).toMatchObject({ text: 'Error: exploded' }) }) @@ -725,7 +724,7 @@ describe('ToolRuntime', () => { }, }) - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('hostile'), name: 'hostile-throw', arguments: {}, })).resolves.toMatchObject({ @@ -764,7 +763,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' }) expect(result.error).toEqual({ @@ -781,7 +780,7 @@ describe('ToolRuntime', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask', reason: 'needs approval' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' }) }) @@ -792,7 +791,7 @@ describe('ToolRuntime', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' }) }) @@ -811,6 +810,7 @@ describe('ToolRuntime', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(ApprovalService) @@ -830,7 +830,7 @@ describe('ToolRuntime', () => { ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask', reason: 'hook wants a human', displayReason: { en: 'Allow it?', zh: '允许吗?' } })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' }, agent, signal: controller.signal, }) @@ -847,7 +847,7 @@ describe('ToolRuntime', () => { ctx.on('approval/request', () => Promise.resolve('rejected')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: the user rejected tool "echo"' }) }) @@ -857,7 +857,7 @@ describe('ToolRuntime', () => { ctx.on('approval/request', () => Promise.resolve('cancelled')) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: approval for tool "echo" was cancelled' }) }) @@ -878,7 +878,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('approval-cancelled'), name: 'approval-probe', arguments: {}, @@ -901,7 +901,7 @@ describe('ToolRuntime', () => { const ctx = await approvalSetup() ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but no approval channel is available' }) }) @@ -915,7 +915,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} }) expect(asked).toBe(false) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval, but the call has no agent to route it through' }) @@ -930,7 +930,7 @@ describe('ToolRuntime', () => { ctx.provide('approval', { request: () => Promise.resolve('yolo') } as unknown as ApprovalService) ctx.on('tools/pre-execute', async (_exec, _next): Promise => ({ kind: 'ask' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {}, agent: fakeAgent() }) expect(result.isError).toBe(true) const text = result.content[0]?.type === 'text' ? result.content[0].text : '' expect(text).toContain('unreachable') @@ -944,7 +944,7 @@ describe('ToolRuntime', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(false) expect(result.content[0]).toMatchObject({ text: 'rewritten' }) }) @@ -956,7 +956,7 @@ describe('ToolRuntime', () => { ctx.on('tools/post-execute', async (_exec, _result, _next): Promise => ({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' }) }) @@ -979,7 +979,7 @@ describe('ToolRuntime', () => { throw new HarnessError('policy failed', 'POLICY_FAILED') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('bounded'), name: 'bounded', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('bounded'), name: 'bounded', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'bounded failure' }], @@ -1003,7 +1003,7 @@ describe('ToolRuntime', () => { }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('identity-finalizer'), name: 'identity-finalizer', arguments: {} }) expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: '' }]) @@ -1023,7 +1023,7 @@ describe('ToolRuntime', () => { })], })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'rejected' }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'why it was rejected' }], source: { kind: 'test' } }]) @@ -1038,7 +1038,7 @@ describe('ToolRuntime', () => { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'test' }, })] })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.additionalContexts).toMatchObject([{ content: [{ text: 'fyi' }], source: { kind: 'test' } }]) }) @@ -1083,7 +1083,7 @@ describe('ToolRuntime', () => { } }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('composite'), name: 'composite', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('composite'), name: 'composite', arguments: {} }) expect(result.additionalContexts?.map(context => context.source)).toEqual([ { kind: 'nested-1' }, @@ -1107,7 +1107,7 @@ describe('ToolRuntime', () => { }, })) - const failed = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('failed'), name: 'failing-composite', arguments: {} }) + const failed = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('failed'), name: 'failing-composite', arguments: {} }) expect(failed.isError).toBe(true) expect(failed.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'nested' }]) @@ -1118,7 +1118,7 @@ describe('ToolRuntime', () => { content: [{ type: 'text', text: 'block-only' }], source: { kind: 'blocker' }, })], })) - const blocked = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('blocked'), name: 'failing-composite', arguments: {} }) + const blocked = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('blocked'), name: 'failing-composite', arguments: {} }) expect(blocked.isError).toBe(true) expect(blocked.additionalContexts?.map(context => context.source)).toEqual([{ kind: 'blocker' }]) }) @@ -1141,7 +1141,7 @@ describe('ToolRuntime', () => { return decision }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'x' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'x' } }) expect(result.isError).toBe(false) // pre runs fully (gate) before dispatch, then post runs over the result. expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after']) @@ -1169,7 +1169,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'traced', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'traced', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false, value: [{ type: 'text', text: 'hi' }] }) // The around-dispatch extension point wraps dispatch; pre gates before it, post runs over its result. expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post']) @@ -1192,7 +1192,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-in-pre'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1217,7 +1217,7 @@ describe('ToolRuntime', () => { }) ctx.on('tools/pre-execute', async () => ({ kind: 'cancel' })) - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { callId: ToolCallId('cancelled-by-policy'), name: 'cancelled-by-policy', arguments: {}, @@ -1249,7 +1249,7 @@ describe('ToolRuntime', () => { return { kind: 'deny', reason: 'policy denied the call' } }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('denied-after-cancel'), name: 'denied-after-cancel', arguments: {}, signal: controller.signal, }) @@ -1282,7 +1282,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-pre-error'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1321,7 +1321,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-in-around'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1355,7 +1355,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('cancelled-wrapper'), name: 'must-not-run', arguments: {}, signal: controller.signal, }) @@ -1390,7 +1390,7 @@ describe('ToolRuntime', () => { } }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-short-circuit'), name: 'short-circuited', arguments: {}, @@ -1432,7 +1432,7 @@ describe('ToolRuntime', () => { return result }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-after-body'), name: 'completed-before-wrapper', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1475,7 +1475,7 @@ describe('ToolRuntime', () => { } }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-in-post'), name: 'completed-before-post', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1509,7 +1509,7 @@ describe('ToolRuntime', () => { throw new HarnessError('wrapper failed', 'WRAPPER_FAILURE') }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('wrapper-failure'), name: 'wrapper-failure', arguments: {}, signal: controller.signal, }) @@ -1541,7 +1541,7 @@ describe('ToolRuntime', () => { }, }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('tool-failure'), name: 'tool-failure', arguments: {}, signal: controller.signal, }) @@ -1566,7 +1566,7 @@ describe('ToolRuntime', () => { throw new HarnessError('post-policy failed', 'POST_FAILURE') }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('post-failure'), name: 'echo', arguments: {}, signal: controller.signal, }) @@ -1609,7 +1609,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('cancelled-body'), name: 'cooperative', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1643,7 +1643,7 @@ describe('ToolRuntime', () => { }) const controller = new AbortController() - await ctx.tools.execute({ + await executeToolFixture(ctx, { callId: ToolCallId('restored-signal'), name: 'echo', arguments: {}, signal: controller.signal, }) @@ -1667,7 +1667,7 @@ describe('ToolRuntime', () => { }, }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('drain-body'), name: 'uncooperative', arguments: {}, signal: controller.signal, }) await entered.promise @@ -1714,7 +1714,7 @@ describe('ToolRuntime', () => { observedResult = result }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('pre-aborted'), name: 'domain-abort', get arguments() { argumentReads += 1; return callerArguments }, @@ -1746,7 +1746,7 @@ describe('ToolRuntime', () => { let observed = 0 ctx.on('tools/result', () => { observed += 1 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('invalid-pre-aborted'), name: 'missing', arguments: { invalid: () => undefined }, @@ -1772,7 +1772,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: nope' }) expect(entered).toBe(false) // A denied call never enters the around-dispatch extension point. @@ -1795,7 +1795,7 @@ describe('ToolRuntime', () => { return result }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) expect(seen).toEqual({ isError: true, error: { message: 'kaboom', info: { name: 'HarnessError', code: 'BOOM' } }, @@ -1818,7 +1818,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('frozen-canonical'), name: 'echo', arguments: { text: 'original' }, }) @@ -1841,7 +1841,7 @@ describe('ToolRuntime', () => { return next() }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'boom', arguments: {} }) expect(postSaw).toBe(true) // the normalized isError still flows through post-execute expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: exploded' }) @@ -1869,7 +1869,7 @@ describe('ToolRuntime', () => { return next() }) - await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) + await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream }) expect(seenSignal).toBeDefined() expect(seenSignal).not.toBe(upstream) expect(seenSignal).not.toBe(replacement) @@ -1887,7 +1887,7 @@ describe('ToolRuntime', () => { ctx.on('tools/execute', async (_exec: ToolDispatchExecution, _next: () => Promise): Promise => ({ content: [{ type: 'text', text: 'ignored authored content' }], isError: false, value: 'short-circuited' })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'never-runs', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'never-runs', arguments: {} }) expect(dispatched).toBe(false) // returning without next() skips core dispatch expect(result.content[0]).toMatchObject({ text: 'short-circuited' }) }) @@ -1926,10 +1926,10 @@ describe('ToolRuntime', () => { return next() }) - const first = await ctx.tools.execute({ + const first = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('cached-first'), name: 'string-output', arguments: {}, }) - const second = await ctx.tools.execute({ + const second = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('cached-second'), name: 'object-output', arguments: {}, }) @@ -1954,7 +1954,7 @@ describe('ToolRuntime', () => { })], })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('around-context'), name: 'echo', arguments: {}, }) @@ -1971,7 +1971,7 @@ describe('ToolRuntime', () => { ctx.tools.register(echoTool) ctx.on('tools/execute', async () => { throw new Error('wrapper broke') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: wrapper broke' }], error: { message: 'wrapper broke' }, @@ -1986,7 +1986,7 @@ describe('ToolRuntime', () => { throw new Error('permission hook broke') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: permission hook broke' }], @@ -2002,7 +2002,7 @@ describe('ToolRuntime', () => { throw new Error('post hook broke') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'Error: post hook broke' }], @@ -2018,7 +2018,7 @@ describe('ToolRuntime', () => { throw new HarnessError('denied', 'DENIED') }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: { text: 'hi' } }) expect(result).toMatchObject({ isError: true, @@ -2244,7 +2244,7 @@ describe('defineTool / schema DSL', () => { }, }]) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'typed-echo', @@ -2303,7 +2303,7 @@ describe('defineTool / schema DSL', () => { }) // Execution round-trip - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'roundtrip', @@ -2340,7 +2340,7 @@ describe('defineTool / schema DSL', () => { required: ['path'], }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'raw-tool', @@ -2519,7 +2519,7 @@ describe('schema DSL optional and nested contracts', () => { throw { message: 'denied by object' } }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-thrower', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: denied by object' }) }) @@ -2534,7 +2534,7 @@ describe('schema DSL optional and nested contracts', () => { throw 'kaboom' }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'string-thrower', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'string-thrower', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' }) }) @@ -2549,7 +2549,7 @@ describe('schema DSL optional and nested contracts', () => { throw { code: 500 } }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-no-message', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'object-no-message', arguments: {} }) expect(result.isError).toBe(true) const firstContent = result.content[0]! expect(firstContent.type).toBe('text') @@ -2688,7 +2688,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }, })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toMatchObject({ text: 'Error: invalid arguments: missing required property "path"', @@ -2705,7 +2705,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: `read ${args.path}` }] }, })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: { path: '/x' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: { path: '/x' } }) expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false, @@ -2732,7 +2732,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () return [{ type: 'text', text: args.path }] }, })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'reader', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ message: 'invalid arguments: missing required property "path"', @@ -2750,7 +2750,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new HarnessError('disk full', 'ENOSPC') }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'coded', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'coded', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ message: 'disk full', info: { name: 'HarnessError', code: 'ENOSPC' } }) expect(result.content[0]).toMatchObject({ text: 'Error: disk full' }) @@ -2765,7 +2765,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () throw new Error('just a message') }, }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'plain', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'plain', arguments: {} }) expect(result.isError).toBe(true) expect(result.error).toEqual({ message: 'just a message' }) expect(result.content[0]).toMatchObject({ text: 'Error: just a message' }) @@ -2788,7 +2788,7 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', () }) // Missing the "required" path — but raw tools validate their own input, so // this reaches execute rather than being rejected by the harness. - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'raw', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'raw', arguments: {} }) expect(result.isError).toBe(false) }) @@ -2893,3 +2893,52 @@ describe('defineTool presentation (presentCall / presentResult)', () => { expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined() }) }) + +it('executes raw native calls without an Agent or Session and keeps input order', async () => { + const ctx = await setup() + try { + ctx.tools.register(echoTool) + const batch = await ctx.tools.execute({ + signal: testToolSignal, + calls: [ + { type: 'tool-call', id: ToolCallId('raw'), name: 'echo', arguments: '{ "text": "raw value" }' }, + { type: 'tool-call', id: ToolCallId('malformed'), name: 'echo', arguments: '{invalid' }, + { type: 'tool-call', id: ToolCallId('empty'), name: 'echo', arguments: '' }, + ], + }) + expect(batch.results.map(outcome => outcome.callId)).toEqual(['raw', 'malformed', 'empty']) + expect(batch.results[0]?.result).toMatchObject({ isError: false, value: 'raw value' }) + expect(batch.results[1]?.result.isError).toBe(true) + expect(batch.results[2]?.result).toMatchObject({ isError: false, value: '' }) + expect(batch.additionalContexts).toEqual([]) + expect(batch.concluded).toBe(false) + expect(ctx.get('sessions')).toBeUndefined() + } finally { + await ctx.fiber.dispose() + } +}) + +it('settles every unowned call when cancellation precedes the batch', async () => { + const ctx = await setup() + try { + let invoked = false + ctx.tools.register(defineTool({ + ...echoTool, + parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + async execute() { invoked = true; return 'unexpected' }, + })) + const abort = new AbortController() + abort.abort() + const batch = await ctx.tools.execute({ + signal: abort.signal, + calls: ['first', 'second'].map(id => ({ type: 'tool-call', id: ToolCallId(id), name: 'echo', arguments: '{}' })), + }) + expect(invoked).toBe(false) + expect(batch.results.map(({ result }) => result.error?.info?.code)).toEqual([ + TOOL_ABORTED_BEFORE_DISPATCH, TOOL_ABORTED_BEFORE_DISPATCH, + ]) + } finally { + await ctx.fiber.dispose() + } +}) diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index 95c7ee1a48..f26682444c 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -11,9 +11,6 @@ { "path": "../../core/session" }, - { - "path": "../../ptc-runtime/ptc-runtime" - }, { "path": "../../../vendor/cosmokit" }, @@ -37,15 +34,6 @@ }, { "path": "../../interaction/user-approval" - }, - { - "path": "../../sandbox/sandbox" - }, - { - "path": "../../sandbox/sandbox-policy" - }, - { - "path": "../../session/working-directory" } ] } diff --git a/packages/core/tools/tsdown.config.ts b/packages/core/tools/tsdown.config.ts new file mode 100644 index 0000000000..62fc0d1b77 --- /dev/null +++ b/packages/core/tools/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + entry: { index: 'lib/types/index.js', pipeline: 'lib/types/pipeline.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + outputOptions: { chunkFileNames: 'shared-[hash].js' }, +}) diff --git a/packages/deliverables/tool-present/package.json b/packages/deliverables/tool-present/package.json index 8acb591637..4b085edad5 100644 --- a/packages/deliverables/tool-present/package.json +++ b/packages/deliverables/tool-present/package.json @@ -53,6 +53,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index e8b0bee060..7ac3083124 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -38,7 +38,7 @@ try { ctx.on('tools/result', (_exec, result) => events.push(result)) let n = 0 for (const [files, code] of [[[{ path: 'missing' }], 'FS_NOT_FOUND'], [[{ path: 42 }], 'INVALID_ARGS']]) { - const result = await ctx.tools.execute({ signal: new AbortController().signal, name: 'present', callId: 'call-' + (++n), arguments: { files }, agent: owner }) + const result = await executeToolFixture(ctx, { 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) diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index afbbf5958a..1ae239ec92 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -1,4 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Explicit deliveries commit only after a successful final tool result. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { mkdtemp, rm, writeFile, symlink, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -59,6 +61,7 @@ async function setup() { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(SessionProjectionRegistry) @@ -67,7 +70,7 @@ async function setup() { await fiber const owner = await agent(ctx, root) owner.session.append('turn/start', { turn: 1 }) - const execute = (files: unknown) => ctx.tools.execute({ + const execute = (files: unknown) => executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId(`call-${++callNumber}`), name: 'present', arguments: { files }, agent: owner, }) @@ -157,13 +160,13 @@ it('validates deployment limits before registering the tool', () => { 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' }] } }) + const detached = await executeToolFixture(ctx, { 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 }) + const absent = await executeToolFixture(ctx, { 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/experimental/auto-review/package.json b/packages/experimental/auto-review/package.json index 38916cdfd3..78cafaec01 100644 --- a/packages/experimental/auto-review/package.json +++ b/packages/experimental/auto-review/package.json @@ -67,8 +67,11 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/experimental/auto-review/src/index.ts b/packages/experimental/auto-review/src/index.ts index d4975b54f5..4653392c52 100644 --- a/packages/experimental/auto-review/src/index.ts +++ b/packages/experimental/auto-review/src/index.ts @@ -8,6 +8,7 @@ * @module @deepseek-ai/dsh-experimental-auto-review */ +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/experimental/auto-review/tests/auto-review.e2e.ts b/packages/experimental/auto-review/tests/auto-review.e2e.ts index 04f9c613db..381b9f79fb 100644 --- a/packages/experimental/auto-review/tests/auto-review.e2e.ts +++ b/packages/experimental/auto-review/tests/auto-review.e2e.ts @@ -1,3 +1,4 @@ +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { randomUUID } from 'node:crypto' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -314,7 +315,7 @@ it('certifies eight Auto risk/authorization cases with zero retries and zero ski sessionId: SessionId(randomUUID()), meta: { cwd: workspace }, agentOptions: { provider: PROVIDER, model }, }) try { - if (path === 'ptc-inner') handle.agent.ctx.tools.presentAs('ptc') + if (path === 'ptc-inner') handle.agent.ctx.plugin(PtcToolExecutor) selectFinalAuto(ctx, handle.agent.session) const command = `rm -- ${quote(target)}` await runCase(handle.agent, 'M01-unauthorized', path, command, @@ -375,7 +376,7 @@ it.each(['native', 'ptc-inner'] as const)('feeds denial back, re-reviews a new c sessionId: SessionId(randomUUID()), meta: { cwd: root }, agentOptions: { provider: PROVIDER, model: FLASH }, }) try { - if (path === 'ptc-inner') handle.agent.ctx.tools.presentAs('ptc') + if (path === 'ptc-inner') handle.agent.ctx.plugin(PtcToolExecutor) selectFinalAuto(ctx, handle.agent.session) const rawReason = ` TEST_ONLY_SECRET_${'x'.repeat(16_384)}\nexact deletion was not authorized ` for (let attempt = 0; attempt < 2; attempt += 1) { diff --git a/packages/experimental/auto-review/tests/auto-review.spec.ts b/packages/experimental/auto-review/tests/auto-review.spec.ts index 096addd35b..f174fd66f9 100644 --- a/packages/experimental/auto-review/tests/auto-review.spec.ts +++ b/packages/experimental/auto-review/tests/auto-review.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { externalTestParent } from '../../../subagent/subagent/tests/external-activation-helpers.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -35,13 +37,8 @@ import SubagentRuntime, { import type {} from '@deepseek-ai/dsh-shell' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' -import ToolRuntime, { - defineContentToolFixture, - RUN_CODE_NAME, - TOOL_ABORTED_BEFORE_DISPATCH, - type PreToolDecision, - type ToolExecutionToken, -} from '@deepseek-ai/dsh-tools' +import ToolRuntime, { RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, type PreToolDecision, type ToolExecutionToken } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import ApprovalService, { setApprovalPolicy, type ApprovalOutcome } from '@deepseek-ai/dsh-user-approval' import * as AutoReview from '@deepseek-ai/dsh-experimental-auto-review' @@ -139,6 +136,7 @@ async function harness( await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) ctx.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('auto-review tests do not execute shell requests') }, @@ -245,7 +243,7 @@ function requestSections(request: GenerateOptions): Record { return sections } -function expectReviewFailure(result: Awaited>, cause?: string): void { +function expectReviewFailure(result: Awaited>, cause?: string): void { if (!result.isError) throw new Error('the reviewed call executed') expect(result.error.info).toBeUndefined() const prefix = 'Auto review of tool "probe" failed; its body was not executed: auto-review: ' @@ -370,7 +368,7 @@ describe('native review request', () => { arguments: { path: 'second-ptc-history' }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: currentCallId, name: 'probe', @@ -498,7 +496,7 @@ describe('native review request', () => { ], 1, 2) appendNativeCall(session, callId, 'probe', '{"path":"current"}', 1, 2) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -570,7 +568,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -606,7 +604,7 @@ describe('native review request', () => { type: 'tool-call', id: incompleteCallId, name: 'probe', arguments: '{}', }]) appendNativeCall(incomplete, incompleteCallId, 'probe', '{}') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: incompleteCallId, name: 'probe', @@ -644,7 +642,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -698,7 +696,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -729,7 +727,7 @@ describe('native review request', () => { const emptyId = ToolCallId('empty-arguments') appendAssistant(session, [{ type: 'tool-call', id: emptyId, name: 'probe', arguments: '' }]) appendNativeCall(session, emptyId, 'probe', '') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: emptyId, name: 'probe', @@ -741,7 +739,7 @@ describe('native review request', () => { session.append('step/end', { turn: 1, step: 1 }) appendAssistant(session, [{ type: 'tool-call', id: invalidId, name: 'probe', arguments: 'not-json' }], 1, 2) appendNativeCall(session, invalidId, 'probe', 'not-json', 1, 2) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: new AbortController().signal, callId: invalidId, name: 'probe', @@ -797,7 +795,7 @@ describe('native review request', () => { const callId = ToolCallId(`legal-${item.id}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -837,7 +835,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) @@ -864,7 +862,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) @@ -960,7 +958,7 @@ describe('native review request', () => { const callId = ToolCallId(`${id}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -993,7 +991,7 @@ describe('native review request', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) @@ -1035,7 +1033,7 @@ describe('PTC and bypass semantics', () => { arguments: { path: 'inside' }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, rootCallId: outerCallId, parent: Symbol('outer execution') as ToolExecutionToken, @@ -1133,7 +1131,7 @@ describe('PTC and bypass semantics', () => { arguments: { path: 'current' }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, rootCallId: outerCallId, parent: Symbol('outer execution') as ToolExecutionToken, @@ -1172,20 +1170,20 @@ describe('PTC and bypass semantics', () => { const ordinaryAgent = agentFor(ordinary) const auto = autoSession(ctx, 'outer-run-code') - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('without-agent'), name: 'probe', arguments: {}, })).resolves.toMatchObject({ isError: false }) - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('ordinary'), name: 'probe', arguments: {}, agent: ordinaryAgent, })).resolves.toMatchObject({ isError: false }) - await expect(ctx.tools.execute({ + await expect(executeModelToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('outer-run-code'), name: RUN_CODE_NAME, @@ -1257,7 +1255,7 @@ describe('out-of-process delegation boundary', () => { type: 'tool-call', id: deniedId, name: 'delegate_remote', arguments: rawArgs, }]) appendNativeCall(session, deniedId, 'delegate_remote', rawArgs) - const denied = await ctx.tools.execute({ + const denied = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: deniedId, name: 'delegate_remote', @@ -1282,7 +1280,7 @@ describe('out-of-process delegation boundary', () => { type: 'tool-call', id: allowedId, name: 'delegate_remote', arguments: rawArgs, }], 2, 1) appendNativeCall(session, allowedId, 'delegate_remote', rawArgs, 2, 1) - const allowed = await ctx.tools.execute({ + const allowed = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: allowedId, name: 'delegate_remote', @@ -1330,7 +1328,7 @@ describe('cancellation and integration teardown', () => { appendNativeCall(session, callId, 'probe', '{}') const controller = new AbortController() - const pending = ctx.tools.execute({ signal: controller.signal, callId, name: 'probe', arguments: {}, agent }) + const pending = executeToolFixture(ctx, { signal: controller.signal, callId, name: 'probe', arguments: {}, agent }) await entered.promise controller.abort(new Error('caller stopped')) expect(adapter.requests[0]?.signal?.aborted).toBe(true) @@ -1359,7 +1357,7 @@ describe('cancellation and integration teardown', () => { const callId = ToolCallId(`dispose-${outcome}-call`) appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) await entered.promise @@ -1369,7 +1367,7 @@ describe('cancellation and integration teardown', () => { expect(ctx.permissionPresets.current(session)).toBe('danger-full-access') expect(ctx.permissionPresets.names).toContain(AUTO_PRESET) - const afterClose = await ctx.tools.execute({ + const afterClose = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId(`after-close-${outcome}`), name: 'probe', @@ -1404,7 +1402,7 @@ describe('cancellation and integration teardown', () => { appendNativeCall(second.session, callId, 'probe', '{}') let observedPreset: string | undefined let selectionFailure: unknown - let competingCall: ReturnType | undefined + let competingCall: ReturnType | undefined ctx.on('session/event', (session, event) => { if (session !== first.session || event.type !== 'permission/preset' || event.data.preset !== 'danger-full-access') return @@ -1414,7 +1412,7 @@ describe('cancellation and integration teardown', () => { } catch (error) { selectionFailure = error } - competingCall = ctx.tools.execute({ + competingCall = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -1456,7 +1454,7 @@ describe('cancellation and integration teardown', () => { const callId = ToolCallId('dispose-downstream-call') appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', arguments: {}, agent, }) await downstreamEntered.promise @@ -1491,7 +1489,7 @@ describe('cancellation and integration teardown', () => { appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - await expect(ctx.tools.execute({ + await expect(executeToolFixture(ctx, { signal: controller.signal, callId, name: 'probe', arguments: {}, agent, })).resolves.toMatchObject({ isError: true, @@ -1524,6 +1522,7 @@ describe('cancellation and integration teardown', () => { await invalid.plugin(SessionProjectionRegistry) await invalid.plugin(SystemPrompt, {}) await invalid.plugin(ToolRuntime) + await invalid.plugin(NativeToolExecutor) invalid.provide('shell', { sandboxMode: 'workspace-write', resolve() { throw new Error('unused') }, @@ -1681,7 +1680,7 @@ describe('logged-fact failures', () => { const { session, agent } = autoSession(ctx, id) const callId = ToolCallId(`${id}-call`) prepare(session, callId) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -1704,7 +1703,7 @@ describe('logged-fact failures', () => { const callId = ToolCallId('missing-cwd-call') appendAssistant(session, [{ type: 'tool-call', id: callId, name: 'probe', arguments: '{}' }]) appendNativeCall(session, callId, 'probe', '{}') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId, name: 'probe', @@ -1813,7 +1812,7 @@ describe('logged-fact failures', () => { session.append('step/start', { turn: 1, step: 1 }) } item.starts(session, outer, inner) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, rootCallId: item.execute?.root ?? outer, parent: Symbol('parent') as ToolExecutionToken, diff --git a/packages/experimental/auto-review/tsconfig.json b/packages/experimental/auto-review/tsconfig.json index efbd2586aa..c1573e29d0 100644 --- a/packages/experimental/auto-review/tsconfig.json +++ b/packages/experimental/auto-review/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../../session/working-directory" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/experimental/browser-use-runtime/package.json b/packages/experimental/browser-use-runtime/package.json index 9715e09ddd..81b9498069 100644 --- a/packages/experimental/browser-use-runtime/package.json +++ b/packages/experimental/browser-use-runtime/package.json @@ -50,6 +50,9 @@ "@deepseek-ai/dsh-browser-use": "workspace:*", "@deepseek-ai/dsh-mcp-client": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", diff --git a/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts b/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts index 003ea14822..bc6e18eb51 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts @@ -1,4 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real upstream MCP browser checks against private loopback pages and disposable Chromium. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { spawn } from 'node:child_process' import { once } from 'node:events' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -58,7 +60,7 @@ export async function verifyMcpBrowser( browserConfig = { mode: 'attach', endpoint: `http://127.0.0.1:${port}` } } const modules = new Map([ - ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm], + ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['toolExecutor', NativeToolExecutor], ['llm', Llm], ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections], ['browser', provider], ['fs', LocalFileSystem], ['workingDirectory', WorkingDirectory], ]) @@ -82,7 +84,7 @@ export async function verifyMcpBrowser( const schemas = ctx.tools.schemas(owner.agent) expect(schemas.length).toBeGreaterThan(5) expect(schemas.every(tool => tool.name.startsWith(`mcp__${namespace}__`))).toBe(true) - const result = await ctx.tools.execute({ agent: owner.agent, name: `mcp__${namespace}__${navigate.name}`, arguments: navigate.arguments(url), callId: ToolCallId('navigate'), signal: new AbortController().signal }) + const result = await executeToolFixture(ctx, { agent: owner.agent, name: `mcp__${namespace}__${navigate.name}`, arguments: navigate.arguments(url), callId: ToolCallId('navigate'), signal: new AbortController().signal }) expect(result.isError, JSON.stringify(result.content)).toBe(false) expect(observed.has(`/${namespace}-${mode}`)).toBe(true) expect(result.content.length).toBeGreaterThan(0) diff --git a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts index d9c4465447..ea1068ecf2 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp.spec.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp.spec.ts @@ -1,3 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -69,7 +72,7 @@ async function load(exclusive = false, mode?: string, toolCallTimeoutMs?: number roots.push(root) const model = new FixtureModel() const modules = new Map([ - ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['llm', Llm], + ['browserUse', BrowserUse], ['prompt', SystemPrompt], ['tools', Tools], ['toolExecutor', NativeToolExecutor], ['llm', Llm], ['sessions', Sessions], ['agents', Agents], ['loop', AgentLoop], ['projections', Projections], ['fs', LocalFileSystem], ['workingDirectory', WorkingDirectory], ['model', { inject: ['llm'], apply(ctx: Context) { ctx.effect(() => ctx.llm.registerAdapter(['fixture'], model)) } }], @@ -103,11 +106,11 @@ async function warm(ctx: Context, agent: Agent, signal = new AbortController().s } function execute(ctx: Context, agent: Agent, name = TOOL) { - return ctx.tools.execute({ agent, name, arguments: name === TOOL ? { label: 'direct' } : {}, callId: ToolCallId('direct'), signal: new AbortController().signal }) + return executeToolFixture(ctx, { agent, name, arguments: name === TOOL ? { label: 'direct' } : {}, callId: ToolCallId('direct'), signal: new AbortController().signal }) } function resource(ctx: Context, agent: Agent | undefined, name = 'read_mcp_resource', server = 'browser-fixture', callId = name) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { ...agent === undefined ? {} : { agent }, name, arguments: { server, ...name === 'read_mcp_resource' ? { uri: 'browser-fixture://state' } : {} }, callId: ToolCallId(callId), signal: new AbortController().signal, @@ -350,7 +353,7 @@ describe('Session MCP Loader composition', () => { expect((await resource(ctx, child.agent, 'read_mcp_resource', 'docs')).value) .toEqual({ contents: [{ uri: 'docs://memo', text: 'Independent document.' }] }) for (const args of [null, 'invalid arguments']) { - expect((await ctx.tools.execute({ agent: child.agent, name: 'read_mcp_resource', arguments: args, callId: ToolCallId('invalid-resource'), signal: new AbortController().signal })).isError).toBe(true) + expect((await executeToolFixture(ctx, { agent: child.agent, name: 'read_mcp_resource', arguments: args, callId: ToolCallId('invalid-resource'), signal: new AbortController().signal })).isError).toBe(true) } expect(browserState(await resource(ctx, parent.agent)).counter).toBe(0) await parent.dispose() @@ -423,7 +426,7 @@ describe('Session MCP Loader composition', () => { sessionId: SessionId('ptc-first-request'), agentOptions: { provider: 'fixture', model: 'fixture' }, setup: (inner, agent) => { initializing = agent - inner.tools.presentAs('ptc') + inner.plugin(PtcToolExecutor) inner.on('agent/created', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Describe the available browser tools.' }], source: { kind: 'user' } })) }, { prepend: true }) diff --git a/packages/experimental/browser-use-stagehand-native/package.json b/packages/experimental/browser-use-stagehand-native/package.json index a30099c51a..015fe5af73 100644 --- a/packages/experimental/browser-use-stagehand-native/package.json +++ b/packages/experimental/browser-use-stagehand-native/package.json @@ -62,6 +62,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "tsx": "^4.23.13" } diff --git a/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts b/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts index 253f2dd6a9..c02ece4f6f 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts @@ -1,5 +1,6 @@ /** A real Loader and AgentLoop exercise independently configured browser inference and durable screenshots. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -80,6 +81,7 @@ it('loads browser tools from cordis.yml, logs browser results, and admits the sc ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-attachment-local', LocalAttachmentStore], diff --git a/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts b/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts index 99ed0a1498..c8657985a7 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Opt-in installed Stagehand smoke against an owned local page and optional real model. */ import { createServer } from 'node:http' @@ -49,7 +50,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1')('launches and attaches to insta ctx.llm.registerAdapter(['visual-fixture'], new ImageCapabilities()) const harness = await mountAgentLoopTestHarness(ctx) const agent = await harness.create(SessionId('stagehand-live'), { provider: 'visual-fixture', model: 'vision' }) - const execute = (suffix: string, args: unknown) => ctx.tools.execute({ + const execute = (suffix: string, args: unknown) => executeToolFixture(ctx, { agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`live-${suffix}`), signal, }) const launched = ctx.plugin(Provider, { model: nativeModel, mode: 'launch', executablePath: executable }) @@ -123,7 +124,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1' || !process.env.DSH_STAGEHAND_MO await ctx.plugin(Provider, { model: stagehandModelSchema.parse({ modelName: process.env.DSH_STAGEHAND_MODEL, apiKey: process.env.DSH_STAGEHAND_MODEL_API_KEY }), mode: 'launch', ...process.env.DSH_BROWSER_EXECUTABLE === undefined ? {} : { executablePath: process.env.DSH_BROWSER_EXECUTABLE } }) const harness = await mountAgentLoopTestHarness(ctx) const agent = await harness.create(SessionId('stagehand-real-model'), {}) - const execute = (suffix: string, args: unknown) => ctx.tools.execute({ agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`api-${suffix}`), signal }) + const execute = (suffix: string, args: unknown) => executeToolFixture(ctx, { agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`api-${suffix}`), signal }) const navigation = await execute('navigate', { url: `http://127.0.0.1:${address.port}` }) expect(navigation.isError, JSON.stringify(navigation.content)).toBe(false) const result = await execute('extract', { @@ -183,7 +184,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1' || process.platform === 'win32') await mountAgentLoopTestHarness(ctx) await ctx.plugin(Provider, { model: nativeModel, mode: 'launch', executablePath: wrapper }) const owner = await ctx.agents.create({ sessionId: SessionId('stagehand-stalled-init') }) - const operation = owner.agent.runMaintenance(signal => ctx.tools.execute({ + const operation = owner.agent.runMaintenance(signal => executeToolFixture(ctx, { agent: owner.agent, name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('stalled-init'), signal, })) await connected.promise @@ -232,7 +233,7 @@ it.skipIf(process.env.DSH_STAGEHAND_E2E !== '1')('drains canceled navigation bef await mountAgentLoopTestHarness(ctx) await ctx.plugin(Provider, { model: nativeModel, mode: 'launch', executablePath: executable }) const owner = await ctx.agents.create({ sessionId: SessionId('stagehand-cancel-reconnect') }) - const call = (method: string, args: unknown) => owner.agent.runMaintenance(signal => ctx.tools.execute({ + const call = (method: string, args: unknown) => owner.agent.runMaintenance(signal => executeToolFixture(ctx, { agent: owner.agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`reconnect-${method}`), signal, })) expect((await call('navigate', { url: url.replace('/pending', '/retained') })).isError).toBe(false) diff --git a/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts b/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts index 4cc9491f08..43992071c6 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Native browser ownership through real Agent, Session, and ToolRuntime services. */ import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -90,7 +91,7 @@ beforeEach(async () => { afterEach(async () => { await ctx.fiber.dispose() }) function execute(agent: Agent, suffix: string, args: unknown, signal = new AbortController().signal) { - return ctx.tools.execute({ agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`test-${suffix}`), signal }) + return executeToolFixture(ctx, { agent, name: `stagehand_${suffix}`, arguments: args, callId: ToolCallId(`test-${suffix}`), signal }) } it('lazily launches distinct browsers and preserves each Session tab state', async () => { @@ -148,9 +149,9 @@ it('uses its independent model for actions, observations, and a caller-selected it('refuses unavailable tabs and calls without an exact live Agent', async () => { await ctx.plugin(Provider, { model: nativeModel, mode: 'launch' }) expect((await execute(first, 'navigate', { url: 'https://example.com', pageId: 'missing' })).isError).toBe(true) - expect((await ctx.tools.execute({ name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('agentless'), signal: new AbortController().signal })).isError).toBe(true) + expect((await executeToolFixture(ctx, { name: 'stagehand_tabs', arguments: { action: 'list' }, callId: ToolCallId('agentless'), signal: new AbortController().signal })).isError).toBe(true) ctx.tools.register({ name: 'unrelated', description: 'An independent tool.', parameters: { type: 'object' }, output: { schema: { type: 'boolean' }, render: () => [{ type: 'text', text: 'independent' }] }, execute: async () => true }) - const unrelated = await ctx.tools.execute({ agent: first, name: 'unrelated', arguments: {}, callId: ToolCallId('unrelated'), signal: new AbortController().signal }) + const unrelated = await executeToolFixture(ctx, { agent: first, name: 'unrelated', arguments: {}, callId: ToolCallId('unrelated'), signal: new AbortController().signal }) expect(unrelated.isError).toBe(false) }) diff --git a/packages/experimental/claude-code-mods/package.json b/packages/experimental/claude-code-mods/package.json index c2b79d69ef..307e8aef8f 100644 --- a/packages/experimental/claude-code-mods/package.json +++ b/packages/experimental/claude-code-mods/package.json @@ -93,6 +93,8 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 3d6363b463..0dbb1f1caf 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -363,16 +363,19 @@ export function createHostOps(options: HostOpsOptions): OpTable { // The pipeline raises `tool.call` for this call from the calling mod, so only earlier mods see it. options.callOrigins.set(callId, context.mod) try { - const result = await registry.execute({ - callId, - name: aliases.toHarness(requireString(tool, '$.tool.call tool')), - arguments: args, + const batch = await registry.execute({ + calls: [{ + type: 'tool-call', id: callId, + name: aliases.toHarness(requireString(tool, '$.tool.call tool')), + arguments: JSON.stringify(args), + }], ...agent === undefined ? {} : { agent }, signal: context.signal, }) // Guidance a tool defers to the next request still reaches the model when a mod made the call. - if (agent !== undefined) for (const context of result.additionalContexts ?? []) agent.inject(context) - return toolCallResultOf(result) + if (agent !== undefined) for (const context of batch.additionalContexts) agent.inject(context) + // oxlint-disable-next-line typescript/no-non-null-assertion -- execute returns one result per call + return toolCallResultOf(batch.results[0]!.result) } finally { options.callOrigins.delete(callId) } diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 4ee329ce99..3f7ded871f 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' @@ -23,7 +24,7 @@ import Storage from '@deepseek-ai/dsh-storage' import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' import * as StorageJson from '@deepseek-ai/dsh-storage-json' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import UserQuestionService from '@deepseek-ai/dsh-user-questions' import ClaudeCodeMods, { defineMod, MODS_API_VERSION, type Config, type ModPlugin, type ModRegister } from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -416,7 +417,7 @@ describe('tool.call: answers and rewrites', () => { 'claude-code-mods: reroute-mod: tool.call hook skipped: rerouted the call from echo to Read; the logged call runs the tool it named', ) // A call without an agent (a host-side execute) is answered by the same schema rule. - const direct = await h.ctx.tools.execute({ callId: ToolCallIdOf('direct'), name: 'greet', arguments: {}, signal: new AbortController().signal }) + const direct = await executeToolFixture(h.ctx, { callId: ToolCallIdOf('direct'), name: 'greet', arguments: {}, signal: new AbortController().signal }) expect(direct.isError).toBe(false) expect(direct.content).toEqual([{ type: 'text', text: 'rendered hello from answer-mod' }]) const refusals = h.warn.mock.calls.map(call => String(call[0])).filter(line => line.includes('rewrote the arguments of echo')) @@ -786,7 +787,7 @@ describe('subagents, programmatic calls, and malformed tool arguments', () => { 'watch-mod: tool.call echo agent=root n=1', 'watch-mod: turn.complete agent=root inputs=[{}]', ]) - const direct = await h.ctx.tools.execute({ callId: ToolCallIdOf('direct'), name: 'echo', arguments: { command: 'direct' }, signal: new AbortController().signal }) + const direct = await executeToolFixture(h.ctx, { callId: ToolCallIdOf('direct'), name: 'echo', arguments: { command: 'direct' }, signal: new AbortController().signal }) expect(direct.isError).toBe(false) // The nested `$.tool.call` reaches only mods loaded before watch-mod, so its own hook is not re-entered. expect(h.info).toHaveBeenCalledWith('watch-mod: direct saw echo and {"result":"ran nested"}') diff --git a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts index 5eae8a59d6..4c1fb0d546 100644 --- a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts +++ b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts @@ -1,5 +1,6 @@ /** Real Loader composition: the bridge mounted from a cordis.yml row beside the shipped core plugins. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -17,7 +18,8 @@ import LlmRuntime, { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as ClaudeCodeMods from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -44,6 +46,7 @@ it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally th ['@deepseek-ai/dsh-fs-local', LocalFileSystem], ['@deepseek-ai/dsh-working-directory', WorkingDirectory], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-commands', CommandRuntime], diff --git a/packages/experimental/computer-use-cua-driver-mcp/package.json b/packages/experimental/computer-use-cua-driver-mcp/package.json index 08163c4f0d..bea17c06f6 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/package.json +++ b/packages/experimental/computer-use-cua-driver-mcp/package.json @@ -42,6 +42,8 @@ "@deepseek-ai/cordis-plugin-include": "workspace:~", "@deepseek-ai/dsh-computer-use": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", diff --git a/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts b/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts index b904d88429..f23deaa812 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts +++ b/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts @@ -1,4 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Loader composition with a real stdio child and a scripted external model. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -63,6 +65,7 @@ async function load(mode?: string): Promise<{ ctx: Context; root: string; model: ['@deepseek-ai/dsh-computer-use', ComputerUse], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-agent', AgentRegistry], @@ -163,14 +166,14 @@ describe('installed Cua Driver Loader composition', () => { it('retains exclusive ownership when the external process disconnects and reconnects', async () => { const { ctx, root } = await load() - await ctx.tools.execute({ name: 'mcp__cua-driver-mcp__disconnect', arguments: {}, callId: ToolCallId('disconnect'), signal: new AbortController().signal }) + await executeToolFixture(ctx, { name: 'mcp__cua-driver-mcp__disconnect', arguments: {}, callId: ToolCallId('disconnect'), signal: new AbortController().signal }) expect(ctx.computerUse.providerName).toBe('cua-driver-mcp') expect(() => ctx.computerUse.register(ComputerUseProviderName('replacement'))).toThrow('already registered') await vi.waitFor(async () => { expect((await driverEvents(root)).filter(event => event.event === 'initialize')).toHaveLength(2) }, { timeout: 10_000 }) await vi.waitFor(async () => { - const result = await ctx.tools.execute({ name: TOOL, arguments: { display: 2 }, callId: ToolCallId('reconnected'), signal: new AbortController().signal }) + const result = await executeToolFixture(ctx, { name: TOOL, arguments: { display: 2 }, callId: ToolCallId('reconnected'), signal: new AbortController().signal }) expect(result.isError).not.toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Display 2' }) }) diff --git a/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts b/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts index 90be0b0a52..0f03725add 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts +++ b/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts @@ -1,4 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Explicit opt-in compatibility check against an installed Cua Driver executable. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { isAbsolute } from 'node:path' import { Context } from '@deepseek-ai/cordis' import ComputerUse from '@deepseek-ai/dsh-computer-use' @@ -32,6 +34,7 @@ it.skipIf(executable === undefined || executable === '')( await ctx.plugin(ComputerUse) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const provider = await ctx.plugin(Provider, { command: executable, args: driverArguments(), @@ -43,7 +46,7 @@ it.skipIf(executable === undefined || executable === '')( expect(schemas.every(tool => tool.name.startsWith('mcp__cua-driver-mcp__'))).toBe(true) const permissions = ctx.tools.get('mcp__cua-driver-mcp__check_permissions') expect(permissions).toBeDefined() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'mcp__cua-driver-mcp__check_permissions', arguments: { prompt: false }, callId: ToolCallId('installed-driver-permissions'), diff --git a/packages/experimental/computer-use-cua-driver-mcp/tests/lifecycle.spec.ts b/packages/experimental/computer-use-cua-driver-mcp/tests/lifecycle.spec.ts index 70b7da1529..a0b952ee29 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/tests/lifecycle.spec.ts +++ b/packages/experimental/computer-use-cua-driver-mcp/tests/lifecycle.spec.ts @@ -1,4 +1,5 @@ /** The external MCP process is replaced by barriers to pin disposal ordering. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import ComputerUse from '@deepseek-ai/dsh-computer-use' @@ -42,6 +43,7 @@ async function context(): Promise { await ctx.plugin(ComputerUse) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } diff --git a/packages/experimental/computer-use-cua-driver-native/package.json b/packages/experimental/computer-use-cua-driver-native/package.json index 41cca2b144..b67b3f3a87 100644 --- a/packages/experimental/computer-use-cua-driver-native/package.json +++ b/packages/experimental/computer-use-cua-driver-native/package.json @@ -54,6 +54,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts index 4066e22a7b..6f65e0a574 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Provider unload owns screenshot admission as well as the native call. */ import { mkdtemp, rm } from 'node:fs/promises' @@ -63,7 +64,7 @@ it('aborts model admission on unload, restores caller cancellation, and closes t return next() }) const controller = new AbortController() - const result = ctx.tools.execute({ + const result = executeToolFixture(ctx, { agent, signal: controller.signal, callId: ToolCallId('native-admission'), name: 'cua_driver_native__get_window_state', arguments: { pid: 9, window_id: 7 }, }) diff --git a/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts index e78af90f9a..df447f6810 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts @@ -1,5 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real Loader and AgentLoop composition with only external native/model fixtures. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -65,6 +67,7 @@ it('loads from cordis.yml and logs the native screenshot before the next model r ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-attachment-local', LocalAttachmentStore], @@ -116,7 +119,7 @@ it('loads from cordis.yml and logs the native screenshot before the next model r expect(Buffer.from(stored.data).toString('base64')).toBe(screenshotBase64) expect(JSON.stringify(model.requests[1]?.messages)).toContain(JSON.stringify(image.attachment)) expect(JSON.stringify(toolResult.content)).not.toContain(screenshotBase64) - const direct = await context.tools.execute({ + const direct = await executeToolFixture(context, { agent, signal: new AbortController().signal, callId: ToolCallId('programmatic-window'), name: 'cua_driver_native__get_window_state', arguments: { pid: 9, window_id: 7 }, }) diff --git a/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts b/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts index 2abcb102a3..bc3cd015c1 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts @@ -1,5 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Opt-in native SDK compatibility check without screenshots, input, or permission prompts. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' @@ -17,6 +19,7 @@ it.skipIf(process.env.DSH_COMPUTER_USE_NATIVE_E2E !== '1')( await ctx.plugin(ComputerUseRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const provider = ctx.plugin(NativeProvider) await provider expect(ctx.computerUse.providerName).toBe('cua-driver-native') @@ -25,7 +28,7 @@ it.skipIf(process.env.DSH_COMPUTER_USE_NATIVE_E2E !== '1')( expect(names).toContain('cua_driver_native__get_window_state') expect(names.every(name => name.startsWith('cua_driver_native__'))).toBe(true) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'cua_driver_native__check_permissions', callId: ToolCallId('native-live-permissions'), arguments: { prompt: false }, diff --git a/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts index 39b9c2bcd8..2a453439f8 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts @@ -1,5 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Native SDK lifecycle and catalog behavior without desktop access. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import ComputerUseRegistry from '@deepseek-ai/dsh-computer-use' @@ -20,6 +22,7 @@ beforeEach(async () => { await ctx.plugin(ComputerUseRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) }) afterEach(async () => { @@ -27,7 +30,7 @@ afterEach(async () => { }) function execute(rawName: string, args: Record = {}) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { name: `cua_driver_native__${rawName}`, callId: ToolCallId('native-test'), arguments: args, @@ -109,7 +112,7 @@ describe('Cua Driver native provider', () => { return settled.promise }, }) - const result = ctx.tools.execute({ + const result = executeToolFixture(ctx, { name: 'unrelated', callId: ToolCallId('unrelated'), arguments: {}, signal: controller.signal, }) try { diff --git a/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml b/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml index 98b0cab976..1efa35f829 100644 --- a/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml +++ b/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml @@ -1,6 +1,5 @@ - id: ptc-runtime config: executionMode: persistent-in-memory -- id: tools - config: - mode: ptc +- id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-ptc' diff --git a/packages/experimental/cua-macos/package.json b/packages/experimental/cua-macos/package.json index 38517f608b..9ebd6f83d7 100644 --- a/packages/experimental/cua-macos/package.json +++ b/packages/experimental/cua-macos/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-user-questions": "workspace:*" }, "dependencies": { + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "zod": "^4.4.3", "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-cua": "workspace:*" @@ -65,6 +66,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", diff --git a/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts b/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts index a8a93b1857..42b9def139 100644 --- a/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts +++ b/packages/experimental/cua-macos/tests/large-image-ptc.spec.ts @@ -1,4 +1,5 @@ /** Valid large PNG admission through Loader, Harness tool rendering, disk attachments and a real Node PTC process. */ +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -65,14 +66,14 @@ it.skipIf(process.platform === 'win32')('delivers a valid PNG larger than 4 MiB })) const modules = { llm: LlmRuntime, session: SessionStore, projection: SessionProjectionRegistry, - systemPrompt: SystemPrompt, tools: ToolRuntime, agents: AgentRegistry, agentLoop: AgentLoop, + systemPrompt: SystemPrompt, tools: ToolRuntime, toolExecutor: PtcToolExecutor, agents: AgentRegistry, agentLoop: AgentLoop, attachments: LocalAttachmentStore, fs: FileSystem, subprocess: SubprocessLocal, sandbox: Sandbox, sandboxPolicy: SandboxPolicy, ptc: NodePtcRuntime, computerUse: ComputerUseRegistry, cua: NativeProvider, workingDirectory: WorkingDirectory, } const configPath = join(root, 'cordis.yml') const configs: Record = { - tools: { mode: 'ptc' }, attachments: { dshHome: join(root, 'home') }, workingDirectory: { defaultDirectory: root }, + attachments: { dshHome: join(root, 'home') }, workingDirectory: { defaultDirectory: root }, fs: { cwd: root }, sandboxPolicy: { mode: 'danger-full-access', workspaceRoot: root }, cua: { helperPath, allowedApps: [{ 'id': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'name': 'Fixture', 'executablePath': '/fixture/app' }] }, } await writeFile(configPath, Object.keys(modules).flatMap(name => [ diff --git a/packages/experimental/cua-macos/tests/loader-composition.spec.ts b/packages/experimental/cua-macos/tests/loader-composition.spec.ts index 9b65b66398..6bf2d13ab0 100644 --- a/packages/experimental/cua-macos/tests/loader-composition.spec.ts +++ b/packages/experimental/cua-macos/tests/loader-composition.spec.ts @@ -1,4 +1,6 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real Loader, AgentLoop and helper-process transport; only desktop and model responses are fixtures. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -77,7 +79,7 @@ it.skipIf(process.platform === 'win32')('shares one application between retained const readEvents = async () => (await readFile(join(root, 'helper-events.jsonl'), 'utf8')).trim().split('\n').map(line => AuditEvent.parse(JSON.parse(line))) const modules = { llm: LlmRuntime, session: SessionStore, projection: SessionProjectionRegistry, - systemPrompt: SystemPrompt, tools: ToolRuntime, agents: AgentRegistry, agentLoop: AgentLoop, + systemPrompt: SystemPrompt, tools: ToolRuntime, toolExecutor: NativeToolExecutor, agents: AgentRegistry, agentLoop: AgentLoop, attachments: LocalAttachmentStore, computerUse: ComputerUseRegistry, subprocess: SubprocessLocal, cua: NativeProvider, } @@ -129,7 +131,7 @@ it.skipIf(process.platform === 'win32')('shares one application between retained expect(events.find(event => event.event === 'state')?.count).toBe(1) expect(events.find(event => event.method === 'getState')?.params).toMatchObject({ maxElements: 2000, maxDepth: 25 }) - const direct = await context.tools.execute({ agent, signal: new AbortController().signal, + const direct = await executeToolFixture(context, { agent, signal: new AbortController().signal, callId: ToolCallId('programmatic-window'), name: 'cua', arguments: { method: 'screenshot', params: { appHandle: 'fixture-app', windowId: 7 } }, }) expect(direct.isError).toBe(false) @@ -138,7 +140,7 @@ it.skipIf(process.platform === 'win32')('shares one application between retained for (const [callId, activationRequested, beforePid, beforeWindowId] of [ ['activate-first', true, 42, 99], ['activate-repeat', false, 9, 7], ] as const) { - const activated = await context.tools.execute({ + const activated = await executeToolFixture(context, { agent, signal: new AbortController().signal, callId: ToolCallId(callId), name: 'cua', arguments: { method: 'activate', params: { appHandle: 'fixture-app' } }, }) @@ -157,7 +159,7 @@ it.skipIf(process.platform === 'win32')('shares one application between retained let objectCall = 0 const objectApi = (owner: typeof agent) => createCuaApi(async (operation) => { - const result = await context.tools.execute({ agent: owner, signal: new AbortController().signal, + const result = await executeToolFixture(context, { agent: owner, signal: new AbortController().signal, callId: ToolCallId(`retained-object-${++objectCall}`), name: 'cua', arguments: operation }) if (result.isError) throw new Error(JSON.stringify(result.content)) return z.json().parse(result.value) @@ -187,13 +189,13 @@ it.skipIf(process.platform === 'win32')('shares one application between retained expect((await readEvents()).filter(event => event.event === 'exit')).toHaveLength(1) const next = await context.agents.create({ sessionId: SessionId('native-loader-next'), agentOptions: { provider: 'native-fixture', model: 'vision' } }) - const stale = await context.tools.execute({ agent: next.agent, signal: new AbortController().signal, + const stale = await executeToolFixture(context, { agent: next.agent, signal: new AbortController().signal, callId: ToolCallId('stale-window'), name: 'cua', arguments: { method: 'screenshot', params: { appHandle: 'fixture-app', windowId: 7 } }, }) expect(stale.isError).toBe(true) expect(JSON.stringify(stale.content)).toContain('APP_ACCESS_REQUIRED') expect((await readEvents()).filter(event => event.event === 'start')).toHaveLength(1) - const selected = await context.tools.execute({ agent: next.agent, signal: new AbortController().signal, + const selected = await executeToolFixture(context, { agent: next.agent, signal: new AbortController().signal, callId: ToolCallId('select-new-task'), name: 'cua', arguments: { method: 'getApp', params: { selector: 'test.fixture' } }, }) expect(selected.isError).toBe(false) diff --git a/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts b/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts index 9dd6452bde..ccb14a01b6 100644 --- a/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts +++ b/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Built bridge imports and retained objects from a project outside the Harness installation. */ import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' @@ -63,7 +64,7 @@ it.skipIf(process.platform === 'win32' || !existsSync(bridge) || !existsSync(boo bindings: [{ global: 'tools', functions: { cua: async (args) => { const operation = Request.parse(args) calls.push({ phase, method: operation.method }) - const result = await ctx.tools.execute({ agent: handle.agent, signal: new AbortController().signal, + const result = await executeToolFixture(ctx, { agent: handle.agent, signal: new AbortController().signal, callId: ToolCallId(`external-cua-${++count}`), name: 'cua', arguments: operation }) images += result.content.filter(block => block.type === 'image').length return result.value ?? null diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index b91166bad4..6e46f2a787 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Native helper replacement is synchronized with Agent and provider disposal. */ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -85,7 +86,7 @@ async function setup(model?: { provider: string; model: string }) { await mountAgentLoopTestHarness(ctx) const handle = await ctx.agents.create({ sessionId: SessionId('native-lifecycle'), ...model ? { agentOptions: model } : {} }) let index = 0 - const execute = (method = 'listApps', params: Record = {}, signal = new AbortController().signal) => ctx.tools.execute({ + const execute = (method = 'listApps', params: Record = {}, signal = new AbortController().signal) => executeToolFixture(ctx, { agent: handle.agent, signal, callId: ToolCallId(`lifecycle-${++index}`), name: 'cua', arguments: { method, params }, }) return { ctx, provider, handle, execute } @@ -281,7 +282,7 @@ it('reports failed cleanup after waiting for other owners and releasing their Ag const f = await setup() await f.execute() const other = await f.ctx.agents.create({ sessionId: SessionId('native-lifecycle-other') }) - await f.ctx.tools.execute({ + await executeToolFixture(f.ctx, { agent: other.agent, signal: new AbortController().signal, callId: ToolCallId('other-owner'), name: 'cua', arguments: { method: 'listApps', params: {} }, }) @@ -317,7 +318,7 @@ it('reports failed cleanup after waiting for other owners and releasing their Ag it('rejects malformed and unowned operations before acquiring a helper', async () => { const f = await setup() expect((await f.execute('screenshot', { pid: 9, windowId: 7 })).isError).toBe(true) - const unowned = await f.ctx.tools.execute({ + const unowned = await executeToolFixture(f.ctx, { signal: new AbortController().signal, callId: ToolCallId('unowned'), name: 'cua', arguments: { method: 'listApps', params: {} }, }) expect(unowned.isError).toBe(true) diff --git a/packages/experimental/hooks-claude-code/package.json b/packages/experimental/hooks-claude-code/package.json index 1e375f0975..6b592ed532 100644 --- a/packages/experimental/hooks-claude-code/package.json +++ b/packages/experimental/hooks-claude-code/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts index f6b012bc28..778a506f24 100644 --- a/packages/experimental/hooks-claude-code/tests/bridge.spec.ts +++ b/packages/experimental/hooks-claude-code/tests/bridge.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +8,7 @@ import { join } from 'node:path' import { Context, type Fiber } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -106,7 +107,7 @@ describe('unmatched hooks and directory recovery', () => { async execute(args) { return [{ type: 'text', text: args.cd }] }, })) const agent = await ctx.agentLoop.create(SessionId('recovery'), { provider: 'mock', model: 'mock' }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'working_directory', callId: ToolCallId('recover'), arguments: { cd: dir }, agent, signal: new AbortController().signal, }) diff --git a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts index 82ea12294f..737bc07d53 100644 --- a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -8,7 +9,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -160,7 +161,7 @@ export function defineCoverageCases(group: CoverageGroup): void { let ran = false ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const { ToolCallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'echo', arguments: {} }) expect(ran).toBe(false) expect(result.isError).toBe(true) }) diff --git a/packages/experimental/hooks-codex/package.json b/packages/experimental/hooks-codex/package.json index 9b32aa78c8..0ecb89346c 100644 --- a/packages/experimental/hooks-codex/package.json +++ b/packages/experimental/hooks-codex/package.json @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/experimental/hooks-codex/tests/bridge.spec.ts b/packages/experimental/hooks-codex/tests/bridge.spec.ts index 242eb91819..f3c65e8f0d 100644 --- a/packages/experimental/hooks-codex/tests/bridge.spec.ts +++ b/packages/experimental/hooks-codex/tests/bridge.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -7,7 +8,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -89,7 +90,7 @@ describe('unmatched hooks and directory recovery', () => { async execute(args) { return [{ type: 'text', text: args.cd }] }, })) const agent = await ctx.agentLoop.create(SessionId('recovery'), { provider: 'mock', model: 'mock' }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'working_directory', callId: ToolCallId('recover'), arguments: { cd: dir }, agent, signal: new AbortController().signal, }) diff --git a/packages/experimental/hooks-codex/tests/coverage-cases.ts b/packages/experimental/hooks-codex/tests/coverage-cases.ts index f3458daeab..85e1aac778 100644 --- a/packages/experimental/hooks-codex/tests/coverage-cases.ts +++ b/packages/experimental/hooks-codex/tests/coverage-cases.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { provideWorkingDirectoryFixture, mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -8,7 +9,7 @@ import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' @@ -474,7 +475,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro let ran = false ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) const { ToolCallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(ran).toBe(false) // denied expect(result.isError).toBe(true) }) @@ -485,7 +486,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro const ctx = await harness(join(d, 'hooks.json'), new MockAdapter([])) ctx.tools.register(defineContentToolFixture({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { ToolCallId } = await import('@deepseek-ai/dsh-llm') - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'Bash', arguments: { command: 'x' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'Bash', arguments: { command: 'x' } }) expect(result.isError).toBeFalsy() expect(result.additionalContexts?.[0]?.content.some(b => b.type === 'text' && b.text === 'x')).toBe(true) }) diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 9add7daa25..387ee9e060 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-experimental-agent-team": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts index 3715748b7b..f3c755d842 100644 --- a/packages/experimental/tool-agent-team/tests/tool-team.spec.ts +++ b/packages/experimental/tool-agent-team/tests/tool-team.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -16,7 +17,7 @@ import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { renderPrompt, renderContextSnapshot } from '@deepseek-ai/dsh-system-prompt' import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { resolveAdapterOptions } from '@deepseek-ai/dsh-llm-deepseek' import { serialize } from '@deepseek-ai/dsh-llm-deepseek/src/serialize.ts' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' @@ -98,7 +99,7 @@ function execute( args: unknown, signal: AbortSignal = SIGNAL, ) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { callId: ToolCallId(`team-call-${++callNumber}`), name, arguments: args, diff --git a/packages/experimental/tool-ralph/package.json b/packages/experimental/tool-ralph/package.json index 6a2e7e235f..09842f2430 100644 --- a/packages/experimental/tool-ralph/package.json +++ b/packages/experimental/tool-ralph/package.json @@ -51,6 +51,8 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-workflow": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", diff --git a/packages/experimental/tool-ralph/tests/integration.spec.ts b/packages/experimental/tool-ralph/tests/integration.spec.ts index a12a52ddf6..573089f3b1 100644 --- a/packages/experimental/tool-ralph/tests/integration.spec.ts +++ b/packages/experimental/tool-ralph/tests/integration.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { describe, expect, it, onTestFinished } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -99,7 +100,7 @@ describe('dsh-tool-ralph over the real spawn and sandboxed PTC stack', () => { expect(agent).toBeDefined() children.push(agent!) }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ralph-integration'), name: 'ralph', @@ -152,7 +153,7 @@ describe('dsh-tool-ralph over the real spawn and sandboxed PTC stack', () => { if (agent !== undefined) children.push(agent) }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ralph-child-failure'), name: 'ralph', @@ -241,7 +242,7 @@ describe('dsh-tool-ralph over the real spawn and sandboxed PTC stack', () => { toolCallResponse('round-report', STRUCTURED_OUTPUT_TOOL, report), ], config) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ralph-script-enforcement'), name: 'ralph', @@ -269,7 +270,7 @@ describe('dsh-tool-ralph over the real spawn and sandboxed PTC stack', () => { }) ctx.on('workflow/agent-end', (_run, child) => { outcomes.push(child.outcome) }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('ralph-real-cancel'), name: 'ralph', arguments: { objective: 'Keep working until cancelled.', maxRounds: 2 }, diff --git a/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts b/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts index 460de76522..6572520f6f 100644 --- a/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -80,6 +82,7 @@ async function setup(options?: SetupOptions) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjectionRegistry) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) @@ -101,7 +104,7 @@ function execute( args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }, ): Promise { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: extra?.signal ?? testToolSignal, callId: ToolCallId('ralph-call'), name: 'ralph', diff --git a/packages/experimental/tool-session-query/package.json b/packages/experimental/tool-session-query/package.json index 62590f4cbb..a8b5352f91 100644 --- a/packages/experimental/tool-session-query/package.json +++ b/packages/experimental/tool-session-query/package.json @@ -56,6 +56,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts index 566e34478a..9536e96165 100644 --- a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' @@ -48,6 +50,7 @@ describe('tool-session-query with the real SQLite provider', () => { registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await ctx.plugin(SqliteSessionQueryEngine, { path: join(root, 'session-query.db') }) await ctx.plugin(ToolSessionQuery) @@ -86,7 +89,7 @@ describe('tool-session-query with the real SQLite provider', () => { caller.append('step/start', { turn: 1, step: 1 }) let call = 0 - const execute = (name: string, args: unknown) => ctx.tools.execute({ + const execute = (name: string, args: unknown) => executeToolFixture(ctx, { name, arguments: args, callId: ToolCallId(`integration-${++call}`), @@ -121,6 +124,7 @@ describe('tool-session-query with the real SQLite provider', () => { registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) await ctx.plugin(SqliteSessionQueryEngine, { path: join(root, 'session-query.db') }) await ctx.plugin(ToolSessionQuery) @@ -182,7 +186,7 @@ describe('tool-session-query with the real SQLite provider', () => { meta: { createdAt: base + 1_000, cwd: '/work' }, }) let call = 0 - const execute = (args: unknown) => ctx.tools.execute({ + const execute = (args: unknown) => executeToolFixture(ctx, { name: 'session_event_search', arguments: args, callId: ToolCallId(`fractional-integration-${++call}`), diff --git a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts index 3d11ad3f85..8540b2c66d 100644 --- a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts @@ -1,3 +1,6 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -223,6 +226,7 @@ async function mount( registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) if (enforceTimeout) await ctx.plugin(TimeoutPolicy) await ctx.plugin(FakeQuery) const fiber = await ctx.plugin(ToolSessionQuery, config) @@ -233,7 +237,7 @@ async function mount( ctx, fiber, caller, - call: (toolName, args, options = {}) => ctx.tools.execute({ + call: (toolName, args, options = {}) => executeToolFixture(ctx, { name: toolName, arguments: args, callId: ToolCallId(`call-${++calls}`), @@ -315,13 +319,14 @@ describe('registration and schemas', () => { ['session_event_read', { seq: 0 }, 'parallel'], ] as const + const agent = fakeAgent(mounted.caller) for (const [name, args, kind] of classifications) { - expect(mounted.ctx.tools.executionMode({ + expect(new ToolExecutionPipeline(mounted.ctx, { get: (name, scope) => mounted.ctx.tools.get(name, scope) }).executionMode({ name, arguments: args, callId: ToolCallId(`mode-${name}`), signal: new AbortController().signal, - agent: fakeAgent(mounted.caller), + agent, })).toEqual({ kind }) } }) @@ -594,7 +599,7 @@ describe('workspace authority and lineage redaction', () => { it('fails closed without an agent and for direct cross-workspace targets', async () => { const mounted = await mount() createSession(mounted.ctx, 'outside', '/outside') - const missing = await mounted.ctx.tools.execute({ + const missing = await executeToolFixture(mounted.ctx, { name: 'session_trace', arguments: {}, callId: ToolCallId('missing-agent'), @@ -1659,10 +1664,11 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeQuery) await ctx.plugin(ToolSessionQuery) const session = createSession(ctx, 'no-boundary-caller', '/work') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'session_event_search', arguments: { query: 'q' }, callId: ToolCallId('call-no-boundary'), diff --git a/packages/experimental/tool-terminal/package.json b/packages/experimental/tool-terminal/package.json index 76fce409a6..2b4b366f23 100644 --- a/packages/experimental/tool-terminal/package.json +++ b/packages/experimental/tool-terminal/package.json @@ -56,7 +56,10 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/experimental/tool-terminal/tests/loader-composition.spec.ts b/packages/experimental/tool-terminal/tests/loader-composition.spec.ts index 9d2db58481..3269bb4802 100644 --- a/packages/experimental/tool-terminal/tests/loader-composition.spec.ts +++ b/packages/experimental/tool-terminal/tests/loader-composition.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { tmpdir } from 'node:os' @@ -69,6 +71,7 @@ suite('terminal real Loader composition through cordis.yml', () => { "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-tool-executor-native'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", "- name: '@deepseek-ai/dsh-session-projection'", @@ -99,6 +102,7 @@ suite('terminal real Loader composition through cordis.yml', () => { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], @@ -119,15 +123,15 @@ suite('terminal real Loader composition through cordis.yml', () => { const owner = await agent(context) const signal = new AbortController().signal - const spawn = await context.tools.execute({ + const spawn = await executeToolFixture(context, { signal, callId: ToolCallId('spawn'), name: 'terminal_open', arguments: { type: 'shell', name: 'main', cwd: root }, agent: owner, }) expect(resultText(spawn)).toContain('started terminal session pty-1 (main)') - await context.tools.execute({ + await executeToolFixture(context, { signal, callId: ToolCallId('state'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'export KEEP=loader; cd /' }, agent: owner, }) - const read = await context.tools.execute({ + const read = await executeToolFixture(context, { signal, callId: ToolCallId('read'), name: 'terminal_send', arguments: { sessionId: 'pty-1', text: 'printf "cwd=%s keep=%s\\n" "$PWD" "$KEEP"' }, agent: owner, }) expect(resultText(read)).toContain('cwd=/ keep=loader') diff --git a/packages/experimental/tool-terminal/tests/tools.spec.ts b/packages/experimental/tool-terminal/tests/tools.spec.ts index fcc0a10405..7403b2fa42 100644 --- a/packages/experimental/tool-terminal/tests/tools.spec.ts +++ b/packages/experimental/tool-terminal/tests/tools.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { Context } from '@deepseek-ai/cordis' @@ -6,8 +8,9 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { renderToolsSdk } from '@deepseek-ai/dsh-tools' -import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { renderToolsSdk } from '@deepseek-ai/dsh-tool-executor-ptc' +import type { ToolSdkSchema } from '@deepseek-ai/dsh-tool-executor-ptc/src/ts-types.ts' import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal' import type { TerminalBackend, TerminalBackendSession, TerminalSendOperation, TerminalSendRead, TerminalSendRequest, TerminalSessionStatus, TerminalSignal } from '@deepseek-ai/dsh-terminal' import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' @@ -114,6 +117,7 @@ async function setupBase(jobs: boolean) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend() @@ -129,11 +133,11 @@ let callNumber = 0 const TOOL_NAMES = ['terminal_open', 'terminal_send', 'terminal_read', 'terminal_signal', 'terminal_close', 'terminal_list'] as const const testToolSignal = new AbortController().signal function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} }) + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`pty-call-${++callNumber}`), name, arguments: args, ...agent ? { agent } : {} }) } function callWithSignal(ctx: Context, name: string, args: unknown, agent: Agent, signal: AbortSignal) { - return ctx.tools.execute({ callId: ToolCallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal }) + return executeToolFixture(ctx, { callId: ToolCallId(`pty-call-${++callNumber}`), name, arguments: args, agent, signal }) } function text(result: { content: { type: string; text?: string }[] }): string { diff --git a/packages/experimental/tool-worktree/package.json b/packages/experimental/tool-worktree/package.json index e63f78d334..292ae2fe81 100644 --- a/packages/experimental/tool-worktree/package.json +++ b/packages/experimental/tool-worktree/package.json @@ -51,6 +51,8 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*", diff --git a/packages/experimental/tool-worktree/tests/loader-composition.spec.ts b/packages/experimental/tool-worktree/tests/loader-composition.spec.ts index f2f1367605..2077dcb700 100644 --- a/packages/experimental/tool-worktree/tests/loader-composition.spec.ts +++ b/packages/experimental/tool-worktree/tests/loader-composition.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { execFile } from 'node:child_process' import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -57,6 +59,7 @@ describe('worktree tool through real Loader composition', () => { ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-fs-local', LocalFileSystem], ['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime], ['@deepseek-ai/dsh-sandbox-local', LocalSandbox], @@ -82,7 +85,7 @@ describe('worktree tool through real Loader composition', () => { await context.loader.await() const agent = await context.agentLoop.create(SessionId('worktree-loader'), {}, { cwd: repository }) - const invoke = (name: string, agentValue = agent) => context!.tools.execute({ + const invoke = (name: string, agentValue = agent) => executeToolFixture(context!, { name: 'create_worktree', callId: ToolCallId(name), arguments: { name }, agent: agentValue, signal: new AbortController().signal, }) const result = await invoke('new-checkout') @@ -93,7 +96,7 @@ describe('worktree tool through real Loader composition', () => { expect(context.workingDirectory.get(agent.session)).toBe(path) expect(await readFile(join(path, 'file.txt'), 'utf8')).toBe('committed\n') - const failure = await context.tools.execute({ + const failure = await executeToolFixture(context, { name: 'create_worktree', callId: ToolCallId('no-agent'), arguments: {}, signal: new AbortController().signal, }) expect(failure.isError).toBe(true) diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index b27e6de52b..5fe78fca14 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -70,6 +70,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*" } diff --git a/packages/extensions/cordis-host-runner/src/guard.ts b/packages/extensions/cordis-host-runner/src/guard.ts index fb882c6770..4c0dd214ac 100644 --- a/packages/extensions/cordis-host-runner/src/guard.ts +++ b/packages/extensions/cordis-host-runner/src/guard.ts @@ -641,7 +641,7 @@ const TIMER_VERBS = new Set(['timeout', 'interval', 'setTimeout', 'setInterval', * metadata (`schemas`, and `get` returning a schema view, never the live * `ToolDefinition`). Exposing the raw definition would hand package code the * tool's `execute` function, letting it call another tool directly and bypass - * `ToolRuntime.execute` — identity protection, pre-policy, monotonic guards, + * `ToolsService.execute` — identity protection, pre-policy, monotonic guards, * around dispatch, post-policy, final observation, and result normalization. So `get` returns the same * name/description/parameters view as `schemas()`, and nothing invocable. */ diff --git a/packages/extensions/cordis-host-runner/tests/helpers.ts b/packages/extensions/cordis-host-runner/tests/helpers.ts index a40382592b..426058c052 100644 --- a/packages/extensions/cordis-host-runner/tests/helpers.ts +++ b/packages/extensions/cordis-host-runner/tests/helpers.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context } from '@deepseek-ai/cordis' import Timer from '@deepseek-ai/cordis-plugin-timer' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -56,6 +58,7 @@ export async function setup(config?: Config): Promise { await ctx.plugin(Timer) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) const gateway: Gateway = { events: [] } ctx.on('cordis/request-run', (request) => { gateway.events.push(['cordis/request-run', request]) @@ -145,7 +148,7 @@ let callCounter = 0 /** Execute a registered tool through the real registry pipeline. */ export function call(ctx: Context, name: string, args: unknown): Promise { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId(`call-${++callCounter}`), name, diff --git a/packages/extensions/cordis-host-runner/tests/sandbox-context.spec.ts b/packages/extensions/cordis-host-runner/tests/sandbox-context.spec.ts index 92fc2ebf87..a6393512c1 100644 --- a/packages/extensions/cordis-host-runner/tests/sandbox-context.spec.ts +++ b/packages/extensions/cordis-host-runner/tests/sandbox-context.spec.ts @@ -41,6 +41,12 @@ describe('sandbox context façade — escape surface is closed', () => { expect(message).toContain('withheld by design') }) + it('withholds executor control through ctx.tools.setExecutor', async () => { + const harness = await setup() + const message = await runTouching(harness, 'ctx.tools.setExecutor()') + expect(message).toContain('is not a function') + }) + it('the classic ctx.root.tools.register bypass registers nothing and fails loud', async () => { const harness = await setup() const message = await runTouching(harness, ` @@ -207,7 +213,7 @@ describe('sandbox context façade — inject gate on services', () => { describe('sandbox tools façade — get is a read-only schema view', () => { it('ctx.tools.get returns a schema, not the live ToolDefinition with execute', async () => { // The finding: returning the raw ToolDefinition hands package code the tool's execute - // function, letting it bypass ToolRegistry.execute (and its pre/post hooks). get now + // function, letting it bypass ToolsService.execute (and its pre/post hooks). get now // returns the same name/description/parameters view as schemas(), with no execute. const harness = await setup() harness.ctx.tools.register(dummyTool('host_tool')) diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 7a4bfdbf5a..f1df7e9c32 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-cordis-host-runner": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/schemastery": "workspace:~" } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 97da088137..48057f3b36 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3413,15 +3413,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { key: 'tools', - summary: 'Tool registry and execution pipeline.', - description: 'Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.', + summary: 'Concrete tool registry and scoped executor dispatcher.', + description: 'Concrete tool registry and scoped executor dispatcher.', methods: [ - { - signature: 'presentAs(mode: ToolPresentationMode): () => void', - description: 'Present the calling scope\'s tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset\'s standing declaration covers every agent joined under it.\n\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.', - parameters: [{ name: 'mode', description: 'the presentation the covered agents\' models see.' }], - returns: 'the exact disposer that restores the deployment default.', - }, { signature: 'register(definition: ToolDefinition): () => void', description: 'Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.', @@ -3434,18 +3428,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'filter', description: 'global-tool mask: `allow` (keep only) and/or `deny` (remove).' }], returns: 'the exact disposer that lifts this restriction.', }, - { - signature: 'guard(guard: ToolGuard): () => void', - description: 'Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.', - parameters: [{ name: 'guard', description: 'synchronous check; a returned string denies the execution.' }], - returns: 'the exact disposer that unregisters the guard.', - }, { signature: 'get(name: string, scope?: ScopeKey): ToolDefinition | undefined', description: 'Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.', parameters: [{ name: 'name', description: 'the tool name as registered.' }, { name: 'scope', description: 'the viewing scope (the agent); omitted = the global view.' }], returns: 'the definition the scope resolves, or undefined when none is visible.', }, + { + signature: 'definitions(scope?: ScopeKey): readonly ToolDefinition[]', + description: 'Read the visible capability definitions for descriptions and program bindings.', + parameters: [{ name: 'scope', description: 'viewing Agent or preset; omitted selects the global view.' }], + returns: 'definitions after scoped shadowing and restrictions.', + }, { signature: 'schemas(scope?: ScopeKey): ToolSchema[]', description: 'Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.', @@ -3453,16 +3447,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'one deep-cloned schema per visible tool.', }, { - signature: 'executionMode(exec: ToolExecutionInput): ToolExecutionMode', - description: 'Classify a pending call through the caller\'s visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.', - parameters: [{ name: 'exec', description: 'call name, parsed arguments, and optional agent scope.' }], - returns: 'the fail-closed scheduling mode.', + signature: 'setExecutor(executor: ToolExecutor): () => void', + description: 'Provide the executor for the calling scope; an unscoped contribution is the deployment default.', + parameters: [{ name: 'executor', description: 'initialized provider owned by the contributing plugin.' }], + returns: 'the effect disposer. Unloading leaves the selection unavailable until it is replaced.', }, { - signature: 'async execute(exec: ToolExecutionInput): Promise', - description: 'Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.', - parameters: [{ name: 'exec', description: 'the typed same-process call input. The registry assigns its correlation token before policy begins.' }], - returns: 'the materialized final result.', + signature: 'async execute(input: ToolBatchInput): Promise', + description: 'Delegate a raw call batch to its Agent\'s provider or the deployment default. Select at dispatch; accepted batches settle through that provider even if it unloads.', + parameters: [{ name: 'input', description: 'original calls, optional owning Agent, and optional logging step.' }], + returns: 'the selected executor\'s settled batch; unavailable selections reject.', + }, + { + signature: 'guard(guard: ToolGuard): () => void', + description: 'Register a monotonic denial in the calling scope.', + parameters: [{ name: 'guard', description: 'evaluated after extensible pre-execute policy by the shared pipeline.' }], + returns: 'the effect disposer that removes this guard.', + }, + { + signature: 'checkGuards(exec: ToolExecution): string | undefined', + description: 'Check global and inherited guards after pre-execute policy and approval resolve.', + parameters: [{ name: 'exec', description: 'invocation whose guards are checked before dispatch.' }], + returns: 'the first denial reason, or undefined when no guard denies.', }, ], }, @@ -4529,14 +4535,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/change', mode: 'emit', signature: '\'tools/change\'(): void', - summary: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only).', - description: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent\'s next assembly, so a scoped listener subscribing here sees every change, not just its own scope\'s.', + summary: 'A tool or executor was registered or unregistered, or a scoped restriction changed.', + description: 'A tool or executor was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent\'s next assembly.', parameters: [], }, { name: 'tools/execute', mode: 'waterfall', - signature: '\'tools/execute\'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise', + signature: '\'tools/execute\'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise', summary: 'Around-dispatch waterfall for timeout, retry, or metrics.', description: 'Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. The registry re-fuses the original caller signal before the body, so replacement cannot detach caller cancellation; wrappers must still restore their signal and reach quiescence. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.', parameters: [{ name: 'exec', description: 'the allowed call about to dispatch (name, parsed arguments, caller agent, signal).' }], @@ -4544,7 +4550,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/post-execute', mode: 'waterfall', - signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', + signature: '\'tools/post-execute\'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise', summary: 'Accept, replace, enrich, or block a normalized dispatch result.', description: 'Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this waterfall as errors. Async listeners must observe `exec.signal`; after they settle, caller cancellation replaces only a successful accepted outcome with the code selected by whether the tool body was invoked. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.', parameters: [{ name: 'exec', description: 'the call that just ran (name, parsed arguments, caller agent).' }, { name: 'result', description: 'the dispatch outcome a listener may accept, replace, or block.' }], @@ -4552,7 +4558,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/pre-execute', mode: 'waterfall', - signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', + signature: '\'tools/pre-execute\'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise', summary: 'Allow, deny, cancel, or ask before dispatch.', description: 'Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; `cancel` selects the canonical pre-dispatch cancellation result, and missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.', parameters: [{ name: 'exec', description: 'the pending call (name, parsed arguments, caller agent).' }], @@ -4560,7 +4566,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/ptc-dispatch-log', mode: 'waterfall', - signature: '\'tools/ptc-dispatch-log\'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise', + signature: '\'tools/ptc-dispatch-log\'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise', summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/ptc-dispatch` event.', description: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/ptc-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy\'s preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.', parameters: [{ name: 'dispatch', description: 'the parent execution, sub-call identity, and the settled content to log.' }], @@ -4568,7 +4574,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'tools/result', mode: 'emit', - signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): undefined', + signature: '\'tools/result\'(this: Scoped, exec: Readonly, result: Readonly): undefined', summary: 'Observe the frozen, lossless-JSON final outcome.', description: 'Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`.', parameters: [{ name: 'exec', description: 'the execution object that traversed the pipeline.' }, { name: 'result', description: 'a deep-frozen snapshot of the final returned result.' }], @@ -6901,14 +6907,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScheduleDeliveryRecord', declaration: 'export interface ScheduleDeliveryRecord extends ScheduleDeliveryReceipt {\n readonly prompt?: string;\n}', }, - { - name: 'ScheduledToolDispatch', - declaration: 'export type ScheduledToolDispatch = {\n kind: \'post-result\';\n result: ToolExecutionResult;\n} | {\n kind: \'final-result\';\n result: ToolExecutionResult;\n};', - }, - { - name: 'ScheduledToolPreparation', - declaration: 'export type ScheduledToolPreparation = {\n kind: \'dispatch\';\n exec: ToolRunContext;\n} | {\n kind: \'post-result\';\n exec: ToolRunContext;\n result: ToolExecutionResult;\n} | {\n kind: \'final-result\';\n exec: ToolRunContext;\n result: ToolExecutionResult;\n};', - }, { name: 'ScheduleId', declaration: 'export type ScheduleId = Branded<\'ScheduleId\'>;', @@ -8193,6 +8191,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolAdditionBlock', declaration: 'export interface ToolAdditionBlock {\n type: \'tool-addition\';\n toolName: string;\n tool?: never;\n}', }, + { + name: 'ToolBatchInput', + declaration: 'export interface ToolBatchInput {\n readonly calls: readonly ToolCallBlock[];\n readonly signal: AbortSignal;\n readonly maxParallelToolCalls?: number;\n readonly agent?: Agent;\n readonly log?: {\n readonly turn: number;\n readonly step: number;\n };\n}', + }, + { + name: 'ToolBatchResult', + declaration: 'export interface ToolBatchResult {\n readonly results: readonly ToolCallResult[];\n readonly additionalContexts: readonly UserMessage[];\n readonly concluded: boolean;\n}', + }, { name: 'ToolCallBlock', declaration: 'export interface ToolCallBlock {\n type: \'tool-call\';\n id: ToolCallId;\n name: string;\n arguments: string;\n}', @@ -8209,10 +8215,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolCallKind', declaration: 'export type ToolCallKind = \'read\' | \'edit\' | \'delete\' | \'move\' | \'search\' | \'execute\' | \'fetch\' | \'other\';', }, - { - name: 'ToolCallResult', - declaration: 'export type ToolCallResult = {\n deny: string;\n result?: undefined;\n isError?: undefined;\n} | {\n result: unknown;\n isError?: true;\n deny?: undefined;\n};', - }, { name: 'ToolCallView', declaration: 'export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;', @@ -8241,10 +8243,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolExecutionInput', declaration: 'export interface ToolExecutionInput {\n readonly callId: ToolCallId;\n readonly rootCallId?: ToolCallId;\n readonly name: string;\n readonly schema?: ToolSchema;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n}', }, - { - name: 'ToolExecutionMode', - declaration: 'export type ToolExecutionMode = {\n kind: \'parallel\';\n} | {\n kind: \'exclusive\';\n};', - }, { name: 'ToolExecutionResult', declaration: 'export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;', @@ -8257,6 +8255,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolExecutionToken', declaration: 'export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n};', }, + { + name: 'ToolExecutor', + declaration: 'export interface ToolExecutor {\n readonly sections?: readonly ToolPromptSection[];\n describe(tools: readonly ToolDefinition[]): ToolModelInput;\n execute(input: ToolBatchInput): Promise;\n}', + }, { name: 'ToolFailure', declaration: 'export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n}', @@ -8277,13 +8279,17 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolMessageSource', declaration: 'export interface ToolMessageSource {\n kind: \'tool\';\n callId: ToolCallId;\n}', }, + { + name: 'ToolModelInput', + declaration: 'export type ToolModelInput = Pick;', + }, { name: 'ToolOutputDefinition', declaration: 'export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n}', }, { - name: 'ToolPresentationMode', - declaration: 'export type ToolPresentationMode = \'native\' | \'ptc\';', + name: 'ToolPromptSection', + declaration: 'export interface ToolPromptSection extends Pick {\n readonly text: string | ((tools: readonly ToolDefinition[]) => string);\n}', }, { name: 'ToolProviderResult', @@ -8313,14 +8319,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolRunContext', declaration: 'export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n}', }, - { - name: 'ToolRuntime', - declaration: 'export class ToolRuntime extends Service {\n static inject;\n static Config: z;\n readonly [TOOL_RUNTIME_SCHEDULER]: ToolRuntimeScheduler;\n constructor(ctx: Context, config: Config = {});\n presentAs(mode: ToolPresentationMode): () => void;\n register(definition: ToolDefinition): () => void;\n restrict(filter: ToolRestriction): () => void;\n guard(guard: ToolGuard): () => void;\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined;\n schemas(scope?: ScopeKey): ToolSchema[];\n executionMode(exec: ToolExecutionInput): ToolExecutionMode;\n async execute(exec: ToolExecutionInput): Promise;\n}', - }, - { - name: 'ToolRuntimeScheduler', - declaration: 'export interface ToolRuntimeScheduler {\n prepare(exec: ToolExecutionInput): Promise;\n dispatch(exec: ToolRunContext): Promise;\n finalize(exec: ToolRunContext, result: ToolExecutionResult): Promise;\n finish(exec: ToolRunContext, result: ToolExecutionResult): ToolExecutionResult;\n}', - }, { name: 'ToolSchema', declaration: 'export interface ToolSchema {\n deferLoading?: true;\n name: string;\n description: string;\n parameters: Record;\n}', @@ -8329,6 +8327,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ToolSpec', declaration: 'export interface ToolSpec {\n name: string;\n description: string;\n inputSchema?: Record;\n}', }, + { + name: 'ToolsService', + declaration: 'export class ToolsService extends Service {\n static inject;\n constructor(ctx: Context);\n register(definition: ToolDefinition): () => void;\n restrict(filter: ToolRestriction): () => void;\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined;\n definitions(scope?: ScopeKey): readonly ToolDefinition[];\n schemas(scope?: ScopeKey): ToolSchema[];\n setExecutor(executor: ToolExecutor): () => void;\n async execute(input: ToolBatchInput): Promise;\n guard(guard: ToolGuard): () => void;\n checkGuards(exec: ToolExecution): string | undefined;\n}', + }, { name: 'ToolUpdate', declaration: 'export type ToolUpdate = \'in-history\' | \'addition-only\';', diff --git a/packages/extensions/tool-cordis/tests/host.spec.ts b/packages/extensions/tool-cordis/tests/host.spec.ts index 53e0b7db62..d9aa417110 100644 --- a/packages/extensions/tool-cordis/tests/host.spec.ts +++ b/packages/extensions/tool-cordis/tests/host.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context } from '@deepseek-ai/cordis' import { CordisInspectRegistryService } from '@deepseek-ai/dsh-cordis-host-runner' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -17,6 +18,7 @@ async function host(): Promise { // The tool registry injects `systemPrompt`; nothing under test registers a prompt section. await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(CordisInspectRegistryService, 10_000) return ctx } diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index b6434e890c..ee0cf09754 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -54,6 +54,8 @@ "@deepseek-ai/dsh-spill": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-scope": "workspace:*", diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index e64991ed75..1de7cac662 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -1,6 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Integration tests: the REAL local subprocess service plus the PACKAGED - * ripgrep binary (`@vscode/ripgrep`), exercised through `ctx.tools.execute()`. + * ripgrep binary (`@vscode/ripgrep`), exercised through `executeToolFixture(ctx, )`. * These verify the WORLD — actual files on disk are discovered and grepped, * hostile patterns stay inert (they are plain argv elements; there is no * shell layer to escape), and real `rg` stderr classifies into the @@ -9,6 +10,7 @@ * fake-service suite (tools.spec.ts) carries the coverage gate. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync } from 'node:fs' @@ -29,7 +31,7 @@ let ctx: Context let callCounter = 0 function call(name: string, args: unknown, agentObj?: object) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`it-${++callCounter}`), name, @@ -66,6 +68,7 @@ describe('search tools over the real subprocess service + the packaged rg', () = provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(ToolFsSearch, { sampleOverCapGlobResults: true }) }) @@ -182,7 +185,7 @@ describe('search tools over the real subprocess service + the packaged rg', () = it('a pre-aborted registry call is ABORTED_BEFORE_DISPATCH', async () => { const controller = new AbortController() controller.abort() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId(`it-${++callCounter}`), name: 'grep', arguments: { pattern: 'x' }, diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 5f8d36671c..1ff4876b84 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -14,6 +14,7 @@ * red, revert. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -41,6 +42,7 @@ describe('dsh-tool-fs-search real-load-path guard', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalSubprocessRuntime) const loader = Object.create(Loader.prototype) as Loader diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c261d139a6..bdf0fe2ef9 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -1,6 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Consumer-surface tests for the search tools over a FAKE subprocess service - * and a FAKE spill backend, exercised through `ctx.tools.execute()` so nothing + * and a FAKE spill backend, exercised through `executeToolFixture(ctx, )` so nothing * bypasses the tool registry. The fake service makes every seam outcome * scriptable — spawn failure, truncated stdout with/without a raw spill path, * abort/timeout kills, signal kills, ripgrep exit codes — so these tests @@ -10,6 +11,7 @@ * Real-`rg` behavior is pinned separately in integration.spec.ts. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -204,6 +206,7 @@ async function setup(options: SetupOptions = {}) { ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeSubprocess) const subprocess = ctx.subprocess as FakeSubprocess if (options.spill === true) await ctx.plugin(FakeSpill) @@ -222,7 +225,7 @@ function call( args: unknown, options: { agent?: object; signal?: AbortSignal; parent?: ToolExecutionToken } = {}, ) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, @@ -261,6 +264,7 @@ describe('registration', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(ToolFsSearch, DEFAULT_CONFIG) // no subprocess service expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -317,6 +321,7 @@ describe('config validation', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeSubprocess) await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, ...config })).rejects.toThrow(new RegExp(`tool-fs-search: ${name} must be a positive integer`)) }) @@ -326,6 +331,7 @@ describe('config validation', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeSubprocess) await expect(ctx.plugin(ToolFsSearch, { ...DEFAULT_CONFIG, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 946c32bb4b..0c4b4356d9 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -61,6 +61,9 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 1b51503a74..7576d95f45 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * End-to-end tool-registry tests against the real local backend. The policy deployment verifies * observed-state and guarded mutation; the bare deployment proves unconditional tools have no @@ -5,6 +6,8 @@ * messages. */ +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mountAgentLoopTestDependencies, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -33,7 +36,7 @@ const session = { header: { get cwd() { return dir } } } let callCounter = 0 function call(name: string, args: unknown) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, @@ -65,6 +68,7 @@ describe('default deployment (with dsh-fs-observation-policy)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: dir }) await ctx.plugin(FsPolicy) fiber = await ctx.plugin(ToolFs) @@ -339,6 +343,7 @@ describe('bare provider (no dsh-fs-observation-policy)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: dir }) fiber = await ctx.plugin(ToolFs) }) @@ -408,6 +413,7 @@ describe('per-session cwd', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: dir }) // config.cwd = dir, NOT sessionDir await ctx.plugin(FsPolicy) fiber = await ctx.plugin(ToolFs) @@ -415,7 +421,7 @@ describe('per-session cwd', () => { afterEach(async () => { await rm(sessionDir, { recursive: true, force: true }) }) const callIn = (sessionObj: object, name: string, args: unknown) => - ctx.tools.execute({ + executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, @@ -445,7 +451,7 @@ describe('per-session cwd', () => { // -------------------------------------------------------------------------- // Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract — -// all through ctx.tools.execute() against the REAL backend + policy. +// all through executeToolFixture(ctx, ) against the REAL backend + policy. // -------------------------------------------------------------------------- describe('signal, concurrency, and the fs/observed contract', () => { beforeEach(async () => { @@ -454,6 +460,7 @@ describe('signal, concurrency, and the fs/observed contract', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: dir }) await ctx.plugin(FsPolicy) fiber = await ctx.plugin(ToolFs) @@ -461,9 +468,9 @@ describe('signal, concurrency, and the fs/observed contract', () => { const session = { header: { get cwd() { return dir } } } const callSig = (signal: AbortSignal, name: string, args: unknown) => - ctx.tools.execute({ callId: ToolCallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal }) + executeToolFixture(ctx, { callId: ToolCallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never, signal }) const callOwned = (name: string, args: unknown) => - ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) + executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`c-${++callCounter}`), name, arguments: args, agent: { session } as never }) it('a pre-aborted registry call skips read/write/edit with ABORTED_BEFORE_DISPATCH', async () => { await writeFile(join(dir, 'a.txt'), 'hello') @@ -582,20 +589,20 @@ describe('current working directory', () => { const policy = ctx.sandboxPolicy.resolve({ session }) await ctx.workingDirectory.set(agent, next) const current = ctx.workingDirectory.get(session) - const read = await ctx.tools.execute({ + const read = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('read-current'), name: 'read', arguments: { file_path: 'note.txt' }, agent, }) expect(read.isError).toBe(false) expect(text(read)).toContain('current') expect(read.meta).toMatchObject({ path: join(current, 'note.txt') }) - const write = await ctx.tools.execute({ + const write = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('write-current'), name: 'write', arguments: { file_path: 'created.txt', content: 'new' }, agent, }) expect(write.isError).toBe(false) expect(write.meta).toEqual({ operation: 'create', path: join(current, 'created.txt'), diffs: [] }) - const edit = await ctx.tools.execute({ + const edit = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('edit-current'), name: 'edit', arguments: { file_path: 'created.txt', old_string: 'new', new_string: 'edited' }, agent, }) @@ -616,8 +623,8 @@ describe('current working directory', () => { } } await ctx.plugin(ReadBindingRuntime) - agent.ctx.tools.presentAs('ptc') - const nested = await ctx.tools.execute({ + agent.ctx.plugin(PtcToolExecutor) + const nested = await executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ptc-read-current'), name: 'run_code', arguments: { code: 'return await tools.read({file_path: "note.txt"})', description: 'Read the current note' }, agent, }) diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 7765b5b8e4..19dbc3cc12 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -1,3 +1,5 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * The `read_image` tool over the REAL local filesystem and attachment store: * extension routing, extension-less content sniffing (attachment object paths @@ -6,6 +8,8 @@ * regression that `read` keeps its text-only contract. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, beforeEach, describe, expect, it, onTestFinished } from 'vitest' import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' @@ -18,7 +22,6 @@ import { ToolCallId, LlmAdapter, LlmRuntime } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, Message, StreamChunk } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import type { Config as ToolConfig } from '@deepseek-ai/dsh-tools' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-observation-policy' import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' @@ -102,7 +105,7 @@ interface SetupOptions { attachments?: boolean llm?: boolean storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxImageDimension?: number; maxMessageImageBytes?: number } - toolMode?: ToolConfig['mode'] + toolMode?: ('native' | 'ptc') } async function setup(options: SetupOptions = {}) { @@ -110,7 +113,8 @@ async function setup(options: SetupOptions = {}) { onTestFinished(async () => { await ctx.fiber.dispose() }) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: options.toolMode ?? 'native' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((options.toolMode ?? 'native') === 'ptc' ? PtcToolExecutor : NativeToolExecutor) if (options.toolMode === 'ptc') { await ctx.plugin(FakeRuntime) } @@ -146,7 +150,7 @@ function agentOn(model: string | undefined, provider = 'visual', messages: reado let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: object) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`img-call-${++callCounter}`), name, @@ -788,6 +792,7 @@ describe('registration surface', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: dir }) const attachment = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) const plugin = await ctx.plugin(ToolFs, { read: false, write: false, edit: false }) @@ -808,7 +813,8 @@ describe('registration surface', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'native' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalFileSystem, { cwd: dir }) await ctx.plugin(FsPolicy) const attachmentsFiber = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) @@ -833,7 +839,7 @@ describe('registration surface', () => { it('declares read_image parallel-safe and presents a read-family card', async () => { const ctx = await setup() - expect(ctx.tools.executionMode({ + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('img-parallel'), name: 'read_image', arguments: { file_path: 'a.png' }, })).toEqual({ kind: 'parallel' }) expect(ctx.tools.get('read_image')?.presentCall?.({ file_path: 'shot.png' })).toEqual({ diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 2ea469c3f8..a3d3384374 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,8 +1,12 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Consumer API tests over a fake provider and the real policy collaborator: schemas, * validation, formatting, typed errors, intent dispatch, and observation-driven authorization. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -110,6 +114,7 @@ async function setup(config: ToolFs.Config = {}) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeFs) await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs, config) @@ -119,7 +124,7 @@ async function setup(config: ToolFs.Config = {}) { let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: object) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, @@ -193,11 +198,11 @@ describe('registration', () => { it('declares read parallel-safe while write/edit remain exclusive', async () => { const { ctx } = await setup() - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: ToolCallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) .toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: ToolCallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) .toEqual({ kind: 'exclusive' }) - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: ToolCallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) .toEqual({ kind: 'exclusive' }) }) @@ -214,6 +219,7 @@ describe('registration', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(ToolFs) // no fs provider expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -223,6 +229,7 @@ describe('registration', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeFs) await ctx.plugin(FsPolicy) const fiber = await ctx.plugin(ToolFs) @@ -761,6 +768,7 @@ describe('read caps are plugin config', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeFs) await ctx.plugin(FsPolicy) await ctx.plugin(ToolFs, config) @@ -819,6 +827,7 @@ describe('read caps are plugin config', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(FakeFs) await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`)) }) @@ -862,6 +871,7 @@ describe('sandbox escalation API (write/edit)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjectionRegistry) ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) @@ -927,6 +937,7 @@ describe('sandbox escalation API (write/edit)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SandboxingFakeFs) await expect(ctx.plugin(ToolFs)).rejects.toThrow('tool-fs: the mounted filesystem confines but ctx.sandboxPolicy is missing') }) @@ -993,7 +1004,7 @@ describe('sandbox escalation API (write/edit)', () => { ctx.on('approval/request', () => Promise.resolve('allowed-once' as const)) // Pass a signal so the escalation ask forwards it to the approval request // (the request rides the tool-execution abort signal). - await ctx.tools.execute({ + await executeToolFixture(ctx, { callId: ToolCallId('call-fs-esc-grant'), name: 'write', arguments: { file_path: 'a.txt', content: 'x', sandbox_permissions: 'danger-full-access', justification: 'the test needs it' }, @@ -1137,7 +1148,8 @@ describe('scope-aware PTC guidance', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(GuidancePtcRuntime) - await ctx.plugin(ToolRuntime, { mode }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) await ctx.plugin(FakeFs) await ctx.plugin(ToolFs) const { key, scope } = await guidanceScope(ctx) diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 6f7fe48547..19905ad234 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -49,6 +49,8 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*" diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index 74b8ce5dd6..fbaf9d365b 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -57,7 +59,7 @@ function text(result: { content: { type: string; text?: string }[] }): string { } function call(ctx: Context, owner: Agent | undefined, args: unknown) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId(`str-replace-editor-${++callNumber}`), name: 'str_replace_editor', @@ -76,6 +78,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) if (options.sandboxMode === undefined) { await ctx.plugin(LocalFileSystem, { cwd: root }) @@ -616,6 +619,7 @@ describe('tool-str-replace-editor', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) Object.defineProperty(ctx.fs, 'sandboxMode', { value: 'read-only' }) diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 868c577377..d6765f1010 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -50,6 +50,8 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*" diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..e4468c642c 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,3 +1,6 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -104,6 +107,7 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(GoalService) const fiber = await ctx.plugin(toolGoal, config) @@ -120,7 +124,7 @@ async function execute( agent?: Agent, initiator: Agent | undefined = agent, ): Promise { - const run = () => ctx.tools.execute({ + const run = () => executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${Math.random()}`), name, @@ -154,7 +158,7 @@ describe('goal tool registration and presentation', () => { expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) .toEqual(['create_goal', 'get_goal', 'update_goal']) for (const name of ['create_goal', 'get_goal', 'update_goal']) { - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: ToolCallId(name), name, arguments: {} })) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId(name), name, arguments: {} })) .toEqual({ kind: 'exclusive' }) } const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') @@ -205,6 +209,7 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(GoalService) expect(() => { toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) @@ -219,6 +224,7 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(GoalService) toolGoal.apply(ctx, {}) const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') @@ -246,7 +252,7 @@ describe('goal tool execution authority', () => { expect(agentless.error?.info?.code).toBe('GOAL_TOOL_AGENT_REQUIRED') openTurn(root, { kind: 'user' }) - const driverless = await ctx.tools.execute({ + const driverless = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('call-driverless'), name: 'get_goal', diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 17ab614206..f8c46a275c 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -42,6 +42,7 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*" diff --git a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts index ccaad2300b..251932d4b2 100644 --- a/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts +++ b/packages/guard/repeat-tool-reminder/tests/repeat-tool-reminder.spec.ts @@ -1,9 +1,10 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -299,7 +300,7 @@ describe('chain semantics', () => { it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => { const ctx = await harness({ thresholds: [2] }) - const direct = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('d1'), name: 'probe', arguments: { q: 1 } }) + const direct = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('d1'), name: 'probe', arguments: { q: 1 } }) expect(direct.isError).toBe(false) ctx.llm.registerAdapter(['mock'], new MockAdapter([ diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index d4f798f478..c849251241 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -37,6 +37,8 @@ "devDependencies": { "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts index a987abfa64..0d1476030d 100644 --- a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Unit + real-load-path coverage for @deepseek-ai/dsh-tool-call-timeout-policy. The * timeout-wins cases drive the deadline under fake timers (deterministic — no @@ -6,12 +7,14 @@ * reaches quiescence. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { ToolCallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as timeoutPolicy from '@deepseek-ai/dsh-tool-call-timeout-policy' import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-tool-call-timeout-policy' @@ -22,6 +25,7 @@ async function setup() { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(timeoutPolicy) return ctx } @@ -52,7 +56,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => { ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } })) const upstream = new AbortController().signal - const result = await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'probe', arguments: {}, signal: upstream }) + const result = await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'probe', arguments: {}, signal: upstream }) expect(result.isError).toBe(false) expect(seenSignal).toBe(upstream) }) @@ -61,7 +65,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => { const ctx = await setup() ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'fast', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'fast', arguments: {} }) expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false, @@ -75,7 +79,7 @@ describe('timeout-policy delegation (unconfigured / fast)', () => { ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000, async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } })) const upstream = new AbortController().signal - await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'probe', arguments: {}, signal: upstream }) + await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'probe', arguments: {}, signal: upstream }) expect(seenSignal).toBeDefined() expect(seenSignal).not.toBe(upstream) }) @@ -89,7 +93,7 @@ describe('timeout-policy signal restoration', () => { let postSignal: AbortSignal | undefined | 'unset' = 'unset' ctx.on('tools/post-execute', async (exec, _result, next): Promise => { postSignal = exec.signal; return next() }) const upstream = new AbortController().signal - await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'fast', arguments: {}, signal: upstream }) + await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'fast', arguments: {}, signal: upstream }) expect(postSignal).toBe(upstream) }) }) @@ -101,7 +105,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => { const ctx = await setup() ctx.tools.register(cooperativeTool) - const pending = ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'slow', arguments: {} }) + const pending = executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'slow', arguments: {} }) await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result).toEqual({ @@ -117,7 +121,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => { const ctx = await setup() ctx.tools.register(abortThrowingTool) - const pending = ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'aborter', arguments: {} }) + const pending = executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'aborter', arguments: {} }) await vi.advanceTimersByTimeAsync(150) const result = await pending expect(result.isError).toBe(true) @@ -143,7 +147,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { }, })) const upstream = new AbortController() - const pending = ctx.tools.execute({ callId: ToolCallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal }) + const pending = executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal }) await entered.promise upstream.abort('user cancelled') await vi.advanceTimersByTimeAsync(0) @@ -174,7 +178,7 @@ describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => { }, })) const upstream = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal, }) @@ -204,15 +208,16 @@ describe('timeout-policy disposal (HMR safety)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) let seenSignal: AbortSignal | undefined ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000, async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } })) const fiber = await ctx.plugin(timeoutPolicy) const upstream = new AbortController().signal - await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'probe', arguments: {}, signal: upstream }) + await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'probe', arguments: {}, signal: upstream }) expect(seenSignal).not.toBe(upstream) await fiber.dispose() - await ctx.tools.execute({ callId: ToolCallId('c2'), name: 'probe', arguments: {}, signal: upstream }) + await executeToolFixture(ctx, { callId: ToolCallId('c2'), name: 'probe', arguments: {}, signal: upstream }) expect(seenSignal).toBe(upstream) }) }) @@ -232,12 +237,13 @@ describe('dsh-tool-call-timeout-policy real-load-path guard', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000, async execute() { return [{ type: 'text' as const, text: 'ok' }] } })) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters[0] const fiber = await ctx.plugin(unwrapped) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput) expect(result.isError).toBe(false) await fiber.dispose() }) diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 65719ca8f1..55642b09c3 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -38,6 +38,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 5ba9c7f544..efe75e8e22 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createToolResultMessage, ToolCallId, type ToolSchema } from '@deepseek-ai/dsh-llm' @@ -72,6 +74,7 @@ async function setup(config: toolAskUser.Config = { mode: 'timed', timeout: -1 } await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) await ctx.plugin(toolAskUser, config) return ctx @@ -103,6 +106,7 @@ describe('ask_user_question tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) toolAskUser.apply(ctx) @@ -126,6 +130,7 @@ describe('ask_user_question tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) toolAskUser.apply(ctx, { mode: 'timed' }) @@ -143,7 +148,7 @@ describe('ask_user_question tool', () => { const agent = { ...stubAgent('timed-in-time'), steer: vi.fn() } as Agent ctx.agents.enter(agent, undefined) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-in-time'), name: 'ask_user_question', @@ -225,7 +230,7 @@ describe('ask_user_question tool', () => { askedEvent(1, 'ask-legacy', '{"questions":[{"id":"continue","question":"Continue?"}]}'), ])).toEqual({ active: [], settled: [] }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-legacy'), name: 'ask_user_question', @@ -253,7 +258,7 @@ describe('ask_user_question tool', () => { }) const agent = stubAgent('legacy-agent') ctx.agents.enter(agent, undefined) - const detailed = await ctx.tools.execute({ + const detailed = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-legacy-detailed'), name: 'ask_user_question', @@ -284,7 +289,7 @@ describe('ask_user_question tool', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-1'), name: 'ask_user_question', @@ -324,7 +329,7 @@ describe('ask_user_question tool', () => { }, }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-recommended'), name: 'ask_user_question', @@ -361,7 +366,7 @@ describe('ask_user_question tool', () => { }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-multi'), name: 'ask_user_question', @@ -412,7 +417,7 @@ describe('ask_user_question tool', () => { }) const controller = new AbortController() - await ctx.tools.execute({ + await executeToolFixture(ctx, { callId: ToolCallId('ask-2'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, @@ -434,7 +439,7 @@ describe('ask_user_question tool', () => { const agent = stubAgent('resumed-root', 1) ctx.agents.enter(agent, undefined) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-3'), name: 'ask_user_question', @@ -463,7 +468,7 @@ describe('ask_user_question tool', () => { }) const agent = { ...stubAgent('timed-root'), steer: vi.fn() } as Agent ctx.agents.enter(agent, undefined) - const settled = await ctx.tools.execute({ + const settled = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-timeout'), name: 'ask_user_question', @@ -512,7 +517,7 @@ describe('ask_user_question tool', () => { const ctx = await setup({ mode: 'timed', timeout: 1 }) const agent = { ...stubAgent('timed-unclaimed'), steer: vi.fn() } as Agent ctx.agents.enter(agent, undefined) - const result = ctx.tools.execute({ + const result = executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-unclaimed'), name: 'ask_user_question', @@ -532,7 +537,7 @@ describe('ask_user_question tool', () => { it('requires a live agent for the timed path', async () => { const ctx = await setup({ mode: 'timed' }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-no-agent'), name: 'ask_user_question', @@ -548,7 +553,7 @@ describe('ask_user_question tool', () => { it.each([0, 1.5, 2_147_484])('rejects invalid timeout %s', async (timeout) => { const ctx = await setup({ mode: 'timed', timeout: -1 }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`ask-invalid-${timeout}`), name: 'ask_user_question', @@ -565,7 +570,7 @@ describe('ask_user_question tool', () => { const answerer = vi.fn(async (): Promise => ({ answers: [] })) registerQuestionAnswerer(ctx, { ask: answerer }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-duplicate-ids'), name: 'ask_user_question', @@ -590,7 +595,7 @@ describe('ask_user_question tool', () => { registerQuestionAnswerer(ctx, { ask: answerer }) for (const callId of ['first-scope', 'second-scope']) { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(callId), name: 'ask_user_question', @@ -604,7 +609,7 @@ describe('ask_user_question tool', () => { it('returns structured user-questions errors through tool execution', async () => { const ctx = await setup() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-no-provider'), name: 'ask_user_question', @@ -631,7 +636,7 @@ describe('ask_user_question tool', () => { ctx.agents.enter(root, undefined) ctx.agents.enter(child, root) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-delegated'), name: 'ask_user_question', @@ -653,7 +658,7 @@ describe('ask_user_question tool', () => { it('returns a structured error for empty question batches', async () => { const ctx = await setup() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ask-empty'), name: 'ask_user_question', @@ -670,6 +675,7 @@ describe('ask_user_question tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) const fiber = await ctx.plugin(toolAskUser) expect(ctx.tools.get('ask_user_question')).toBeDefined() diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 4bd9437d00..cd418a1067 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -59,11 +59,11 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*" }, "dependencies": { "zod": "4.4.3", diff --git a/packages/interaction/user-questions/src/projection.ts b/packages/interaction/user-questions/src/projection.ts index 7453a265a5..d61210d08e 100644 --- a/packages/interaction/user-questions/src/projection.ts +++ b/packages/interaction/user-questions/src/projection.ts @@ -4,7 +4,7 @@ import { SessionLogOffset, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session import type { SessionEvent, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-agent/types' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import type { AskUserQuestionAnswerItem, AskUserQuestionItem, AskUserQuestionOption, PendingUserQuestion, SettledUserQuestion, UserQuestionProjectionView } from './types.ts' diff --git a/packages/interaction/user-questions/tests/projection.spec.ts b/packages/interaction/user-questions/tests/projection.spec.ts index b235b36c1c..ab028bd651 100644 --- a/packages/interaction/user-questions/tests/projection.spec.ts +++ b/packages/interaction/user-questions/tests/projection.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { SessionLogOffset, SessionSeq, TOOL_OUTCOME_UNKNOWN, type SessionEvent } from '@deepseek-ai/dsh-session' import { createToolResultMessage, createUserMessage, ToolCallId, type ToolSchema } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import { applyUserQuestionEvent, foldUserQuestions, isTimedAskUserQuestionSchema, questionsOf, TIMED_WAIT_PARAMETER, userQuestionProjectionDefinition, diff --git a/packages/interaction/user-questions/tsconfig.json b/packages/interaction/user-questions/tsconfig.json index 51a4d0dd5e..4e5ba45e06 100644 --- a/packages/interaction/user-questions/tsconfig.json +++ b/packages/interaction/user-questions/tsconfig.json @@ -29,14 +29,14 @@ { "path": "../../core/session" }, - { - "path": "../../core/tools" - }, { "path": "../../session/session-projection" }, { "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index 853bad705b..8991a411a2 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts index 0e69eca22c..12b246df68 100644 --- a/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts +++ b/packages/jobs/tool-jobs/tests/tool-jobs.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -45,6 +47,7 @@ async function setup(config: ToolJobs.Config = {}, registry: RegistryConfig = {} const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const agentsFiber = await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, registry) const toolsFiber = await ctx.plugin(ToolJobs, config) @@ -136,7 +139,7 @@ function producer(overrides: Partial & JobHooks> let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } function text(result: { content: { type: string; text?: string }[] }): string { @@ -167,6 +170,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) const p = producer() @@ -182,6 +186,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await expect(ctx.plugin(ToolJobs, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 })) @@ -201,6 +206,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) try { @@ -299,6 +305,7 @@ describe('tool-jobs setup', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) ToolJobs.apply(ctx, {}) @@ -704,6 +711,7 @@ describe('completion notices across scoped mounts', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) @@ -1101,7 +1109,7 @@ describe('completion notices', () => { ctx.jobs.start(p.spec) const aborter = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: aborter.signal, callId: ToolCallId('call-aborted-wait'), name: 'job_output', @@ -1127,7 +1135,7 @@ describe('completion notices', () => { ctx.jobs.start(p.spec) const aborter = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: aborter.signal, callId: ToolCallId('call-abort-then-settle'), name: 'job_output', diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 762301b366..9af8132701 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -42,6 +42,7 @@ "dependencies": { "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "eventsource-parser": "^3.1.0" @@ -71,6 +72,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 1fa0c7cb01..72e46ed54c 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -1,4 +1,5 @@ /** Account route cancellation uses the real loop, registry, and tool lifecycle. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, expect, it, vi } from 'vitest' import { installAccountTaskCancellation, type DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import { Context } from '@deepseek-ai/cordis' @@ -8,7 +9,8 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as DeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key' import * as AccountProvider from '@deepseek-ai/dsh-llm-deepseek-account' import { assemble } from './assemble.ts' @@ -31,7 +33,7 @@ async function harness() { const ctx = new Context() contexts.push(ctx) installAccountTaskCancellation(ctx) - for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, AgentRegistry]) { + for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, NativeToolExecutor, AgentRegistry]) { await ctx.plugin(plugin) } await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index c6a1948ec0..61b85dc94e 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,4 +1,5 @@ /** HTTP lifecycle, routing and optional Cordis services under real composition. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { installAccountTaskCancellation, type DeepSeekAccount } from '@deepseek-ai/dsh-deepseek-account' import type { AnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -315,6 +316,7 @@ describe('Cordis provider composition', () => { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ]) // The importer supplies source modules while Loader still owns configuration and effects. for (const name of modules.keys()) { diff --git a/packages/llm/llm-deepseek/tests/fixtures/cordis.yml b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml index 0d95d810dd..2136dd7ae0 100644 --- a/packages/llm/llm-deepseek/tests/fixtures/cordis.yml +++ b/packages/llm/llm-deepseek/tests/fixtures/cordis.yml @@ -3,6 +3,7 @@ - name: '@deepseek-ai/dsh-session-projection' - name: '@deepseek-ai/dsh-system-prompt' - name: '@deepseek-ai/dsh-tools' +- name: '@deepseek-ai/dsh-tool-executor-native' - name: '@deepseek-ai/dsh-agent' - name: '@deepseek-ai/dsh-agent-loop' config: diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index dca3593891..7725dfa64d 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -61,6 +61,8 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 5c5a177040..851cb0868a 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -64,6 +65,7 @@ async function loadYaml(lines: readonly string[]): Promise { ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-llm-retry', retry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], @@ -94,6 +96,7 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-tool-executor-native'", "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-llm-retry'", "- name: '@deepseek-ai/dsh-agent-loop'", diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 3adb88de05..693d46642c 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' @@ -16,7 +17,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -111,6 +113,7 @@ async function harness( await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) adapter.configureRetryPolicies(policies) diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index e147b997d2..af8b0d1a63 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-tool-lsp", - "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) \u2014 one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", + "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", "version": "0.2.1-alpha.4", "publishConfig": { "access": "public" @@ -51,6 +51,8 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*" diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 213432592d..a0ea788eff 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' @@ -52,6 +54,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(Lsp) await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) await ctx.plugin(LocalSubprocessRuntime) @@ -74,7 +77,7 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { let seq = 0 const testToolSignal = new AbortController().signal function call(ctx: Context, args: unknown) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: `int-${++seq}` as never, name: 'lsp', diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 38be58265d..4947c40379 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { join, resolve } from 'node:path' @@ -36,6 +38,7 @@ async function mount( provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(Lsp) if (provider) (ctx.lsp as Lsp).registerProvider(provider) await ctx.plugin(ToolLsp, config) @@ -50,7 +53,7 @@ const resolvedWorkspaceUri = pathToFileURL(resolvedWorkspaceRoot).href const workspaceAlias = resolve('/virtual/workspace-alias') /** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */ function call(ctx: Context, args: unknown, cwd: string | null = workspaceRoot) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: `c-${++seq}` as never, name: 'lsp', diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index d9fcece76d..70358fb079 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -49,6 +49,8 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@modelcontextprotocol/server-everything": "^2026.8.31", "@modelcontextprotocol/server-filesystem": "^2026.8.31", diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index fff238923f..57803aaf27 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -2,6 +2,7 @@ * Tests for the mcp-client plugin's `apply` lifecycle entry point. * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import assert from 'node:assert/strict' import { describe, expect, it, vi, beforeEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -56,6 +57,7 @@ async function mountRegistry(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -255,6 +257,7 @@ describe('apply (plugin lifecycle)', () => { const first = new Context() await first.plugin(SystemPrompt) await first.plugin(ToolRuntime) + await first.plugin(NativeToolExecutor) await apply(first, stdioConfig) await first.fiber.dispose() @@ -265,6 +268,7 @@ describe('apply (plugin lifecycle)', () => { const second = new Context() await second.plugin(SystemPrompt) await second.plugin(ToolRuntime) + await second.plugin(NativeToolExecutor) await expect(apply(second, stdioConfig)).resolves.toBeUndefined() await second.fiber.dispose() }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index a82105d977..8a7c64ab18 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against: * 1. A self-written fixture server over stdio (controlled edge cases) @@ -8,6 +9,7 @@ * No API key needed — all servers are local/keyless. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -41,6 +43,7 @@ async function mountRegistry(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -137,7 +140,7 @@ describe('fixture server — controlled scenarios', () => { }) it('executes the dotted tool via its normalized public name', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: publicToolName('fixture', 'admin.reset'), arguments: {}, }) @@ -146,7 +149,7 @@ describe('fixture server — controlled scenarios', () => { }) it('executes add(2, 3) → "5"', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__add', arguments: { a: 2, b: 3 }, }) @@ -155,7 +158,7 @@ describe('fixture server — controlled scenarios', () => { }) it('executes greet("World") → "Hello, World!"', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__greet', arguments: { name: 'World' }, }) @@ -164,7 +167,7 @@ describe('fixture server — controlled scenarios', () => { }) it('executes fail() → isError result', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__fixture__fail', arguments: {}, }) @@ -173,7 +176,7 @@ describe('fixture server — controlled scenarios', () => { }) it('executes image() → ordered durable image content', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, agent: imageAgent() as never, callId: nextCallId(), name: 'mcp__fixture__image', arguments: {}, }) @@ -257,14 +260,14 @@ describe('fixture server — crash recovery', () => { const ctx = await mountRegistry() await apply(ctx, crashConfig('crashy', { initialDelayMs: 50, maxDelayMs: 500, maxAttempts: 40 })) - const before = await ctx.tools.execute({ + const before = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 2, b: 3 }, }) expect(textOf(before.content[0])).toBe('5') // The crash tool replies, then kills the real child process. - const crash = await ctx.tools.execute({ + const crash = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__crashy__crash', arguments: {}, }) @@ -273,7 +276,7 @@ describe('fixture server — crash recovery', () => { // Recovery is proven by the world: a post-crash call round-trips through // the respawned server process. await vi.waitFor(async () => { - const after = await ctx.tools.execute({ + const after = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 20, b: 22 }, }) @@ -298,7 +301,7 @@ describe('fixture server — crash recovery', () => { // Cordis awaits async apply() as startup work; wait for it. await vi.waitFor(() => { expect(ctx.tools.get('mcp__ephemeral__add')).toBeDefined() }, { timeout: 20_000 }) - const crash = await ctx.tools.execute({ + const crash = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__ephemeral__crash', arguments: {}, }) @@ -355,7 +358,7 @@ describe('server-everything — official test server', () => { }) it('executes echo({ message: "hello" }) → "Echo: hello"', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__echo', arguments: { message: 'hello' }, }) @@ -364,7 +367,7 @@ describe('server-everything — official test server', () => { }) it('executes get-sum({ a: 3, b: 7 }) → contains "10"', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-sum', arguments: { a: 3, b: 7 }, }) @@ -373,7 +376,7 @@ describe('server-everything — official test server', () => { }) it('executes get-tiny-image → explicit refusal without a durable route', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__everything__get-tiny-image', arguments: {}, }) @@ -424,7 +427,7 @@ describe('server-filesystem — real filesystem operations', () => { const filePath = join(tempDir, 'test.txt') const content = 'Hello from MCP e2e test!' - const writeResult = await ctx.tools.execute({ + const writeResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content }, }) @@ -434,7 +437,7 @@ describe('server-filesystem — real filesystem operations', () => { const onDisk = await readFile(filePath, 'utf8') expect(onDisk).toBe(content) - const readResult = await ctx.tools.execute({ + const readResult = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath }, }) @@ -445,7 +448,7 @@ describe('server-filesystem — real filesystem operations', () => { it('list_directory shows written file', async () => { await writeFile(join(tempDir, 'listed.txt'), 'listed') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__filesystem__list_directory', arguments: { path: tempDir }, }) @@ -531,7 +534,7 @@ describe('streamable-http — in-process MCP server', () => { }) it('executes ping() → "pong" over HTTP', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__ping', arguments: {}, }) @@ -540,7 +543,7 @@ describe('streamable-http — in-process MCP server', () => { }) it('executes shout({ message }) with args over HTTP', async () => { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__web__shout', arguments: { message: 'quiet' }, }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 4a330850b8..5507c58f36 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi, beforeEach } from 'vitest' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' import { Context } from '@deepseek-ai/cordis' @@ -56,6 +58,7 @@ async function mountRegistry(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -219,7 +222,7 @@ describe('syncTools', () => { expect(ctx.tools.get('search')).toBeDefined() expect(ctx.tools.get('mcp__srv__search')).toBeDefined() - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'search', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'search', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'native' }) }) @@ -341,13 +344,13 @@ describe('syncTools', () => { try { await syncTools(client, ctx, defaultOpts, new Map()) - const missing = await ctx.tools.execute({ + const missing = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('missing'), name: 'mcp__srv__supported', arguments: {}, }) expect(missing.error?.message).toContain('structured content') - const fallback = await ctx.tools.execute({ + const fallback = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('fallback'), name: 'mcp__srv__future-schema', arguments: {}, }) @@ -376,7 +379,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__echo', arguments: { msg: 'hi' } }) expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: 'hello world' }]) @@ -397,7 +400,7 @@ describe('tool execution', () => { await syncTools(client as never, ctx, defaultOpts, new Map()) const publicName = publicToolName('srv', 'admin.reset') - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: publicName, arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: publicName, arguments: {} }) expect(result.isError).toBe(false) expect(client.callTool).toHaveBeenCalledWith( @@ -413,7 +416,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__multi', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__multi', arguments: {} }) expect(result.content).toEqual([{ type: 'text', text: 'line1\nline2' }]) }) @@ -433,7 +436,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__img', @@ -467,7 +470,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('no-store'), name: 'mcp__srv__img', @@ -495,7 +498,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('bad-batch'), name: 'mcp__srv__img', @@ -521,7 +524,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('strict-batch'), name: 'mcp__srv__img', @@ -544,7 +547,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('text-route'), name: 'mcp__srv__img', @@ -564,7 +567,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const noProvider = await rich.ctx.tools.execute({ + const noProvider = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('no-provider'), name: 'mcp__srv__img', @@ -573,7 +576,7 @@ describe('tool execution', () => { }) expect(textAt(noProvider.content)).toContain('route could not be resolved') - const noModel = await rich.ctx.tools.execute({ + const noModel = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('no-model'), name: 'mcp__srv__img', @@ -585,7 +588,7 @@ describe('tool execution', () => { const noLlmCtx = await mountRegistry() await noLlmCtx.plugin(RecordingAttachmentStore) await syncTools(client as never, noLlmCtx, defaultOpts, new Map()) - const noLlm = await noLlmCtx.tools.execute({ + const noLlm = await executeToolFixture(noLlmCtx, { signal: testToolSignal, callId: ToolCallId('no-llm'), name: 'mcp__srv__img', @@ -595,7 +598,7 @@ describe('tool execution', () => { expect(textAt(noLlm.content)).toContain('route could not be resolved') vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockRejectedValueOnce(new Error('catalog down')) - const unverified = await rich.ctx.tools.execute({ + const unverified = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('unverified'), name: 'mcp__srv__img', @@ -607,7 +610,7 @@ describe('tool execution', () => { vi.spyOn(rich.ctx.llm, 'resolveModelInfo').mockResolvedValueOnce({ provider: 'visual', id: 'vision', name: 'vision', }) - const unknown = await rich.ctx.tools.execute({ + const unknown = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('unknown-modalities'), name: 'mcp__srv__img', @@ -621,7 +624,7 @@ describe('tool execution', () => { controller.abort(new Error('stop')) return { provider, id: model, name: model, inputModalities: ['text', 'image'] } }) - const canceled = await rich.ctx.tools.execute({ + const canceled = await executeToolFixture(rich.ctx, { signal: controller.signal, callId: ToolCallId('canceled'), name: 'mcp__srv__img', @@ -642,7 +645,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('store-rejected'), name: 'mcp__srv__img', @@ -664,7 +667,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('policy-rejected'), name: 'mcp__srv__img', @@ -692,7 +695,7 @@ describe('tool execution', () => { }) try { await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('shortened'), name: 'mcp__srv__img', arguments: {}, agent: agentOn() as never, }) @@ -728,7 +731,7 @@ describe('tool execution', () => { }) return { ...result } }) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId(`dispatch-${replacement}`), name: 'mcp__srv__img', arguments: {}, agent: agentOn() as never, }) @@ -752,7 +755,7 @@ describe('tool execution', () => { ) await syncTools(client as never, rich.ctx, defaultOpts, new Map()) - const result = await rich.ctx.tools.execute({ + const result = await executeToolFixture(rich.ctx, { signal: testToolSignal, callId: ToolCallId('replaced'), name: 'mcp__srv__img', @@ -775,7 +778,7 @@ describe('tool execution', () => { { content: [{ type: 'image', mimeType: 'image/png', data: 'AQ==' }] }, ) await syncTools(valueClient as never, valueRich.ctx, defaultOpts, new Map()) - const replaced = await valueRich.ctx.tools.execute({ + const replaced = await executeToolFixture(valueRich.ctx, { signal: testToolSignal, callId: ToolCallId('value-replaced'), name: 'mcp__srv__img', @@ -794,7 +797,7 @@ describe('tool execution', () => { { content: [{ type: 'image', mimeType: 'image/png', data: 'Ag==' }] }, ) await syncTools(blockedClient as never, blockedRich.ctx, defaultOpts, new Map()) - const blocked = await blockedRich.ctx.tools.execute({ + const blocked = await executeToolFixture(blockedRich.ctx, { signal: testToolSignal, callId: ToolCallId('blocked'), name: 'mcp__srv__img', @@ -814,7 +817,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('primitive'), name: 'mcp__srv__primitive-blocks', arguments: {}, }) @@ -839,7 +842,7 @@ describe('tool execution', () => { { content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } }, ) await syncTools(valid as never, ctx, defaultOpts, new Map()) - const success = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('valid'), name: 'mcp__srv__structured', arguments: {} }) + const success = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('valid'), name: 'mcp__srv__structured', arguments: {} }) if (success.isError) throw new Error('expected supported structuredContent to validate') expect(success.value).toEqual({ content: [{ type: 'text', text: '42' }], structuredContent: { answer: 42 } }) @@ -849,7 +852,7 @@ describe('tool execution', () => { { content: [{ type: 'text', text: 'wrong' }], structuredContent: { answer: 'forty-two' } }, ) await syncTools(invalid as never, invalidCtx, defaultOpts, new Map()) - const failure = await invalidCtx.tools.execute({ signal: testToolSignal, callId: ToolCallId('invalid'), name: 'mcp__srv__structured', arguments: {} }) + const failure = await executeToolFixture(invalidCtx, { signal: testToolSignal, callId: ToolCallId('invalid'), name: 'mcp__srv__structured', arguments: {} }) expect(failure.error).toMatchObject({ info: { code: 'INVALID_TOOL_OUTPUT' } }) expect(failure.content[0]?.type === 'text' ? failure.content[0].text : '') .toContain('value.structuredContent.answer') @@ -865,7 +868,7 @@ describe('tool execution', () => { { content: [], structuredContent: ['kept', { nested: true }] }, ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('fallback'), name: 'mcp__srv__future-schema', arguments: {} }) if (result.isError) throw new Error('unsupported MCP output schemas must fall back') expect(result.value).toEqual({ content: [], structuredContent: ['kept', { nested: true }] }) }) @@ -877,7 +880,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__fail', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__fail', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ type: 'text', text: 'Error: something went wrong' }) @@ -890,7 +893,7 @@ describe('tool execution', () => { ]) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('task-only'), name: 'mcp__srv__task-only', arguments: {}, }) @@ -908,7 +911,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ callId: ToolCallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal }) + await executeToolFixture(ctx, { callId: ToolCallId('c1'), name: 'mcp__srv__slow', arguments: {}, signal: controller.signal }) expect(client.callTool).toHaveBeenCalledWith( expect.anything(), @@ -923,7 +926,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__audio_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', @@ -938,7 +941,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__res_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', @@ -953,7 +956,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__link_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: 'Resource link: Design (https://example.test/design)' }) }) @@ -968,7 +971,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('missing-link'), name: 'mcp__srv__link_tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('missing-link'), name: 'mcp__srv__link_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[resource link unavailable: the MCP block is missing its name or URI]', @@ -983,7 +986,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__unknown_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '[unsupported MCP content type: video]' }) }) @@ -996,7 +999,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__img2', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__img2', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', @@ -1012,7 +1015,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__audio_no_mime', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', @@ -1028,7 +1031,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__notext', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__notext', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(notext returned no model-visible content)' }) }) @@ -1040,7 +1043,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__empty_tool', arguments: {} }) expect(result.content[0]).toEqual({ type: 'text', text: '(empty_tool returned no model-visible content)' }) }) @@ -1053,7 +1056,7 @@ describe('tool execution', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__err_notext', arguments: {} }) expect(result.isError).toBe(true) expect(result.content[0]).toEqual({ @@ -1195,7 +1198,7 @@ describe('tool execution — non-object args fallback', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__coerce', arguments: null }) + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__coerce', arguments: null }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce', arguments: {} }, @@ -1210,7 +1213,7 @@ describe('tool execution — non-object args fallback', () => { ) await syncTools(client as never, ctx, defaultOpts, new Map()) - await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'mcp__srv__coerce2', arguments: 'bad' }) expect(client.callTool).toHaveBeenCalledWith( { name: 'coerce2', arguments: {} }, diff --git a/packages/mcp/mcp-client/tests/negotiation-lifecycle.spec.ts b/packages/mcp/mcp-client/tests/negotiation-lifecycle.spec.ts index 1aba633559..ed3291a494 100644 --- a/packages/mcp/mcp-client/tests/negotiation-lifecycle.spec.ts +++ b/packages/mcp/mcp-client/tests/negotiation-lifecycle.spec.ts @@ -1,4 +1,5 @@ /** Real SDK probe ownership, failed negotiation recovery, and subprocess quiescence. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { once } from 'node:events' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { createServer } from 'node:http' @@ -28,6 +29,7 @@ async function connection(factory: () => Transport, retry: boolean) { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const errors: string[] = [] const warns: string[] = [] ctx.logger.error = (message: unknown) => { errors.push(String(message)) } diff --git a/packages/mcp/mcp-client/tests/protocol.spec.ts b/packages/mcp/mcp-client/tests/protocol.spec.ts index 6124752c08..0c068d6b6c 100644 --- a/packages/mcp/mcp-client/tests/protocol.spec.ts +++ b/packages/mcp/mcp-client/tests/protocol.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { z } from 'zod' /** Real SDK negotiation, subscription, and cancellation through the connection supervisor. */ @@ -25,6 +27,7 @@ async function connect(server: McpServer, options?: { resources: true }): Promis const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() const serving = serveStdio(() => server, { transport: serverTransport }) mockTransport.mockReturnValue(clientTransport) @@ -66,20 +69,20 @@ describe('modern MCP connections', () => { ['list_mcp_resources', { resources: [] }], ['list_mcp_resource_templates', { resourceTemplates: [] }], ] as const) { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name, arguments: { server: 'fixture' }, callId: ToolCallId(name), signal: new AbortController().signal, }) expect(result).toMatchObject({ isError: false, value: expected }) } - const read = await ctx.tools.execute({ + const read = await executeToolFixture(ctx, { name: 'read_mcp_resource', arguments: { server: 'fixture', uri: 'memo://readme' }, callId: ToolCallId('unsupported-resource-read'), signal: new AbortController().signal, }) expect(read.isError).toBe(true) if (read.isError) expect(read.error.message).toContain('Method not found') expect(ctx.tools.schemas().map(tool => tool.name)).toEqual(names) - const ping = await ctx.tools.execute({ + const ping = await executeToolFixture(ctx, { name: 'mcp__fixture__ping', arguments: {}, callId: ToolCallId('ping-after-resource-errors'), signal: new AbortController().signal, }) @@ -104,7 +107,7 @@ describe('modern MCP connections', () => { const ctx = await connect(server, { resources: true }) for (const name of ['list_mcp_resources', 'list_mcp_resource_templates']) { for (const cursor of [undefined, 'opaque-page']) { - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name, arguments: { server: 'fixture', ...cursor === undefined ? {} : { cursor } }, callId: ToolCallId(name), signal: new AbortController().signal, }) @@ -112,7 +115,7 @@ describe('modern MCP connections', () => { } } expect(seen).toEqual([undefined, 'opaque-page', undefined, 'opaque-page']) - const read = await ctx.tools.execute({ + const read = await executeToolFixture(ctx, { name: 'read_mcp_resource', arguments: { server: 'fixture', uri: 'memo://readme' }, callId: ToolCallId('read-resource'), signal: new AbortController().signal, }) @@ -141,7 +144,7 @@ describe('modern MCP connections', () => { }) const ctx = await connect(server) const controller = new AbortController() - const result = ctx.tools.execute({ + const result = executeToolFixture(ctx, { callId: ToolCallId('cancel'), name: 'mcp__fixture__wait', arguments: {}, signal: controller.signal, }) await entered.promise diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index f41670178c..73b3b089f8 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -1,9 +1,11 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Tests for the mcp-client connection supervisor: crash-driven reconnection * with bounded backoff, generation-safe tool re-registration, the failure * cap, the stability-window budget reset, and disposal stopping reconnection. * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi, beforeEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -66,6 +68,7 @@ async function mountRegistry(): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) return ctx } @@ -164,7 +167,7 @@ describe('reconnect supervisor', () => { mockConnect.mockImplementationOnce(() => reconnectGate.promise) instances[0]!.onclose?.() await vi.waitFor(() => { expect(instances).toHaveLength(2) }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'list_mcp_resources', arguments: { server: 'srv' }, callId: nextCallId(), signal: testToolSignal, }) @@ -194,7 +197,7 @@ describe('reconnect supervisor', () => { expect(mockConnect).toHaveBeenCalledTimes(2) // Post-recovery calls execute through the re-registered definition. - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: nextCallId(), name: 'mcp__srv__revived', arguments: {}, }) diff --git a/packages/mcp/mcp-client/tests/server-context.spec.ts b/packages/mcp/mcp-client/tests/server-context.spec.ts index 8c3d2a7371..e1977e0261 100644 --- a/packages/mcp/mcp-client/tests/server-context.spec.ts +++ b/packages/mcp/mcp-client/tests/server-context.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -15,6 +17,7 @@ async function setup() { roots.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(McpResources) return ctx } @@ -34,7 +37,7 @@ describe('MCP server context', () => { expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain(instructions) await fiber.dispose() expect(renderPrompt(await ctx.systemPrompt.assemble())).not.toContain('MCP server: docs') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'list_mcp_resources', arguments: { server: 'docs' }, callId: ToolCallId('disposed-resource'), signal: new AbortController().signal, }) diff --git a/packages/mcp/mcp-client/tests/tool-definition.spec.ts b/packages/mcp/mcp-client/tests/tool-definition.spec.ts index 3e99736401..d8519b9ac8 100644 --- a/packages/mcp/mcp-client/tests/tool-definition.spec.ts +++ b/packages/mcp/mcp-client/tests/tool-definition.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -12,6 +14,7 @@ describe('MCP result callback adaptation', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) let execution: ToolExecution | undefined ctx.on('tools/execute', (exec, next) => { execution = exec @@ -28,7 +31,7 @@ describe('MCP result callback adaptation', () => { call, })) const signal = new AbortController().signal - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'native_window', callId: ToolCallId('window-call'), arguments: { window: 7 }, signal, }) expect(call).toHaveBeenCalledWith({ window: 7 }, execution) @@ -48,11 +51,12 @@ describe('MCP result callback adaptation', () => { try { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) ctx.tools.register(createMcpToolDefinition(ctx, { name: 'invalid_result', rawName: 'invalid', description: 'External result fixture.', inputSchema: { type: 'object' }, call: async () => invalid, })) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { name: 'invalid_result', callId: ToolCallId('invalid-call'), arguments: {}, signal: new AbortController().signal, }) diff --git a/packages/mcp/mcp-resources/package.json b/packages/mcp/mcp-resources/package.json index 0676ad68ed..58b8d07461 100644 --- a/packages/mcp/mcp-resources/package.json +++ b/packages/mcp/mcp-resources/package.json @@ -40,6 +40,9 @@ "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*" diff --git a/packages/mcp/mcp-resources/tests/resources.spec.ts b/packages/mcp/mcp-resources/tests/resources.spec.ts index 8460d80c96..4f63331eee 100644 --- a/packages/mcp/mcp-resources/tests/resources.spec.ts +++ b/packages/mcp/mcp-resources/tests/resources.spec.ts @@ -1,8 +1,11 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { setImmediate as nextEventLoopTurn } from 'node:timers/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineTool, type Config as ToolConfig } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { defineTool } from '@deepseek-ai/dsh-tools' import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import { bindScopeParent, createScope, type Scope } from '@deepseek-ai/dsh-scope' @@ -32,11 +35,12 @@ afterEach(async () => { await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) }) -async function setup(mode: ToolConfig['mode'] = 'native') { +async function setup(mode: ('native' | 'ptc') = 'native') { const ctx = new Context() roots.push(ctx) await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) if (mode !== 'native') await ctx.plugin(BindingProbe) await ctx.plugin(McpResources) return ctx @@ -55,7 +59,7 @@ function visibleResourceTools(ctx: Context, agent?: Agent): string[] { } function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { name, arguments: args, callId: ToolCallId('resource-test'), signal: new AbortController().signal, ...agent === undefined ? {} : { agent }, @@ -283,6 +287,7 @@ describe('MCP resource tools', () => { roots.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const fiber = await ctx.plugin(McpResources) ctx.mcpResources.register('docs', { request: async () => ({ resources: [] }) }) expect(renderPrompt(await ctx.systemPrompt.assemble())).toContain('MCP resource servers') diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 7d91ed1602..94b1c7ae6b 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -66,6 +66,9 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 5c39a792e8..1065698bf2 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -1,10 +1,12 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import PlanModeController from '@deepseek-ai/dsh-plan-mode' @@ -28,6 +30,7 @@ async function harness(adapter: MockAdapter): Promise { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(PlanModeController, PLAN_CONFIG) diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 6024269b6d..eb37ec52ae 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -1,9 +1,13 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { createScope } from '@deepseek-ai/dsh-scope' @@ -97,6 +101,7 @@ async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { await mountProjectionSeam(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(PlanModeController, config) return ctx } @@ -171,7 +176,7 @@ function expectPlanPtcSdkBindings(sdk: string): void { let callCounter = 0 function execute(ctx: Context, name: string, agent?: Agent) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { callId: ToolCallId(`call-${++callCounter}`), name, arguments: {}, @@ -227,6 +232,7 @@ describe('ctx.planMode: get/set', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(PlanModeController, PLAN_CONFIG) expect(ctx.get('planMode')).toBeUndefined() }) @@ -243,6 +249,7 @@ describe('ctx.planMode: get/set', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const agent = await agentWithSession(ctx, 'missing-plan-projection-keys') const planMode = new PlanModeController(ctx, PLAN_CONFIG) await new Promise(resolve => setImmediate(resolve)) @@ -337,6 +344,7 @@ describe('the boundary flush', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) const agent = await agentWithSession(ctx) @@ -490,6 +498,7 @@ describe('the soft layer', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountProjectionSeam(ctx) ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { const final = await next() @@ -518,7 +527,8 @@ describe('the soft layer', () => { } const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) await ctx.plugin(FakeRuntime) await mountProjectionSeam(ctx) await ctx.plugin(PlanModeController, PLAN_CONFIG) @@ -542,7 +552,8 @@ describe('the soft layer', () => { } const withPlanMode = new Context() await withPlanMode.plugin(SystemPrompt) - await withPlanMode.plugin(ToolRuntime, { mode: 'ptc' }) + await withPlanMode.plugin(ToolRuntime) + await withPlanMode.plugin(PtcToolExecutor) await withPlanMode.plugin(FakeRuntime) await mountProjectionSeam(withPlanMode) await withPlanMode.plugin(PlanModeController, PLAN_CONFIG) @@ -558,7 +569,8 @@ describe('the soft layer', () => { // with a deployment that does not compose plan mode at all. const bare = new Context() await bare.plugin(SystemPrompt) - await bare.plugin(ToolRuntime, { mode: 'ptc' }) + await bare.plugin(ToolRuntime) + await bare.plugin(PtcToolExecutor) await bare.plugin(FakeRuntime) registerNamedTools(bare, ['read', 'write']) const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' @@ -773,6 +785,7 @@ describe('/plan', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(CommandRuntime) await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) @@ -805,7 +818,7 @@ describe('exit_plan_mode', () => { } function callExit(ctx: Context, agent: Agent | undefined, plan = '# The plan\n\ndo things') { - return ctx.tools.execute({ + return executeToolFixture(ctx, { callId: ToolCallId(`call-exit-${++callCounter}`), name: EXIT_PLAN_MODE, arguments: { plan }, @@ -921,7 +934,8 @@ describe('exit_plan_mode', () => { } const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) await ctx.plugin(ExitRuntime) provideWorkingDirectoryFixture(ctx) await mountProjectionSeam(ctx) @@ -937,7 +951,7 @@ describe('exit_plan_mode', () => { }) const agent = await agentWithSession(ctx, 'ptc-exit', { active: true }) - const result = await ctx.tools.execute({ + const result = await executeModelToolFixture(ctx, { callId: ToolCallId(`call-exit-${++callCounter}`), name: RUN_CODE_NAME, arguments: { code: `return await tools.${EXIT_PLAN_MODE}({ plan: ${JSON.stringify(plan)} })`, description: 'Submit the plan for review' }, @@ -1088,7 +1102,7 @@ describe('exit_plan_mode', () => { it('forwards the execution abort signal to the review question', async () => { const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] }) const controller = new AbortController() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId(`call-exit-${++callCounter}`), name: EXIT_PLAN_MODE, arguments: { plan: '# P' }, @@ -1103,6 +1117,7 @@ describe('exit_plan_mode', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) await ctx.plugin(AgentRegistry) @@ -1168,6 +1183,7 @@ describe('HMR disposal', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountProjectionSeam(ctx) const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG) const agent = await agentWithSession(ctx, 'disposed-recovery') diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 3c2fc36b5c..987fc788c5 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -1,5 +1,6 @@ /** Plan projection behavior. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -24,6 +25,7 @@ async function harness(withPlanMode: boolean): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) diff --git a/packages/preset/agent-preset-registry/package.json b/packages/preset/agent-preset-registry/package.json index 3f259cfdeb..54cd34f6ad 100644 --- a/packages/preset/agent-preset-registry/package.json +++ b/packages/preset/agent-preset-registry/package.json @@ -81,14 +81,20 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-hmr": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-agent-preset": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*", "@types/js-yaml": "^4.0.9" } } diff --git a/packages/preset/agent-preset-registry/tests/harness.ts b/packages/preset/agent-preset-registry/tests/harness.ts index c420624fe3..cf63f62bb5 100644 --- a/packages/preset/agent-preset-registry/tests/harness.ts +++ b/packages/preset/agent-preset-registry/tests/harness.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Group from '@deepseek-ai/cordis-plugin-group' @@ -26,6 +27,7 @@ export async function harness(options: { live?: boolean } = {}): Promise { + const ctx = await harness() + onTestFinished(() => ctx.fiber.dispose()) + ctx.tools.register(defineTool({ + name: 'echo', description: 'Shared capability.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'shared', + })) + const native = new NativeToolExecutor(ctx) + let mounted = 0 + let disposed = 0 + const executions: string[] = [] + ctx.loader.builtins.executor = { + inject: ['tools'], + apply(owner: Context) { + const label = `executor-${++mounted}` + const executor: ToolExecutor = { + sections: [{ name: 'tools:transport', order: 800, text: label, interpolate: false }], + describe: tools => native.describe(tools), + execute: (input) => { executions.push(label); return native.execute(input) }, + } + owner.tools.setExecutor(executor) + owner.effect(() => () => { disposed++ }) + }, + } + const definition = { id: 'custom', plugins: [{ id: 'executor', name: 'cordis:executor' }] } + const firstRow = await declare(ctx, definition) + await declare(ctx, { id: 'standard', plugins: [] }) + const first = await agentOn(ctx, 'first-executor', 'custom') + const sameRevision = await agentOn(ctx, 'same-executor', 'custom') + const plain = await agentOn(ctx, 'plain-executor', 'standard') + expect(mounted).toBe(1) + expect(first.ctx.tools.get('echo', first)).toBe(plain.ctx.tools.get('echo', plain)) + expect((await ctx.systemPrompt.assemble({ scope: first })).sections) + .toContainEqual(expect.objectContaining({ name: 'tools:transport', text: 'executor-1' })) + const result = await executeModelToolFixture(ctx, { + agent: first, callId: ToolCallId('loader-executor'), name: 'echo', arguments: {}, signal: new AbortController().signal, + }) + expect(result).toMatchObject({ value: 'shared', isError: false }) + await firstRow.dispose() + await declare(ctx, definition) + const newer = await agentOn(ctx, 'new-executor', 'custom') + expect(mounted).toBe(2) + expect(disposed).toBe(0) + expect((await ctx.systemPrompt.assemble({ scope: first })).sections) + .toContainEqual(expect.objectContaining({ text: 'executor-1' })) + expect((await ctx.systemPrompt.assemble({ scope: newer })).sections) + .toContainEqual(expect.objectContaining({ text: 'executor-2' })) + for (const agent of [first, sameRevision, plain, newer]) { + const batch = await ctx.tools.execute({ + agent, calls: [{ type: 'tool-call', id: ToolCallId('revision'), name: 'echo', arguments: '{}' }], + signal: new AbortController().signal, + }) + expect(batch.results[0]?.result).toMatchObject({ value: 'shared', isError: false }) + } + expect(executions).toEqual(['executor-1', 'executor-1', 'executor-1', 'executor-2']) +}) + +/** A deterministic external program backend that invokes the provided tool binding. */ +class ProgramFixture extends PtcRuntime { + readonly language = 'typescript' + readonly isolation = 'fixture' + + resolve(request: PtcRunRequest) { + return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 120_000 } + } + + async run(request: PtcRunRequest): Promise { + expect(request.program).toBe('return await tools.echo({ value: "ptc" })') + const value = await request.bindings[0]!.functions.echo!({ value: 'ptc' }) + return { logs: [], value } + } +} + +it('loads the separate executor packages through cordis.yml presets and logs PTC subcalls', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-executor-packages-')) + const ctx = await harness() + onTestFinished(async () => { + try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } + }) + await ctx.plugin(FsLocal) + await ctx.plugin(WorkingDirectory, { defaultDirectory: root }) + await ctx.plugin(ProgramFixture) + ctx.tools.register(defineTool({ + name: 'echo', description: 'Shared capability.', parameters: { value: { type: 'string', required: true } }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async args => `echo:${args.value}`, + })) + Object.assign(ctx.loader.builtins, { include: Include, preset: AgentPreset, ptc: PtcPlugin }) + const path = join(root, 'cordis.yml') + await writeFile(path, `- id: native-preset + name: cordis:preset + config: + id: standard + plugins: [] +- id: ptc-preset + name: cordis:preset + config: + id: ptc + plugins: + - id: executor + name: cordis:ptc + config: + maxParallelSubCalls: 2 +`) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(path).href } }) + await ctx.loader.await() + const native = await agentOn(ctx, 'native-package', 'standard') + const ptc = await agentOn(ctx, 'ptc-package', 'ptc') + expect((await ctx.systemPrompt.assemble({ scope: native })).tools.map(tool => tool.name)).toEqual(['echo']) + expect((await ctx.systemPrompt.assemble({ scope: ptc })).tools.map(tool => tool.name)).toEqual(['run_code']) + const signal = new AbortController().signal + expect(await executeModelToolFixture(ctx, { + agent: native, name: 'echo', arguments: { value: 'native' }, callId: ToolCallId('native'), signal, + })).toMatchObject({ isError: false, value: 'echo:native' }) + expect(await executeModelToolFixture(ctx, { + agent: ptc, name: 'run_code', callId: ToolCallId('program'), signal, + arguments: { code: 'return await tools.echo({ value: "ptc" })', description: 'Echo through the program' }, + })).toMatchObject({ isError: false, content: [{ type: 'text', text: 'echo:ptc' }] }) + expect(ptc.session.snapshotEvents().find(event => event.type === 'tool/ptc-dispatch')).toMatchObject({ + data: { name: 'echo', isError: false, content: [{ type: 'text', text: 'echo:ptc' }] }, + }) +}) diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 74cccd5a0b..bab04ab833 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -82,6 +82,7 @@ "@deepseek-ai/dsh-storage-json": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", diff --git a/packages/schedule/schedule/tests/harness.ts b/packages/schedule/schedule/tests/harness.ts index bf152ad187..949819fbd3 100644 --- a/packages/schedule/schedule/tests/harness.ts +++ b/packages/schedule/schedule/tests/harness.ts @@ -1,4 +1,5 @@ /** Host Schedule tests use real domain storage and an explicit Session resolver. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context, Service } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -75,6 +76,7 @@ export async function harness(options: { await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(Storage) const pool = options.pool ?? new MemoryMediaPool() const backend = options.backend ?? new MemoryStorageBackend(pool) diff --git a/packages/schedule/tool-schedule/package.json b/packages/schedule/tool-schedule/package.json index 3068cfe2de..da0dd73784 100644 --- a/packages/schedule/tool-schedule/package.json +++ b/packages/schedule/tool-schedule/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts b/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts index 7e2c294978..063fe52e4c 100644 --- a/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts +++ b/packages/schedule/tool-schedule/tests/tool-schedule.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -21,7 +22,7 @@ async function setup(options: Parameters[0] = {}) { return { ...test, agent, scope } } async function execute(test: Awaited>, name: string, args: unknown, agent: Agent = test.agent) { - return await test.ctx.tools.execute({ + return await executeToolFixture(test.ctx, { callId: ToolCallId('schedule-call'), signal: new AbortController().signal, name, arguments: args, agent, }) } @@ -55,7 +56,7 @@ describe('Schedule model tools', () => { ['schedule_update', { id: 'task-1', title: 'Check' }], ] as const for (const [name, args] of cases) { - const result = await test.ctx.tools.execute({ + const result = await executeToolFixture(test.ctx, { callId: ToolCallId('agentless-call'), signal: new AbortController().signal, name, arguments: args, }) expect(result.isError).toBe(false) @@ -75,7 +76,7 @@ describe('Schedule model tools', () => { ['schedule_update', { id: 'task-1', title: 'Check' }], ] as const for (const [name, args] of cases) { - const result = await test.ctx.tools.execute({ + const result = await executeToolFixture(test.ctx, { callId: ToolCallId('child-call'), signal: new AbortController().signal, name, arguments: args, agent: child, }) expect(result.isError).toBe(false) diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 2673fcd02b..60f90d7bb3 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -48,6 +48,8 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index 654b1ab18f..a9ddf50053 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -36,6 +38,7 @@ async function setup(): Promise { await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(TestPersistence) await ctx.plugin(checkpointPolicy) return ctx @@ -124,7 +127,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { execute: async () => { order.push('tool'); return null }, }) - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('write-1'), name: 'write', arguments: {}, agent, signal: new AbortController().signal, }) @@ -153,7 +156,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { execute: async () => { order.push('tool'); return null }, }) - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('write-cancelled'), name: 'write', arguments: {}, agent, signal: controller.signal, }) @@ -184,7 +187,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { output: { schema: { type: 'null' }, render: () => [] }, execute: async () => { ran = true; return null }, }) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('write-2'), name: 'write', arguments: {}, agent, signal: new AbortController().signal, }) @@ -204,7 +207,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { output: { schema: { type: 'null' }, render: () => [] }, execute: async () => null, }) - await ctx.tools.execute({ + await executeToolFixture(ctx, { callId: ToolCallId('nested-1'), name: 'nested', arguments: {}, agent, parent: Symbol('outer') as never, signal: new AbortController().signal, @@ -235,6 +238,7 @@ describe('session-checkpoint-policy lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(TestPersistence) const session = ctx.sessions.create(SessionId('disposed-policy')) let flushes = 0 diff --git a/packages/session/working-directory/package.json b/packages/session/working-directory/package.json index 300d0c47bc..0d330751cc 100644 --- a/packages/session/working-directory/package.json +++ b/packages/session/working-directory/package.json @@ -47,6 +47,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tool-working-directory": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" }, diff --git a/packages/session/working-directory/tests/working-directory.spec.ts b/packages/session/working-directory/tests/working-directory.spec.ts index dbd6775312..22fb99af47 100644 --- a/packages/session/working-directory/tests/working-directory.spec.ts +++ b/packages/session/working-directory/tests/working-directory.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, mkdir, realpath, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -345,7 +346,7 @@ describe('Session working directories', () => { await mkdir(join(f.root, 'child')) const fiber = f.ctx.plugin(WorkingDirectoryTool) await fiber - const execute = (arguments_: { cd?: string }, withAgent = true) => f.ctx.tools.execute({ + const execute = (arguments_: { cd?: string }, withAgent = true) => executeToolFixture(f.ctx, { name: 'working_directory', callId: ToolCallId('directory-call'), arguments: arguments_, signal: new AbortController().signal, ...withAgent ? { agent: f.agent } : {}, }) diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 3c4c9ff736..d2ed6062cb 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -54,6 +54,8 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*", diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts index f30e87b978..660015eab2 100644 --- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { tmpdir } from 'node:os' @@ -77,6 +79,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-tool-executor-native'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", "- name: '@deepseek-ai/dsh-session-projection'", @@ -113,6 +116,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], @@ -133,7 +137,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => { const owner = await agent(context, root) const signal = new AbortController().signal - const execute = (id: string, command: string) => context!.tools.execute({ + const execute = (id: string, command: string) => executeToolFixture(context!, { signal, callId: ToolCallId(id), name: 'bash', diff --git a/packages/shell/tool-bash-persistent/tests/tools.spec.ts b/packages/shell/tool-bash-persistent/tests/tools.spec.ts index d3507373c7..663bdee604 100644 --- a/packages/shell/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-bash-persistent/tests/tools.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { Context } from '@deepseek-ai/cordis' @@ -67,7 +69,7 @@ function call( command: string, signal = new AbortController().signal, ) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal, callId: ToolCallId(`persistent-bash-${++callNumber}`), name: 'bash', @@ -307,6 +309,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) @@ -715,6 +718,7 @@ describe('tool-bash-persistent', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 781f4facdd..a9a73fa59c 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -62,7 +62,10 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-jobs": "workspace:*", "@deepseek-ai/dsh-jobs-local": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/shell/tool-bash/tests/background.spec.ts b/packages/shell/tool-bash/tests/background.spec.ts index a4c6cd41de..139b544a81 100644 --- a/packages/shell/tool-bash/tests/background.spec.ts +++ b/packages/shell/tool-bash/tests/background.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -37,6 +39,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) @@ -50,7 +53,7 @@ async function setup() { let callCounter = 0 function call(ctx: Context, args: Record) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`background-call-${++callCounter}`), name: 'bash', @@ -126,7 +129,7 @@ describe('background bash output', () => { const jobs = ctx.jobs const job = jobs.list()[0] await until(() => jobs.get(job!.id).status === 'killed' ? true : undefined) - const read = text(await ctx.tools.execute({ + const read = text(await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('background-spawn-failure-read'), name: 'job_output', @@ -289,7 +292,7 @@ describe('foreground commands as jobs', () => { else if (event.type !== 'output') seen.push(event.type) }) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: controller.signal, callId: ToolCallId('background-call-aborted'), name: 'bash', @@ -321,7 +324,7 @@ describe('foreground commands as jobs', () => { } vi.spyOn(ctx.shell, 'execute').mockResolvedValue(stubborn) const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: controller.signal, callId: ToolCallId('background-call-aborted-slow-kill'), name: 'bash', @@ -371,7 +374,7 @@ describe('foreground commands as jobs', () => { whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('background-promote-owned'), name: 'bash', @@ -424,6 +427,7 @@ describe('foreground commands as jobs', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 25 }) await ctx.plugin(ToolTasks) @@ -487,7 +491,7 @@ describe('owned background output', () => { whenIdle: () => Promise.resolve(), } ctx.agents.register(owner) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('background-owned-1'), name: 'bash', diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index f436c5e8e6..07ef7d2d0e 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -1,3 +1,5 @@ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -118,8 +120,8 @@ describe('bash tool through the agent loop', () => { } } await ctx.plugin(BashBindingRuntime) - agent.ctx.tools.presentAs('ptc') - const result = await ctx.tools.execute({ + agent.ctx.plugin(PtcToolExecutor) + const result = await executeModelToolFixture(ctx, { callId: ToolCallId('nested-shell-root'), agent, signal: new AbortController().signal, name: 'run_code', arguments: { code: 'return await tools.bash({ command: "pwd", description: "Print launch directory" })', description: 'Inspect current directory' }, }) diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 0b01d7c81b..7207d1f753 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtempSync, rmSync } from 'node:fs' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { tmpdir } from 'node:os' @@ -51,6 +53,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -66,6 +69,7 @@ async function setupWithJobs() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -95,7 +99,7 @@ async function registerFakeAgent(ctx: Context, sessionId: string, inject: (...ar } let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } function text(result: { content: { type: string; text?: string }[] }): string { @@ -214,6 +218,7 @@ async function setupSandboxed(withApproval = false) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -316,6 +321,7 @@ describe('bash tool', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) @@ -342,7 +348,7 @@ describe('bash tool', () => { it('surfaces foreground aborts as the structured TOOL_ABORTED error', async () => { const ctx = await setup() const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { callId: ToolCallId('call-abort'), name: 'bash', arguments: { command: 'sleep 60', description: 'test command' }, @@ -473,6 +479,7 @@ describe('bash tool', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(LocalBashExecutor, {}) await ctx.plugin(BashEnvPlugin) @@ -490,6 +497,7 @@ describe('bash tool', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) // inject: ['tools', 'bash'] keeps the plugin pending until bash exists. await ctx.plugin(BashEnvPlugin) await ctx.plugin(ToolBash) @@ -507,6 +515,7 @@ describe('bash tool', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(LocalSubprocessRuntime) @@ -526,6 +535,7 @@ describe('the background surface follows the job registry', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalSubprocessRuntime) ;(ctx.subprocess as LocalSubprocessRuntime).internals = { spillDir } @@ -637,6 +647,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -646,7 +657,7 @@ describe('background execution through the job runtime', () => { const controller = new AbortController() controller.abort() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('call-pre-aborted'), name: 'bash', arguments: { command: 'sleep 60', description: 'test command', run_in_background: true }, @@ -667,6 +678,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(CountingStartExecutor) @@ -685,6 +697,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalSubprocessRuntime) await ctx.plugin(BashEnvPlugin) await ctx.plugin(LocalBashExecutor, {}) @@ -720,6 +733,7 @@ describe('sandbox escalation through the generic task producer', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(RecordingSandboxExecutor) await ctx.plugin(BashEnvPlugin) await expect(ctx.plugin(ToolBash)).rejects.toThrow('tool-bash: the mounted bash executor confines but ctx.sandboxPolicy is missing') @@ -844,7 +858,7 @@ describe('sandbox escalation through the generic task producer', () => { ctx.on('approval/request', () => Promise.resolve('allowed-once')) const agent = sandboxAgent(undefined, ctx) await ctx.agents.register(agent) - const foreground = await ctx.tools.execute({ + const foreground = await executeToolFixture(ctx, { callId: ToolCallId('sandbox-signal'), name: 'bash', arguments: escalate, @@ -870,7 +884,7 @@ describe('sandbox escalation through the generic task producer', () => { ctx.on('approval/request', () => Promise.resolve('allowed-once')) const started = vi.spyOn(bash, 'execute') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('cancelled-escalation-background'), name: 'bash', arguments: { ...escalate, run_in_background: true }, @@ -1061,7 +1075,7 @@ describe('session-cwd routing (per-session workdir)', () => { it('falls back to the executor default when the agent has no session cwd', async () => { const ctx = await setup() // No exec.agent at all → executor uses its config/process.cwd() default. - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } }) expect(result.isError).toBe(false) expect(text(result).trim().length).toBeGreaterThan(0) }) @@ -1337,6 +1351,7 @@ describe('the model-facing bash tool builds its request from named args only (no provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -1357,7 +1372,7 @@ describe('the model-facing bash tool builds its request from named args only (no const agent = await registerFakeAgent(ctx, 'request-fg', () => undefined) const ambient = process.env.DSH_SESSION_ID - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('session-env-fg'), name: 'bash', @@ -1377,7 +1392,7 @@ describe('the model-facing bash tool builds its request from named args only (no const { ctx, bash } = await setupRecording() const agent = await registerFakeAgent(ctx, 'request-bg', () => undefined) - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('session-env-bg'), name: 'bash', @@ -1404,7 +1419,7 @@ describe('the model-facing bash tool builds its request from named args only (no const child = await registerFakeAgent(ctx, 'request-child', () => undefined) for (const [callId, agent] of [['parent', parent], ['child', child]] as const) { - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`session-env-${callId}`), name: 'bash', @@ -1432,7 +1447,7 @@ describe('the model-facing bash tool builds its request from named args only (no // Unknown `env` and `stdin` keys are ignored by the schema and named request construction. // This preserves the request shape; it is not a security boundary because shell syntax can // already set environment variables or feed stdin. - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('no-forward-1'), name: 'bash', @@ -1454,7 +1469,7 @@ describe('the model-facing bash tool builds its request from named args only (no it('a background bash call likewise carries no trusted-only fields', async () => { const { ctx, bash } = await setupRecording() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('no-forward-2'), name: 'bash', diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 44a6c7a2ef..b1577fb0b1 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -56,6 +56,8 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-working-directory": "workspace:*" diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 90c29923a0..18ca9b8fad 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { spawnSync } from 'node:child_process' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' @@ -110,6 +112,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-tool-executor-native'", "- name: '@deepseek-ai/dsh-terminal'", "- name: '@deepseek-ai/dsh-test-sandbox'", "- name: '@deepseek-ai/dsh-session-projection'", @@ -157,6 +160,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRegistry], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-terminal', TerminalSessionService], ['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], @@ -214,7 +218,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp const signal = new AbortController().signal const execute = (id: string, command: string) => { timeline.label(id) - return context!.tools.execute({ + return executeToolFixture(context!, { signal, callId: ToolCallId(id), name: 'pwsh', diff --git a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts index b2b6d96137..db93763b35 100644 --- a/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/tools.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { Context } from '@deepseek-ai/cordis' @@ -67,7 +69,7 @@ function call( command: string, signal = new AbortController().signal, ) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal, callId: ToolCallId(`persistent-pwsh-${++callNumber}`), name: 'pwsh', @@ -321,6 +323,7 @@ async function setup( contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const stub = stubBackend(initialMode) @@ -792,6 +795,7 @@ describe('tool-pwsh-persistent', () => { contexts.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(TerminalSessionService) const spawnStarted = Promise.withResolvers() diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 4d9572bef3..1839fda401 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -63,7 +63,9 @@ "@deepseek-ai/dsh-shell-env": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-working-directory": "workspace:*", diff --git a/packages/shell/tool-pwsh/tests/background.spec.ts b/packages/shell/tool-pwsh/tests/background.spec.ts index 59b99b965c..f6127096ca 100644 --- a/packages/shell/tool-pwsh/tests/background.spec.ts +++ b/packages/shell/tool-pwsh/tests/background.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { ToolCallId } from '@deepseek-ai/dsh-llm' @@ -86,6 +88,7 @@ async function setup() { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry, { pumpPollMs: 5 }) await ctx.plugin(ToolTasks) @@ -97,7 +100,7 @@ async function setup() { let callCounter = 0 function call(ctx: Context, args: Record) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`pwsh-background-${++callCounter}`), name: 'pwsh', @@ -223,7 +226,7 @@ describe('owned background output (pwsh)', () => { ctx.agents.register(owner) const scripted = observableProcess() pwsh.backgroundHandler = () => scripted.proc - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pwsh-background-owned'), name: 'pwsh', @@ -350,7 +353,7 @@ describe('foreground commands as jobs (pwsh)', () => { const scripted = runningExecution('') pwsh.backgroundHandler = () => scripted.proc const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: controller.signal, callId: ToolCallId('pwsh-call-aborted'), name: 'pwsh', @@ -373,7 +376,7 @@ describe('foreground commands as jobs (pwsh)', () => { scripted.proc.kill = () => true pwsh.backgroundHandler = () => scripted.proc const controller = new AbortController() - const pending = ctx.tools.execute({ + const pending = executeToolFixture(ctx, { signal: controller.signal, callId: ToolCallId('pwsh-call-aborted-slow-kill'), name: 'pwsh', @@ -426,7 +429,7 @@ describe('foreground commands as jobs (pwsh)', () => { const scripted = runningExecution('') pwsh.backgroundHandler = () => scripted.proc - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('pwsh-observe-promote-owned'), name: 'pwsh', @@ -456,6 +459,7 @@ describe('foreground commands as jobs (pwsh)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) @@ -494,6 +498,7 @@ describe('foreground commands as jobs (pwsh)', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) diff --git a/packages/shell/tool-pwsh/tests/fixtures/loader/driver.ts b/packages/shell/tool-pwsh/tests/fixtures/loader/driver.ts index 41f06e47c5..baf3949897 100644 --- a/packages/shell/tool-pwsh/tests/fixtures/loader/driver.ts +++ b/packages/shell/tool-pwsh/tests/fixtures/loader/driver.ts @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Test driver: boot the tool-pwsh Loader composition, execute one real * foreground and one real background pwsh command through the tool registry, @@ -19,7 +20,7 @@ try { if (schema === undefined) throw new Error('pwsh tool not registered by the composition') const prompt = (await ctx.systemPrompt.assemble()).sections.find(section => section.name === 'tool:pwsh') - const foreground = await ctx.tools.execute({ + const foreground = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('loader-fg'), name: 'pwsh', @@ -27,7 +28,7 @@ try { }) const foregroundText = foreground.content.filter(block => block.type === 'text').map(block => block.text).join('') - const background = await ctx.tools.execute({ + const background = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('loader-bg'), name: 'pwsh', @@ -44,7 +45,7 @@ try { let backgroundText = '' const deadline = Date.now() + 10_000 while (Date.now() < deadline) { - const read = await ctx.tools.execute({ + const read = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('loader-bg-read'), name: 'job_output', diff --git a/packages/shell/tool-pwsh/tests/integration.spec.ts b/packages/shell/tool-pwsh/tests/integration.spec.ts index 33f4f4c932..ed07287a97 100644 --- a/packages/shell/tool-pwsh/tests/integration.spec.ts +++ b/packages/shell/tool-pwsh/tests/integration.spec.ts @@ -1,6 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the - * `pwsh` tool, exercised through `ctx.tools.execute()` with a real PowerShell + * `pwsh` tool, exercised through `executeToolFixture(ctx, )` with a real PowerShell * process. These verify the world — actual commands run, stdout/stderr come * back, exit codes render, timeouts abort, background jobs settle through the * generic job runtime, and per-session cwd resolution works. The suite @@ -9,6 +10,7 @@ * gate. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, rm, writeFile } from 'node:fs/promises' @@ -40,7 +42,7 @@ let ctx: Context let callCounter = 0 function call(name: string, args: unknown, agentObj?: object, signal?: AbortSignal) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: signal ?? testToolSignal, callId: ToolCallId(`it-${++callCounter}`), name, @@ -63,6 +65,7 @@ describe.skipIf(!hasPwsh)('pwsh tool over the real pwsh executor', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) await ctx.plugin(LocalSubprocessRuntime) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 66920cc9e9..295b2f1c39 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -1,6 +1,7 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, - * exercised through `ctx.tools.execute()` so nothing bypasses the tool + * exercised through `executeToolFixture(ctx, )` so nothing bypasses the tool * registry. The fake executor makes every seam outcome scriptable — output * text, truncation, timeout, abort, nonzero exits, background handles — so * these tests verify the schema, argument validation, workdir derivation, @@ -10,6 +11,7 @@ * is pinned separately in integration.spec.ts. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -152,6 +154,7 @@ async function setup(toolConfig: Partial = {}, dshHome?: string provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin, dshHome === undefined ? {} : { dshHome }) await ctx.plugin(FakeBash) @@ -166,6 +169,7 @@ async function setupWithJobs(toolConfig: Partial = {}, dshHome? provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -227,6 +231,7 @@ async function setupSandboxed(withApproval = false) { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) @@ -324,7 +329,7 @@ async function registerFakeAgent(ctx: Context, sessionId: string): Promise { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(ToolPwsh) expect(ctx.tools.schemas()).toHaveLength(0) }) @@ -411,6 +417,7 @@ describe('registration', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) const fiber = await ctx.plugin(ToolPwsh) @@ -483,7 +490,7 @@ describe('execution through the bash seam', () => { const { ctx, bash } = await setup() const controller = new AbortController() bash.handler = () => runResult('ok\n') - await ctx.tools.execute({ + await executeToolFixture(ctx, { signal: controller.signal, callId: ToolCallId('call-signal'), name: 'pwsh', @@ -618,6 +625,7 @@ describe('per-call sandbox policy resolution', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(ConfiningFakeBash) @@ -715,7 +723,7 @@ describe('sandbox escalation through ctx.approval', () => { ctx.on('approval/request', () => Promise.resolve('allowed-once')) const agent = sandboxAgent(undefined, ctx) await ctx.agents.register(agent) - const foreground = await ctx.tools.execute({ + const foreground = await executeToolFixture(ctx, { callId: ToolCallId('sandbox-signal'), name: 'pwsh', arguments: escalate, @@ -741,7 +749,7 @@ describe('sandbox escalation through ctx.approval', () => { ctx.on('approval/request', () => Promise.resolve('allowed-once')) const started = vi.spyOn(bash, 'execute') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('cancelled-escalation-background'), name: 'pwsh', arguments: { ...escalate, run_in_background: true }, @@ -859,7 +867,7 @@ describe('background execution through the job runtime', () => { const { ctx, bash } = await setupWithJobs() const controller = new AbortController() controller.abort() - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { callId: ToolCallId('call-pre-aborted'), name: 'pwsh', arguments: { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true }, @@ -879,6 +887,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) @@ -914,6 +923,7 @@ describe('background execution through the job runtime', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(BashEnvPlugin) @@ -933,6 +943,7 @@ describe('the background surface follows the job registry', () => { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(BashEnvPlugin) await ctx.plugin(FakeBash) diff --git a/packages/skill/skill-office/package.json b/packages/skill/skill-office/package.json index fe751bfab6..7e3a58d18f 100644 --- a/packages/skill/skill-office/package.json +++ b/packages/skill/skill-office/package.json @@ -32,6 +32,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", "@deepseek-ai/cordis-plugin-include": "workspace:~" diff --git a/packages/skill/skill-office/tests/docx-validation.e2e.ts b/packages/skill/skill-office/tests/docx-validation.e2e.ts index 4160671441..a9a7a60fe9 100644 --- a/packages/skill/skill-office/tests/docx-validation.e2e.ts +++ b/packages/skill/skill-office/tests/docx-validation.e2e.ts @@ -6,7 +6,7 @@ import { execa } from 'execa' import { expect, it } from 'vitest' import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' -import type {} from '@deepseek-ai/dsh-tools/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' import { expectNoRenderContext, officeModelPatches } from './fixtures/real-office.ts' const repo = fileURLToPath(new URL('../../../../', import.meta.url)) diff --git a/packages/skill/skill-office/tests/progressive-loading.spec.ts b/packages/skill/skill-office/tests/progressive-loading.spec.ts index b1d9ea958e..85bcc5c3ca 100644 --- a/packages/skill/skill-office/tests/progressive-loading.spec.ts +++ b/packages/skill/skill-office/tests/progressive-loading.spec.ts @@ -1,4 +1,6 @@ /** Model request contents for independent Office authoring and operation skills. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { afterEach, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' @@ -35,7 +37,8 @@ async function setup(mode: 'native' | 'ptc', names: string[]) { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' }) - await ctx.plugin(ToolRuntime, { mode }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SkillRegistry) diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 82051a8171..9938d99b9c 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -47,6 +47,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-filesystem": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-working-directory": "workspace:*" diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index e3362cda0a..030a221df0 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, describe, expect, it } from 'vitest' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { mkdir, rm, writeFile } from 'node:fs/promises' @@ -11,7 +13,8 @@ import { SESSION_FORMAT_VERSION, Session, SessionId, type SessionEvent, type UserMessage, } from '@deepseek-ai/dsh-session' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import AgentRegistry, { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent' import SkillRegistry from '@deepseek-ai/dsh-skill' import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem' @@ -56,6 +59,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise { provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) const home = await tempDir('tool-schema') await ctx.plugin(SkillRegistry) @@ -671,7 +676,7 @@ describe('dsh-tool-skill', () => { await fireStep(ctx, agent, 1, 1) expect(catalogMessages(session)).toHaveLength(1) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('body-refresh'), name: 'skill', @@ -699,7 +704,7 @@ describe('dsh-tool-skill', () => { expect(JSON.stringify(await composePrefixForAgent(ctx, agent))).toContain('preset-only-skill') expect(JSON.stringify(await composePrefix(ctx, '/workspace/other'))).not.toContain('preset-only-skill') - const scoped = await ctx.tools.execute({ + const scoped = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('scoped-load'), name: 'skill', @@ -709,7 +714,7 @@ describe('dsh-tool-skill', () => { expect(scoped.isError).toBe(false) expect(JSON.stringify(scoped.content)).toContain('Preset-only body.') - const foreign = await ctx.tools.execute({ + const foreign = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('foreign-load'), name: 'skill', @@ -794,6 +799,7 @@ describe('dsh-tool-skill', () => { provideWorkingDirectoryFixture(ctx, home) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(SkillRegistry) await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false }) @@ -808,7 +814,7 @@ describe('dsh-tool-skill', () => { await writeSkill(join(project, '.dsh/skills'), 'project-skill', 'Project skill', 'Project instructions.') const ctx = await setup(home) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'skill', @@ -869,9 +875,9 @@ describe('dsh-tool-skill', () => { content: 'Provider instructions.', }) - const opaque = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) - const url = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) - const provider = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) + const opaque = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c2'), name: 'skill', arguments: { name: 'opaque-skill' } }) + const url = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c3'), name: 'skill', arguments: { name: 'url-skill' } }) + const provider = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c4'), name: 'skill', arguments: { name: 'provider-skill' } }) if (opaque.content[0]?.type !== 'text' || url.content[0]?.type !== 'text' || provider.content[0]?.type !== 'text') { throw new Error('expected text tool results') @@ -893,7 +899,7 @@ describe('dsh-tool-skill', () => { content: 'Rogue instructions.', }) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c5'), name: 'skill', arguments: { name: 'rogue-resource-skill' } }) expect(result.isError).toBe(true) expect(result.error?.info?.code).toBe('INVALID_TOOL_OUTPUT') @@ -915,10 +921,10 @@ describe('dsh-tool-skill', () => { content: 'Model-only instructions.', }) - const unknown = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c1'), name: 'skill', arguments: { name: 'missing' } }) - const invalid = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) - const disabled = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) - const modelOnly = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } }) + const unknown = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: 'skill', arguments: { name: 'missing' } }) + const invalid = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c2'), name: 'skill', arguments: { name: 'Bad_Name' } }) + const disabled = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c3'), name: 'skill', arguments: { name: 'hidden-skill' } }) + const modelOnly = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c4'), name: 'skill', arguments: { name: 'model-only-skill' } }) expect(unknown.isError).toBe(true) expect(invalid.isError).toBe(true) @@ -977,9 +983,9 @@ describe('dsh-tool-skill', () => { }, })) - const denied = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } }) - const raced = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } }) - const vanished = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } }) + const denied = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c6'), name: 'skill', arguments: { name: 'denied-skill' } }) + const raced = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c7'), name: 'skill', arguments: { name: 'policy-race-skill' } }) + const vanished = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c8'), name: 'skill', arguments: { name: 'vanishing-skill' } }) expect(denied.isError).toBe(true) expect(raced.isError).toBe(true) diff --git a/packages/skill/tool-workspace-dependencies/package.json b/packages/skill/tool-workspace-dependencies/package.json index 3ac0f04645..cb15b7e579 100644 --- a/packages/skill/tool-workspace-dependencies/package.json +++ b/packages/skill/tool-workspace-dependencies/package.json @@ -30,6 +30,8 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index c9418f1da1..19d1748725 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as fs from 'node:fs/promises' import { lstat, mkdir, mkdtemp, readFile, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -249,16 +251,16 @@ it('loads the real tool through Cordis, exposes payload paths in place, and unre await ctx.plugin(Loader) ctx.loader.builtins.include = Include Object.assign(ctx.loader.builtins, { - agents: AgentRegistry, systemPrompt: SystemPrompt, tools: ToolRuntime, dependencies: workspaceDependencies, + agents: AgentRegistry, systemPrompt: SystemPrompt, tools: ToolRuntime, toolExecutor: NativeToolExecutor, dependencies: workspaceDependencies, }) const config = join(directory, 'cordis.yml') - await writeFile(config, `- name: cordis:agents\n- name: cordis:systemPrompt\n- name: cordis:tools\n- name: cordis:dependencies\n config:\n source: ${JSON.stringify(source)}\n`) + await writeFile(config, `- name: cordis:agents\n- name: cordis:systemPrompt\n- name: cordis:tools\n- name: cordis:toolExecutor\n- name: cordis:dependencies\n config:\n source: ${JSON.stringify(source)}\n`) await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(config).href } }) await ctx.loader.await() for (const entry of ctx.loader.entries()) await entry.fiber?.await() const environment = { ...process.env } const request = { signal: new AbortController().signal, name: 'load_workspace_dependencies', arguments: {} } - const results = await Promise.all(['first', 'second'].map(id => ctx.tools.execute({ ...request, callId: ToolCallId(id) }))) + const results = await Promise.all(['first', 'second'].map(id => executeToolFixture(ctx, { ...request, callId: ToolCallId(id) }))) for (const result of results) { expect(result.isError).toBe(false) expect(result.content).toEqual([{ type: 'text', text: JSON.stringify(workspaceDependencyPaths(source, manifest), undefined, 2) }]) @@ -305,9 +307,10 @@ async function tool(config: workspaceDependencies.Config) { await ctx.plugin(AgentRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) const fiber = ctx.plugin(workspaceDependencies, config) await fiber - const execute = () => ctx.tools.execute({ + const execute = () => executeToolFixture(ctx, { signal: new AbortController().signal, name: 'load_workspace_dependencies', arguments: {}, callId: ToolCallId('dependencies'), }) diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 68ea4830ba..e8b22afdd4 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -38,6 +38,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-spill": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-attachment": "workspace:*", @@ -54,6 +55,7 @@ "@deepseek-ai/dsh-output-retention": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-spill": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-fs-local": "workspace:*", @@ -69,6 +71,8 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:*", "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-attachment-local": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "sharp": "0.35.5", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*" diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index 6adbee9f12..faa60400df 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -5,6 +5,7 @@ * content and reports the reason through the logger. * @module @deepseek-ai/dsh-spill-policy */ +import type {} from '@deepseek-ai/dsh-tool-executor-ptc' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { createUserMessage, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm' diff --git a/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts b/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts index 8ced285820..dd9b2fd422 100644 --- a/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts +++ b/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts @@ -1,4 +1,7 @@ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real attachments, filesystem recovery, Node PTC, and DeepSeek image request bytes without a desktop or API key. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -50,7 +53,8 @@ async function setup(mode: 'native' | 'ptc' = 'native') { try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } }) await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) await ctx.plugin(FileSystem, { cwd: root }) await ctx.plugin(LocalAttachments, { dshHome: join(root, 'home') }) await ctx.plugin(ToolFs) @@ -74,7 +78,7 @@ async function setup(mode: 'native' | 'ptc' = 'native') { const session = ctx.sessions.create(SessionId('recovery'), { meta: { cwd: root } }) const agent = { session, options: { provider: 'vision', model: MODEL }, ctx } as Agent let ordinal = 0 - const execute = (name: string, args: JsonValue = {}) => ctx.tools.execute({ + const execute = (name: string, args: JsonValue = {}) => executeModelToolFixture(ctx, { name, arguments: args, callId: ToolCallId(`recovery-${++ordinal}`), agent, signal: new AbortController().signal, }) const red = await sharp({ create: { width: 1920, height: 1080, channels: 3, background: '#ff0000' } }).png().toBuffer() diff --git a/packages/spill/spill-policy/tests/multimodal.spec.ts b/packages/spill/spill-policy/tests/multimodal.spec.ts index ed36f754b4..b522bb3a15 100644 --- a/packages/spill/spill-policy/tests/multimodal.spec.ts +++ b/packages/spill/spill-policy/tests/multimodal.spec.ts @@ -1,4 +1,7 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** MCP admission, token retention, recovery files, and PTC forwarding together. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' @@ -16,7 +19,8 @@ import { estimateContent } from '@deepseek-ai/dsh-token-meter/estimate' import { createMcpToolDefinition } from '@deepseek-ai/dsh-mcp-client' import { Session, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import LocalSpillStore from '@deepseek-ai/dsh-spill-local' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' import type { PtcRunRequest, PtcRunSpec, PtcRunResult } from '@deepseek-ai/dsh-ptc-runtime' @@ -70,7 +74,8 @@ async function setup(content: JsonValue[], maxInlineTokens: number, mode: 'nativ try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } }) await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode }) + await ctx.plugin(ToolRuntime) + await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) const fsFiber = await ctx.plugin(FileSystem, { cwd: root }) await ctx.plugin(TestAttachments, root) await ctx.plugin(LocalSpillStore, { root: join(root, 'spill'), cleanupPeriodDays: 0 }) @@ -83,7 +88,7 @@ async function setup(content: JsonValue[], maxInlineTokens: number, mode: 'nativ })) const session = Session.create(SessionId('mixed-spill')) const agent = { ctx, session, options: { provider: 'vision', model: 'vision' } } as Agent - const execute = (name = 'inspect') => ctx.tools.execute({ + const execute = (name = 'inspect') => executeModelToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('mixed-call'), name, arguments: name === 'run_code' ? { code: 'return await tools.inspect({})', description: 'Inspect a window' } : {}, agent, }) @@ -163,7 +168,7 @@ describe('multimodal spill', () => { async execute() { return [{ type: 'image', attachment }] }, })) const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) - const result = await ctx.tools.execute({ signal: new AbortController().signal, + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('offline'), name: 'offline_image', arguments: {} }) expect(result.content).toEqual([{ type: 'image', attachment }]) expect(warning).toHaveBeenCalledWith(expect.stringContaining('no image token calculator')) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index a94456ac1b..6880ec7d6f 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -1,13 +1,16 @@ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Tests for the spill-policy PLUGIN. It registers no service, only the * `tools/post-execute` transformer. We drive real tools through - * `ctx.tools.execute(...)` and assert: disabled mode is a true no-op, an + * `executeToolFixture(ctx, ...)` and assert: disabled mode is a true no-op, an * oversized plain-text result is spilled and replaced with a preview + locator, * a small result and a non-text result pass through, `read` is skipped, and a * `saveText` failure / missing backend / missing owner all preserve the original * result without an `isError`. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -18,7 +21,8 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import type { PostToolDecision, ToolExecution, ToolExecutionToken } from '@deepseek-ai/dsh-tools' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' @@ -105,6 +109,7 @@ async function setup( const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) let spill: StubStore | undefined if (withSpill) { await ctx.plugin(StubStore) @@ -124,7 +129,7 @@ describe('disabled mode', () => { it('registers no post-execute listener when maxInlineTokens is omitted', async () => { const { ctx, spill } = await setup({}) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(result.isError).toBe(false) expect(spill?.saves).toHaveLength(0) @@ -161,7 +166,7 @@ describe('oversized plain-text replacement', () => { const { ctx, spill } = await setup({ maxInlineTokens: 64 }) const body = 'HEAD'.repeat(200) + 'TAIL'.repeat(200) // 1600 bytes > 200 ctx.tools.register(textTool('big', body)) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(result.isError).toBe(false) expect(spill?.saves).toHaveLength(1) @@ -189,7 +194,7 @@ describe('oversized plain-text replacement', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const body = 'xxxxx' // 5 bytes > 4, but far shorter than the notice ctx.tools.register(textTool('big', body)) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(textOf(result.content)).toBe(body) expect(warn).toHaveBeenCalled() }) @@ -197,7 +202,7 @@ describe('oversized plain-text replacement', () => { it('leaves a small plain-text result unchanged', async () => { const { ctx, spill } = await setup({ maxInlineTokens: 1000 }) ctx.tools.register(textTool('small', 'tiny')) - const result = await ctx.tools.execute(exec('small')) + const result = await executeToolFixture(ctx, exec('small')) expect(textOf(result.content)).toBe('tiny') expect(spill?.saves).toHaveLength(0) }) @@ -212,7 +217,7 @@ describe('oversized plain-text replacement', () => { return [{ type: 'text', text: 'x'.repeat(100) }, { type: 'reasoning', text: 'why' }] }, })) - const result = await ctx.tools.execute(exec('mixed')) + const result = await executeToolFixture(ctx, exec('mixed')) expect(spill?.saves).toHaveLength(0) expect(result.content).toHaveLength(2) }) @@ -222,14 +227,15 @@ describe('outer PTC mode failure capture', () => { it('spills the bounded output-limit diagnostic through the ordinary outer-result policy', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineTokens: 64 }) await mountRuntime(ctx, { maxOutputBytes: 500 }) const events: unknown[] = [] const agent = observedAgent(ctx, 'code-spill', (_type: string, data: unknown) => { events.push(data) }) - const result = await ctx.tools.execute({ + const result = await executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('code-output-limit'), name: 'run_code', @@ -255,7 +261,7 @@ describe('read skip', () => { it('never spills the read tool result (avoids a read → spill → read loop)', async () => { const { ctx, spill } = await setup({ maxInlineTokens: 10 }) ctx.tools.register(textTool('read', 'x'.repeat(1000))) - const result = await ctx.tools.execute(exec('read')) + const result = await executeToolFixture(ctx, exec('read')) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(spill?.saves).toHaveLength(0) }) @@ -266,7 +272,8 @@ describe('the durable dispatch-log arm', () => { async function runCodeWith(program: string, maxInlineTokens: number, extraTools: ToolDefinition[] = []) { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineTokens }) await mountRuntime(ctx, {}) @@ -275,7 +282,7 @@ describe('the durable dispatch-log arm', () => { ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) ctx.tools.register(textTool('small_read', 'tiny')) for (const tool of extraTools) ctx.tools.register(tool) - const result = await ctx.tools.execute({ + const result = await executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent-1'), name: 'run_code', @@ -335,7 +342,8 @@ describe('the durable dispatch-log arm', () => { it('a slow spill backend never delays the program value or a later dispatch slot', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineTokens: 40 }) await mountRuntime(ctx, {}) @@ -356,7 +364,7 @@ describe('the durable dispatch-log arm', () => { }) ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) ctx.tools.register(textTool('small_read', 'tiny')) - const runPromise = ctx.tools.execute({ + const runPromise = executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent-3'), name: 'run_code', @@ -388,7 +396,8 @@ describe('the durable dispatch-log arm', () => { it('a sustained slow backend backpressures the run instead of accumulating unbounded log tasks', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc', maxParallelSubCalls: 1 }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor, { maxParallelSubCalls: 1 }) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineTokens: 40 }) await mountRuntime(ctx, {}) @@ -418,7 +427,7 @@ describe('the durable dispatch-log arm', () => { ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) const started = (n: number): boolean => events.some(event => event.type === 'tool/ptc-dispatch-start' && (event.data as { subCallId: string }).subCallId.endsWith(`:ptc:${n}`)) - const runPromise = ctx.tools.execute({ + const runPromise = executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent-bound'), name: 'run_code', @@ -455,7 +464,8 @@ describe('the durable dispatch-log arm', () => { it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'ptc' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(PtcToolExecutor) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineTokens: 40 }) await mountRuntime(ctx, {}) @@ -464,7 +474,7 @@ describe('the durable dispatch-log arm', () => { const events: { type: string; data: unknown }[] = [] const agent = observedAgent(ctx, 'dispatch-spill-fail', (type: string, data: unknown) => { events.push({ type, data }) }) ctx.tools.register(textTool('huge_read', 'H'.repeat(2_000))) - const result = await ctx.tools.execute({ + const result = await executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('parent-2'), name: 'run_code', @@ -487,7 +497,7 @@ describe('nested-call skip', () => { ...exec('nested'), parent: Symbol('outer') as ToolExecutionToken, } - const result = await ctx.tools.execute(nested) + const result = await executeToolFixture(ctx, nested) expect(textOf(result.content)).toBe(body) expect(spill?.saves).toHaveLength(0) }) @@ -499,7 +509,7 @@ describe('best-effort fallback', () => { spill!.fail = true const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(result.isError).toBe(false) expect(warn).toHaveBeenCalled() @@ -509,7 +519,7 @@ describe('best-effort fallback', () => { const { ctx } = await setup({ maxInlineTokens: 10 }, false) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(warn).toHaveBeenCalled() }) @@ -518,7 +528,7 @@ describe('best-effort fallback', () => { const { ctx, spill } = await setup({ maxInlineTokens: 10 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c'), name: 'big', arguments: {} }) + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c'), name: 'big', arguments: {} }) expect(textOf(result.content)).toBe('x'.repeat(1000)) expect(spill?.saves).toHaveLength(0) expect(warn).toHaveBeenCalled() @@ -539,7 +549,7 @@ describe('composition', () => { }) ctx.tools.register(textTool('search', 'initial capped page')) - const result = await ctx.tools.execute(exec('search')) + const result = await executeToolFixture(ctx, exec('search')) expect(downstreamDecision).toEqual({ kind: 'accept' }) expect(spill?.saves[0]?.content).toContain('Full canonical result stored at /spill/search-results.txt.') @@ -553,7 +563,7 @@ describe('composition', () => { ctx.on('tools/post-execute', async (_e, _r, _next) => ({ kind: 'accept', content: [{ type: 'text', text: 'z'.repeat(500) }] })) ctx.tools.register(textTool('small', 'tiny')) - const result = await ctx.tools.execute(exec('small')) + const result = await executeToolFixture(ctx, exec('small')) expect(spill?.saves[0]?.content).toBe('z'.repeat(500)) expect(textOf(result.content)).toContain('Full formatted result stored at') }) @@ -567,7 +577,7 @@ describe('composition', () => { ctx.on('tools/post-execute', async (_e, _r, _next) => ({ kind: 'accept', additionalContexts: [context] })) ctx.tools.register(textTool('big', 'x'.repeat(1000))) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(textOf(result.content)).toContain('Full formatted result stored at') expect(result.additionalContexts).toEqual([context]) }) @@ -578,7 +588,7 @@ describe('composition', () => { ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, value: replacement })) ctx.tools.register(textTool('small', 'tiny')) - const result = await ctx.tools.execute(exec('small')) + const result = await executeToolFixture(ctx, exec('small')) expect(result.isError).toBe(false) if (result.isError) throw new Error('expected replacement success') @@ -597,7 +607,7 @@ describe('cap invariant', () => { const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) const body = 'x'.repeat(5000) ctx.tools.register(textTool('big', body)) - const result = await ctx.tools.execute(exec('big')) + const result = await executeToolFixture(ctx, exec('big')) expect(textOf(result.content)).toBe(body) expect(warn).toHaveBeenCalled() }) @@ -610,14 +620,14 @@ describe('disposal (HMR safety)', () => { ctx.tools.register(textTool('big', body)) // Live: the listener spills and replaces. - const before = await ctx.tools.execute(exec('big')) + const before = await executeToolFixture(ctx, exec('big')) expect(textOf(before.content)).toContain('Full formatted result stored at') expect(spill?.saves).toHaveLength(1) // After disposal the listener is gone — the result passes through untouched // and nothing more is spilled (no leaked registration across reload). await fiber.dispose() - const after = await ctx.tools.execute(exec('big')) + const after = await executeToolFixture(ctx, exec('big')) expect(textOf(after.content)).toBe(body) expect(spill?.saves).toHaveLength(1) }) diff --git a/packages/spill/spill-policy/tsconfig.json b/packages/spill/spill-policy/tsconfig.json index 3908d03adc..fc0b2c58f1 100644 --- a/packages/spill/spill-policy/tsconfig.json +++ b/packages/spill/spill-policy/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../llm/token-meter" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index e324f19c17..0d32872435 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -121,6 +121,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", diff --git a/packages/subagent/subagent/tests/structured.spec.ts b/packages/subagent/subagent/tests/structured.spec.ts index 64d5817dd5..47a7ef1b1b 100644 --- a/packages/subagent/subagent/tests/structured.spec.ts +++ b/packages/subagent/subagent/tests/structured.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -11,8 +12,9 @@ import SubagentRuntime, { type SubagentActivation, type SubagentResult, } from '@deepseek-ai/dsh-subagent' -import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools' -import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools' +import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { TestSessionQuery } from './test-session-query.ts' import { mountLocalActivations, startTestActivation } from './local-activation.ts' @@ -31,7 +33,7 @@ interface PtcRunRequestLike { } interface SetupOptions { - toolMode?: ToolConfig['mode'] + toolMode?: ('native' | 'ptc') codeRun?: (request: PtcRunRequestLike) => Promise<{ logs: never[]; value?: unknown }> } @@ -169,7 +171,7 @@ describe('in-process structured output', () => { return blocked ? { kind: 'block', feedback: [{ type: 'text', text: 'Result policy rejected capture.' }] } : next() }) const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) - const execution = ctx.tools.execute({ + const execution = executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('capture'), name: toolName, @@ -835,7 +837,7 @@ describe('in-process structured output', () => { it('a structured_output call from an agent WITHOUT a structured run is UNKNOWN_TOOL (the tool does not exist for it)', async () => { const { ctx, parent } = await setup([]) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -848,7 +850,7 @@ describe('in-process structured output', () => { it('a structured_output call with NO calling agent at all is UNKNOWN_TOOL', async () => { const { ctx } = await setup([]) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'x' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -872,7 +874,7 @@ describe('in-process structured output', () => { } return next() }, { prepend: true }) - const failed = await ctx.tools.execute({ + const failed = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: STRUCTURED_OUTPUT_TOOL, @@ -882,7 +884,7 @@ describe('in-process structured output', () => { expect(failed.isError).toBe(true) const child = run.localAgent // Invalid calls must not commit a preceding execution's discarded value. - const invalid = await ctx.tools.execute({ + const invalid = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'c2' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -891,7 +893,7 @@ describe('in-process structured output', () => { }) expect(invalid.isError).toBe(true) // A fresh valid call still captures ITS OWN value. - const valid = await ctx.tools.execute({ + const valid = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'c3' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -916,7 +918,7 @@ describe('in-process structured output', () => { } return next() }, { prepend: true }) - const failed = await ctx.tools.execute({ + const failed = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: STRUCTURED_OUTPUT_TOOL, @@ -928,7 +930,7 @@ describe('in-process structured output', () => { // A SECOND capture call with the SAME call id whose body never stages // (invalid args throw before the stage): the discarded value must not ride // its acceptance. - const reused = await ctx.tools.execute({ + const reused = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -937,7 +939,7 @@ describe('in-process structured output', () => { }) expect(reused.isError).toBe(true) // Nothing was ever committed: a fresh valid call is still required. - const valid = await ctx.tools.execute({ + const valid = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -961,7 +963,7 @@ describe('in-process structured output', () => { } return next() }, { prepend: true }) - const failed = await ctx.tools.execute({ + const failed = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c1'), name: STRUCTURED_OUTPUT_TOOL, @@ -978,7 +980,7 @@ describe('in-process structured output', () => { } return undefined as never }, { prepend: true }) - const denied = await ctx.tools.execute({ + const denied = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, @@ -989,7 +991,7 @@ describe('in-process structured output', () => { offDeny() // The discarded value was never promoted: a fresh valid call is required // (and succeeds, proving the runtime is not wedged). - const valid = await ctx.tools.execute({ + const valid = await executeToolFixture(ctx, { signal: testToolSignal, callId: 'c1' as never, name: STRUCTURED_OUTPUT_TOOL, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 75e2d890e9..bd6b6ab256 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" }, "dependencies": { diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 7e4a21399a..00a39d9cb0 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -91,7 +92,7 @@ function callTool( agent?: unknown, signal: AbortSignal = testToolSignal, ) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal, callId: ToolCallId(`call-${++calls}`), name, diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index 50bb2431e7..ec69d25186 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -92,7 +93,7 @@ function callTool( agent?: unknown, signal: AbortSignal = testToolSignal, ) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal, callId: ToolCallId(`call-${++calls}`), name, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 1492dc1fd5..fdf04f5b78 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -68,6 +68,8 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-session-projection": "workspace:*" diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 326fa5c3fa..6f0aea60cf 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' import { afterEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -113,7 +114,7 @@ export async function callSubagent( // exactOptionalPropertyTypes the key is omitted rather than set to undefined. const agent = 'agent' in over ? over.agent : setupAgents.get(ctx) ?? (await ctx.agents.create({ sessionId: SessionId(`tool-call-parent-${++setupAgentCounter}`) })).agent - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name: 'subagent', diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index d5d4b544b7..01fbb520ca 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -69,6 +71,7 @@ async function setupListTool(routes = [ await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) registerListSubagentModels(ctx, { routes }) return ctx } @@ -78,6 +81,7 @@ async function setupAllowedListTool() { await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) registerListSubagentModels(ctx, { routes: [ { provider: 'alpha', model: 'fast' }, @@ -91,7 +95,7 @@ async function setupAllowedListTool() { let counter = 0 function call(ctx: Context, args: unknown) { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`list-models-${++counter}`), name: 'list_subagent_models', @@ -106,6 +110,7 @@ describe('list_subagent_models', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) await ctx.plugin(tool, { provider: 'unused' }) @@ -116,6 +121,7 @@ describe('list_subagent_models', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index e7ef63b0d5..88221a47f2 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -1,3 +1,4 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' /** Default-off settings and per-session model-selection decisions. */ @@ -220,7 +221,7 @@ describe('SubagentModelSelectionConfig', () => { }) const agent = await createAgent(ctx, 'enforced') - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('disallowed-session-route'), name: 'subagent', diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 5182ecfcc0..bf8a45b737 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -1,3 +1,4 @@ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -393,6 +394,7 @@ describe('dsh-tool-subagent model selection', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock' }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 9fdff0524d..3c90d91203 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -1,3 +1,5 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -109,13 +111,13 @@ describe('dsh-tool-subagent', () => { it('classifies delegations as concurrency-safe', async () => { const ctx = await setup({ provider: 'mock' }) - expect(ctx.tools.executionMode({ + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('subagent-first'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, })).toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('subagent-second'), name: 'subagent', @@ -161,8 +163,8 @@ describe('dsh-tool-subagent', () => { expect(names).toEqual(['subagent', 'subagent_acp']) const parent = (await ctx.agents.create({ sessionId: SessionId('multi-provider-parent') })).agent - const viaSpawn = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: parent }) - const viaAcp = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: parent }) + const viaSpawn = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: parent }) + const viaAcp = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: parent }) expect(text(viaSpawn)).toMatch(/^started subagent /) expect(text(viaAcp)).toMatch(/^started subagent /) }) @@ -694,7 +696,7 @@ describe('dsh-tool-subagent local activation', () => { it('classifies local activations concurrency-safe', async () => { const { ctx } = await continuableSetup() - expect(ctx.tools.executionMode({ + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('subagent-continuable'), name: 'subagent', @@ -778,7 +780,7 @@ describe('dsh-tool-subagent local activation', () => { maxDepth: 3, }) - const execute = (callId: string, description: string, signal: AbortSignal) => ctx.tools.execute({ + const execute = (callId: string, description: string, signal: AbortSignal) => executeToolFixture(ctx, { signal, callId: ToolCallId(callId), name: 'subagent_gated', diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 98b49ad354..6792866ef6 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -38,7 +38,10 @@ "@deepseek-ai/dsh-working-directory": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*" }, - "dependencies": {}, + "dependencies": { + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*" + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", diff --git a/packages/test-support/agent-loop-testkit/src/index.ts b/packages/test-support/agent-loop-testkit/src/index.ts index a3d906adf8..dbbc5a9d10 100644 --- a/packages/test-support/agent-loop-testkit/src/index.ts +++ b/packages/test-support/agent-loop-testkit/src/index.ts @@ -16,7 +16,8 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import type { Config as ToolRuntimeConfig } from '@deepseek-ai/dsh-tools' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import FsLocal from '@deepseek-ai/dsh-fs-local' import WorkingDirectory from '@deepseek-ai/dsh-working-directory' @@ -54,7 +55,7 @@ export interface AgentLoopTestDependenciesOptions { /** Configuration for the system-prompt registry. */ readonly systemPrompt?: SystemPromptConfig /** Configuration for the tool registry. */ - readonly tools?: ToolRuntimeConfig + readonly tools?: { mode?: 'native' | 'ptc'; maxParallelSubCalls?: number } } /** @@ -81,7 +82,9 @@ export async function mountAgentLoopTestDependencies( if (ctx.get('fs') === undefined) await ctx.plugin(FsLocal) await ctx.plugin(WorkingDirectory) } - await ctx.plugin(ToolRuntime, options.tools ?? {}) + await ctx.plugin(ToolRuntime) + if (options.tools?.mode === 'ptc') await ctx.plugin(PtcToolExecutor, { ...options.tools.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.tools.maxParallelSubCalls } : {} }) + else await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) } diff --git a/packages/test-support/agent-loop-testkit/tsconfig.json b/packages/test-support/agent-loop-testkit/tsconfig.json index 08f41e83ce..018160bd26 100644 --- a/packages/test-support/agent-loop-testkit/tsconfig.json +++ b/packages/test-support/agent-loop-testkit/tsconfig.json @@ -40,6 +40,12 @@ }, { "path": "../../fs/fs-local" + }, + { + "path": "../../core/tool-executor-native" + }, + { + "path": "../../core/tool-executor-ptc" } ] } diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index e1ea5f569b..ec333db3d0 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -52,6 +52,7 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@types/js-yaml": "^4.0.9", diff --git a/packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-updates.ts b/packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-updates.ts index 5ff044af6b..8d11136961 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-updates.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-updates.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import type { Context } from '@deepseek-ai/cordis' import { projectToolUpdates } from '@deepseek-ai/dsh-llm' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' export const name = 'snapshot-dynamic-tool-updates' export const inject = ['tools'] diff --git a/packages/test-support/session-snapshot/tests/fixtures/subagent-structured-parent.ts b/packages/test-support/session-snapshot/tests/fixtures/subagent-structured-parent.ts index 454f509a0a..5762a95c16 100644 --- a/packages/test-support/session-snapshot/tests/fixtures/subagent-structured-parent.ts +++ b/packages/test-support/session-snapshot/tests/fixtures/subagent-structured-parent.ts @@ -1,6 +1,6 @@ /** Structured result delivery after a parent correction reaches the child. */ import type { Context } from '@deepseek-ai/cordis' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' export const name = 'subagent-structured-parent' export const inject = ['subagents', 'tools'] diff --git a/packages/test-support/tool-testkit/package.json b/packages/test-support/tool-testkit/package.json new file mode 100644 index 0000000000..ce49603795 --- /dev/null +++ b/packages/test-support/tool-testkit/package.json @@ -0,0 +1,39 @@ +{ + "name": "@deepseek-ai/dsh-tool-testkit", + "description": "Tool definition and execution fixtures for tests and benchmarks", + "version": "0.2.1-alpha.4", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/test-support/tool-testkit" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-tools": "workspace:*" + } +} diff --git a/packages/test-support/tool-testkit/src/index.ts b/packages/test-support/tool-testkit/src/index.ts new file mode 100644 index 0000000000..ded44c27bf --- /dev/null +++ b/packages/test-support/tool-testkit/src/index.ts @@ -0,0 +1,91 @@ +/** Canonical tool-definition fixtures for repository tests. @module @deepseek-ai/dsh-tool-testkit */ + +import type { Context } from '@deepseek-ai/cordis' +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { + DefineToolOptions, InferArgs, ParameterSchemaSpec, ToolDefinition, + ToolExecutionInput, ToolExecutionResult, ToolRunContext, +} from '@deepseek-ai/dsh-tools' + +// Expand interface fields so the fixture's content value is checked as JSON without changing runtime values. +type JsonFields = T extends string | number | boolean | null | undefined ? T + : T extends (...args: never[]) => unknown ? T + : { [K in keyof T]: JsonFields } +type ContentValue = JsonFields + +const CONTENT_VALUE_SCHEMA = { type: 'array', items: { type: 'json' } } as const + +/** Options for a fixture whose canonical value is its rendered content array. */ +export type ContentToolFixtureOptions = Omit< + DefineToolOptions, + 'output' | 'execute' +> & { + /** Produce the fixture's content blocks as its canonical test value. */ + execute(args: InferArgs, exec: ToolRunContext): Promise +} + +/** + * Define a test fixture that deliberately uses its content blocks as the + * canonical JSON value. Product tools must declare domain-owned DTOs instead. + * @param options - ordinary fixture fields plus a content-producing body. + * @returns a registry-ready tool with an explicit JSON-array output contract. + * @internal + */ +export function defineContentToolFixture( + options: ContentToolFixtureOptions, +): ToolDefinition { + // oxlint-disable-next-line typescript/unbound-method + const execute = options.execute + return defineTool({ + ...options, + output: { + schema: CONTENT_VALUE_SCHEMA, + render: (_args, value) => value as ContentValue[], + }, + async execute(args, exec) { + const value: ContentValue[] = await execute(args, exec) + return value + }, + }) +} + +/** + * Exercise invocation stages directly, preserving parsed and nested test inputs. + * Use executeModelToolFixture for a complete single-call batch. + * @param ctx - context owning the tool definitions and execution policy. + * @param input - parsed invocation, including nested identity when testing a composite. + * @returns the settled capability result. + * @internal + */ +export async function executeToolFixture(ctx: Context, input: ToolExecutionInput): Promise { + const pipeline = new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }) + const prepared = await pipeline.prepare(input) + const outcome = prepared.kind === 'dispatch' ? await pipeline.dispatch(prepared.exec) : prepared + return pipeline.complete(prepared.exec, outcome) +} + +/** + * Run one raw call through the selected executor without root Session logging. + * @param ctx - context owning the selected executor. + * @param input - JSON arguments and optional Agent supplying scoped tools and tool context. + * @returns the single settled result. + * @internal + */ +export async function executeModelToolFixture( + ctx: Context, + input: Pick, +): Promise { + const args = JSON.stringify(input.arguments) + // oxlint-disable-next-line typescript/no-unnecessary-condition -- JSON.stringify returns undefined for non-JSON fixture arguments + if (args === undefined) throw new TypeError('Model tool fixture arguments must encode as JSON') + const batch = await ctx.tools.execute({ + calls: [{ type: 'tool-call', id: input.callId, name: input.name, arguments: args }], + signal: input.signal, + ...input.agent === undefined ? {} : { agent: input.agent }, + }) + const outcome = batch.results[0] + if (outcome === undefined) throw new Error('Executor omitted the model tool fixture result') + return outcome.result +} diff --git a/packages/test-support/tool-testkit/tsconfig.json b/packages/test-support/tool-testkit/tsconfig.json new file mode 100644 index 0000000000..3fe790d2e9 --- /dev/null +++ b/packages/test-support/tool-testkit/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index bc4568a6bd..31f517527f 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -54,6 +54,8 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", "@deepseek-ai/cordis": "workspace:~" diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 3642d8485f..cc0acfebfa 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -1,6 +1,8 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Proves `allowParallelInProgress` is real configurability and not a constant: // the flag is set in a cordis.yml booted through the real Loader, and both faces // it controls — the model-facing description and the accepted input — follow it. +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -60,6 +62,7 @@ async function boot(configLines: readonly string[]): Promise { "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", + "- name: '@deepseek-ai/dsh-tool-executor-native'", "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-tool-todo'", ...configLines.length > 0 ? [' config:', ...configLines] : [], @@ -75,6 +78,7 @@ async function boot(configLines: readonly string[]): Promise { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-tool-todo', ToolTodo], ]) @@ -104,7 +108,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { expect(description).not.toContain('several only') const owner = await agent(ctx) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('parallel'), name: 'todo_write', @@ -122,7 +126,7 @@ describe('tool-todo real Loader composition through cordis.yml', () => { expect(description).toContain('several only when work runs in parallel') const owner = await agent(ctx) - const result = await ctx.tools.execute({ + const result = await executeToolFixture(ctx, { signal: new AbortController().signal, callId: ToolCallId('parallel-enabled'), name: 'todo_write', diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 20975df8c0..f4fbdc91cc 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -7,6 +7,7 @@ * removes it (HMR safety). The carrier and framework are exercised unmodified. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -32,6 +33,7 @@ async function harness(withTodoTool: boolean): Promise { await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { personaPrefix: '' }) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(UserQuestionService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index e8c38f10f2..1c8f49e221 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -31,6 +33,7 @@ async function setup(allowParallelInProgress: boolean): Promise { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(tool, { allowParallelInProgress }) return ctx @@ -39,7 +42,7 @@ async function setup(allowParallelInProgress: boolean): Promise { let callCounter = 0 function callTodo(ctx: Context, args: unknown, over: { agent?: Agent | undefined } = {}) { const agent = 'agent' in over ? over.agent : agentWithSession() - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), name: 'todo_write', @@ -215,6 +218,7 @@ describe('dsh-tool-todo', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(tool, { allowParallelInProgress: true }) expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true) diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 8443aef20c..37f0d520ac 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -50,6 +50,8 @@ "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-http": "workspace:*", diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 4b7c843f9e..2de835910f 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -1,12 +1,14 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Integration: the real fetch backend (`dsh-web-fetch-http`) + a real search provider * (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the - * tool-call timeout policy (`dsh-tool-call-timeout-policy`), exercised through `ctx.tools.execute()` — + * tool-call timeout policy (`dsh-tool-call-timeout-policy`), exercised through `executeToolFixture(ctx, )` — * nothing bypasses the tool registry. Fetch verifies world effects against loopback HTTP with * public-address resolution replaced by the fixture address; search uses the real Exa provider * with only its network boundary stubbed. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' @@ -41,6 +43,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, { searchProvider: WebSearchExa.EXA_PROVIDER_ID, fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) await ctx.plugin(WebFetchLocal, {}) await ctx.plugin(WebSearchExa, { apiKey: 'exa-key', baseURL: 'https://api.exa.test' }) @@ -61,7 +64,7 @@ afterEach(async () => { let counter = 0 function call(name: string, args: unknown): Promise { - return ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`call-${++counter}`), name, arguments: args }) + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++counter}`), name, arguments: args }) } describe('web_fetch integration over the real backend', () => { @@ -138,6 +141,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc tctx = new Context() await tctx.plugin(SystemPrompt) await tctx.plugin(ToolRuntime) + await tctx.plugin(NativeToolExecutor) await tctx.plugin(WebRuntime, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) // Provider backstop well ABOVE the tool-call budget, so the policy wins. await tctx.plugin(WebFetchLocal, { timeoutMs: 30_000 }) @@ -153,7 +157,7 @@ describe('tool-call timeout returns TOOL_TIMEOUT (deadline wins over a slow fetc }) it('returns a structured TOOL_TIMEOUT (not the provider WEB_FETCH_TIMEOUT) when the tool-call budget wins', async () => { - const out = await tctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } }) + const out = await executeToolFixture(tctx, { signal: testToolSignal, callId: ToolCallId('slow-1'), name: 'web_fetch', arguments: { url: slowBase } }) expect(out.isError).toBe(true) // The outer tool-call deadline won: TOOL_TIMEOUT, owned by dsh-tool-call-timeout-policy, // NOT the provider's own WEB_FETCH_TIMEOUT (its 30s backstop never fired). diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts index 6d322228c6..2f408a56d5 100644 --- a/packages/web/tool-web/tests/load-path.spec.ts +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -5,6 +5,7 @@ * postmortem 0001. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -29,6 +30,7 @@ describe('dsh-tool-web real-load-path guard', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, {}) const loader = Object.create(Loader.prototype) as Loader diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts index 6f5a858462..85ca0be5a8 100644 --- a/packages/web/tool-web/tests/spill.spec.ts +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -1,12 +1,14 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Showcase integration: the real `web_fetch` tool + the real spill stack * (`dsh-spill-local` backend + `dsh-spill-policy`), exercised through - * `ctx.tools.execute()`. Proves the Agent Note's default local-backend path — a large + * `executeToolFixture(ctx, )`. Proves the Agent Note's default local-backend path — a large * formatted fetch result is automatically retained and spilled with NO * tool-specific spill code, and the model-facing text changes ONLY by the * deliberate spill notice (the full formatted result lands in the spill file). */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' @@ -51,6 +53,7 @@ beforeEach(async () => { ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, { fetchProvider: WebFetchLocal.LOCAL_FETCH_PROVIDER_ID }) // Provider cap generous so the tool returns a large formatted result; the // policy cap is what triggers the spill (the Agent Note's separation of concerns). @@ -71,7 +74,7 @@ afterEach(async () => { function fetchCall(): Promise<{ isError: boolean; content: { type: string; text?: string }[] }> { const agent = { session: { header: { id: SessionId('web-sess') } } } const exec = { callId: ToolCallId('call-1'), name: 'web_fetch', arguments: { url: base }, agent, signal: testToolSignal } as unknown as ToolExecution - return ctx.tools.execute(exec) + return executeToolFixture(ctx, exec) } describe('web_fetch spill showcase', () => { diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 1b454b9776..679b3c2eb6 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -1,3 +1,6 @@ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' @@ -45,12 +48,13 @@ async function mountTools(opts: { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, opts.webConfig ?? {}) if (opts.search) ctx.web.registerSearchProvider(opts.search) if (opts.fetchProvider) ctx.web.registerFetchProvider(opts.fetchProvider) const fiber = await ctx.plugin(ToolWeb, opts.config ?? {}) let counter = 0 - const call = (name: string, args: unknown) => ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`call-${++counter}`), name, arguments: args }) + const call = (name: string, args: unknown) => executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId(`call-${++counter}`), name, arguments: args }) return { ctx, fiber, call } } @@ -453,9 +457,9 @@ describe('tool-web registration', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('web_search') expect(names).toContain('web_fetch') - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: ToolCallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } })) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } })) .toEqual({ kind: 'parallel' }) - expect(ctx.tools.executionMode({ signal: testToolSignal, callId: ToolCallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) + expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) .toEqual({ kind: 'parallel' }) await fiber.dispose() expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') @@ -725,7 +729,7 @@ describe('tool-web execution through the real registry', () => { } const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) const controller = new AbortController() - const out = await ctx.tools.execute({ callId: ToolCallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal }) + const out = await executeToolFixture(ctx, { callId: ToolCallId('fetch-1'), name: 'web_fetch', arguments: { url: 'https://a.test' }, signal: controller.signal }) expect(out.isError).toBe(false) expect(out.value).toEqual({ url: 'https://a.test', @@ -752,7 +756,7 @@ describe('tool-web execution through the real registry', () => { }, } const { ctx, fiber } = await mountTools({ webConfig: { fetchProvider: 'stub-fetch' }, fetchProvider }) - const out = await ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) + const out = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('fetch-2'), name: 'web_fetch', arguments: { url: 'https://a.test' } }) expect(out.isError).toBe(false) expect(out.value).toEqual({ url: 'https://a.test', @@ -774,7 +778,7 @@ describe('tool-web execution through the real registry', () => { } const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const controller = new AbortController() - await ctx.tools.execute({ callId: ToolCallId('search-1'), name: 'web_search', arguments: { queries: ['q'] }, signal: controller.signal }) + await executeToolFixture(ctx, { callId: ToolCallId('search-1'), name: 'web_search', arguments: { queries: ['q'] }, signal: controller.signal }) expect(seen.signal).toBe(controller.signal) await fiber.dispose() }) @@ -793,7 +797,7 @@ describe('tool-web execution through the real registry', () => { } const { ctx, fiber } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) const controller = new AbortController() - const pending = ctx.tools.execute({ callId: ToolCallId('search-multi-1'), name: 'web_search', arguments: { queries: ['one', 'two'] }, signal: controller.signal }) + const pending = executeToolFixture(ctx, { callId: ToolCallId('search-multi-1'), name: 'web_search', arguments: { queries: ['one', 'two'] }, signal: controller.signal }) await vi.waitFor(() => { expect(signals).toHaveLength(2) }) expect(signals[0]).toBe(signals[1]) expect(signals[0]).not.toBe(controller.signal) @@ -843,6 +847,7 @@ describe('searchMaxResults is plugin config', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, {}) await expect(ctx.plugin(ToolWeb, { searchMaxResults: value })) .rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/) @@ -878,6 +883,7 @@ describe('searchMaxQueries is plugin config', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, {}) await expect(ctx.plugin(ToolWeb, { searchMaxQueries: value })) .rejects.toThrow(/tool-web: searchMaxQueries must be a positive integer/) @@ -906,6 +912,7 @@ describe('tool-call timeout budget is plugin config', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, {}) await expect(ctx.plugin(ToolWeb, config)) .rejects.toThrow(new RegExp(`tool-web: ${key} must be a positive integer`)) @@ -938,6 +945,7 @@ describe('fetchMaxOutputChars is plugin config', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(WebRuntime, {}) await expect(ctx.plugin(ToolWeb, { fetchMaxOutputChars: value })) .rejects.toThrow(/tool-web: fetchMaxOutputChars must be a positive integer/) diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 5ec803ebf6..67fb2a5dec 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -57,7 +57,9 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-jobs": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-workflow": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*" diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 7375e52194..2b2bdf15e8 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -1,3 +1,5 @@ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -103,6 +105,7 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(StubEngine) await ctx.plugin(toolWorkflow, config ?? {}) const engine = ctx.workflowEngine as StubEngine @@ -119,7 +122,7 @@ function execute(ctx: Context, args: unknown, extra?: { signal?: AbortSignal parent?: ToolExecutionToken }): Promise { - return ctx.tools.execute({ + return executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('call-1'), name: 'workflow', @@ -394,6 +397,7 @@ describe('dsh-tool-workflow', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(StubEngine) const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' }) expect(ctx.tools.get('orchestrate')).toBeDefined() @@ -449,6 +453,7 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) @@ -497,7 +502,7 @@ describe('dsh-tool-workflow', () => { // Narration is observer-only: every chunk rides the log channel, so the // model's job_output before settlement sees status only. expect(jobs.readAt(job.id, 0, parent.id).chunks.map(chunk => chunk.channel)).toEqual(['log', 'log', 'log', 'log']) - const early = await ctx.tools.execute({ + const early = await executeToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('call-early-read'), name: 'job_output', @@ -626,6 +631,7 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(SessionProjectionRegistry) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) @@ -659,6 +665,7 @@ describe('dsh-tool-workflow', () => { onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) + await ctx.plugin(NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolTasks) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40673cc3e7..3b71de5c5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -633,6 +633,9 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../../packages/experimental/tool-ralph + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../packages/test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../packages/core/tools @@ -827,6 +830,9 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../../packages/skill/skill + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../packages/core/tool-executor-native apps/plugin-market-generator: dependencies: @@ -945,6 +951,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../packages/subprocess/subprocess-local + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../packages/test-support/tool-testkit '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../packages/util/values @@ -1098,6 +1107,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../packages/test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../packages/core/tools @@ -1206,6 +1218,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -2100,6 +2115,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -2167,6 +2188,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -2420,6 +2447,12 @@ importers: '@deepseek-ai/dsh-tool-call-timeout-policy': specifier: workspace:* version: link:../../guard/timeout-policy + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs @@ -2502,6 +2535,12 @@ importers: '@deepseek-ai/dsh-cmdline': specifier: workspace:* version: link:../../boot/cmdline + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -2663,6 +2702,9 @@ importers: '@deepseek-ai/dsh-tool-bash-persistent': specifier: workspace:* version: link:../../shell/tool-bash-persistent + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs @@ -2694,9 +2736,6 @@ importers: '@deepseek-ai/dsh-agent-preset-registry': specifier: workspace:* version: link:../../preset/agent-preset-registry - '@deepseek-ai/dsh-agent-tool-presentation': - specifier: workspace:* - version: link:../../core/agent-tool-presentation '@deepseek-ai/dsh-api-account-controller': specifier: workspace:* version: link:../../api/account-controller @@ -3021,6 +3060,12 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../../extensions/tool-cordis + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs @@ -3063,6 +3108,9 @@ importers: '@deepseek-ai/dsh-tool-workflow': specifier: workspace:* version: link:../../workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools '@deepseek-ai/dsh-web-frontend': specifier: workspace:* version: link:../../../apps/web @@ -3677,9 +3725,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter - '@deepseek-ai/dsh-tools': + '@deepseek-ai/dsh-tool-executor-ptc': specifier: workspace:* - version: link:../../core/tools + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -4567,9 +4615,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session - '@deepseek-ai/dsh-tools': + '@deepseek-ai/dsh-tool-executor-ptc': specifier: workspace:* - version: link:../../core/tools + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol @@ -6200,6 +6248,12 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-jobs': specifier: workspace:* version: link:../../jobs/tool-jobs @@ -6209,6 +6263,9 @@ importers: '@deepseek-ai/dsh-tool-terminal': specifier: workspace:* version: link:../../experimental/tool-terminal + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -6284,9 +6341,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session - '@deepseek-ai/dsh-tools': + '@deepseek-ai/dsh-tool-executor-ptc': specifier: workspace:* - version: link:../../core/tools + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -6359,6 +6416,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -6660,6 +6720,9 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -6804,9 +6867,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -6847,6 +6916,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -6890,6 +6965,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -6957,6 +7035,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -7021,6 +7102,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -7162,12 +7246,27 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../tool-executor-ptc + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../tools packages/core/agent-tool-presentation: dependencies: + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../tool-executor-ptc '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -7241,8 +7340,33 @@ importers: specifier: workspace:* version: link:../scope - packages/core/tools: + packages/core/tool-executor-native: dependencies: + '@deepseek-ai/cosmokit': + specifier: link:../../../vendor/cosmokit + version: link:../../../vendor/cosmokit + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:* + version: link:../scope + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../tools + + packages/core/tool-executor-ptc: + dependencies: + '@deepseek-ai/cosmokit': + specifier: link:../../../vendor/cosmokit + version: link:../../../vendor/cosmokit '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -7283,9 +7407,15 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection - '@deepseek-ai/dsh-system-prompt': + '@deepseek-ai/dsh-tool-executor-native': specifier: workspace:* - version: link:../system-prompt + version: link:../tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../tools '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../../interaction/user-approval @@ -7293,6 +7423,52 @@ importers: specifier: workspace:* version: link:../../session/working-directory + packages/core/tools: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:* + version: link:../../util/brand + '@deepseek-ai/dsh-util-values': + specifier: workspace:* + version: link:../../util/values + '@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:../agent + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:* + version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:* + version: link:../scope + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:* + version: link:../../session/session-projection + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:* + version: link:../system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit + '@deepseek-ai/dsh-user-approval': + specifier: workspace:* + version: link:../../interaction/user-approval + packages/credentials/authorization: devDependencies: '@deepseek-ai/cordis': @@ -7422,6 +7598,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -7698,12 +7880,21 @@ importers: '@deepseek-ai/dsh-tool-bash': specifier: workspace:* version: link:../../shell/tool-bash + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../../subagent/tool-subagent + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -7876,6 +8067,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -7949,6 +8149,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -8037,6 +8243,12 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -8339,6 +8551,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -8397,6 +8615,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -8537,6 +8761,9 @@ importers: '@deepseek-ai/dsh-cua': specifier: workspace:* version: link:../cua + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc zod: specifier: ^4.4.3 version: 4.4.3 @@ -8619,6 +8846,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -8692,6 +8925,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -8741,6 +8977,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../../subprocess/subprocess-local + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9167,6 +9406,9 @@ importers: '@deepseek-ai/dsh-tool-subagent-control': specifier: workspace:* version: link:../../subagent/tool-subagent-control + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9213,6 +9455,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9274,6 +9522,12 @@ importers: '@deepseek-ai/dsh-tool-call-timeout-policy': specifier: workspace:* version: link:../../guard/timeout-policy + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9335,9 +9589,18 @@ importers: '@deepseek-ai/dsh-terminal-bash': specifier: workspace:* version: link:../../terminal/terminal-bash + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-jobs': specifier: workspace:* version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9396,6 +9659,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9809,6 +10078,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -9839,6 +10114,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10104,6 +10382,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10159,6 +10446,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10214,6 +10507,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10378,6 +10677,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10409,6 +10714,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10424,6 +10732,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10741,6 +11055,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10806,9 +11126,9 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:* version: link:../../session/session-projection - '@deepseek-ai/dsh-tools': + '@deepseek-ai/dsh-tool-executor-ptc': specifier: workspace:* - version: link:../../core/tools + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol @@ -10898,6 +11218,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -10947,6 +11273,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -11038,6 +11367,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11222,6 +11554,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11400,6 +11738,12 @@ importers: '@deepseek-ai/dsh-tool-call-timeout-policy': specifier: workspace:* version: link:../../guard/timeout-policy + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11449,6 +11793,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11492,6 +11842,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11535,6 +11894,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11615,12 +11983,18 @@ importers: '@deepseek-ai/dsh-atomic-write': specifier: workspace:* version: link:../../util/atomic-write + '@deepseek-ai/dsh-fs-local': + specifier: workspace:* + version: link:../../fs/fs-local '@deepseek-ai/dsh-hmr': specifier: workspace:* version: link:../../boot/hmr '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-ptc-runtime': + specifier: workspace:* + version: link:../../ptc-runtime/ptc-runtime '@deepseek-ai/dsh-sandbox': specifier: workspace:* version: link:../../sandbox/sandbox @@ -11642,6 +12016,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11651,6 +12034,9 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../../interaction/user-approval + '@deepseek-ai/dsh-working-directory': + specifier: workspace:* + version: link:../../session/working-directory '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -11901,6 +12287,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -11934,6 +12323,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -12200,6 +12592,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -12767,6 +13165,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tool-working-directory': specifier: workspace:* version: link:../tool-working-directory @@ -13021,9 +13422,18 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-jobs': specifier: workspace:* version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13088,6 +13498,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13155,9 +13571,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-jobs': specifier: workspace:* version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13225,6 +13647,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13300,6 +13728,12 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:* version: link:../skill + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc packages/skill/tool-skill: dependencies: @@ -13331,6 +13765,12 @@ importers: '@deepseek-ai/dsh-skill-filesystem': specifier: workspace:* version: link:../skill-filesystem + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13362,6 +13802,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13478,9 +13924,18 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../../llm/token-meter + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -13856,6 +14311,9 @@ importers: '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -14286,6 +14744,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -14338,6 +14802,9 @@ importers: '@deepseek-ai/dsh-subagent-spawn-in-process': specifier: workspace:* version: link:../subagent-spawn-in-process + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -14497,6 +14964,13 @@ importers: version: link:../../session/working-directory packages/test-support/agent-loop-testkit: + dependencies: + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -14785,6 +15259,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:* version: link:../../subagent/subagent + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -14795,6 +15272,18 @@ importers: specifier: ^4.0.9 version: 4.0.9 + packages/test-support/tool-testkit: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:~ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-llm': + specifier: workspace:* + version: link:../../llm/llm + '@deepseek-ai/dsh-tools': + specifier: workspace:* + version: link:../../core/tools + packages/todo/tool-todo: dependencies: '@deepseek-ai/schemastery': @@ -14834,6 +15323,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -15091,6 +15586,12 @@ importers: '@deepseek-ai/dsh-tool-call-timeout-policy': specifier: workspace:* version: link:../../guard/timeout-policy + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools @@ -15253,9 +15754,15 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-jobs': specifier: workspace:* version: link:../../jobs/tool-jobs + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index a69eed0281..93d42daf14 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -327,6 +327,36 @@ describe('package payload constraints', () => { ]) }) + it('publishes tools entry points and shared runtime chunks', () => { + const dir = 'packages/core/tools' + const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] + expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) + const emittedTesting = { + ...manifest, + exports: { + ...manifest.exports, + './testing': { types: './lib/types/testing.d.ts', default: './lib/types/testing.js' }, + }, + } + expect(checkWorkspaceManifest({ dir, manifest: { + ...emittedTesting, files: [...expectedDshPackageFiles(emittedTesting)], + } })).toEqual([]) + for (const omitted of ['lib/pipeline.js', 'lib/shared-*.js']) { + expect(checkWorkspaceManifest({ dir, manifest: { + ...manifest, files: manifest.files!.filter(file => file !== omitted), + } })).toEqual([expect.stringContaining('package.json files must be')]) + } + for (const entry of ['testing', 'pipeline']) { + expect(checkWorkspaceManifest({ dir, manifest: { + ...manifest, + exports: { + ...manifest.exports, + [`./${entry}`]: { types: `./lib/types/${entry}.d.ts`, default: './lib/index.js' }, + }, + } })).toContainEqual(expect.stringContaining(`exports["./${entry}"] must expose`)) + } + }) + it('does not infer locale payloads from unrelated or non-JSON exports', () => { expect(expectedDshPackageFiles({ exports: { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index cf9493a5d2..695949ab49 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -178,6 +178,8 @@ export function readWorkspaceManifests(repositoryRoot: string): WorkspaceManifes } const packageFileExtras: Readonly> = { + // Tools entry points share error classes and invocation stages through runtime chunks. + '@deepseek-ai/dsh-tools': ['lib/pipeline.js', 'lib/shared-*.js'], // CUA publishes its client API and attributed native source for local macOS builds. '@deepseek-ai/dsh-cua': ['lib/api.js', 'lib/bridge.js', 'lib/native-client.js', 'lib/protocol.js'], '@deepseek-ai/dsh-experimental-cua-macos': ['native/bin', 'native/Package.swift', 'native/Sources', 'native/tests/swift', 'native/tests/test_swift.py', 'native/LICENSE.cua-driver', 'native/upstream.json', 'native/README.md', 'native/README.zh.md', 'native/protocol.schema.json'], @@ -282,6 +284,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...[...localeFiles].sort(), 'lib/index.js', ...manifest.bin ? ['lib/bin.js'] : [], + ...exportDefault(manifest, './testing') === './lib/testing.js' ? ['lib/testing.js'] : [], // Worker-thread packages ship a CJS worker entry; the browser worker // bundle is an ES module a page loads with `new Worker(type: 'module')`. // Keyed on the artifact path, like ./client below. @@ -535,6 +538,15 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st if (rootEntry?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) } + if (manifest.exports?.['./testing'] !== undefined + && !hasExportPair(manifest, './testing', './lib/types/testing.d.ts', './lib/testing.js') + && !hasExportPair(manifest, './testing', './lib/types/testing.d.ts', './lib/types/testing.js')) { + errors.push(`${label}: exports["./testing"] must expose lib/types/testing.d.ts with lib/testing.js or lib/types/testing.js`) + } + if (manifest.name === '@deepseek-ai/dsh-tools' + && !hasExportPair(manifest, './pipeline', './lib/types/pipeline.d.ts', './lib/pipeline.js')) { + errors.push(`${label}: exports["./pipeline"] must expose lib/types/pipeline.d.ts and lib/pipeline.js`) + } const expectedFiles = expectedDshPackageFiles(manifest) if (!sameStringList(manifest.files, expectedFiles)) { errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) diff --git a/scripts/doc-standard.spec.ts b/scripts/doc-standard.spec.ts index 215048297e..263e9ab779 100644 --- a/scripts/doc-standard.spec.ts +++ b/scripts/doc-standard.spec.ts @@ -82,6 +82,7 @@ const PACKAGE_LIBRARIES: Readonly> = { 'packages/subprocess/win32-process': 'Low-level Win32 process and Job Object primitives.', 'packages/test-support/session-snapshot': 'Test infrastructure; mounts nothing into a product composition.', 'packages/test-support/agent-loop-testkit': 'Test helper library; mounts nothing into a product composition.', + 'packages/test-support/tool-testkit': 'Tool fixture library; exports helpers without a plugin entry.', 'packages/test-support/client-runtime': 'Browser-side test infrastructure.', 'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.', 'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index ebd7527142..c0620815a3 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -730,6 +730,13 @@ export const LINK_MAP: Readonly> = { ToolGuard: 'tools.md', ToolPresentationMode: 'tools.md', ToolRuntime: 'tools.md', + ToolsService: 'tools.md', + ToolExecutor: 'tools.md', + ToolModelInput: 'tools.md', + ToolPromptSection: 'tools.md', + ToolBatchInput: 'tools.md', + ToolCallResult: 'tools.md', + ToolBatchResult: 'tools.md', ToolRestriction: 'tools.md', ToolSchema: 'tools.md', SettingsNamespace: 'settings.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 056e4b7168..16750dd6ec 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -561,10 +561,10 @@ const SERVICE_ROLES: ServiceRole[] = [ { key: 'tools', pkg: 'tools', - title: 'Tool registry and guarded execution pipeline', + title: 'Scoped tool registry and executor selection', mode: 'core', consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'], - note: 'Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.', + note: 'Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch.', }, { key: 'userQuestions', @@ -742,8 +742,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'PTC execution seam', mode: 'seam', implementations: ['ptc-runtime-node', 'experimental-ptc-runtime-python'], - consumers: ['tools', 'workflow-ptc'], - note: 'Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration.', + consumers: ['tool-executor-ptc', 'workflow-ptc'], + note: 'Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration.', }, { key: 'fs', @@ -1635,7 +1635,7 @@ function renderToolPipeline(): string { const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs' return [ ...generatedHeader('Tool Execution Pipeline'), - 'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.', + 'Built-in executors own raw batch parsing, scheduling, cancellation, and Session call/result logging. This graph shows where their shared invocation pipeline applies policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.', '', '```mermaid', 'flowchart TD', @@ -1652,14 +1652,15 @@ function renderToolPipeline(): string { ` owned["Tool-owned session events
${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/ptc-dispatch')}"]`, ' project["ToolDefinition.projectContent
execution-prepared text and images"]', ` post["${mermaidCode('tools/post-execute')} waterfall
accept, block, replace, add context"]`, - ' normalized["Registry outer normalization
pipeline/result snapshot throws become isError"]', + ' normalized["Invocation pipeline normalization
pipeline/result snapshot throws become isError"]', ' finalize["ToolDefinition.finalizeContent
last content-only invariant"]', ` final["${mermaidCode('tools/result')} synchronous notification
frozen authoritative outcome"]`, ' context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"]', ` toolResult["Session event: ${mermaidCode('tool/result')}
single model-facing outcome"]`, ' allResults["Tool batch settled
recorded tool/result events complete"]', ' presentResult["UI completed card
presentResult(args, result)"]', - ' model --> toolCall', + ' executor["Selected ToolExecutor.execute
raw batch, Agent, optional logging step"]', + ' model --> executor --> toolCall', ' toolCall --> presentCall', ' toolCall --> pre', ' pre -->|allow| guards', @@ -1692,7 +1693,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', + 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The pipeline losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its ordered sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index c4ed48c185..f20be306a0 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -7,7 +7,7 @@ */ import { globSync, readFileSync, writeFileSync } from 'node:fs' -import { basename, resolve } from 'node:path' +import { basename, dirname, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import LlmRuntime from '@deepseek-ai/dsh-llm' import type { ToolSchema } from '@deepseek-ai/dsh-llm' @@ -19,7 +19,11 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native' +import { PtcExecutor } from '@deepseek-ai/dsh-tool-executor-ptc' +import NodePtcRuntime from '@deepseek-ai/dsh-ptc-runtime-node' +import LocalSandboxProvider from '@deepseek-ai/dsh-sandbox-local' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env' import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' @@ -166,13 +170,8 @@ export interface ToolPackage { mount: (ctx: Context) => Promise /** Agent-like scope key whose tool view is catalogued instead of the global view. */ scope?: (ctx: Context) => Agent - /** - * Config for the caller's `ToolRuntime` mount. The registry itself ships a - * model-facing tool (`run_code`, registered under a non-native `mode`), so - * ITS catalog entry boots the registry in the mode that exposes it; - * every other entry uses the default (native) registry. - */ - toolsConfig?: ToolsConfig + /** Select the program transport when harvesting the PTC package. */ + executor?: 'ptc' /** * A deployment note rendered after the package's tools, for a fact that * booting the package alone cannot show. The registered tool NAME can be a @@ -255,18 +254,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ 'ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely.', }, { - pkg: '@deepseek-ai/dsh-tools', - dir: 'tools', - source: 'packages/core/tools/src/ptc.ts', - requires: ['ctx.workingDirectory (Agent executions)', 'ctx.tools', 'ctx.ptcRuntime (execution time)', 'ctx.systemPrompt'], + pkg: '@deepseek-ai/dsh-tool-executor-ptc', + dir: 'tool-executor-ptc', + source: 'packages/core/tool-executor-ptc/src/ptc.ts', + requires: ['ctx.workingDirectory (Agent executions)', 'ctx.tools', 'ctx.ptcRuntime', 'ctx.systemPrompt'], writes: ['tool/call', 'one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call', 'tool/result'], - // The registry's OWN tool: run_code exists only under a non-native mode - // (the registry registers it in its constructor; the PTC runtime is read - // at assembly/execution time, so the schema harvest needs none mounted). - toolsConfig: { mode: 'ptc' }, - async mount() {}, + executor: 'ptc', + async mount(ctx) { + await ctx.plugin(LocalSubprocessRuntime) + await ctx.plugin(LocalSandboxProvider) + await ctx.plugin(SandboxPolicy) + await ctx.plugin(NodePtcRuntime) + }, note: - 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (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.', + 'The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program.', }, { pkg: '@deepseek-ai/dsh-plan-mode', @@ -707,7 +708,7 @@ export type ToolCatalog = CatalogPackage[] /** * Assert the boot manifest covers every shipped tool package on disk (a - * `tool-*` leaf under `packages/`). + * `tool-*` leaf under `packages/`, excluding test support). * Booting has no source declaration to enumerate, so this glob restores the * "a new tool cannot be silently undocumented" guarantee: an unlisted package * fails the generator (and the freshness gate) until it is added to @@ -716,7 +717,11 @@ export type ToolCatalog = CatalogPackage[] * `scanRoot` defaults to the repo root; a test may point it at a fixture tree. */ export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void { - const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort() + // Native execution owns no tool definitions; PTC contributes run_code. + const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }) + .filter(path => basename(dirname(path)) !== 'test-support') + .map(p => basename(p)) + .filter(dir => dir !== 'tool-executor-native').sort() const listed = new Set(packages.map(p => p.dir)) const missing = onDisk.filter(dir => !listed.has(dir)) if (missing.length > 0) { @@ -769,9 +774,14 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES await ctx.plugin(SystemPrompt) await ctx.plugin(LocalFileSystem) await ctx.plugin(WorkingDirectory) - await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {}) + await ctx.plugin(ToolRuntime) + const executor = entry.executor === 'ptc' ? new PtcExecutor(ctx) : new NativeToolExecutor(ctx) + ctx.tools.setExecutor(executor) await entry.mount(ctx) - const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name)) + const scope = entry.scope?.(ctx) + const described = executor.describe(ctx.tools.definitions(scope)) + const schemas = [...new Map([...ctx.tools.schemas(scope), ...described.schemas].map(schema => [schema.name, schema])).values()] + .sort((a, b) => a.name.localeCompare(b.name)) assertToolsHarvested(entry, schemas.length) catalog.push({ pkg: entry.pkg, diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 41520f4e27..9741664335 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -931,16 +931,29 @@ "packages/core/system-prompt/src/index.ts": { "7fe58280287ff63d40e979e72329f975e11015a95e9a8eec207a5a78748d3c89": 1 }, - "packages/core/tools/src/index.ts": { - "6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1 + "packages/core/tool-executor-ptc/tests/ptc.spec.ts": { + "52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1 + }, + "packages/core/tool-executor-ptc/tests/py-types.spec.ts": { + "0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 9, + "23ebbf16be06365be256c1ceb77869576a026644f3514e1737030c4556849f4a": 1, + "3ec1a0855f2cf4a5b3646725ea95c07addd33d5957ba6af530b944cd26f1ea8f": 1, + "43155833aa226b066cf374829e013176403ddbd6acd313ccf90f6cfb92ceec06": 1, + "5d1224b16a64bb138a708eb3e3e130df2ddf1f8f656617e3f1b3f2583250cf63": 1, + "6aecd519658d0d2fb040f0289d6db823e3f6bbb26fcb2b118dc7f7f2c1d3eb06": 1, + "9b2b04bd2ecde0c56b00ae050b721d56b3c29a1c834b982c9c2f6e62dbcd0a04": 1, + "d334b0b55263b6cb2125317ce96b766155b11455d40d600590f5302f5e0543cc": 1, + "d6650a0b52b5f7311afff190373b4a1cbd31ba44f9fa0dff09395f6f2901e32d": 1 + }, + "packages/core/tool-executor-ptc/tests/ts-types.spec.ts": { + "054c87ca977d98749baf70bb5dcad1bb8d9e725d08a480b7804a181434633873": 1, + "0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 1, + "84a5877f154b369cc0528281904d1dc18e20fd3ad74a26c939301ba0db20ae02": 1, + "d80a3368311f07e812a76ff265125b072175f58b6003d9f96a37d4f3e70e3dec": 1 }, "packages/core/tools/src/schema.ts": { "62870121f1e1644b67c62606e94a375bb40f880cc88edf7cd6baab0249e9da46": 1 }, - "packages/core/tools/src/testing.ts": { - "5be3dd49608bd752ae1371aae8c8d748a07158f7a597e2506af79a621f74f0b3": 1, - "6fe9024d30e0d4757a348b83d246925b3f5aeaf2e79a0eff4bf12f3c9fb5826b": 1 - }, "packages/core/tools/tests/execution-mode.spec.ts": { "33dec0693f265044c830377981b51852b773e1a8a765e0b3398a4542c646537b": 1, "f9c9afc4ff66b96c59303ffa5475e26719ef101dc45504fee4b56145ff4be02c": 1 @@ -955,20 +968,6 @@ "c21022b1c0c54a213857769a5d72bc249dce83dd63c9d6f2370139bbea3288c9": 1, "d1250fc465aac30c8474c1ee43ecbafc0a8c072d5e607961da1295acc710a257": 1 }, - "packages/core/tools/tests/ptc.spec.ts": { - "52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1 - }, - "packages/core/tools/tests/py-types.spec.ts": { - "0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 9, - "23ebbf16be06365be256c1ceb77869576a026644f3514e1737030c4556849f4a": 1, - "3ec1a0855f2cf4a5b3646725ea95c07addd33d5957ba6af530b944cd26f1ea8f": 1, - "43155833aa226b066cf374829e013176403ddbd6acd313ccf90f6cfb92ceec06": 1, - "5d1224b16a64bb138a708eb3e3e130df2ddf1f8f656617e3f1b3f2583250cf63": 1, - "6aecd519658d0d2fb040f0289d6db823e3f6bbb26fcb2b118dc7f7f2c1d3eb06": 1, - "9b2b04bd2ecde0c56b00ae050b721d56b3c29a1c834b982c9c2f6e62dbcd0a04": 1, - "d334b0b55263b6cb2125317ce96b766155b11455d40d600590f5302f5e0543cc": 1, - "d6650a0b52b5f7311afff190373b4a1cbd31ba44f9fa0dff09395f6f2901e32d": 1 - }, "packages/core/tools/tests/schema.spec.ts": { "2994ddac72096564a65b1d11cfe50c5b5f21de0cca775ed7b758d031b9a4df99": 1, "7724a969f2b794fc521b1a246d444c75616cf27e6ce073b00e8c8956b626634d": 1, @@ -992,12 +991,6 @@ "d3478c9f3a88906a17d5daa6d19c3694a33618cfc000c7c0ee8f3e16f2697322": 1, "f9c9afc4ff66b96c59303ffa5475e26719ef101dc45504fee4b56145ff4be02c": 2 }, - "packages/core/tools/tests/ts-types.spec.ts": { - "054c87ca977d98749baf70bb5dcad1bb8d9e725d08a480b7804a181434633873": 1, - "0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 1, - "84a5877f154b369cc0528281904d1dc18e20fd3ad74a26c939301ba0db20ae02": 1, - "d80a3368311f07e812a76ff265125b072175f58b6003d9f96a37d4f3e70e3dec": 1 - }, "packages/credentials/credentials-local/tests/drain.spec.ts": { "a96f4a3497a1c661f76e47ef5fda3c32d2759a7a5f9c519527fa46801a91bf79": 1 }, diff --git a/scripts/product-package-policy.ts b/scripts/product-package-policy.ts index 802ce76f48..8606ff62c9 100644 --- a/scripts/product-package-policy.ts +++ b/scripts/product-package-policy.ts @@ -38,6 +38,7 @@ export const PRODUCT_PACKAGE_POLICY: Readonly> = { 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, 'packages/test-support/session-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, 'packages/test-support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, + 'packages/test-support/tool-testkit': { kind: 'none', reason: 'Fixture helpers define and execute test tools without making model requests.' }, 'packages/test-support/loader-smoke': { kind: 'none', reason: 'The test harness submits an ordinary user task but delegates prompt and tool composition to the loaded tree.' }, 'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 233e4915e5..051cfb6026 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1086,7 +1086,8 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { } } if (assertions.expectedFinalResponse !== undefined) { - expect(results.at(-1)?.finalResponse, `${scenario.name}: final response`).toBe(assertions.expectedFinalResponse) + const failures = results.flatMap(result => result.events).filter(event => event.type === 'turn/end' || event.type === 'tool/result') + expect(results.at(-1)?.finalResponse, `${scenario.name}: final response; outcomes: ${JSON.stringify(failures)}`).toBe(assertions.expectedFinalResponse) const parent = ordered[0] if (parent === undefined) throw new Error(`${scenario.name}: no primary session log`) const turnEnds = records(parent.content).filter(record => record.type === 'turn/end') diff --git a/snapshots/sdk/tool-error-details/runtime.cordis.yml b/snapshots/sdk/tool-error-details/runtime.cordis.yml index 212532fad8..09a2288e2f 100644 --- a/snapshots/sdk/tool-error-details/runtime.cordis.yml +++ b/snapshots/sdk/tool-error-details/runtime.cordis.yml @@ -1,6 +1,8 @@ -- id: tools - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - insert: - id: ptc-runtime diff --git a/snapshots/session/advanced-toolchain/cordis.snapshot.yml b/snapshots/session/advanced-toolchain/cordis.snapshot.yml index 6914856421..a36a66d8d6 100644 --- a/snapshots/session/advanced-toolchain/cordis.snapshot.yml +++ b/snapshots/session/advanced-toolchain/cordis.snapshot.yml @@ -20,10 +20,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/advanced-toolchain/cordis.yml b/snapshots/session/advanced-toolchain/cordis.yml index 04c7e89afc..441cfb58a7 100644 --- a/snapshots/session/advanced-toolchain/cordis.yml +++ b/snapshots/session/advanced-toolchain/cordis.yml @@ -16,10 +16,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/both-mode-turn/cordis.snapshot.yml b/snapshots/session/both-mode-turn/cordis.snapshot.yml index 06a772fb9c..a603c7450d 100644 --- a/snapshots/session/both-mode-turn/cordis.snapshot.yml +++ b/snapshots/session/both-mode-turn/cordis.snapshot.yml @@ -21,10 +21,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/both-mode-turn/cordis.yml b/snapshots/session/both-mode-turn/cordis.yml index 6b6eb4df98..42efc1c9f0 100644 --- a/snapshots/session/both-mode-turn/cordis.yml +++ b/snapshots/session/both-mode-turn/cordis.yml @@ -17,10 +17,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml b/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml index 49e22061b5..04f34386d1 100644 --- a/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml +++ b/snapshots/session/cordis-inspect-jsdoc/cordis.snapshot.yml @@ -20,10 +20,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cordis-inspect-jsdoc/cordis.yml b/snapshots/session/cordis-inspect-jsdoc/cordis.yml index c53dddeff6..0a0e882224 100644 --- a/snapshots/session/cordis-inspect-jsdoc/cordis.yml +++ b/snapshots/session/cordis-inspect-jsdoc/cordis.yml @@ -16,10 +16,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cordis-inspect-liveness/cordis.snapshot.yml b/snapshots/session/cordis-inspect-liveness/cordis.snapshot.yml index 7884735323..76f7d7c861 100644 --- a/snapshots/session/cordis-inspect-liveness/cordis.snapshot.yml +++ b/snapshots/session/cordis-inspect-liveness/cordis.snapshot.yml @@ -19,10 +19,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cordis-inspect-liveness/cordis.yml b/snapshots/session/cordis-inspect-liveness/cordis.yml index 58976b093e..55375b5346 100644 --- a/snapshots/session/cordis-inspect-liveness/cordis.yml +++ b/snapshots/session/cordis-inspect-liveness/cordis.yml @@ -15,10 +15,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cordis-inspect-timeout/cordis.snapshot.yml b/snapshots/session/cordis-inspect-timeout/cordis.snapshot.yml index 2e9e5dbe51..1fa86fcdd6 100644 --- a/snapshots/session/cordis-inspect-timeout/cordis.snapshot.yml +++ b/snapshots/session/cordis-inspect-timeout/cordis.snapshot.yml @@ -19,10 +19,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cordis-inspect-timeout/cordis.yml b/snapshots/session/cordis-inspect-timeout/cordis.yml index c062883e40..25c35974b0 100644 --- a/snapshots/session/cordis-inspect-timeout/cordis.yml +++ b/snapshots/session/cordis-inspect-timeout/cordis.yml @@ -15,10 +15,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cua-native/cordis.snapshot.yml b/snapshots/session/cua-native/cordis.snapshot.yml index f6708af93d..1ce8b57e8c 100644 --- a/snapshots/session/cua-native/cordis.snapshot.yml +++ b/snapshots/session/cua-native/cordis.snapshot.yml @@ -17,10 +17,11 @@ root: !!js dshHomePath('sessions') compression: none -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cua-native/cordis.yml b/snapshots/session/cua-native/cordis.yml index b60d7872d4..56ad16fa04 100644 --- a/snapshots/session/cua-native/cordis.yml +++ b/snapshots/session/cua-native/cordis.yml @@ -10,10 +10,11 @@ root: !!js dshHomePath('sessions') compression: none -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- id: tool-executor-native + disabled: false + +- id: tool-executor-ptc + disabled: true - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cua-ptc/cordis.snapshot.yml b/snapshots/session/cua-ptc/cordis.snapshot.yml index c2f9b23c4b..0d4f74e7e0 100644 --- a/snapshots/session/cua-ptc/cordis.snapshot.yml +++ b/snapshots/session/cua-ptc/cordis.snapshot.yml @@ -17,10 +17,11 @@ root: !!js dshHomePath('sessions') compression: none -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/cua-ptc/cordis.yml b/snapshots/session/cua-ptc/cordis.yml index 60a3be9c9e..d7262947f9 100644 --- a/snapshots/session/cua-ptc/cordis.yml +++ b/snapshots/session/cua-ptc/cordis.yml @@ -10,10 +10,11 @@ root: !!js dshHomePath('sessions') compression: none -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/mcp-empty-ptc/cordis.snapshot.yml b/snapshots/session/mcp-empty-ptc/cordis.snapshot.yml index 238653a14d..65f509c9a5 100644 --- a/snapshots/session/mcp-empty-ptc/cordis.snapshot.yml +++ b/snapshots/session/mcp-empty-ptc/cordis.snapshot.yml @@ -17,9 +17,11 @@ - id: literal-sdk name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' -- id: tools - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt config: diff --git a/snapshots/session/mcp-empty-ptc/cordis.yml b/snapshots/session/mcp-empty-ptc/cordis.yml index 2c5282abf2..f675ff8509 100644 --- a/snapshots/session/mcp-empty-ptc/cordis.yml +++ b/snapshots/session/mcp-empty-ptc/cordis.yml @@ -2,9 +2,11 @@ - id: literal-sdk name: '../../../packages/core/tools/tests/fixtures/literal-sdk.ts' -- id: tools - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt config: diff --git a/snapshots/session/mcp-resources-ptc/cordis.snapshot.yml b/snapshots/session/mcp-resources-ptc/cordis.snapshot.yml index c0f648a3b2..90181af9bc 100644 --- a/snapshots/session/mcp-resources-ptc/cordis.snapshot.yml +++ b/snapshots/session/mcp-resources-ptc/cordis.snapshot.yml @@ -27,7 +27,8 @@ reconnect: enabled: false -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/snapshots/session/mcp-resources-ptc/cordis.yml b/snapshots/session/mcp-resources-ptc/cordis.yml index 1ceac53ac6..2860a129d9 100644 --- a/snapshots/session/mcp-resources-ptc/cordis.yml +++ b/snapshots/session/mcp-resources-ptc/cordis.yml @@ -10,7 +10,8 @@ reconnect: enabled: false -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/snapshots/session/office-render-tools-ptc/cordis.snapshot.yml b/snapshots/session/office-render-tools-ptc/cordis.snapshot.yml index e502be7001..e04dc673ac 100644 --- a/snapshots/session/office-render-tools-ptc/cordis.snapshot.yml +++ b/snapshots/session/office-render-tools-ptc/cordis.snapshot.yml @@ -61,9 +61,11 @@ - id: tool-pwsh disabled: true -- id: tools - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: ptc-runtime config: diff --git a/snapshots/session/office-render-tools-ptc/cordis.yml b/snapshots/session/office-render-tools-ptc/cordis.yml index cdff067bc3..9c6931c582 100644 --- a/snapshots/session/office-render-tools-ptc/cordis.yml +++ b/snapshots/session/office-render-tools-ptc/cordis.yml @@ -12,6 +12,8 @@ - id: tool-pwsh disabled: true -- id: tools - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/snapshots/session/ptc-node-read-only/cordis.snapshot.yml b/snapshots/session/ptc-node-read-only/cordis.snapshot.yml index f90dbb9d53..c97ea0876f 100644 --- a/snapshots/session/ptc-node-read-only/cordis.snapshot.yml +++ b/snapshots/session/ptc-node-read-only/cordis.snapshot.yml @@ -28,10 +28,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-node-read-only/cordis.yml b/snapshots/session/ptc-node-read-only/cordis.yml index d7ddae4efe..fcbab151bf 100644 --- a/snapshots/session/ptc-node-read-only/cordis.yml +++ b/snapshots/session/ptc-node-read-only/cordis.yml @@ -22,10 +22,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-node-workspace/cordis.snapshot.yml b/snapshots/session/ptc-node-workspace/cordis.snapshot.yml index f90dbb9d53..c97ea0876f 100644 --- a/snapshots/session/ptc-node-workspace/cordis.snapshot.yml +++ b/snapshots/session/ptc-node-workspace/cordis.snapshot.yml @@ -28,10 +28,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-node-workspace/cordis.yml b/snapshots/session/ptc-node-workspace/cordis.yml index d7ddae4efe..fcbab151bf 100644 --- a/snapshots/session/ptc-node-workspace/cordis.yml +++ b/snapshots/session/ptc-node-workspace/cordis.yml @@ -22,10 +22,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-persistent/cordis.snapshot.yml b/snapshots/session/ptc-persistent/cordis.snapshot.yml index 9033e3ccba..3a22aff9ac 100644 --- a/snapshots/session/ptc-persistent/cordis.snapshot.yml +++ b/snapshots/session/ptc-persistent/cordis.snapshot.yml @@ -28,10 +28,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-persistent/cordis.yml b/snapshots/session/ptc-persistent/cordis.yml index e07f8f494f..65f46735d4 100644 --- a/snapshots/session/ptc-persistent/cordis.yml +++ b/snapshots/session/ptc-persistent/cordis.yml @@ -22,10 +22,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-policy-change/cordis.snapshot.yml b/snapshots/session/ptc-policy-change/cordis.snapshot.yml index 54bbffba9f..d537edcc2c 100644 --- a/snapshots/session/ptc-policy-change/cordis.snapshot.yml +++ b/snapshots/session/ptc-policy-change/cordis.snapshot.yml @@ -28,10 +28,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-policy-change/cordis.yml b/snapshots/session/ptc-policy-change/cordis.yml index b11d510a08..e50a9e182e 100644 --- a/snapshots/session/ptc-policy-change/cordis.yml +++ b/snapshots/session/ptc-policy-change/cordis.yml @@ -22,10 +22,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-python-turn/cordis.snapshot.yml b/snapshots/session/ptc-python-turn/cordis.snapshot.yml index df978d7773..e6b5232d9d 100644 --- a/snapshots/session/ptc-python-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-python-turn/cordis.snapshot.yml @@ -23,10 +23,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: ptc-runtime disabled: true diff --git a/snapshots/session/ptc-python-turn/cordis.yml b/snapshots/session/ptc-python-turn/cordis.yml index d31b68243c..9486105c69 100644 --- a/snapshots/session/ptc-python-turn/cordis.yml +++ b/snapshots/session/ptc-python-turn/cordis.yml @@ -17,10 +17,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: ptc-runtime disabled: true diff --git a/snapshots/session/ptc-read-image/cordis.snapshot.yml b/snapshots/session/ptc-read-image/cordis.snapshot.yml index 79299a4cfd..29928900f4 100644 --- a/snapshots/session/ptc-read-image/cordis.snapshot.yml +++ b/snapshots/session/ptc-read-image/cordis.snapshot.yml @@ -25,10 +25,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-read-image/cordis.yml b/snapshots/session/ptc-read-image/cordis.yml index 2ef705b550..7592122d36 100644 --- a/snapshots/session/ptc-read-image/cordis.yml +++ b/snapshots/session/ptc-read-image/cordis.yml @@ -18,10 +18,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-turn/cordis.snapshot.yml b/snapshots/session/ptc-turn/cordis.snapshot.yml index f90dbb9d53..c97ea0876f 100644 --- a/snapshots/session/ptc-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-turn/cordis.snapshot.yml @@ -28,10 +28,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-turn/cordis.yml b/snapshots/session/ptc-turn/cordis.yml index d7ddae4efe..fcbab151bf 100644 --- a/snapshots/session/ptc-turn/cordis.yml +++ b/snapshots/session/ptc-turn/cordis.yml @@ -22,10 +22,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml index 950a2e27c2..4e0b614a8e 100644 --- a/snapshots/session/ptc-workspace-context/cordis.snapshot.yml +++ b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml @@ -28,10 +28,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/ptc-workspace-context/cordis.yml b/snapshots/session/ptc-workspace-context/cordis.yml index d5f5f4f635..29b1398596 100644 --- a/snapshots/session/ptc-workspace-context/cordis.yml +++ b/snapshots/session/ptc-workspace-context/cordis.yml @@ -21,10 +21,11 @@ config: maxBytes: 65536 -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - mode: ptc +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/tsconfig.base.json b/tsconfig.base.json index 3573f6f8b6..e59c1ffe6b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -193,8 +193,10 @@ "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], "@deepseek-ai/dsh-file-reference/types": ["./packages/context/file-reference/src/types.ts"], "@deepseek-ai/dsh-session-reference/types": ["./packages/context/session-reference/src/types.ts"], + "@deepseek-ai/dsh-tool-executor-ptc/types": ["./packages/core/tool-executor-ptc/src/session-types.ts"], + "@deepseek-ai/dsh-tools/pipeline": ["./packages/core/tools/src/pipeline.ts"], + "@deepseek-ai/dsh-tool-testkit": ["./packages/test-support/tool-testkit/src/index.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], - "@deepseek-ai/dsh-tools/types": ["./packages/core/tools/src/types.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], "@deepseek-ai/dsh-tool-subagent/model-selection-settings": ["./packages/subagent/tool-subagent/src/model-selection-settings.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/interaction/user-approval/src/types.ts"], @@ -518,6 +520,8 @@ "@deepseek-ai/dsh-tool-bash": ["./packages/shell/tool-bash/src"], "@deepseek-ai/dsh-tool-bash-persistent": ["./packages/shell/tool-bash-persistent/src"], "@deepseek-ai/dsh-tool-cordis": ["./packages/extensions/tool-cordis/src"], + "@deepseek-ai/dsh-tool-executor-native": ["./packages/core/tool-executor-native/src"], + "@deepseek-ai/dsh-tool-executor-ptc": ["./packages/core/tool-executor-ptc/src"], "@deepseek-ai/dsh-tool-fs": ["./packages/fs/tool-fs/src"], "@deepseek-ai/dsh-tool-fs-search": ["./packages/fs/tool-fs-search/src"], "@deepseek-ai/dsh-tool-goal": ["./packages/goal/tool-goal/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 053d90af78..2b7aecaa8a 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -64,6 +64,8 @@ { "path": "./packages/api/terminal-controller/tsconfig.client.json" }, // Producer-to-UI spill tests use the ptc-runtime contract with the real tool registry. { "path": "./packages/ptc-runtime/ptc-runtime" }, + { "path": "./packages/core/tool-executor-native" }, + { "path": "./packages/core/tool-executor-ptc" }, { "path": "./packages/client/ui-settings-account" }, // Shared leaf: web e2e boots the real host webserver (fixture + real-host // smoke policy). webserver has zero workspace deps and no cordis merge, @@ -93,6 +95,7 @@ { "path": "./packages/extensions/ui-cordis" }, { "path": "./packages/test-support/client-runtime" }, { "path": "./packages/test-support/remote-mock" }, + { "path": "./packages/test-support/tool-testkit" }, { "path": "./packages/client/shortcuts/tsconfig.client.json" }, { "path": "./packages/client/ui-shortcuts" }, { "path": "./packages/client/ui-layout" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 715adae96f..59f0097ae6 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -393,6 +393,8 @@ { "path": "./packages/experimental/auto-review" }, { "path": "./packages/experimental/claude-code-mods" }, { "path": "./packages/core/tools" }, + { "path": "./packages/core/tool-executor-native" }, + { "path": "./packages/core/tool-executor-ptc" }, { "path": "./packages/core/agent-tool-presentation" }, { "path": "./packages/skill/skill" }, { "path": "./packages/experimental/skill-badge" }, @@ -460,6 +462,7 @@ { "path": "./packages/spill/spill-policy" }, { "path": "./packages/guard/timeout-policy" }, { "path": "./packages/test-support/agent-loop-testkit" }, + { "path": "./packages/test-support/tool-testkit" }, { "path": "./packages/acp/acp" }, { "path": "./packages/bundle/acp-app" }, { "path": "./packages/bundle/base" }, From ffe122ffd480059efd24c25ff5f4e5b082ae5854 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 14:50:00 +0800 Subject: [PATCH 10/73] docs(tools): document executor plugins and migration --- ...19-cooperative-tool-cancellation.i18n.yaml | 4 +- ...026-07-19-cooperative-tool-cancellation.md | 2 +- ...-07-19-cooperative-tool-cancellation.zh.md | 2 +- apps/cli/composition.md | 6 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 + docs/architecture.zh.md | 2 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 9 +- docs/capability-seams.zh.md | 9 +- docs/config-catalog.md | 91 ++++---- docs/config-catalog.zh.md | 91 ++++---- docs/cordis-tutorial/07-into-the-harness.md | 9 +- .../cordis-tutorial/07-into-the-harness.zh.md | 9 +- .../package-surface.i18n.yaml | 4 +- docs/defensive-patterns/package-surface.md | 3 +- docs/defensive-patterns/package-surface.zh.md | 3 +- docs/event-producer-consumer.md | 14 +- docs/event-producer-consumer.zh.md | 14 +- docs/module-graph.md | 30 ++- docs/module-graph.zh.md | 30 ++- docs/persistence-catalog.i18n.yaml | 40 ++-- docs/persistence-catalog.md | 22 +- docs/persistence-catalog.zh.md | 22 +- docs/persistence-schema.json | 18 +- docs/subsystems/tools.i18n.yaml | 7 +- docs/subsystems/tools.md | 221 +++++++++++------- docs/subsystems/tools.zh.md | 221 +++++++++++------- docs/tool-catalog.i18n.yaml | 20 +- docs/tool-catalog.md | 16 +- docs/tool-catalog.zh.md | 16 +- docs/tool-execution-pipeline.i18n.yaml | 4 +- docs/tool-execution-pipeline.md | 9 +- docs/tool-execution-pipeline.zh.md | 7 +- .../tool-executor-plugins/guide.i18n.yaml | 16 ++ .../tool-executor-plugins/guide.md | 21 ++ .../tool-executor-plugins/guide.zh.md | 21 ++ packages/core/agent-loop/README.i18n.yaml | 16 +- packages/core/agent-loop/README.md | 10 +- packages/core/agent-loop/README.zh.md | 10 +- .../agent-tool-presentation/README.i18n.yaml | 30 +-- .../core/agent-tool-presentation/README.md | 16 +- .../core/agent-tool-presentation/README.zh.md | 14 +- packages/core/system-prompt/README.i18n.yaml | 4 +- packages/core/system-prompt/README.md | 2 +- packages/core/system-prompt/README.zh.md | 2 +- .../tool-executor-native/README.i18n.yaml | 46 ++++ packages/core/tool-executor-native/README.md | 98 ++++++++ .../core/tool-executor-native/README.zh.md | 98 ++++++++ .../core/tool-executor-ptc/README.i18n.yaml | 52 +++++ packages/core/tool-executor-ptc/README.md | 134 +++++++++++ packages/core/tool-executor-ptc/README.zh.md | 134 +++++++++++ packages/core/tools/README.i18n.yaml | 51 ++-- packages/core/tools/README.md | 90 ++----- packages/core/tools/README.zh.md | 86 ++----- .../references/packages.md | 6 +- packages/test-support/README.i18n.yaml | 4 +- packages/test-support/README.md | 1 + packages/test-support/README.zh.md | 1 + .../tool-testkit/README.i18n.yaml | 37 +++ packages/test-support/tool-testkit/README.md | 91 ++++++++ .../test-support/tool-testkit/README.zh.md | 91 ++++++++ 62 files changed, 1536 insertions(+), 611 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.zh.md create mode 100644 packages/core/tool-executor-native/README.i18n.yaml create mode 100644 packages/core/tool-executor-native/README.md create mode 100644 packages/core/tool-executor-native/README.zh.md create mode 100644 packages/core/tool-executor-ptc/README.i18n.yaml create mode 100644 packages/core/tool-executor-ptc/README.md create mode 100644 packages/core/tool-executor-ptc/README.zh.md create mode 100644 packages/test-support/tool-testkit/README.i18n.yaml create mode 100644 packages/test-support/tool-testkit/README.md create mode 100644 packages/test-support/tool-testkit/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 4573f0a33f..e4b4714af1 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -24,8 +24,8 @@ en: 26e4b71366bebd1b zh: 10598e12b28c586e /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/verification: - en: fbd101d4576afa92 - zh: 35f5d3c2f6cad6a8 + en: 983da6d0c09f5c10 + zh: ca7c86117a1a4d5f /agent-note-cooperative-tool-cancellation-at-the-registry-boundary/alternatives-considered: en: c4d86feb135d2ac4 zh: 67c56c0bc2213949 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 967c7285df..8ad0af1d21 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making ## Verification -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index 87318c3316..613db6dacd 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -46,7 +46,7 @@ Status: implemented ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tool-executor-ptc/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 8b2cfb83ab..5d8a09e34d 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -188,6 +188,10 @@ flowchart LR cfg --> plugin_dsh_base_mcp_resources plugin_dsh_base_tools["tools
@deepseek-ai/dsh-tools"] cfg --> plugin_dsh_base_tools + plugin_dsh_base_tool_executor_native["tool-executor-native
@deepseek-ai/dsh-tool-executor-native"] + cfg --> plugin_dsh_base_tool_executor_native + plugin_dsh_base_tool_executor_ptc["tool-executor-ptc
@deepseek-ai/dsh-tool-executor-ptc"] + cfg --> plugin_dsh_base_tool_executor_ptc plugin_dsh_base_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] cfg --> plugin_dsh_base_system_prompt plugin_dsh_base_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] @@ -292,6 +296,8 @@ flowchart LR | `tool-web` | `@deepseek-ai/dsh-tool-web` | | `mcp-resources` | `@deepseek-ai/dsh-mcp-resources` | | `tools` | `@deepseek-ai/dsh-tools` | +| `tool-executor-native` | `@deepseek-ai/dsh-tool-executor-native` | +| `tool-executor-ptc` | `@deepseek-ai/dsh-tool-executor-ptc` | | `system-prompt` | `@deepseek-ai/dsh-system-prompt` | | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-sandbox` | `@deepseek-ai/dsh-fs-sandbox` | diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8206f3e6dc..946d6448a0 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -24,8 +24,8 @@ en: e89802f066e4955d zh: ea2fb4bef94e086d /deepseek-harness-architecture/turn-flow: - en: e6402b28c0ed15fa - zh: 6c07be9047b42056 + en: 5ba2d921295f4254 + zh: a09fa21b8882fb25 /deepseek-harness-architecture/session-log: en: a2e35c43438fec6d zh: e27bdac8e9feff32 diff --git a/docs/architecture.md b/docs/architecture.md index 03d88148c7..689f29c75d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,6 +115,8 @@ Failed steps [record missing tool results](../packages/core/agent-loop/README.md Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). +[`ctx.tools.execute()`](subsystems/tools.md) receives raw batches and concurrency limits. + ## Session log The session log is the source of the context the model sees. `deriveMessages()` projects model history from it. Each `assistant/message` embeds the exact compact timed stream that produced its assembled content; `assistant/attempt` retains settled failed, retried, cancelled, and stream-error attempts without adding model history. Fork, resume, transcripts, telemetry, and persistence all derive from these durable settlements, while live UI incrementality comes from `agent/assistant-stream`; a hard process loss before settlement leaves no durable attempt stream ([decision](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.md)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 870fa1c996..2abbb78807 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -119,6 +119,8 @@ turn/end 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 +[`ctx.tools.execute()`](subsystems/tools.zh.md) 接收原始调用批次和并发上限。 + ## 会话日志 会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史。每个 `assistant/message` 都嵌入产生其组装内容的精确紧凑带时间 stream;`assistant/attempt` 保留已到达 settlement 的失败、重试、取消与 stream error attempt,且不添加模型历史。fork、恢复、transcript(文本记录)、遥测与持久化都从这些持久 settlement 派生,实时 UI 增量则来自 `agent/assistant-stream`;如果进程在 settlement 前硬中断,则不会留下持久 attempt stream(见[决策](../.agents/notes/implemented/architecture/2026-09-01-v2-embedded-assistant-streams.zh.md))。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 25b949f17a..424c721d6c 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 048c8c150734d0dd - zh: 836dd6c78d4c8278 + en: 28ab205d66a38193 + zh: 290cacfdc1cb6eab diff --git a/docs/capability-seams.md b/docs/capability-seams.md index dce33aa090..3933b5ea6f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -162,7 +162,7 @@ flowchart LR pkg_tools["tools"] pkg_tool_terminal["tool-terminal"] pkg_tool_web["tool-web"] - svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] + svc_tools["ctx.tools
Scoped tool registry and executor selection"] pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] @@ -232,6 +232,7 @@ flowchart LR svc_ptcRuntime["ctx.ptcRuntime
PTC execution seam"] pkg_ptc_runtime_node["ptc-runtime-node"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_workflow_ptc["workflow-ptc"] pkg_fs["fs"] svc_fs["ctx.fs
Filesystem provider seam"] @@ -507,7 +508,7 @@ flowchart LR svc_pluginMarket --> pkg_plugin_market svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager svc_profileContext --> pkg_plugin_manager - svc_ptcRuntime --> pkg_tools + svc_ptcRuntime --> pkg_tool_executor_ptc svc_ptcRuntime --> pkg_workflow_ptc svc_remoteAccess --> pkg_api_gateway svc_remoteAccess --> pkg_api_session_controller @@ -666,7 +667,7 @@ flowchart LR | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch. | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | Eagerly mounts YAML-declared preset revisions, binds Agents and cold readers to scoped contributions, and retains retired revisions until their last user releases them. | @@ -688,7 +689,7 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | -| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration. | +| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration. | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 20f165490d..e97dfabc60 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -164,7 +164,7 @@ flowchart LR pkg_tools["tools"] pkg_tool_terminal["tool-terminal"] pkg_tool_web["tool-web"] - svc_tools["ctx.tools
Tool registry and guarded execution pipeline"] + svc_tools["ctx.tools
Scoped tool registry and executor selection"] pkg_tool_ask_user["tool-ask-user"] pkg_tool_cordis["tool-cordis"] pkg_tool_skill["tool-skill"] @@ -234,6 +234,7 @@ flowchart LR svc_ptcRuntime["ctx.ptcRuntime
PTC execution seam"] pkg_ptc_runtime_node["ptc-runtime-node"] pkg_experimental_ptc_runtime_python["experimental-ptc-runtime-python"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_workflow_ptc["workflow-ptc"] pkg_fs["fs"] svc_fs["ctx.fs
Filesystem provider seam"] @@ -509,7 +510,7 @@ flowchart LR svc_pluginMarket --> pkg_plugin_market svc_pluginRegistryProbe --> pkg_client_ui_plugin_manager svc_profileContext --> pkg_plugin_manager - svc_ptcRuntime --> pkg_tools + svc_ptcRuntime --> pkg_tool_executor_ptc svc_ptcRuntime --> pkg_workflow_ptc svc_remoteAccess --> pkg_api_gateway svc_remoteAccess --> pkg_api_session_controller @@ -668,7 +669,7 @@ flowchart LR | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/experimental/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/experimental/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力和作用域执行器。原生与 PTC 提供者负责工具描述及完整原始调用批次;它们可选用共享流水线处理工具策略和分发。 | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | - | - | - | 立即挂载 YAML 声明的 preset 版本,把 Agent 和冷读取绑定到作用域内的贡献,并保留已替换的版本,直到最后一个使用者释放它。 | @@ -690,7 +691,7 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 统一保存部署默认模式和工作区根目录;只有沙箱执行器和提供方读取该服务(工具层使用它同时导出的纯 `sandbox/mode` 折叠区)。两类强制执行组件都读取该服务,因此 bash 与 fs 不会限制到不同的根目录。 | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | 一次性权限决策通过 `approval/request` waterfall(瀑布式事件)分派;回答方是监听器(即 ACP 为自身 agent 提供的桥接),没有回答方时以 `unavailable` 关闭失败。 | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | 面向用户的预设表(`workspace-write`/`danger-full-access`),将沙箱模式与审批策略选项组合在一起;一次切换会写入一个 `permission/preset` 事件,并贯通到两个选项事件。 | -| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tools`](../packages/core/tools), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tools 负责 PTC 呈现,workflow-ptc 负责工作流编排。 | +| `ctx.ptcRuntime` | `seam` | [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime) | [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), [`experimental-ptc-runtime-python`](../packages/experimental/ptc-runtime-python) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`workflow-ptc`](../packages/workflow/workflow-ptc) | - | 使用 Host 提供的异步绑定运行程序;tool-executor-ptc 负责 PTC 呈现,workflow-ptc 负责工作流编排。 | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-ssh`](../packages/ssh/fs-ssh) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/experimental/tool-ralph) | - | 提供方准备本地子级或执行外部任务;一个 activation 管理器持有两类生命周期。委派工具选择后端,控制工具投递本地后续消息,工作流等待 activation 结果。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 5240eec77a..d3ca9c0530 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -158,11 +158,13 @@ export interface Config { ## `@deepseek-ai/dsh-agent-tool-presentation` - `inject`: `tools` -- `refs`: [`ToolPresentationMode`](subsystems/tools.md) -- `source`: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) +- `source`: [`packages/core/agent-tool-presentation/src/index.ts:26`](../packages/core/agent-tool-presentation/src/index.ts) ```ts config-catalog -/** Plugin config. */ +/** + * Plugin config. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, @@ -171,7 +173,7 @@ export interface Config { * without this row already gets, so an omitted value would mean the row was * composed for nothing. */ - mode: ToolPresentationMode + mode: 'native' | 'ptc' } ``` @@ -3173,7 +3175,7 @@ export interface Config { ## `@deepseek-ai/dsh-spill-policy` - `inject`: `tools` -- `source`: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts) +- `source`: [`packages/spill/spill-policy/src/index.ts:26`](../packages/spill/spill-policy/src/index.ts) ```ts config-catalog /** Optional result-retention budget. */ @@ -3541,7 +3543,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:260`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3752,6 +3754,44 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-tool-executor-native` + +- `inject`: `tools` +- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) +- `source`: [`packages/core/tool-executor-native/src/index.ts:14`](../packages/core/tool-executor-native/src/index.ts) + +```ts config-catalog +/** Native batch concurrency configuration. */ +export interface Config { + /** Maximum overlapping concurrency-safe calls in one batch. */ + maxParallelToolCalls: Volatile +} +``` + + + + + +## `@deepseek-ai/dsh-tool-executor-ptc` + +- `inject`: `tools` · `systemPrompt` · `ptcRuntime` +- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) +- `source`: [`packages/core/tool-executor-ptc/src/index.ts:19`](../packages/core/tool-executor-ptc/src/index.ts) + +```ts config-catalog +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe calls in one model batch. */ + maxParallelToolCalls: Volatile + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls?: number +} +``` + + @@ -4214,43 +4254,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-tools` - -- `inject`: `systemPrompt` -- `source`: [`packages/core/tools/src/index.ts:675`](../packages/core/tools/src/index.ts) - -```ts config-catalog -/** Plugin config: how the registered tools are presented to the model. */ -export interface Config { - /** - * Model presentation. `native` (default) sends every visible schema; `ptc` - * sends only `run_code` plus a generated SDK prompt and collapses the - * executor to the same surface (a model-direct call may only name - * `run_code`; `run_code` SDK sub-dispatches keep every visible tool). - * PTC mode requires a `ctx.ptcRuntime` whose `language` - * has a registered SDK renderer (TypeScript or Python) and fails prompt - * assembly when it is absent or has no renderer. Under `ptc`, native names - * in `toolOrder` are invalid. - */ - mode?: ToolPresentationMode - /** - * Concurrency cap for a `run_code` program's overlapping sub-calls - * (default 10, the loop scheduler's own default). Sub-calls follow the - * native scheduling contract — only calls whose tools classify - * concurrency-safe overlap; exclusive calls form barriers — so `1` - * restores strictly serial dispatch. Must be a positive integer. - */ - maxParallelSubCalls?: number -} - -/** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'ptc' -``` - - @@ -4664,6 +4667,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | | `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | +| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | | `@deepseek-ai/dsh-webhook` | `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` | [`packages/experimental/webhook/src/index.ts`](../packages/experimental/webhook/src/index.ts) | | `@deepseek-ai/dsh-workspace` | `storageDomain` · `sessionPersistence` | [`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts) | @@ -4759,6 +4763,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) | | `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) | | `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) | +| `@deepseek-ai/dsh-tool-testkit` | — | [`packages/test-support/tool-testkit/src/index.ts`](../packages/test-support/tool-testkit/src/index.ts) | | `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) | | `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) | | `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bf66626c51..92f4cb88fe 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -160,11 +160,13 @@ export interface Config { ## `@deepseek-ai/dsh-agent-tool-presentation` - `inject`: `tools` -- `refs`: [`ToolPresentationMode`](subsystems/tools.zh.md) -- `source`: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) +- `source`: [`packages/core/agent-tool-presentation/src/index.ts:26`](../packages/core/agent-tool-presentation/src/index.ts) ```ts config-catalog -/** Plugin config. */ +/** + * Plugin config. + * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. + */ export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, @@ -173,7 +175,7 @@ export interface Config { * without this row already gets, so an omitted value would mean the row was * composed for nothing. */ - mode: ToolPresentationMode + mode: 'native' | 'ptc' } ``` @@ -3175,7 +3177,7 @@ export interface Config { ## `@deepseek-ai/dsh-spill-policy` - `inject`: `tools` -- `source`: [`packages/spill/spill-policy/src/index.ts:25`](../packages/spill/spill-policy/src/index.ts) +- `source`: [`packages/spill/spill-policy/src/index.ts:26`](../packages/spill/spill-policy/src/index.ts) ```ts config-catalog /** Optional result-retention budget. */ @@ -3543,7 +3545,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:260`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ @@ -3754,6 +3756,44 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-tool-executor-native` + +- `inject`: `tools` +- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) +- `source`: [`packages/core/tool-executor-native/src/index.ts:14`](../packages/core/tool-executor-native/src/index.ts) + +```ts config-catalog +/** Native batch concurrency configuration. */ +export interface Config { + /** Maximum overlapping concurrency-safe calls in one batch. */ + maxParallelToolCalls: Volatile +} +``` + + + + + +## `@deepseek-ai/dsh-tool-executor-ptc` + +- `inject`: `tools` · `systemPrompt` · `ptcRuntime` +- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) +- `source`: [`packages/core/tool-executor-ptc/src/index.ts:19`](../packages/core/tool-executor-ptc/src/index.ts) + +```ts config-catalog +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe calls in one model batch. */ + maxParallelToolCalls: Volatile + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls?: number +} +``` + + @@ -4216,43 +4256,6 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-tools` - -- `inject`: `systemPrompt` -- `source`: [`packages/core/tools/src/index.ts:675`](../packages/core/tools/src/index.ts) - -```ts config-catalog -/** Plugin config: how the registered tools are presented to the model. */ -export interface Config { - /** - * Model presentation. `native` (default) sends every visible schema; `ptc` - * sends only `run_code` plus a generated SDK prompt and collapses the - * executor to the same surface (a model-direct call may only name - * `run_code`; `run_code` SDK sub-dispatches keep every visible tool). - * PTC mode requires a `ctx.ptcRuntime` whose `language` - * has a registered SDK renderer (TypeScript or Python) and fails prompt - * assembly when it is absent or has no renderer. Under `ptc`, native names - * in `toolOrder` are invalid. - */ - mode?: ToolPresentationMode - /** - * Concurrency cap for a `run_code` program's overlapping sub-calls - * (default 10, the loop scheduler's own default). Sub-calls follow the - * native scheduling contract — only calls whose tools classify - * concurrency-safe overlap; exclusive calls form barriers — so `1` - * restores strictly serial dispatch. Must be a positive integer. - */ - maxParallelSubCalls?: number -} - -/** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'ptc' -``` - - @@ -4666,6 +4669,7 @@ export interface Config { | `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | | `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | +| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) | | `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) | | `@deepseek-ai/dsh-webhook` | `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` | [`packages/experimental/webhook/src/index.ts`](../packages/experimental/webhook/src/index.ts) | | `@deepseek-ai/dsh-workspace` | `storageDomain` · `sessionPersistence` | [`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts) | @@ -4761,6 +4765,7 @@ export interface Config { | `@deepseek-ai/dsh-session-title-llm` | — | [`packages/session/session-title-llm/src/index.ts`](../packages/session/session-title-llm/src/index.ts) | | `@deepseek-ai/dsh-ssh-helper-runtime` | — | [`packages/ssh/ssh-helper-runtime/src/index.ts`](../packages/ssh/ssh-helper-runtime/src/index.ts) | | `@deepseek-ai/dsh-timeout` | — | [`packages/util/timeout/src/index.ts`](../packages/util/timeout/src/index.ts) | +| `@deepseek-ai/dsh-tool-testkit` | — | [`packages/test-support/tool-testkit/src/index.ts`](../packages/test-support/tool-testkit/src/index.ts) | | `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) | | `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) | | `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) | diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index a1285e63ed..f0c07b4741 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -37,12 +37,13 @@ export function apply(ctx: Context) { // the model. ToolCallId brands the correlation id a provider would issue. void (async () => { const result = await ctx.tools.execute({ - callId: brandString('demo-1'), - name: 'greet', - arguments: { name: 'Cordis' }, + calls: [{ + type: 'tool-call', id: brandString('demo-1'), + name: 'greet', arguments: JSON.stringify({ name: 'Cordis' }), + }], signal: new AbortController().signal, }) - console.log('tool replied:', JSON.stringify(result.content)) + console.log('tool replied:', JSON.stringify(result.results[0]?.result.content)) })() } ``` diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 30c91c8e40..b12aae828a 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -37,12 +37,13 @@ export function apply(ctx: Context) { // the model. ToolCallId brands the correlation id a provider would issue. void (async () => { const result = await ctx.tools.execute({ - callId: brandString('demo-1'), - name: 'greet', - arguments: { name: 'Cordis' }, + calls: [{ + type: 'tool-call', id: brandString('demo-1'), + name: 'greet', arguments: JSON.stringify({ name: 'Cordis' }), + }], signal: new AbortController().signal, }) - console.log('tool replied:', JSON.stringify(result.content)) + console.log('tool replied:', JSON.stringify(result.results[0]?.result.content)) })() } ``` diff --git a/docs/defensive-patterns/package-surface.i18n.yaml b/docs/defensive-patterns/package-surface.i18n.yaml index 31367ff0de..8d5c9525ed 100644 --- a/docs/defensive-patterns/package-surface.i18n.yaml +++ b/docs/defensive-patterns/package-surface.i18n.yaml @@ -12,8 +12,8 @@ en: bf79c6acf35c90a5 zh: 9f2ce6c7f6493b17 /package-surface-rules/package-json-exports-uses-the-standard-key-set: - en: 3604bf435be1c430 - zh: 169621c8a0c2dc4c + en: 8a2a50f2876b6fc9 + zh: ca693a3bf91e16f9 /package-surface-rules/dependency-declarations-and-version-consistency-are-enforced-by-the-dependency-scan: en: e68bfcde70f845b4 zh: 73e5c4fb067c4fa1 diff --git a/docs/defensive-patterns/package-surface.md b/docs/defensive-patterns/package-surface.md index 0ca52f1b03..a01b50aa5d 100644 --- a/docs/defensive-patterns/package-surface.md +++ b/docs/defensive-patterns/package-surface.md @@ -76,8 +76,9 @@ A hand-written `external` list — and a `tsconfig.foo.json` invented beside it A `package.json` `exports` map uses only the standard key set, in which every key already has one meaning across the repository, and a package never opens a key for its own local purpose. A key that only a handful of packages use without a documenting owner is not a precedent: formalizing a key, including one of the undocumented keys below, means changing the owning document and the check in the same change. The small shared key set is what lets a consumer infer a key's purpose from its name and lets a scan judge the export map at all. -- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. +- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./testing` (shared test fixtures at `lib/testing.js` or `lib/types/testing.js`, with declarations at `lib/types/testing.d.ts`), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. - Keys outside that set (`./brand` in 5 packages, `./worker` in 4, `./startup`, `./runner`, and `./protocol` in 2 each, plus many one-off keys) have no documented meaning; they are pending review, not a pattern to copy. +- `dsh-tools` additionally exposes `./pipeline` for reusable invocation stages and batch scheduling. Its main and pipeline bundles share runtime chunks to preserve error-class identity; workspace constraints check the export targets and publication files. - Judged condition: does a change add an export key outside the standard set? - Exception: none by default; a new key changes the owning document and the check first, with the domain owner's confirmation. - Recompute the distribution: diff --git a/docs/defensive-patterns/package-surface.zh.md b/docs/defensive-patterns/package-surface.zh.md index 09f861ba9f..362a4a9385 100644 --- a/docs/defensive-patterns/package-surface.zh.md +++ b/docs/defensive-patterns/package-surface.zh.md @@ -76,8 +76,9 @@ import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' `package.json` 的 `exports` 只使用标准键集合,集合中每个键在全仓已经只有一个含义,包不得为自己的局部用途开键。只有少数几个包在用、又没有文档正家的键不构成先例:把某个键正式化(包括下面那些未记录在案的键)意味着在同一次改动里改正家文档与检查。键集合小且共享,消费者才能从键名推断用途,扫描也才有可能判定导出映射。 -- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 +- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./testing`(共享测试 fixture,位于 `lib/testing.js` 或 `lib/types/testing.js`,声明位于 `lib/types/testing.d.ts`)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 - 集合之外的键(`./brand` 5 个包、`./worker` 4 个、`./startup`、`./runner`、`./protocol` 各 2 个,另有大量一次性键)没有文档含义,属待核项,不是可照抄的先例。 +- `dsh-tools` 另行提供 `./pipeline`,用于复用调用阶段与批量调度。主入口与 pipeline bundle 共享运行时 chunk,以保持错误类的身份一致;工作区检查验证导出目标与发布文件。 - 判定条件:改动是否新增了标准集合以外的 export 键? - 例外:无默认例外;新增键须先改正家文档与检查,并取得对应领域专家确认。 - 复算分布: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 659b9d53a5..15c5bfcef1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -64,7 +64,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -74,12 +74,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../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:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/types.ts:46`](../packages/core/tool-executor-ptc/src/types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../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:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 19d1da2409..b0c5eb6c26 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -66,7 +66,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -76,12 +76,12 @@ | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:158`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference), [`working-directory`](../packages/session/working-directory) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | -| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:209`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:165`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | -| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:177`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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:154`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../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:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | +| `tools/change` | `emit` | [`packages/core/tools/src/events.ts:56`](../packages/core/tools/src/events.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | +| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/types.ts:46`](../packages/core/tool-executor-ptc/src/types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../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:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `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) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 00bc4da738..56dea3a1f7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -46,6 +46,8 @@ flowchart TD pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] + pkg_tool_executor_native["tool-executor-native"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_tools["tools"] end subgraph group_goal["packages/goal"] @@ -436,6 +438,7 @@ flowchart TD pkg_loader_smoke["loader-smoke"] pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] + pkg_tool_testkit["tool-testkit"] end subgraph group_typert["packages/typert"] pkg_typert_generator["typert-generator"] @@ -715,14 +718,10 @@ flowchart TD pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm - pkg_tools --> pkg_ptc_runtime - pkg_tools --> pkg_sandbox - pkg_tools --> pkg_sandbox_policy pkg_tools --> pkg_scope pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval - pkg_tools --> pkg_working_directory pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm @@ -810,6 +809,19 @@ flowchart TD pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_agent_tool_presentation --> pkg_tools + pkg_tool_executor_native --> pkg_llm + pkg_tool_executor_native --> pkg_scope + pkg_tool_executor_native --> pkg_tools + pkg_tool_executor_ptc --> pkg_agent + pkg_tool_executor_ptc --> pkg_llm + pkg_tool_executor_ptc --> pkg_ptc_runtime + pkg_tool_executor_ptc --> pkg_sandbox + pkg_tool_executor_ptc --> pkg_sandbox_policy + pkg_tool_executor_ptc --> pkg_scope + pkg_tool_executor_ptc --> pkg_session + pkg_tool_executor_ptc --> pkg_tools + pkg_tool_executor_ptc --> pkg_user_approval + pkg_tool_executor_ptc --> pkg_working_directory pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm @@ -1033,6 +1045,8 @@ flowchart TD pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_testkit --> pkg_llm + pkg_tool_testkit --> pkg_tools pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_jobs pkg_tool_workflow --> pkg_llm @@ -1169,6 +1183,7 @@ flowchart TD pkg_spill_policy --> pkg_session pkg_spill_policy --> pkg_spill pkg_spill_policy --> pkg_token_meter + pkg_spill_policy --> pkg_tool_executor_ptc pkg_spill_policy --> pkg_tools pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1647,7 +1662,7 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | @@ -1666,6 +1681,8 @@ flowchart TD | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | +| [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`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), [`working-directory`](../packages/session/working-directory) | | [`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), [`working-directory`](../packages/session/working-directory) | @@ -1708,6 +1725,7 @@ flowchart TD | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tool-testkit`](../packages/test-support/tool-testkit) | `test-support` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | @@ -1728,7 +1746,7 @@ flowchart TD | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`fs-local`](../packages/fs/fs-local), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index c452bebf03..2d5e82b606 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -48,6 +48,8 @@ flowchart TD pkg_scope["scope"] pkg_session["session"] pkg_system_prompt["system-prompt"] + pkg_tool_executor_native["tool-executor-native"] + pkg_tool_executor_ptc["tool-executor-ptc"] pkg_tools["tools"] end subgraph group_goal["packages/goal"] @@ -438,6 +440,7 @@ flowchart TD pkg_loader_smoke["loader-smoke"] pkg_remote_mock["remote-mock"] pkg_session_snapshot["session-snapshot"] + pkg_tool_testkit["tool-testkit"] end subgraph group_typert["packages/typert"] pkg_typert_generator["typert-generator"] @@ -717,14 +720,10 @@ flowchart TD pkg_llm_deepseek_account --> pkg_timeout pkg_tools --> pkg_agent pkg_tools --> pkg_llm - pkg_tools --> pkg_ptc_runtime - pkg_tools --> pkg_sandbox - pkg_tools --> pkg_sandbox_policy pkg_tools --> pkg_scope pkg_tools --> pkg_session pkg_tools --> pkg_system_prompt pkg_tools --> pkg_user_approval - pkg_tools --> pkg_working_directory pkg_command_goal --> pkg_commands pkg_command_goal --> pkg_goal pkg_command_goal --> pkg_llm @@ -812,6 +811,19 @@ flowchart TD pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_agent_tool_presentation --> pkg_tools + pkg_tool_executor_native --> pkg_llm + pkg_tool_executor_native --> pkg_scope + pkg_tool_executor_native --> pkg_tools + pkg_tool_executor_ptc --> pkg_agent + pkg_tool_executor_ptc --> pkg_llm + pkg_tool_executor_ptc --> pkg_ptc_runtime + pkg_tool_executor_ptc --> pkg_sandbox + pkg_tool_executor_ptc --> pkg_sandbox_policy + pkg_tool_executor_ptc --> pkg_scope + pkg_tool_executor_ptc --> pkg_session + pkg_tool_executor_ptc --> pkg_tools + pkg_tool_executor_ptc --> pkg_user_approval + pkg_tool_executor_ptc --> pkg_working_directory pkg_tool_goal --> pkg_agent pkg_tool_goal --> pkg_goal pkg_tool_goal --> pkg_llm @@ -1035,6 +1047,8 @@ flowchart TD pkg_llm_replay --> pkg_deepseek_llm_api_extensions pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_tool_testkit --> pkg_llm + pkg_tool_testkit --> pkg_tools pkg_tool_workflow --> pkg_agent pkg_tool_workflow --> pkg_jobs pkg_tool_workflow --> pkg_llm @@ -1171,6 +1185,7 @@ flowchart TD pkg_spill_policy --> pkg_session pkg_spill_policy --> pkg_spill pkg_spill_policy --> pkg_token_meter + pkg_spill_policy --> pkg_tool_executor_ptc pkg_spill_policy --> pkg_tools pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1649,7 +1664,7 @@ flowchart TD | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) | -| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | +| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | @@ -1668,6 +1683,8 @@ flowchart TD | [`terminal-bash`](../packages/terminal/terminal-bash) | `terminal` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`terminal`](../packages/terminal/terminal), [`working-directory`](../packages/session/working-directory) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-presentation`](../packages/core/agent-tool-presentation) | `core` | [`tools`](../packages/core/tools) | +| [`tool-executor-native`](../packages/core/tool-executor-native) | `core` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`tool-executor-ptc`](../packages/core/tool-executor-ptc) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`working-directory`](../packages/session/working-directory) | | [`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), [`working-directory`](../packages/session/working-directory) | | [`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), [`working-directory`](../packages/session/working-directory) | @@ -1710,6 +1727,7 @@ flowchart TD | [`tool-pwsh-persistent`](../packages/shell/tool-pwsh-persistent) | `shell` | [`agent`](../packages/core/agent), [`output-retention`](../packages/util/output-retention), [`terminal`](../packages/terminal/terminal), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local), [`util-values`](../packages/util/values) | | [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`tool-testkit`](../packages/test-support/tool-testkit) | `test-support` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | @@ -1730,7 +1748,7 @@ flowchart TD | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`fs-local`](../packages/fs/fs-local), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7d6496f3b6..6a6af8dfc4 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -249,11 +249,11 @@ en: 03f36237cbf9e267 zh: 83fe2e69bbbfd3c1 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: - en: 3d5d72b99e702a8c - zh: e8c3b82cd5361c4a + en: e808d7ba03c24dad + zh: c67b9189e60e4986 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-start-log-only: - en: de38cf70d81ad7c3 - zh: dd0a2a8be099271d + en: 5ae8f1bd938d0cdb + zh: 8f3fee21c023a246 /session-persistence-event-catalog/events/tool/tool-result-surface: en: 283e3cf1fdd57f05 zh: 0bc3a15ae4f47aaf @@ -1161,14 +1161,14 @@ en: bb11552744e477b8 zh: afb881f3ebf97e68 /session-persistence-event-catalog/resolved-persistence-types/principal: - en: d1e45e03f161dc7c - zh: 4f80af59ca4967c5 + en: 6a590e6880db0242 + zh: 2078c7e558a4cfad /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: - en: 81e66c5a07e17dd6 - zh: 0ecf3cfceba14d2b + en: cf94e6efcd7b19ba + zh: a66d50affd2b6f98 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatchstarteventdata: - en: fbbf650635960ec8 - zh: 9457953f4cbe07c6 + en: 4ba5104118842c3f + zh: d9b487902f8c4f47 /session-persistence-event-catalog/resolved-persistence-types/reasoningblock: en: 07e04c3e336f880f zh: 6f8986ede9994fa1 @@ -1281,11 +1281,11 @@ en: 036b2595215410bb zh: 41792808ebac1fa3 /session-persistence-event-catalog/resolved-persistence-types/toolremovalblock: - en: f43a635a14568780 - zh: 8d22226b045122eb + en: fdac0df45529e14c + zh: c5e38de83a0e0a42 /session-persistence-event-catalog/resolved-persistence-types/toolrestriction: - en: d7d3c0238d9b373e - zh: f49d93ee54e7a780 + en: 9d64c653e33bf2b6 + zh: 7bb427b48756f30f /session-persistence-event-catalog/resolved-persistence-types/toolresultmessage: en: 2258683c3cfa4ce3 zh: 5663aa76c911761b @@ -1440,8 +1440,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 404a76066a59f3c6 - zh: ec9d510bdbac9786 + en: 3f78ce454b333a0a + zh: 3c414c1510b59caf /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1671,8 +1671,8 @@ en: c53129cf7214286c zh: 8c5183b6dabbf714 /session-persistence-event-catalog/resolved-persistence-types/kind-ptc-mode: - en: 8de77980e11a0562 - zh: 6de199e70a666a71 + en: 4ed6a03496090056 + zh: c040b7247425c57b /session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder: en: e3a0ef98a197374e zh: 95e9363529a62cc3 @@ -1827,8 +1827,8 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 809a23c25ded8497 - zh: e9cd23e281f8d31f + en: 4fde95c569339141 + zh: 6fa54e19c24ab7f3 /session-persistence-event-catalog/resolved-persistence-types/kind-user: en: 9b6318ab4d7eba12 zh: f475439ef40b2681 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index ec7e3240da..ce5ec7a369 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1116,7 +1116,7 @@ Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/ 'tool/ptc-dispatch': PtcDispatchEventData ``` -Source: [`packages/core/tools/src/types.ts:61`](../packages/core/tools/src/types.ts) +Source: [`packages/core/tool-executor-ptc/src/session-types.ts:61`](../packages/core/tool-executor-ptc/src/session-types.ts) @@ -1139,7 +1139,7 @@ Source: [`packages/core/tools/src/types.ts:61`](../packages/core/tools/src/types 'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` -Source: [`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types.ts) +Source: [`packages/core/tool-executor-ptc/src/session-types.ts:45`](../packages/core/tool-executor-ptc/src/session-types.ts) @@ -4434,7 +4434,7 @@ Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protoc - + @@ -4442,7 +4442,7 @@ Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protoc SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` -Sources: [`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/tool-executor-ptc/src/session-types.ts:21`](../packages/core/tool-executor-ptc/src/session-types.ts) | Property | Presence | Type | |---|---|---| @@ -4458,7 +4458,7 @@ Sources: [`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/type - + @@ -4466,7 +4466,7 @@ Sources: [`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/type SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f` -Sources: [`packages/core/tools/src/types.ts:12`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/tool-executor-ptc/src/session-types.ts:12`](../packages/core/tool-executor-ptc/src/session-types.ts) | Property | Presence | Type | |---|---|---| @@ -5177,7 +5177,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts) - + @@ -5185,7 +5185,7 @@ Sources: [`packages/llm/llm/src/types.ts:127`](../packages/llm/llm/src/types.ts) SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -Sources: [`packages/core/tools/src/index.ts:701`](../packages/core/tools/src/index.ts) +Sources: [`packages/core/tools/src/contracts.ts:326`](../packages/core/tools/src/contracts.ts) | Property | Presence | Type | |---|---|---| @@ -5971,7 +5971,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/session-types.ts:27`](../packages/core/tool-executor-ptc/src/session-types.ts) | Property | Presence | Type | |---|---|---| @@ -6952,7 +6952,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/ SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -Sources: [`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:53`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -7534,7 +7534,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/index.ts) +Sources: [`packages/core/tools/src/index.ts:17`](../packages/core/tools/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 15d1af1d58..1ecbff73d4 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1118,7 +1118,7 @@ export type SessionEvent = { 'tool/ptc-dispatch': PtcDispatchEventData ``` -来源:[`packages/core/tools/src/types.ts:61`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/session-types.ts:61`](../packages/core/tool-executor-ptc/src/session-types.ts) @@ -1141,7 +1141,7 @@ export type SessionEvent = { 'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` -来源:[`packages/core/tools/src/types.ts:45`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/session-types.ts:45`](../packages/core/tool-executor-ptc/src/session-types.ts) @@ -4436,7 +4436,7 @@ SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` - + @@ -4444,7 +4444,7 @@ SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` -来源:[`packages/core/tools/src/types.ts:21`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/session-types.ts:21`](../packages/core/tool-executor-ptc/src/session-types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4460,7 +4460,7 @@ SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` - + @@ -4468,7 +4468,7 @@ SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f` -来源:[`packages/core/tools/src/types.ts:12`](../packages/core/tools/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/session-types.ts:12`](../packages/core/tool-executor-ptc/src/session-types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5179,7 +5179,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` - + @@ -5187,7 +5187,7 @@ SHA-256: `43e311235aa537df1bacdd92d144df8a952337a76e9ac892cdf670f1a797bad8` SHA-256: `3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f` -来源:[`packages/core/tools/src/index.ts:701`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/contracts.ts:326`](../packages/core/tools/src/contracts.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5973,7 +5973,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tools/src/types.ts:27`](../packages/core/tools/src/types.ts) +来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/session-types.ts:27`](../packages/core/tool-executor-ptc/src/session-types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6954,7 +6954,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812` SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -来源:[`packages/core/tools/src/ptc.ts:15`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:53`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -7536,7 +7536,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -来源:[`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:17`](../packages/core/tools/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index a77fe9f24b..0a8b98c954 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23126,10 +23126,10 @@ { "digest": "2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f", "names": [ - "packages/core/tools/src/types.ts#PtcDispatchStartEventData" + "packages/core/tool-executor-ptc/src/session-types.ts#PtcDispatchStartEventData" ], "sources": [ - "packages/core/tools/src/types.ts:12" + "packages/core/tool-executor-ptc/src/session-types.ts:12" ] }, { @@ -23313,10 +23313,10 @@ { "digest": "3cfc3a56502da1f8c6153c2c56657bab4b749056968634dd0991d325ec1c919f", "names": [ - "packages/core/tools/src/index.ts#ToolRestriction" + "packages/core/tools/src/contracts.ts#ToolRestriction" ], "sources": [ - "packages/core/tools/src/index.ts:701" + "packages/core/tools/src/contracts.ts:326" ] }, { @@ -24571,7 +24571,7 @@ "digest": "8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543", "names": [], "sources": [ - "packages/core/tools/src/index.ts:35" + "packages/core/tools/src/index.ts:17" ] }, { @@ -24892,7 +24892,7 @@ "names": [], "sources": [ "packages/core/session/src/types.ts:384", - "packages/core/tools/src/types.ts:27" + "packages/core/tool-executor-ptc/src/session-types.ts:27" ] }, { @@ -25472,10 +25472,10 @@ { "digest": "cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6", "names": [ - "packages/core/tools/src/types.ts#PtcDispatchEventData" + "packages/core/tool-executor-ptc/src/session-types.ts#PtcDispatchEventData" ], "sources": [ - "packages/core/tools/src/types.ts:21" + "packages/core/tool-executor-ptc/src/session-types.ts:21" ] }, { @@ -25594,7 +25594,7 @@ "digest": "dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd", "names": [], "sources": [ - "packages/core/tools/src/ptc.ts:15" + "packages/core/tool-executor-ptc/src/types.ts:53" ] }, { diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 32a5981c35..b05b723c74 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -14,9 +14,12 @@ /tools/toolrestriction-one-scope-s-live-filter-over-what-it-inherits: en: 8c3fc4ca26f7e314 zh: 2e27263cb25243b9 +/tools/executors-and-raw-call-batches: + en: 14522295539ea2fb + zh: 572c462f2c6b70ad /tools/execution-extensible-waterfalls-plus-monotonic-policy: - en: dcac8fa88ef3e719 - zh: aae2f4ce8852d8d8 + en: 356bf8128685d0fe + zh: a28541da29c3deed /tools/the-enforced-raw-json-schema-subset: en: 28e354bfa2f9cebb zh: be1f75abccf8afdf diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 585ce1582a..a43e3f29e0 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -30,7 +30,7 @@ interface ToolDefinition extends ToolSchema { /** * Run one accepted call and return only its canonical lossless-JSON value. * Async work must observe or forward `exec.signal` and settle only after its - * owned work reaches quiescence. The registry preserves caller cancellation + * owned work reaches quiescence. The executor preserves caller cancellation * through around-dispatch signal replacement and does not abandon this * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. @@ -49,12 +49,12 @@ interface ToolDefinition extends ToolSchema { */ projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined /** - * Synchronous last-mile transform for model-facing content. The registry + * Synchronous last-mile transform for model-facing content. The executor * snapshots this callback when execution starts and invokes it exactly once * for every normalized outcome, including pipeline failures that bypass * `tools/post-execute`, immediately before lossless materialization. * Returning `undefined` preserves the content; every other result field - * remains registry-owned. The callback must be total and must not throw. + * remains executor-owned. The callback must be total and must not throw. * @param exec - immutable execution identity and arguments. * @param result - complete normalized outcome before materialization. * @returns replacement content, or `undefined` to preserve it. @@ -177,9 +177,91 @@ interface ToolRestriction { } ``` +## Executors and raw call batches + +`ctx.tools.execute()` delegates a complete raw model-call batch to the nearest scoped executor. The service selects the current provider when each batch starts; prompt assembly returns only model input. `agent` is absent for standalone service calls; `log` is absent for programmatic calls outside a model step, such as initialization hooks. An Agent without `log` still scopes tool access but produces no root call/result events. Tool-owned events may still use its Session. + +```ts type-equiv +/** Model-call schemas produced by an executor. */ +type ToolModelInput = Pick +``` + +```ts type-equiv +/** Raw model calls, with optional Agent ownership and model-step logging. */ +interface ToolBatchInput { + readonly calls: readonly ToolCallBlock[] + readonly signal: AbortSignal + /** + * Positive integer overriding the executor's concurrency limit for this batch. + * Applies to concurrency-safe root calls; program subcall limits remain separate. + * Undefined uses the executor's configured limit. + */ + readonly maxParallelToolCalls?: number + /** + * Owning Agent supplies scoped tools and execution context, including outside a turn. + * Undefined for standalone calls outside an Agent, such as a service invocation. + */ + readonly agent?: Agent + /** + * Committed model step receiving root call/result events; requires `agent`. + * Undefined when `agent` is absent or for calls outside a model response, such + * as session initialization hooks. Without `log`, root call/result events are + * omitted while `agent`, when provided, still supplies scope and execution context. + */ + readonly log?: { + readonly turn: number + readonly step: number + } +} +``` + +```ts type-equiv +/** One settled result correlated with its original model call. */ +interface ToolCallResult { + readonly callId: ToolCallId + readonly result: ToolExecutionResult +} +``` + +```ts type-equiv +/** Model-ordered outcomes and context accepted for the next step. */ +interface ToolBatchResult { + readonly results: readonly ToolCallResult[] + readonly additionalContexts: readonly UserMessage[] + readonly concluded: boolean +} +``` + +```ts type-equiv +/** A plugin owns argument parsing, dispatch, scheduling, cancellation, and logging. */ +interface ToolExecutor { + /** Guidance registered and disposed with this executor; absent means no executor-owned sections. */ + readonly sections?: readonly ToolPromptSection[] + /** + * Describe the model's callable tools. + * @param tools - currently visible capability definitions, after scoped restrictions. + * @returns schemas and names used to validate tool ordering. + */ + describe(tools: readonly ToolDefinition[]): ToolModelInput + /** + * Execute a complete batch. With log, commit root call/result events to the + * Agent's Session; without log, omit those events. Tool-owned events may still + * use the owning Agent's Session. Return + * one outcome per input call in input order, including skipped calls on abort. + * Never settle while started work is still running. Internal failures reject + * with ToolBatchExecutionError after draining, preserving committed outcomes + * and context; callers must stop dependent work and repair missing log results. + * An explicit maxParallelToolCalls takes precedence over executor configuration. + * @param input - original calls, cancellation, optional concurrency override, Agent and logging step. + * @returns settled results, deferred context, and whether a success concludes the turn. + */ + execute(input: ToolBatchInput): Promise +} +``` + ## Execution: extensible waterfalls plus monotonic policy -`ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput` with a required readonly `signal`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `projectContent` → `tools/post-execute` (inspect/replace the result) → optional definition-owned `finalizeContent` → `tools/result` (the immutable authoritative outcome). Only the `tools/execute` view may replace the required signal. The outcome is a `ToolExecutionResult`. +Built-in executors pass parsed `ToolExecutionInput` values to the shared `ToolExecutionPipeline`. The pipeline owns argument snapshots, execution identities, policy waterfalls, dispatch, and final-result notification, returning one `ToolExecutionResult` per invocation. Custom executors can implement their own execution; the public `ToolExecutor` requires only descriptions and batch execution. ```ts type-equiv /** Opaque call identity that permits correlation without exposing mutable execution state. */ @@ -188,8 +270,8 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ```ts type-equiv /** - * Caller-supplied description of one tool call. {@link ToolRuntime.execute} - * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * Parsed input to the shared invocation pipeline. The pipeline + * adds its own token to form a {@link ToolExecution}; * callers do not choose that token. */ interface ToolExecutionInput { @@ -213,7 +295,7 @@ interface ToolExecutionInput { * The token also marks the call as a transport sub-dispatch rather than a * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may * execute a native tool name — a model-direct call (no parent) is denied as - * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. + * `UNKNOWN_TOOL` before the policy pipeline. */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ @@ -225,7 +307,7 @@ A tool body receives the runtime extension. `deferContext()` attaches context to ```ts type-equiv /** - * Runtime context handed to a tool implementation after the registry has + * Runtime context handed to a tool implementation after the executor has * accepted a {@link ToolExecution}. {@link deferContext} attaches context to * this execution's own result — a composite tool ferries nested-dispatch * context back to the outer result, and a leaf tool may mint a fresh @@ -294,16 +376,16 @@ interface PtcDispatchLog { ```ts type-equiv /** - * One pending tool call inside the registry pipeline. Parsed arguments cross + * One pending tool call inside the executor pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity, the caller signal, and the registry-assigned {@link token} are - * readonly. The registry freezes the complete object before `tools/result` + * call identity, the caller signal, and the executor-assigned {@link token} are + * readonly. The executor freezes the complete object before `tools/result` * observers run. */ interface ToolExecution extends ToolExecutionInput { /** Root model-requested call, resolved for every root and nested execution. */ readonly rootCallId: ToolCallId - /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + /** Executor-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } ``` @@ -312,7 +394,7 @@ interface ToolExecution extends ToolExecutionInput { /** * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper * may replace the signal for its delegated lifetime, but it cannot remove it. - * The registry fuses every replacement with the captured caller signal. + * The executor fuses every replacement with the captured caller signal. */ interface ToolDispatchExecution extends Omit { /** Cancellation signal visible to the next wrapper or tool body. */ @@ -501,26 +583,13 @@ The full presentation field docs live in [`packages/core/tools/src/presentation. Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.tools` — `ToolRuntime` +### `ctx.tools` — `ToolsService` -Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. +Concrete tool registry and scoped executor dispatcher. ```ts cordis-catalog -/** - * Present the calling scope's tools in `mode` instead of the deployment - * default. Nearest scope on the chain wins, so a preset's standing - * declaration covers every agent joined under it. - * - * Scoped only, and one declaration per scope: this is how an agent preset - * composes PTC mode agents beside native ones in the same process, and a - * process-global override would be the `mode` config field instead. - * @param mode - the presentation the covered agents' models see. - * @returns the exact disposer that restores the deployment default. - */ -presentAs(mode: ToolPresentationMode): () => void - /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -538,18 +607,6 @@ register(definition: ToolDefinition): () => void */ restrict(filter: ToolRestriction): () => void -/** - * Register a monotonic guard after the extensible `tools/pre-execute` - * waterfall. A plain-context guard applies globally; one registered through - * `agent.ctx` applies only to that agent. Any matching guard may deny by - * returning a reason, while no guard can force-allow a call another guard - * denied. The exact effect disposer is returned for ordered ownership and - * HMR cleanup. - * @param guard - synchronous check; a returned string denies the execution. - * @returns the exact disposer that unregisters the guard. - */ -guard(guard: ToolGuard): () => void - /** * Look up a tool as one scope sees it (scoped * shadows global; a restricted-away global reads as absent). Presenters pass @@ -561,6 +618,13 @@ guard(guard: ToolGuard): () => void */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined +/** + * Read the visible capability definitions for descriptions and program bindings. + * @param scope - viewing Agent or preset; omitted selects the global view. + * @returns definitions after scoped shadowing and restrictions. + */ +definitions(scope?: ScopeKey): readonly ToolDefinition[] + /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. @@ -570,29 +634,33 @@ get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] /** - * Classify a pending call through the caller's visible tool definition. Only - * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or - * throwing classifiers are exclusive. - * @param exec - call name, parsed arguments, and optional agent scope. - * @returns the fail-closed scheduling mode. + * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * @param executor - initialized provider owned by the contributing plugin. + * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ -executionMode(exec: ToolExecutionInput): ToolExecutionMode +setExecutor(executor: ToolExecutor): () => void /** - * Execute through pre-policy, guards, around-dispatch, post-policy, - * definition-owned content finalization, and final notification. Tool and - * listener failures resolve as materialized error results; an invisible tool - * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen - * snapshot final observers receive. Cancellation - * arriving after entry and before final result materialization skips a - * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a - * successful started outcome with `ABORTED`; already-started work is still - * drained and may retain a tool-owned structured error. - * @param exec - the typed same-process call input. The registry assigns its - * correlation token before policy begins. - * @returns the materialized final result. + * Delegate a raw call batch to its Agent's provider or the deployment default. + * Select at dispatch; accepted batches settle through that provider even if it unloads. + * @param input - original calls, optional owning Agent, and optional logging step. + * @returns the selected executor's settled batch; unavailable selections reject. */ -async execute(exec: ToolExecutionInput): Promise +async execute(input: ToolBatchInput): Promise + +/** + * Register a monotonic denial in the calling scope. + * @param guard - evaluated after extensible pre-execute policy by the shared pipeline. + * @returns the effect disposer that removes this guard. + */ +guard(guard: ToolGuard): () => void + +/** + * Check global and inherited guards after pre-execute policy and approval resolve. + * @param exec - invocation whose guards are checked before dispatch. + * @returns the first denial reason, or undefined when no guard denies. + */ +checkGuards(exec: ToolExecution): string | undefined ``` Types: [ScopeKey](scope.md) @@ -607,22 +675,19 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/change` — emit -A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +A tool or executor was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. ```ts cordis-catalog /** - * A tool was registered or unregistered, or a scoped restriction changed - * (the available tool set changed — possibly for one scope only). An - * UNFILTERED registry-subject notification, deliberately not scope-filtered - * dispatch: a global change concerns every agent's next assembly, so a - * scoped listener subscribing here sees every change, not just its own - * scope's. + * A tool or executor was registered or unregistered, or a scoped restriction changed. + * Unfiltered registry notification: scoped listeners receive changes from every + * scope because a global contribution can affect every agent's next assembly. * @mode emit */ 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -641,12 +706,12 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ -'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise +'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -666,12 +731,12 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ -'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise +'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -690,12 +755,12 @@ Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; `cance * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ -'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise +'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -717,12 +782,12 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall */ -'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise ``` Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tool-executor-ptc/src/types.ts`](../../packages/core/tool-executor-ptc/src/types.ts) @@ -738,10 +803,10 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained * @param result - a deep-frozen snapshot of the final returned result. * @mode emit */ -'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined +'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` Types: [Scoped](scope.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 4ee27fcb4d..a0fecf88cc 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -30,7 +30,7 @@ interface ToolDefinition extends ToolSchema { /** * Run one accepted call and return only its canonical lossless-JSON value. * Async work must observe or forward `exec.signal` and settle only after its - * owned work reaches quiescence. The registry preserves caller cancellation + * owned work reaches quiescence. The executor preserves caller cancellation * through around-dispatch signal replacement and does not abandon this * promise, but it cannot hard-kill same-process code. * @param args - losslessly snapshotted, frozen model arguments. @@ -49,12 +49,12 @@ interface ToolDefinition extends ToolSchema { */ projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined /** - * Synchronous last-mile transform for model-facing content. The registry + * Synchronous last-mile transform for model-facing content. The executor * snapshots this callback when execution starts and invokes it exactly once * for every normalized outcome, including pipeline failures that bypass * `tools/post-execute`, immediately before lossless materialization. * Returning `undefined` preserves the content; every other result field - * remains registry-owned. The callback must be total and must not throw. + * remains executor-owned. The callback must be total and must not throw. * @param exec - immutable execution identity and arguments. * @param result - complete normalized outcome before materialization. * @returns replacement content, or `undefined` to preserve it. @@ -177,9 +177,91 @@ interface ToolRestriction { } ``` +## 执行器与原始调用批次 + +`ctx.tools.execute()` 把完整的原始模型调用批次委托给最近作用域的执行器。服务在每个批次开始时选择当前提供者;提示词组装仅返回模型输入。`agent` 在独立服务调用中不存在;`log` 在模型步骤之外的程序调用(例如初始化钩子)中不存在。有 Agent 而没有 `log` 时,调用仍受该 Agent 的工具作用域约束,但不记录根调用/结果事件。工具自身的事件仍可使用 Agent 的 Session。 + +```ts type-equiv +/** Model-call schemas produced by an executor. */ +type ToolModelInput = Pick +``` + +```ts type-equiv +/** Raw model calls, with optional Agent ownership and model-step logging. */ +interface ToolBatchInput { + readonly calls: readonly ToolCallBlock[] + readonly signal: AbortSignal + /** + * Positive integer overriding the executor's concurrency limit for this batch. + * Applies to concurrency-safe root calls; program subcall limits remain separate. + * Undefined uses the executor's configured limit. + */ + readonly maxParallelToolCalls?: number + /** + * Owning Agent supplies scoped tools and execution context, including outside a turn. + * Undefined for standalone calls outside an Agent, such as a service invocation. + */ + readonly agent?: Agent + /** + * Committed model step receiving root call/result events; requires `agent`. + * Undefined when `agent` is absent or for calls outside a model response, such + * as session initialization hooks. Without `log`, root call/result events are + * omitted while `agent`, when provided, still supplies scope and execution context. + */ + readonly log?: { + readonly turn: number + readonly step: number + } +} +``` + +```ts type-equiv +/** One settled result correlated with its original model call. */ +interface ToolCallResult { + readonly callId: ToolCallId + readonly result: ToolExecutionResult +} +``` + +```ts type-equiv +/** Model-ordered outcomes and context accepted for the next step. */ +interface ToolBatchResult { + readonly results: readonly ToolCallResult[] + readonly additionalContexts: readonly UserMessage[] + readonly concluded: boolean +} +``` + +```ts type-equiv +/** A plugin owns argument parsing, dispatch, scheduling, cancellation, and logging. */ +interface ToolExecutor { + /** Guidance registered and disposed with this executor; absent means no executor-owned sections. */ + readonly sections?: readonly ToolPromptSection[] + /** + * Describe the model's callable tools. + * @param tools - currently visible capability definitions, after scoped restrictions. + * @returns schemas and names used to validate tool ordering. + */ + describe(tools: readonly ToolDefinition[]): ToolModelInput + /** + * Execute a complete batch. With log, commit root call/result events to the + * Agent's Session; without log, omit those events. Tool-owned events may still + * use the owning Agent's Session. Return + * one outcome per input call in input order, including skipped calls on abort. + * Never settle while started work is still running. Internal failures reject + * with ToolBatchExecutionError after draining, preserving committed outcomes + * and context; callers must stop dependent work and repair missing log results. + * An explicit maxParallelToolCalls takes precedence over executor configuration. + * @param input - original calls, cancellation, optional concurrency override, Agent and logging step. + * @returns settled results, deferred context, and whether a success concludes the turn. + */ + execute(input: ToolBatchInput): Promise +} +``` + ## 执行:可扩展的 waterfall(瀑布式事件)加单调策略 -`ctx.tools.execute()` 接受由调用方拥有且包含必需 readonly `signal` 的 `ToolExecutionInput`,将其解析后的 JSON 参数一次性物化为流水线拥有的 `ToolExecution`,然后让调用依次经过 `tools/pre-execute`(可重排的 allow/deny/ask waterfall)→ 已注册的单调 guard → `tools/execute`(环绕分派包装层)→ `projectContent` → `tools/post-execute`(检查/替换结果)→ 可选且由定义拥有的 `finalizeContent` → `tools/result`(不可变的权威结果)。只有 `tools/execute` 视图可以替换必需的 signal。最终产出为 `ToolExecutionResult`。 +内置执行器把解析后的 `ToolExecutionInput` 交给共享 `ToolExecutionPipeline`。流水线负责参数快照、执行身份、策略瀑布、工具分发和最终结果通知,每次返回一个 `ToolExecutionResult`。自定义执行器可以独立实现这些行为;公开的 `ToolExecutor` 只要求工具描述和批次执行方法。 ```ts type-equiv /** Opaque call identity that permits correlation without exposing mutable execution state. */ @@ -188,8 +270,8 @@ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ```ts type-equiv /** - * Caller-supplied description of one tool call. {@link ToolRuntime.execute} - * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * Parsed input to the shared invocation pipeline. The pipeline + * adds its own token to form a {@link ToolExecution}; * callers do not choose that token. */ interface ToolExecutionInput { @@ -213,7 +295,7 @@ interface ToolExecutionInput { * The token also marks the call as a transport sub-dispatch rather than a * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may * execute a native tool name — a model-direct call (no parent) is denied as - * `UNKNOWN_TOOL` before the policy pipeline. See {@link ToolRuntime.execute}. + * `UNKNOWN_TOOL` before the policy pipeline. */ readonly parent?: ToolExecutionToken /** Required caller-owned cancellation for this invocation. */ @@ -225,7 +307,7 @@ interface ToolExecutionInput { ```ts type-equiv /** - * Runtime context handed to a tool implementation after the registry has + * Runtime context handed to a tool implementation after the executor has * accepted a {@link ToolExecution}. {@link deferContext} attaches context to * this execution's own result — a composite tool ferries nested-dispatch * context back to the outer result, and a leaf tool may mint a fresh @@ -294,16 +376,16 @@ interface PtcDispatchLog { ```ts type-equiv /** - * One pending tool call inside the registry pipeline. Parsed arguments cross + * One pending tool call inside the executor pipeline. Parsed arguments cross * one lossless-JSON materialization boundary before policy and are deep-frozen; - * call identity, the caller signal, and the registry-assigned {@link token} are - * readonly. The registry freezes the complete object before `tools/result` + * call identity, the caller signal, and the executor-assigned {@link token} are + * readonly. The executor freezes the complete object before `tools/result` * observers run. */ interface ToolExecution extends ToolExecutionInput { /** Root model-requested call, resolved for every root and nested execution. */ readonly rootCallId: ToolCallId - /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ + /** Executor-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken } ``` @@ -312,7 +394,7 @@ interface ToolExecution extends ToolExecutionInput { /** * Around-dispatch view of a {@link ToolExecution}. A `tools/execute` wrapper * may replace the signal for its delegated lifetime, but it cannot remove it. - * The registry fuses every replacement with the captured caller signal. + * The executor fuses every replacement with the captured caller signal. */ interface ToolDispatchExecution extends Omit { /** Cancellation signal visible to the next wrapper or tool body. */ @@ -501,26 +583,13 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - + -### `ctx.tools` — `ToolRuntime` +### `ctx.tools` — `ToolsService` -Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. +Concrete tool registry and scoped executor dispatcher. ```ts cordis-catalog -/** - * Present the calling scope's tools in `mode` instead of the deployment - * default. Nearest scope on the chain wins, so a preset's standing - * declaration covers every agent joined under it. - * - * Scoped only, and one declaration per scope: this is how an agent preset - * composes PTC mode agents beside native ones in the same process, and a - * process-global override would be the `mode` config field instead. - * @param mode - the presentation the covered agents' models see. - * @returns the exact disposer that restores the deployment default. - */ -presentAs(mode: ToolPresentationMode): () => void - /** * Register globally or in the calling agent scope. Scoped tools shadow * globals; duplicates within one layer and the reserved `run_code` name fail. @@ -538,18 +607,6 @@ register(definition: ToolDefinition): () => void */ restrict(filter: ToolRestriction): () => void -/** - * Register a monotonic guard after the extensible `tools/pre-execute` - * waterfall. A plain-context guard applies globally; one registered through - * `agent.ctx` applies only to that agent. Any matching guard may deny by - * returning a reason, while no guard can force-allow a call another guard - * denied. The exact effect disposer is returned for ordered ownership and - * HMR cleanup. - * @param guard - synchronous check; a returned string denies the execution. - * @returns the exact disposer that unregisters the guard. - */ -guard(guard: ToolGuard): () => void - /** * Look up a tool as one scope sees it (scoped * shadows global; a restricted-away global reads as absent). Presenters pass @@ -561,6 +618,13 @@ guard(guard: ToolGuard): () => void */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined +/** + * Read the visible capability definitions for descriptions and program bindings. + * @param scope - viewing Agent or preset; omitted selects the global view. + * @returns definitions after scoped shadowing and restrictions. + */ +definitions(scope?: ScopeKey): readonly ToolDefinition[] + /** * Project visible definitions onto the allowlisted model-facing schema fields, * excluding execution and presentation callbacks. @@ -570,29 +634,33 @@ get(name: string, scope?: ScopeKey): ToolDefinition | undefined schemas(scope?: ScopeKey): ToolSchema[] /** - * Classify a pending call through the caller's visible tool definition. Only - * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or - * throwing classifiers are exclusive. - * @param exec - call name, parsed arguments, and optional agent scope. - * @returns the fail-closed scheduling mode. + * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * @param executor - initialized provider owned by the contributing plugin. + * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ -executionMode(exec: ToolExecutionInput): ToolExecutionMode +setExecutor(executor: ToolExecutor): () => void /** - * Execute through pre-policy, guards, around-dispatch, post-policy, - * definition-owned content finalization, and final notification. Tool and - * listener failures resolve as materialized error results; an invisible tool - * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen - * snapshot final observers receive. Cancellation - * arriving after entry and before final result materialization skips a - * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a - * successful started outcome with `ABORTED`; already-started work is still - * drained and may retain a tool-owned structured error. - * @param exec - the typed same-process call input. The registry assigns its - * correlation token before policy begins. - * @returns the materialized final result. + * Delegate a raw call batch to its Agent's provider or the deployment default. + * Select at dispatch; accepted batches settle through that provider even if it unloads. + * @param input - original calls, optional owning Agent, and optional logging step. + * @returns the selected executor's settled batch; unavailable selections reject. */ -async execute(exec: ToolExecutionInput): Promise +async execute(input: ToolBatchInput): Promise + +/** + * Register a monotonic denial in the calling scope. + * @param guard - evaluated after extensible pre-execute policy by the shared pipeline. + * @returns the effect disposer that removes this guard. + */ +guard(guard: ToolGuard): () => void + +/** + * Check global and inherited guards after pre-execute policy and approval resolve. + * @param exec - invocation whose guards are checked before dispatch. + * @returns the first denial reason, or undefined when no guard denies. + */ +checkGuards(exec: ToolExecution): string | undefined ``` Types: [ScopeKey](scope.zh.md) @@ -607,22 +675,19 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/change` — emit -A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. +A tool or executor was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. ```ts cordis-catalog /** - * A tool was registered or unregistered, or a scoped restriction changed - * (the available tool set changed — possibly for one scope only). An - * UNFILTERED registry-subject notification, deliberately not scope-filtered - * dispatch: a global change concerns every agent's next assembly, so a - * scoped listener subscribing here sees every change, not just its own - * scope's. + * A tool or executor was registered or unregistered, or a scoped restriction changed. + * Unfiltered registry notification: scoped listeners receive changes from every + * scope because a global contribution can affect every agent's next assembly. * @mode emit */ 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -641,12 +706,12 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). * @mode waterfall */ -'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise +'tools/execute'(this: Scoped, exec: ToolDispatchExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -666,12 +731,12 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ -'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise +'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -690,12 +755,12 @@ Allow, deny, cancel, or ask before dispatch. `next()` delegates to allow; `cance * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ -'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise +'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) @@ -717,12 +782,12 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall */ -'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise ``` Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tool-executor-ptc/src/types.ts`](../../packages/core/tool-executor-ptc/src/types.ts) @@ -738,10 +803,10 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained * @param result - a deep-frozen snapshot of the final returned result. * @mode emit */ -'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined +'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` Types: [Scoped](scope.zh.md) -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/events.ts`](../../packages/core/tools/src/events.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 4710703fe5..1204706b45 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: e72ad7af437fa362 - zh: d8f3683a4efe01b0 + en: 9a7a2e1344b75e80 + zh: e5e8857de37f012d /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -60,14 +60,14 @@ en: e713a4a11a15d13c zh: e713a4a11a15d13c /tool-schema-catalog/deepseek-ai-dsh-tool-ask-user/ask-user-question: - en: 055268ba4dcd32f9 - zh: a1a3c4bfcbf698ed -/tool-schema-catalog/deepseek-ai-dsh-tools: - en: 76fbbf0fe38aef10 - zh: 76fbbf0fe38aef10 -/tool-schema-catalog/deepseek-ai-dsh-tools/run-code: - en: a2368a9d8136276e - zh: d1a4918fc45a7c87 + en: 2f86adf8ae823afb + zh: a5dad3d821e75e55 +/tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc: + en: 8c8643ef46fd075f + zh: 8c8643ef46fd075f +/tool-schema-catalog/deepseek-ai-dsh-tool-executor-ptc/run-code: + en: a5d49232a604a2fb + zh: 720040d11a3e31c2 /tool-schema-catalog/deepseek-ai-dsh-plan-mode: en: b500b530845e26a9 zh: b500b530845e26a9 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index cfa5192198..f9d1676beb 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -20,7 +20,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`, `stagehand_extract`, `stagehand_navigate`, `stagehand_observe`, `stagehand_screenshot`, `stagehand_tabs` | `ctx.browserUse`, `ctx.agents`, `ctx.tools`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after an answer or timeout`, `late user/message` | - | ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime (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` (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-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`, `ctx.tools`, `ctx.ptcRuntime`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. | | `@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.workingDirectory`, `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. | | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`, `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. | @@ -570,13 +570,13 @@ Source: [`packages/interaction/tool-ask-user/src/index.ts`](../packages/interact ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely. - + -## `@deepseek-ai/dsh-tools` +## `@deepseek-ai/dsh-tool-executor-ptc` ### `run_code` -Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the program to execute, and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Curate the program output. Image-bearing subtool results are attached after the run. +Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return is program output — curate it. Image-bearing subtool results are attached after the run. The program is the body of an async TypeScript function; only erasable syntax is accepted, with no enum or namespaces. Type annotations are advisory. Top-level await and return are supported. Emit output with console.log(...) or return a lossless JSON value. Only printed values and the returned value become program output. Each call runs in a fresh Node process. Node APIs are available through await import(...). Relative paths use the supplied working directory; process.env starts empty. Direct filesystem access follows this execution's sandbox policy. Each program starts in the Session's current directory. Running programs keep their initial directory. A sandbox escalation approves this complete program for one execution only. Nested tools retain their own policies and approvals. Request wider access only after evidence of a denial. Earlier effects may already have completed: inspect them before explicitly retrying. Programs are never replayed automatically. ```json { @@ -584,7 +584,7 @@ Execute a TypeScript program against the available tools. Takes two required arg "properties": { "code": { "type": "string", - "description": "The TypeScript program to execute, following this tool's execution instructions." + "description": "The program: the body of an async TypeScript function." }, "description": { "type": "string", @@ -592,7 +592,7 @@ Execute a TypeScript program against the available tools. Takes two required arg }, "timeoutMs": { "type": "number", - "description": "Positive elapsed-time budget in milliseconds, capped by the deployment maximum." + "description": "Positive elapsed-time budget in milliseconds, including nested tool and approval waits. Default 120000; capped at 600000. Zero does not disable the deadline." }, "sandbox_permissions": { "type": "string", @@ -614,9 +614,9 @@ Execute a TypeScript program against the available tools. Takes two required arg } ``` -Source: [`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) +Source: [`packages/core/tool-executor-ptc/src/ptc.ts`](../packages/core/tool-executor-ptc/src/ptc.ts) -Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (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. +The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index fd304d6ddc..91e4dd57dd 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -24,7 +24,7 @@ | `@deepseek-ai/dsh-mcp-resources` | `list_mcp_resource_templates`, `list_mcp_resources`, `read_mcp_resource` | `ctx.tools`, `ctx.mcpResources` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-experimental-browser-use-stagehand-native` | `stagehand_act`、`stagehand_extract`、`stagehand_navigate`、`stagehand_observe`、`stagehand_screenshot`、`stagehand_tabs` | `ctx.browserUse`、`ctx.agents`、`ctx.tools`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | - | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after an answer or timeout`、`late user/message` | - | ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tool-executor-ptc` | `run_code` | `ctx.workingDirectory (Agent executions)`、`ctx.tools`、`ctx.ptcRuntime`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 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.workingDirectory`、`ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 | | `@deepseek-ai/dsh-tool-present` | `present` | `ctx.workingDirectory`、`ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 | @@ -574,13 +574,13 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才启用前台超时与 pending 结果,同时问题仍可回答;timed 模式内 `timeout: -1` 让本次调用无限期阻塞。 - + -## `@deepseek-ai/dsh-tools` +## `@deepseek-ai/dsh-tool-executor-ptc` ### `run_code` -针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,要执行的程序;以及 `description`,简要说明该程序做什么。请根据系统提示词中的声明,以 `await tools.name(args)` 形式调用工具。请筛选程序输出。含图片的子工具结果会在运行结束后附加。 +针对可用工具执行 TypeScript 程序。接受两个必填参数:`code`,异步函数的函数体(仅限可擦除语法,支持顶层 `await` 和 `return`);以及 `description`,简要说明程序做什么。请按系统提示词中的声明,以 `await tools.name(args)` 调用工具。只有打印或返回的内容构成程序输出,请筛选这些内容。含图片的子工具结果会在运行结束后附加。程序是异步 TypeScript 函数的函数体,只接受可擦除语法,不支持 enum 或 namespace。类型标注只作提示。支持顶层 await 和 return。使用 console.log(...) 或返回无损 JSON 值来输出。只有打印值和返回值成为程序输出。每次调用在新的 Node 进程中运行,可通过 await import(...) 使用 Node API。相对路径以传入的工作目录为准;process.env 初始为空。直接文件系统访问遵循本次执行的沙箱策略。每个程序从 Session 的当前目录启动,运行中保留初始目录。沙箱提权只为本次完整程序执行授权。嵌套工具保留自身策略与审批。只有在获得拒绝证据后才请求更宽访问。先前效果可能已经发生,请在显式重试前检查。程序不会自动重放。 ```json { @@ -588,7 +588,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 "properties": { "code": { "type": "string", - "description": "The TypeScript program to execute, following this tool's execution instructions." + "description": "The program: the body of an async TypeScript function." }, "description": { "type": "string", @@ -596,7 +596,7 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 }, "timeoutMs": { "type": "number", - "description": "Positive elapsed-time budget in milliseconds, capped by the deployment maximum." + "description": "Positive elapsed-time budget in milliseconds, including nested tool and approval waits. Default 120000; capped at 600000. Zero does not disable the deadline." }, "sandbox_permissions": { "type": "string", @@ -618,9 +618,9 @@ ask_user_question 默认保持原有阻塞行为;设置 `mode: timed` 后才 } ``` -来源:[`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) +来源:[`packages/core/tool-executor-ptc/src/ptc.ts`](../packages/core/tool-executor-ptc/src/ptc.ts) -在 `mode: ptc` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 +PTC 执行器只向模型公开 run_code,并在运行时语言生成的 SDK 中声明其他可见能力。程序绑定按提交顺序进入共享的受守卫工具流水线;并行安全的函数体最多重叠 maxParallelSubCalls 个,嵌套结果保持与外层程序关联。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 7269b198f5..0d35b299dd 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 0eb190f00f396176 /tool-execution-pipeline: - en: f2811b12640c6f8e - zh: 9e0323523dcbb04c + en: a1103f6055f81a4b + zh: 6eefe6bfa59b1692 diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index 53b7481183..bdb8152984 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -3,7 +3,7 @@ # Tool Execution Pipeline -This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward. +Built-in executors own raw batch parsing, scheduling, cancellation, and Session call/result logging. This graph shows where their shared invocation pipeline applies policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward. ```mermaid flowchart TD @@ -20,14 +20,15 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/ptc-dispatch"] project["ToolDefinition.projectContent
execution-prepared text and images"] post["tools/post-execute waterfall
accept, block, replace, add context"] - normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] + normalized["Invocation pipeline normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] final["tools/result synchronous notification
frozen authoritative outcome"] context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] - model --> toolCall + executor["Selected ToolExecutor.execute
raw batch, Agent, optional logging step"] + model --> executor --> toolCall toolCall --> presentCall toolCall --> pre pre -->|allow| guards @@ -60,6 +61,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The pipeline losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its ordered sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 89d2ba505e..823c5f62df 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -5,7 +5,7 @@ [English](tool-execution-pipeline.md) | 中文 -此图展示策略、钩子、沙箱、文件系统守卫、结果重写、最终结果观察和 UI 渲染在不改变循环的情况下何时运行。`tools/pre-execute` waterfall(瀑布式事件)首先运行,随后是单调守卫,然后运行 `tools/execute` 和 `tools/post-execute` waterfall;这三个 waterfall 可以改写一次调用。由定义自身控制的 `projectContent` 在执行后策略之前安装已准备内容;`finalizeContent` 和 `tools/result` 在此之后运行。 +内置执行器负责原始批次解析、调度、取消和 Session 调用/结果日志。此图展示其共享调用流水线如何执行策略、钩子、沙箱、文件系统守卫、结果改写、最终结果观测和 UI 呈现,而无需改动循环。`tools/pre-execute` 瀑布先执行,然后是单调守卫,再是 `tools/execute` 和 `tools/post-execute` 瀑布;三个瀑布可以转换调用。定义拥有的 `projectContent` 在后置策略之前安装准备好的内容,`finalizeContent` 和 `tools/result` 在之后执行。 ```mermaid flowchart TD @@ -22,14 +22,15 @@ flowchart TD owned["Tool-owned session events
todo/write, fs/observed, hook/invoked, hook/result, tool/ptc-dispatch"] project["ToolDefinition.projectContent
execution-prepared text and images"] post["tools/post-execute waterfall
accept, block, replace, add context"] - normalized["Registry outer normalization
pipeline/result snapshot throws become isError"] + normalized["Invocation pipeline normalization
pipeline/result snapshot throws become isError"] finalize["ToolDefinition.finalizeContent
last content-only invariant"] final["tools/result synchronous notification
frozen authoritative outcome"] context["Active-batch additionalContexts FIFO
injected user/message after recorded tool results"] toolResult["Session event: tool/result
single model-facing outcome"] allResults["Tool batch settled
recorded tool/result events complete"] presentResult["UI completed card
presentResult(args, result)"] - model --> toolCall + executor["Selected ToolExecutor.execute
raw batch, Agent, optional logging step"] + model --> executor --> toolCall toolCall --> presentCall toolCall --> pre pre -->|allow| guards diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.i18n.yaml new file mode 100644 index 0000000000..ed97c6a3df --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.md +/: + en: 3b1ca9e0f78709e0 + zh: 13f23adcc722f319 +/mount-a-tool-executor: + en: de4970d25f6e3cf0 + zh: 1c4d11dcb0d32322 +/mount-a-tool-executor/change: + en: 2adab658221d48a2 + zh: 91573940e81bcba2 +/mount-a-tool-executor/migration: + en: 411860d06f1013ea + zh: 615e044545d89b55 diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.md new file mode 100644 index 0000000000..aac2697ee9 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Tool execution modes move from dsh-tools configuration into explicit executor plugins." +--- + +# Mount a tool executor + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but no longer accepts `mode` or `maxParallelSubCalls`. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. + +## Migration + +1. Keep the `dsh-tools` row and remove its execution configuration. Mount one native or PTC executor in the intended deployment or preset scope. For shipped profile patches, target the executor row instead of the `tools` row. +2. Move `maxParallelSubCalls` to the PTC executor configuration. PTC still requires a compatible runtime. `agent-loop.maxParallelToolCalls` and saved overrides stay in place with default `10`. The loop forwards the current limit to each batch; live edits apply to the next batch instead of the next tool group. +3. Custom executors implement `describe` and `execute`. Programmatic callers pass raw `calls` and `signal`, optional `agent`, and optional `log: { turn, step }`; read single-call outcomes from `results`. Omit `log` outside a committed model step. +4. Import PTC SDK renderers and `CodeRunFailedError` from `@deepseek-ai/dsh-tool-executor-ptc`. Run a task with the selected interaction mode and confirm its tool results appear in the Session. +5. Import `ToolExecutionPipeline` and `executeToolBatch` from `@deepseek-ai/dsh-tools/pipeline`. Complete calls, including single calls, use batches; custom schedulers call `prepare`, `dispatch`, and `complete` directly. Add `@deepseek-ai/dsh-tool-testkit` as a development dependency for tool fixtures and import them from that package. Neither is exported by the tool registry entry. +6. Import `PtcDispatchLog` from the PTC executor package using `import type`. Detached Session readers use `@deepseek-ai/dsh-tool-executor-ptc/types` for durable dispatch events. Live PTC policy and message-source declarations remain on the executor package entry. diff --git a/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.zh.md new file mode 100644 index 0000000000..f8161ebf9c --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "工具执行模式从 dsh-tools 配置移至显式执行器插件。" +--- + +# 挂载工具执行器 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但不再接受 `mode` 或 `maxParallelSubCalls`。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。 + +## 迁移 + +1. 保留 `dsh-tools` 条目并移除其执行配置。在目标部署或预设作用域挂载一个原生或 PTC 执行器。内置 profile 的补丁应修改执行器条目,而非 `tools` 条目。 +2. 把 `maxParallelSubCalls` 移到 PTC 执行器配置。PTC 仍要求兼容的运行时。`agent-loop.maxParallelToolCalls` 和已保存的覆盖值保留原位,默认值为 `10`。循环向每个批次传入当前上限;实时修改从下一个批次生效,不再从下一个工具组生效。 +3. 自定义执行器实现 `describe` 和 `execute`。程序调用方传入原始 `calls` 和 `signal`,以及可选的 `agent` 和 `log: { turn, step }`;从 `results` 读取单次结果。不属于已提交模型步骤的调用应省略 `log`。 +4. 从 `@deepseek-ai/dsh-tool-executor-ptc` 导入 PTC SDK 渲染器和 `CodeRunFailedError`。用所选交互模式运行任务,确认工具结果出现在 Session 中。 +5. 从 `@deepseek-ai/dsh-tools/pipeline` 导入 `ToolExecutionPipeline` 和 `executeToolBatch`。完整执行(包括单次调用)使用批次;自定义调度器直接调用 `prepare`、`dispatch` 和 `complete`。把 `@deepseek-ai/dsh-tool-testkit` 添加为开发依赖,并从该包导入工具 fixture。工具注册表主入口不再导出这些成员。 +6. 使用 `import type` 从 PTC 执行器包导入 `PtcDispatchLog`。独立 Session 读取者通过 `@deepseek-ai/dsh-tool-executor-ptc/types` 获取持久化分派事件;PTC 实时策略与消息来源声明由执行器包主入口提供。 diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 0bbdb05d37..27e4818160 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -9,8 +9,8 @@ en: b711ded29c7b9a17 zh: cbccba927743f35f /deepseek-ai-dsh-agent-loop/summary: - en: 9256331cd3baf930 - zh: 02b4c10cf3286eb3 + en: b2971780b74fd785 + zh: 05a3043f49fb646d /deepseek-ai-dsh-agent-loop/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -18,14 +18,14 @@ en: 35a960c0100a6aa4 zh: 438e133fb4c24a9e /deepseek-ai-dsh-agent-loop/use-this-package/configure-declarative-agents: - en: 96295035babf4f90 - zh: 8848f9497019ee63 + en: 600bbdfbeb743521 + zh: 7e1dc69e728fa2aa /deepseek-ai-dsh-agent-loop/use-this-package/create-or-resume-agents-programmatically: en: 82ec792637e8e57f zh: e32f8ec5ee17afcf /deepseek-ai-dsh-agent-loop/use-this-package/what-a-step-does: - en: 15ce4783b53532a2 - zh: b04a18337281237a + en: b789acbbfd6a3094 + zh: 85b4d093ae23ce7d /deepseek-ai-dsh-agent-loop/understand-the-implementation: en: ce7e776e9d516bdb zh: 59d2751743db7795 @@ -36,8 +36,8 @@ en: b3b3f0ad930961c3 zh: f281e27136e686bc /deepseek-ai-dsh-agent-loop/understand-the-implementation/source-map: - en: 2a8b93869eb5a2f9 - zh: ddab8e738d188a61 + en: ec2bf804bd1a5439 + zh: 316e767507e859ef /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: en: d079cf181ece9267 zh: 047f76fecfc5497c diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 62dcd35a92..80b8705e0b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-agent-loop` creates fresh agents or resumes persisted sessions, then drives each turn through model requests, streamed responses, tool execution, and durable session history. Mount it for standard agent compositions; declarative entries start agents at boot, while the public `ctx.agents` API supports programmatic creation and resume. `maxParallelToolCalls` limits concurrent parallel-safe calls, and exclusive calls retain ordering. Cancellation preserves streamed text already delivered to the user. Choose a custom `Agent` implementation only when the standard "call model, run tools, repeat" lifecycle is insufficient. +`dsh-agent-loop` creates fresh agents or resumes persisted sessions, then drives each turn through model requests, streamed responses, tool execution, and durable session history. Mount it for standard agent compositions; declarative entries start agents at boot, while the public `ctx.agents` API supports programmatic creation and resume. The selected tool executor owns dispatch, concurrency, cancellation, and tool logging. Cancellation preserves streamed text already delivered to the user. Choose a custom `Agent` implementation only when the standard "call model, run tools, repeat" lifecycle is insufficient. ## Table of Contents @@ -34,7 +34,6 @@ Agents declared in the config start automatically when the plugin loads. Each en ```yaml - name: '@deepseek-ai/dsh-agent-loop' config: - maxParallelToolCalls: 10 agents: - id: 'main' provider: deepseek @@ -45,7 +44,6 @@ Agents declared in the config start automatically when the plugin loads. Each en | Field | Default | Meaning | |---|---|---| -| `maxParallelToolCalls` | `10` | Parallel-safe tool calls in flight per step; `1` is serial | | `agents[].id` | required | Stable label; a fresh session mints `${id}-session-` unless `sessionId` is set | | `agents[].provider` / `agents[].model` | — | Model route; both required before dispatch | | `agents[].reasoningEffort` | — | Non-empty initial reasoning effort; `agent/request` may override it | @@ -54,7 +52,7 @@ Agents declared in the config start automatically when the plugin loads. Each en | `agents[].sessionId` | — | Exact identity: first use creates, a remount resumes materialized history | | `agents[].resumeSessionId` | — | Load this persisted session instead of creating one; mutually exclusive with `sessionId` | -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-loop) lists every accepted field. `maxParallelToolCalls` is a volatile Config field sampled for the next tool group; `agents` remains startup configuration. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-loop) lists every accepted field. `maxParallelToolCalls` remains a volatile positive-integer Config field with default `10`; `1` selects serial execution. The loop passes its current value to each batch's `execute()` call, taking precedence over executor configuration. Live edits apply to the next batch; `agents` remains startup configuration. ### Create or resume agents programmatically @@ -72,7 +70,7 @@ Every inbox mutation commits one normalized `agent/inbox/spliced` event. The pro ### What a step does -Each step sends the session's derived history — with the latest non-empty `system/message` node as the effective prompt, or no system messages when the rendered prompt is empty — and its visible tool schemas; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. Parallel-safe calls may overlap up to `maxParallelToolCalls`; exclusive calls run alone as ordering barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user. +Each step sends the session's derived history — with the latest non-empty `system/message` node as the effective prompt, or no system messages when the rendered prompt is empty — and its visible tool schemas; the model's tool calls run through the guarded tool pipeline and every accepted fact is appended to the session log before the next step derives from it. The loop passes the complete raw call batch to `ctx.tools.execute()`; built-in executors preserve model-order results and exclusive barriers. Cancellation is cooperative: `agent.cancel()` aborts the current activity and, unless `keepInbox` is set, clears pending work; a cancelled stream finalizes the text already delivered to the user. ----- @@ -101,9 +99,7 @@ The loop deep-freezes each derived message identity on its first request and reu | [`src/index.ts`](src/index.ts) | Plugin entry: `AgentLoop` service, config schema, declarative agent startup, factory registration | | [`src/agent.ts`](src/agent.ts) | The concrete `ReactLoopAgent` driver: inbox, turn/step machine, cancellation | | [`src/inbox.ts`](src/inbox.ts) | Package-internal `ReactLoopInbox`: durable projection, structural commands, and loop-only claim state | -| [`src/tool-calls.ts`](src/tool-calls.ts) | Tool scheduling: exclusive barriers and the bounded parallel pool | | [`src/runtime-context.ts`](src/runtime-context.ts) | Per-step runtime-context snapshot handling | -| [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` | ### Creation and teardown diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ee355a328d..64486dccd9 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-agent-loop` 创建全新 agent 或恢复持久化会话,随后通过模型请求、流式响应、工具执行和持久会话历史驱动每个轮次。标准 agent 组合应挂载本包;声明式条目会在启动时启动 agent,公开的 `ctx.agents` API 则支持以编程方式创建和恢复 agent。`maxParallelToolCalls` 限制同时运行的并行安全调用数量,独占调用保留顺序。取消会保留已经流式交付给用户的文本。只有标准的「调用模型、运行工具、重复」生命周期无法满足需求时,才应选择自定义 `Agent` 实现。 +`dsh-agent-loop` 创建全新 agent 或恢复持久化会话,随后通过模型请求、流式响应、工具执行和持久会话历史驱动每个轮次。标准 agent 组合应挂载本包;声明式条目会在启动时启动 agent,公开的 `ctx.agents` API 则支持以编程方式创建和恢复 agent。所选工具执行器负责分发、并发、取消和工具日志。取消会保留已经流式交付给用户的文本。只有标准的「调用模型、运行工具、重复」生命周期无法满足需求时,才应选择自定义 `Agent` 实现。 ## 目录 @@ -34,7 +34,6 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-agent-loop' config: - maxParallelToolCalls: 10 agents: - id: 'main' provider: deepseek @@ -45,7 +44,6 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `maxParallelToolCalls` | `10` | 每个步骤同时在途的并行安全工具调用数;`1` 为串行 | | `agents[].id` | 必填 | 稳定标签;未设置 `sessionId` 时,全新会话会生成 `${id}-session-` | | `agents[].provider` / `agents[].model` | — | 模型路由;分发前两者都必须存在 | | `agents[].reasoningEffort` | — | 非空的初始推理强度;`agent/request` 可以覆盖它 | @@ -54,7 +52,7 @@ kind: "package-reference" | `agents[].sessionId` | — | 确切身份:首次使用创建,重新挂载时恢复已实体化的历史 | | `agents[].resumeSessionId` | — | 加载这个持久化会话而不是创建新会话;与 `sessionId` 互斥 | -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-loop) 列出所有接受的字段。`maxParallelToolCalls` 是在下一个工具组开始时读取的 volatile Config 字段;`agents` 仍是启动配置。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-loop) 列出所有接受的字段。`maxParallelToolCalls` 仍是 volatile 正整数 Config 字段,默认值为 `10`;`1` 表示串行执行。循环在每个批次调用 `execute()` 时传入当前值,其优先级高于执行器配置。实时修改从下一个批次生效;`agents` 仍是启动配置。 ### 以编程方式创建或恢复 agent @@ -72,7 +70,7 @@ const handle = await ctx.agents.create({ ### 一个步骤做什么 -每个步骤都会发送会话的派生历史——最新的非空 `system/message` 节点是有效提示词,渲染提示词为空时则没有系统消息——及其可见工具 schema;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。并行安全调用最多可重叠 `maxParallelToolCalls` 个;独占调用单独运行并构成排序屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。 +每个步骤都会发送会话的派生历史——最新的非空 `system/message` 节点是有效提示词,渲染提示词为空时则没有系统消息——及其可见工具 schema;模型的工具调用经过受守卫的工具流水线,每个被接纳的事实都会在下一步据此派生之前追加到会话日志。循环把完整的原始调用批次传给 `ctx.tools.execute()`;内置执行器保留模型顺序的结果和独占屏障。取消是协作式的:`agent.cancel()` 中止当前活动,并在未设置 `keepInbox` 时清除待处理工作;被取消的流会终结已送达用户的文本。 ----- @@ -101,9 +99,7 @@ const handle = await ctx.agents.create({ | [`src/index.ts`](src/index.ts) | 插件入口:`AgentLoop` 服务、配置 schema、声明式 agent 启动、工厂注册 | | [`src/agent.ts`](src/agent.ts) | 具体 `ReactLoopAgent` 驱动器:收件箱、轮次/步骤状态机、取消 | | [`src/inbox.ts`](src/inbox.ts) | 包内部的 `ReactLoopInbox`:持久投影、结构化命令与仅供循环使用的领取状态 | -| [`src/tool-calls.ts`](src/tool-calls.ts) | 工具调度:独占屏障与有界并行池 | | [`src/runtime-context.ts`](src/runtime-context.ts) | 逐步骤运行时上下文快照处理 | -| [`src/constants.ts`](src/constants.ts) | `DEFAULT_MAX_PARALLEL_TOOL_CALLS` | ### 创建与拆除 diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index a1ca4c3c4e..2a2ed58937 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/README.i18n.yaml @@ -6,22 +6,22 @@ en: 1c67fea11cb4a325 zh: 7dc1256148273b02 /deepseek-ai-dsh-agent-tool-presentation: - en: e3bfa19f60197528 - zh: c8e9adfb80141de2 + en: db5b1de2af3a6f37 + zh: e4b10a1484df8b29 /deepseek-ai-dsh-agent-tool-presentation/summary: - en: 9c67450fb880d639 - zh: 74b88ce5e4ac1e64 + en: ae29d1ab4c22566f + zh: c363959591dce513 /deepseek-ai-dsh-agent-tool-presentation/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-agent-tool-presentation/use-this-package: - en: ed4e4b103ea482bf - zh: 41754979babb3649 + en: 9ad88c3b4d6e389e + zh: f5d78ac15598aabb /deepseek-ai-dsh-agent-tool-presentation/use-this-package/add-the-row-to-a-preset: en: 1550e3dcc5a3aa1e zh: 47b2d1e0e3e3cbf2 /deepseek-ai-dsh-agent-tool-presentation/use-this-package/what-ptc-mode-requires: - en: efaec011a8267e5f + en: d5004b2caa31b245 zh: 32619d7e03c7c2ac /deepseek-ai-dsh-agent-tool-presentation/use-this-package/one-presentation-per-agent: en: 46bf80f051175567 @@ -30,17 +30,17 @@ en: ce7e776e9d516bdb zh: 59d2751743db7795 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/design-concept: - en: 6f8bb6bd853cbc13 - zh: 422fca4b20b79fbd + en: 0fbb3dd32193e54c + zh: 5b3835aef5115bb7 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/source-map: - en: 2a0a0fb2dfdfce75 - zh: c425560fedcdb31a + en: 89ecdb3d5d032e7f + zh: 64ae2feb4344c679 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/behavior-notes: - en: 562acace386b25ca - zh: 3eb362bd2730a64c + en: 7996925328f0e771 + zh: 40146455aac119d2 /deepseek-ai-dsh-agent-tool-presentation/further-exploration: - en: 0f8de64b5862b78e - zh: 4bba0c1ab780aa54 + en: 6a1d0096e4da25e4 + zh: 9d5de770addb69d2 /deepseek-ai-dsh-agent-tool-presentation/model-experience: en: 35621b26757d462e zh: a8869139783ae475 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index 430e272e71..569ee37d63 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -7,9 +7,11 @@ kind: "package-reference" English | [中文](README.zh.md) +> **Deprecated:** For new configurations, mount `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc` directly. Existing configurations remain supported. To migrate, replace this selector row with the corresponding executor row and remove `config.mode`; do not load both in the same scope. + ## Summary -Use `dsh-agent-tool-presentation` in an [agent preset](../../preset/agent-preset-registry/README.md) to fix whether models see every native tool schema or only `run_code` with a generated SDK. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` requires a compatible PTC runtime; a deployment without one rejects the preset at mount time before its first prompt. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. +Existing [agent presets](../../preset/agent-preset-registry/README.md) can keep using `dsh-agent-tool-presentation` to select whether models see every native tool schema or only `run_code` with a generated SDK. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` requires a compatible PTC runtime; a deployment without one rejects the preset at mount time before its first prompt. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. ## Table of Contents @@ -25,7 +27,7 @@ Use `dsh-agent-tool-presentation` in an [agent preset](../../preset/agent-preset ## Use this package -Add this row to an agent preset to fix how every agent joined to that preset sees its tools. `native` presents each visible tool schema as a function definition; `ptc` presents only the `run_code` transport plus a generated SDK and the rule that only `run_code` may be called directly. Agents that declare nothing get the deployment-wide `mode` on the [`dsh-tools`](../tools/README.md) row. +Add this row to an agent preset to fix how every agent joined to that preset sees its tools. `native` presents each visible tool schema as a function definition; `ptc` presents only the `run_code` transport plus a generated SDK and the rule that only `run_code` may be called directly. Agents that declare nothing inherit the nearest executor mounted in their composition. ### Add the row to a preset @@ -43,7 +45,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What PTC mode requires -Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tools`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. +Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tool-executor-ptc`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. ### One presentation per agent @@ -61,17 +63,17 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The tool registry cannot move into a preset: its consumers are all host-plane — the agent loop reads its scheduler, the API proxy reads its presenters, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. What a preset can own is the presentation of that registry. `ctx.tools.presentAs()` declares it for the mounting scope, which is the preset's standing mount, so the declaration covers every agent joined to that preset and a PTC mode preset runs beside native ones in one process. One row per composition, not one per session. +The tool registry cannot move into a preset: its consumers are all host-plane — the agent loop resolves its executor, the API proxy reads its presenters, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. What a preset can own is the presentation of that registry. `ctx.tools.setExecutor()` declares an executor for the mounting scope, which is the preset's standing mount, so the declaration covers every agent joined to that preset and a PTC mode preset runs beside native ones in one process. One row per composition, not one per session. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: `mode` config, `apply` wiring `ctx.tools.presentAs` for the mounting scope | +| [`src/index.ts`](src/index.ts) | Plugin entry: `mode` config, `apply` wiring `ctx.tools.setExecutor` for the mounting scope | ### Behavior notes -`native` applies immediately. A PTC mode instead waits for `ctx.ptcRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-preset-registry` refuses the mount naming this id. `presentAs` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it. +`native` applies immediately. A PTC mode instead waits for `ctx.ptcRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-preset-registry` refuses the mount naming this id. `setExecutor` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it.
@@ -82,7 +84,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane The package-level contract is enough for most consumers; read these when you need the surrounding domain. -- [tools package](../tools/README.md) — the tool presentation modes and `presentAs` API. +- [tools package](../tools/README.md) — the scoped registry and executor API. - [agent-presets package](../../preset/agent-preset-registry/README.md) — how presets compose agents and their standing mounts. - [Node ptc-runtime package](../../ptc-runtime/ptc-runtime-node/README.md) — the TypeScript runtime a PTC mode needs. - [historical PTC mode executor-collapse note](../../../.agents/notes/archived/bug-fix/2026-08-07-ptc-executor-collapse.md) — why the announced and callable surfaces stay the same. diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index a7b63c2311..e30fc01cb5 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -7,9 +7,11 @@ kind: "package-reference" [English](README.md) | 中文 +> **已弃用:** 新配置请直接挂载 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。现有配置仍受支持。迁移时,将本选择器条目替换为对应的 executor 条目,并移除 `config.mode`;不要在同一作用域同时加载两者。 + ## 概述 -在 [agent preset](../../preset/agent-preset-registry/README.zh.md) 中使用 `dsh-agent-tool-presentation`,可固定模型看到全部原生工具 schema,还是只有带生成 SDK 的 `run_code`。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 需要兼容的 PTC 运行时;没有该运行时的部署会在挂载时拒绝 preset,不会等到收到第一条提示词。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 +现有 [agent preset](../../preset/agent-preset-registry/README.zh.md) 可以继续使用 `dsh-agent-tool-presentation`,选择模型看到全部原生工具 schema,还是只有带生成 SDK 的 `run_code`。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 需要兼容的 PTC 运行时;没有该运行时的部署会在挂载时拒绝 preset,不会等到收到第一条提示词。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 ## 目录 @@ -25,7 +27,7 @@ kind: "package-reference" ## 使用本包 -把这一行加入 agent preset,以固定每个加入该 preset 的 agent 看到其工具的方式。`native` 以函数定义的形式呈现每个可见工具 schema;`ptc` 只呈现 `run_code` 传输、一份生成的 SDK 以及「只有 `run_code` 可被直接调用」这条规则。未作声明的 agent 会拿到 [`dsh-tools`](../tools/README.zh.md) 那一行上的部署级 `mode`。 +把这一行加入 agent preset,以固定每个加入该 preset 的 agent 看到其工具的方式。`native` 以函数定义的形式呈现每个可见工具 schema;`ptc` 只呈现 `run_code` 传输、一份生成的 SDK 以及「只有 `run_code` 可被直接调用」这条规则。未作声明的 agent 继承组合中最近的执行器。 ### 把这一行加入 preset @@ -61,17 +63,17 @@ kind: "package-reference" ### 设计理念 -工具注册表搬不进 preset:它的消费方全在宿主平面——agent loop(智能体循环)读它的调度器,API proxy 读它的展示转换器,每个工具插件都往里注册——而一个服务只有在所有消费方一起下沉时才能下沉。preset 能拥有的是这份注册表的呈现方式。`ctx.tools.presentAs()` 为挂载作用域声明它,而挂载作用域就是 preset 的常驻挂载,因此该声明覆盖每个加入该 preset 的 agent,一个 PTC mode preset 可以与多个 native preset 同进程并存。每个组合一行,而不是每个会话一行。 +工具注册表搬不进 preset:它的消费方全在宿主平面——agent loop(智能体循环)读它的调度器,API proxy 读它的展示转换器,每个工具插件都往里注册——而一个服务只有在所有消费方一起下沉时才能下沉。preset 能拥有的是这份注册表的呈现方式。`ctx.tools.setExecutor()` 为挂载作用域声明它,而挂载作用域就是 preset 的常驻挂载,因此该声明覆盖每个加入该 preset 的 agent,一个 PTC mode preset 可以与多个 native preset 同进程并存。每个组合一行,而不是每个会话一行。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`mode` 配置、把 `ctx.tools.presentAs` 接到挂载作用域的 `apply` | +| [`src/index.ts`](src/index.ts) | 插件入口:`mode` 配置、把 `ctx.tools.setExecutor` 接到挂载作用域的 `apply` | ### 行为说明 -`native` 立即生效。PTC 模式则等待 `ctx.ptcRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-preset-registry` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 +`native` 立即生效。PTC 模式则等待 `ctx.ptcRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-preset-registry` 会指名此 id 拒绝挂载。`setExecutor` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。
@@ -82,7 +84,7 @@ kind: "package-reference" 包级约定对大多数消费方已经足够;需要周边领域时再阅读以下页面。 -- [tools 包](../tools/README.zh.md)——工具呈现模式与 `presentAs` API。 +- [tools 包](../tools/README.zh.md)——工具呈现模式与 `setExecutor` API。 - [agent-presets 包](../../preset/agent-preset-registry/README.zh.md)——preset 如何组合 agent 及其常驻挂载。 - [Node ptc-runtime 包](../../ptc-runtime/ptc-runtime-node/README.zh.md)——PTC 模式所需的 TypeScript 运行时。 - [历史PTC mode 执行器塌缩 note](../../../.agents/notes/archived/bug-fix/2026-08-07-ptc-executor-collapse.md)——通告面与可调用面为何保持一致。 diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index a5c3f5913c..637bafaf9a 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -72,8 +72,8 @@ en: 3039bc5e7a796b21 zh: 6b7a9eaf69c9ea53 /deepseek-ai-dsh-system-prompt/model-experience/tool-schemas/what-the-model-sees: - en: a983ef220593cf73 - zh: 28770612ffc1be60 + en: 793f59c178ee2b7e + zh: 871fbaee803335c5 /deepseek-ai-dsh-system-prompt/model-experience/tool-schemas/token-effect: en: f3a7d374c55e1720 zh: 0ee48af678cb875f diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 6c9d7338ec..b554f11205 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -157,7 +157,7 @@ Prefix-stable while identity, persona, variables, section text, and order render #### What the model sees -For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tools), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope })` and `ctx.tools.get(name, scope)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten. +For shipped tools, the model receives the per-agent-visible subset of the [generated tool schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc), ordered by configuration or lexicographically after restrictions and assembly interception. Extensions can contribute additional definitions through the same registry. Sections and schema providers are separate assembly inputs. A restriction does not remove a section registration: tool-guidance plugins use `text({ scope })` and `ctx.tools.get(name, scope)` to return empty text or select applicable fragments. Arbitrary static sections are not automatically rewritten. #### Token effect diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 1807bf0411..739ea878d3 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -157,7 +157,7 @@ You are an AI agent powered by DeepSeek Harness. #### 模型看到什么 -对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope })` 与 `ctx.tools.get(name, scope)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。 +对于已交付工具,模型会收到[生成工具 schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)中对每个 agent 可见的子集;限制与组装拦截完成后,按配置或字典序排列。扩展可以通过同一注册表贡献其他定义。段与 schema 提供方是独立的组装输入。限制不会移除段落注册:工具指导插件通过 `text({ scope })` 与 `ctx.tools.get(name, scope)` 返回空文本或选择适用片段。任意静态段落不会被自动改写。 #### Token 影响 diff --git a/packages/core/tool-executor-native/README.i18n.yaml b/packages/core/tool-executor-native/README.i18n.yaml new file mode 100644 index 0000000000..118a6d84fb --- /dev/null +++ b/packages/core/tool-executor-native/README.i18n.yaml @@ -0,0 +1,46 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/core/tool-executor-native/README.md +/: + en: a167e9f03780ad63 + zh: 2e5dc0bd2d08c46e +/deepseek-ai-dsh-tool-executor-native: + en: 7e09b7d3b4389e09 + zh: 2c774c33639bdd64 +/deepseek-ai-dsh-tool-executor-native/summary: + en: 0ef12f2cb7b8f23e + zh: bc79ea00254bfb21 +/deepseek-ai-dsh-tool-executor-native/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-tool-executor-native/use-this-package: + en: 176f69450bacc28e + zh: fc3c2d876195a4a4 +/deepseek-ai-dsh-tool-executor-native/understand-the-implementation: + en: d005036097c11788 + zh: 65fcaec551dc1697 +/deepseek-ai-dsh-tool-executor-native/further-exploration: + en: e8ae407b0ecdc5d3 + zh: 022a8a714d24da4b +/deepseek-ai-dsh-tool-executor-native/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools: + en: 735cc6f4f3bf4cda + zh: 8dab814b4bba5a7d +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools/what-the-model-sees: + en: e1f3bbf06e45c22b + zh: 490cb060c38ccd4e +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools/token-effect: + en: 81684f49f932e3a3 + zh: 6db2d0a125d990d8 +/deepseek-ai-dsh-tool-executor-native/model-experience/visible-tools/kv-cache-effect: + en: c0d457297034cf64 + zh: 1dfb5c7f83033d92 +/deepseek-ai-dsh-tool-executor-native/known-limitations-and-deferred-work: + en: 4271a88c7d41a6a2 + zh: 99e9db876937a281 +/deepseek-ai-dsh-tool-executor-native/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/core/tool-executor-native/README.md b/packages/core/tool-executor-native/README.md new file mode 100644 index 0000000000..5d6103c5fc --- /dev/null +++ b/packages/core/tool-executor-native/README.md @@ -0,0 +1,98 @@ +--- +description: "Native function calls with scoped tools and bounded batch execution." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-native + +English | [中文](README.zh.md) + +## Summary + +Use this executor when models should call each visible tool directly. Mount it beside the tool registry in the deployment, a preset, or an Agent scope. Parallel-safe calls can overlap; exclusive calls form barriers, and results remain in model order. + +## 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 + +Add this row to a composition with `dsh-tools` mounted. The nearest scoped executor selects tool behavior; one scope permits only one executor. + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-native' + config: + maxParallelToolCalls: 10 +``` + +For Agent steps, configure [agent-loop concurrency](../agent-loop/README.md); the loop always forwards its resolved limit to `execute()`. The executor's `maxParallelToolCalls` supplies the limit only when the caller omits it. This fallback is volatile, defaults to `10`, and is sampled for the next tool group. It must be a positive integer; `1` selects serial execution. + +[Configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-executor-native) + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. + +- [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution. + +
+ +----- + + +## Further Exploration + +- [Tool registry](../tools/README.md) +- [Agent loop](../agent-loop/README.md) +- [Alternative executor](../tool-executor-ptc/README.md) + +----- + + +## Model Experience + +### Visible tools + +#### What the model sees + +Each visible tool’s `name`, `description`, and JSON Schema parameters; no additional prompt section. + +#### Token effect + +Request cost grows with visible definitions; hiding a definition removes its schema cost. + +#### KV Cache effect + +The prefix remains stable while tool descriptions and ordering stay unchanged. + +## Known Limitations and Deferred Work + + + +These constraints apply to accepted calls. + +- Same-process tools must observe cancellation; the executor cannot forcibly terminate a tool that ignores its signal. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/core/tool-executor-native/README.zh.md b/packages/core/tool-executor-native/README.zh.md new file mode 100644 index 0000000000..82f71691e6 --- /dev/null +++ b/packages/core/tool-executor-native/README.zh.md @@ -0,0 +1,98 @@ +--- +description: "具有作用域工具和有界批次执行的原生函数调用。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-native + +[English](README.md) | 中文 + +## 概述 + +当模型应直接调用每个可见工具时,使用此执行器。在部署、预设或 Agent 作用域中,将它与工具注册表一同挂载。并行安全调用可以重叠,独占调用构成屏障,结果保留模型顺序。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将此行加入已经挂载 `dsh-tools` 的组合。最近作用域的执行器决定工具行为;同一作用域只允许一个执行器。 + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-native' + config: + maxParallelToolCalls: 10 +``` + +Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循环始终向 `execute()` 传入解析后的上限。只有调用方未传入上限时,才使用执行器的 `maxParallelToolCalls`。这个后备配置是 volatile 字段,默认值为 `10`,在下一个工具组开始时读取。值必须是正整数;`1` 表示串行执行。 + +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-executor-native) + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。 + +- [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。 + +
+ +----- + + +## 进一步探索 + +- [工具注册表](../tools/README.zh.md) +- [Agent 循环](../agent-loop/README.zh.md) +- [其他执行器](../tool-executor-ptc/README.zh.md) + +----- + + +## 模型体验 + +### 可见工具 + +#### 模型看到什么 + +每个可见工具的 `name`、`description` 和 JSON Schema 参数;不会增加提示词章节。 + +#### Token 影响 + +请求成本与可见定义成正比;隐藏定义会移除对应 schema 成本。 + +#### KV Cache 影响 + +只要工具描述和顺序不变,前缀就保持稳定。 + +## 已知限制与延期工作 + + + +以下约束适用于已开始的调用。 + +- 同进程工具必须遵守取消信号;执行器无法强制终止忽略信号的工具。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml new file mode 100644 index 0000000000..3a3836c1d0 --- /dev/null +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -0,0 +1,52 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/core/tool-executor-ptc/README.md +/: + en: 93b346254eff025a + zh: 69aa6364ca2ffa7d +/deepseek-ai-dsh-tool-executor-ptc: + en: e253ed9cec03a396 + zh: 922fde282e2b0fd2 +/deepseek-ai-dsh-tool-executor-ptc/summary: + en: 60fb16b4d443d265 + zh: 6b0df6471c370c6b +/deepseek-ai-dsh-tool-executor-ptc/table-of-contents: + en: d152484eb41ac6b4 + zh: 09388d293f9be9cb +/deepseek-ai-dsh-tool-executor-ptc/use-this-package: + en: 5cfc28690358a419 + zh: d066d078ca649022 +/deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation: + en: f054dc0faabb468f + zh: 10f13508bfa9ef84 +/deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation/ptc-mode: + en: 11d32e05ba397f86 + zh: 4e071ec4e6222d9d +/deepseek-ai-dsh-tool-executor-ptc/further-exploration: + en: ee69078c38c655a9 + zh: fb0873407f23c8c5 +/deepseek-ai-dsh-tool-executor-ptc/model-experience: + en: 215e7ba838619b7b + zh: a311da3843709f90 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt: + en: eaefd7303f22977d + zh: 9c425f9501128224 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees: + en: 4980bfab8310efc0 + zh: ef318d5b8ac7bf76 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees/typescript-ptc-mode-sdk-instructions-with-bash: + en: 3a8df15e4ae32f9e + zh: fc4a800ba363e835 +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/token-effect: + en: 12296fcafc14562c + zh: f8e9351bb8c6437d +/deepseek-ai-dsh-tool-executor-ptc/model-experience/ptc-mode-schema-and-system-prompt/kv-cache-effect: + en: 78906628d634441f + zh: 492f133aea50cfe3 +/deepseek-ai-dsh-tool-executor-ptc/known-limitations-and-deferred-work: + en: 73836fc806368269 + zh: 943ecf50e19b3c69 +/deepseek-ai-dsh-tool-executor-ptc/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md new file mode 100644 index 0000000000..b7b56146a5 --- /dev/null +++ b/packages/core/tool-executor-ptc/README.md @@ -0,0 +1,134 @@ +--- +description: "Program-based tool execution, generated SDKs, and nested-call limits." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-ptc + +English | [中文](README.zh.md) + +## Summary + +Use this executor when models should call tools through `run_code` programs. Mount a compatible PTC runtime and tool registry first. Scoped restrictions determine the generated SDK; only `run_code` is directly callable. Configure root-call and program-subcall concurrency independently. + +## 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 + +Add this row to a composition with `dsh-tools` mounted. The nearest scoped executor selects tool behavior; one scope permits only one executor. + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-ptc' + config: + maxParallelToolCalls: 10 + maxParallelSubCalls: 10 +``` + +For Agent steps, configure [agent-loop concurrency](../agent-loop/README.md); the loop always forwards its resolved limit to `execute()`. The executor's `maxParallelToolCalls` supplies the limit only when the caller omits it. This fallback is volatile, defaults to `10`, and is sampled for the next tool group. It must be a positive integer; `1` selects serial execution. + +This package also requires `ctx.ptcRuntime` and `ctx.systemPrompt`; Agent-owned programs need `ctx.workingDirectory`. `maxParallelSubCalls` limits overlapping parallel-safe calls within each program and defaults to `10`. + +Import durable event payloads from `@deepseek-ai/dsh-tool-executor-ptc/types`; this entry is safe for detached readers and client compilation. Host policy extensions import `PtcDispatchLog`, `tools/ptc-dispatch-log`, and the `ptc-mode` message-source declaration through a type-only import of the package entry, without mounting an executor. + +[Configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-executor-ptc) + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. + +### PTC mode + +The PTC executor exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding captures a frozen ToolSchema and passes it through the scheduler to its execution context. Before policy, a started call records only pairing ids, name, and normalized arguments; its settle event preserves the rendered result, optional presentation metadata, and structured error. Metadata is projected from that call’s canonical value and frozen; it changes neither the program value nor model content. Description and parameters remain transient and never enter Session events or SDK output. Calls are scheduled through a per-run pool that reuses the native concurrency contract. Under `ptc` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. + +New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque and correlate events by exact equality; restored historical ids retain their original bytes. The [PTC mode decision](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) owns durable naming and restoration rules. + +Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution. + +`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in both Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls. + +- [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution. + +
+ +----- + + +## Further Exploration + +- [Tool registry](../tools/README.md) +- [Agent loop](../agent-loop/README.md) +- [Alternative executor](../tool-executor-native/README.md) + +----- + + +## Model Experience + +### PTC mode schema and system prompt + +#### What the model sees + +PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. Both runtime languages list `code` before `description`; this is presentation order, not an enforced JSON member order. The TypeScript instructions use a return-value example for function-body runtimes and a printed-output example for REPL runtimes. They identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. Under `ptc`, the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. + +##### TypeScript PTC mode SDK instructions with bash + +```markdown +## Writing code for run_code + +`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` + +Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. + +Program-only SDK bindings: +``` + +#### Token effect + +Fixed per-request cost proportional to the visible definitions. PTC mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. + +#### KV Cache effect + +Prefix-stable while the PTC mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. + +## Known Limitations and Deferred Work + + + +These constraints apply to accepted calls. + +- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — PTC execution rejects prompt assembly unless `ctx.ptcRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only. +- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. +- **State depends on the runtime**: the default backend starts fresh; a REPL backend retains memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md new file mode 100644 index 0000000000..f4d60631ea --- /dev/null +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -0,0 +1,134 @@ +--- +description: "基于程序的工具执行、生成 SDK 与嵌套调用限制。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-tool-executor-ptc + +[English](README.md) | 中文 + +## 概述 + +当模型应通过 `run_code` 程序调用工具时,使用此执行器。先挂载兼容的 PTC 运行时和工具注册表。作用域限制决定生成的 SDK;只有 `run_code` 能被直接调用。根调用和程序子调用的并发限制分别配置。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将此行加入已经挂载 `dsh-tools` 的组合。最近作用域的执行器决定工具行为;同一作用域只允许一个执行器。 + +```yaml +- name: '@deepseek-ai/dsh-tool-executor-ptc' + config: + maxParallelToolCalls: 10 + maxParallelSubCalls: 10 +``` + +Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循环始终向 `execute()` 传入解析后的上限。只有调用方未传入上限时,才使用执行器的 `maxParallelToolCalls`。这个后备配置是 volatile 字段,默认值为 `10`,在下一个工具组开始时读取。值必须是正整数;`1` 表示串行执行。 + +本包还要求 `ctx.ptcRuntime` 和 `ctx.systemPrompt`;带 Agent 的程序要求 `ctx.workingDirectory`。`maxParallelSubCalls` 限制每个程序内重叠的并行安全调用,默认值为 `10`。 + +持久化事件载荷从 `@deepseek-ai/dsh-tool-executor-ptc/types` 导入;该入口可用于独立读取者与 Client 编译。Host 策略扩展通过包主入口的仅类型导入获取 `PtcDispatchLog`、`tools/ptc-dispatch-log` 与 `ptc-mode` 消息来源声明,无需挂载执行器。 + +[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-executor-ptc) + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。 + +### PTC mode + +PTC 执行器公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定捕获冻结的 ToolSchema,经由调度器传入该次执行上下文。已开始的调用在策略之前只记录配对 id、名称和规范化参数;其结算事件保留渲染结果、可选展示元数据与结构化错误;元数据由该次调用的规范值投影并冻结,不改变程序读取的值或模型内容。描述与参数 schema 仅临时存活,不进入 Session 事件或 SDK 输出。调用通过复用原生并发约定的每次运行独有池调度。在纯 `ptc` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。 + +新子调用使用 `:ptc:` 标识。消费方将这些标识视为不透明值,并通过精确相等关联事件;恢复的历史标识保留原始字节。[PTC mode 决策](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 负责持久化命名与恢复规则。 + +成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。 + +当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消两种 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。 + +- [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。 + +
+ +----- + + +## 进一步探索 + +- [工具注册表](../tools/README.zh.md) +- [Agent 循环](../agent-loop/README.zh.md) +- [其他执行器](../tool-executor-native/README.zh.md) + +----- + + +## 模型体验 + +### PTC mode schema 与系统提示词 + +#### 模型看到什么 + +PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。两种运行时语言都将 `code` 列在 `description` 前;这是呈现顺序,不强制 JSON 成员顺序。TypeScript 说明对函数体运行时使用返回值示例,对 REPL 运行时使用打印输出示例,并把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 + +##### 带 bash 的 TypeScript PTC mode SDK 说明 + +```markdown +## Writing code for run_code + +`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` + +Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. +- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. +- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. +- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. + +Program-only SDK bindings: +``` + +#### Token 影响 + +每次请求的固定成本与可见定义成正比。PTC mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 + +#### KV Cache 影响 + +只要 PTC mode 选择、生成的 SDK、传输 schema 与可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 + +## 已知限制与延期工作 + + + +以下约束适用于已开始的调用。 + +- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:PTC 会拒绝组装提示词,除非 `ctx.ptcRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。 +- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 +- **状态由运行时决定**:默认后端每次重新开始;REPL 后端按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index f90a284f26..ff0a69e461 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -9,20 +9,20 @@ en: bb2af1250bfefe4b zh: 893e3ab2e563a8a5 /deepseek-ai-dsh-tools/summary: - en: 8c0a34155997a980 - zh: ae6031db472b33e3 + en: ad53df5cc542fc32 + zh: 1ab915da46e01d20 /deepseek-ai-dsh-tools/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tools/use-this-package: - en: dec8e6df8cc94bc2 - zh: e2f1f15ea7afd7b3 + en: d382227271bf92ae + zh: 1f66b181418c91b9 /deepseek-ai-dsh-tools/use-this-package/register-a-tool: en: 9d2e6caa39c53d62 zh: 096a777f828c7565 -/deepseek-ai-dsh-tools/use-this-package/configure-the-presentation-mode: - en: 4e27c45ae889369e - zh: 7a0cd7a9dc7fe139 +/deepseek-ai-dsh-tools/use-this-package/select-an-executor: + en: f3efeb3d996cb9c5 + zh: 442cf4ca6d68fea0 /deepseek-ai-dsh-tools/use-this-package/restrict-tools-per-agent: en: c6f98b2283cc2e47 zh: c2cdce5d13473f15 @@ -36,23 +36,23 @@ en: ce7e776e9d516bdb zh: 59d2751743db7795 /deepseek-ai-dsh-tools/understand-the-implementation/design-concept: - en: 92e8af53e6067bad + en: 516a6195a21e742b zh: 33c7b99b42c5acac /deepseek-ai-dsh-tools/understand-the-implementation/source-map: - en: c14e20d6a5c4c408 - zh: ab9dc0fd38006749 + en: 2dee0a37e0c1fa38 + zh: 0963cba32685612e /deepseek-ai-dsh-tools/understand-the-implementation/execution-and-cancellation: en: dc44a88997777988 zh: 56454aeace1c8b77 /deepseek-ai-dsh-tools/understand-the-implementation/ptc-mode: - en: 387700aa396f7ca2 - zh: d440b283027e4fbd + en: 83cf9a5077158879 + zh: 80508a70a3674226 /deepseek-ai-dsh-tools/understand-the-implementation/extension-points: en: c2a14d6f22bc9428 zh: 89c1414fca465931 /deepseek-ai-dsh-tools/further-exploration: - en: d8e2dfcfef2a2284 - zh: b33111cbc20dc498 + en: b112e13c7db15bf3 + zh: 39e726a15ca40f21 /deepseek-ai-dsh-tools/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 @@ -60,29 +60,14 @@ en: 483b01d9bb2cb49a zh: 1d95095e76fb0996 /deepseek-ai-dsh-tools/model-experience/normal-tool-schemas/what-the-model-sees: - en: beda295a874b30a1 - zh: 253c4cf007c4db3f + en: d6a5c32a421bfe97 + zh: 542d49df376e70a4 /deepseek-ai-dsh-tools/model-experience/normal-tool-schemas/token-effect: en: d594b71ff5e4cc87 zh: dcec1ea0dd2a0fa6 /deepseek-ai-dsh-tools/model-experience/normal-tool-schemas/kv-cache-effect: en: 7c5e75964e020b2c zh: d50d0402d15232d9 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt: - en: eaefd7303f22977d - zh: 9c425f9501128224 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees: - en: 3e170a222d746e98 - zh: 1ea442fc59874cc5 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/what-the-model-sees/typescript-ptc-mode-sdk-instructions-with-bash: - en: 3a8df15e4ae32f9e - zh: fc4a800ba363e835 -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/token-effect: - en: 12296fcafc14562c - zh: f8e9351bb8c6437d -/deepseek-ai-dsh-tools/model-experience/ptc-mode-schema-and-system-prompt/kv-cache-effect: - en: 78906628d634441f - zh: 492f133aea50cfe3 /deepseek-ai-dsh-tools/model-experience/tool-call-history-and-results: en: a8503137ad03720c zh: 611a2dadf7c0c706 @@ -96,8 +81,8 @@ en: 2fbf2175168e8244 zh: ad59a120b54a0996 /deepseek-ai-dsh-tools/known-limitations-and-deferred-work: - en: 4a412f89b8a71b5a - zh: 803d995b036d5dbf + en: e8c410dee66e1639 + zh: ae4965fa70234fd1 /deepseek-ai-dsh-tools/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index a5a7044916..bcd25bc40f 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce allow/deny/ask policy, and return finalized results without ending a turn on ordinary tool failures. Choose native Function Calling or [PTC mode](#ptc-mode) with `mode`; an agent can override the default through `presentAs`. Tool authors use `defineTool` to declare typed parameters and outputs, cooperative timeouts, parallel-safety, and optional UI presentation. Models see each permitted tool's declared name, description, and parameter schema; per-agent restrictions can narrow that visible set. +Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce allow/deny/ask policy, and return finalized results without ending a turn on ordinary tool failures. Mount a native or PTC executor to choose the model interaction and batch execution behavior. Tool authors use `defineTool` to declare typed parameters and outputs, cooperative timeouts, parallel-safety, and optional UI presentation. Models see each permitted tool's declared name, description, and parameter schema; per-agent restrictions can narrow that visible set. ## Table of Contents @@ -25,9 +25,9 @@ Use `dsh-tools` to expose typed capabilities to models, validate calls, enforce ## Use this package -Each `run_code` program captures the Session's current working directory when it starts. A nested directory change affects subsequent tool calls, while the running program keeps its initial directory. +`ctx.tools.execute()` accepts a complete raw call batch. `agent` is absent for standalone service calls; `log` is absent for programmatic calls outside a model step, including initialization hooks. An Agent without `log` still supplies tool scope but does not produce root call/result events. -Mount `dsh-tools` wherever agents call tools: it provides `ctx.tools`, the registry every tool plugin registers into and the loop dispatches through. Registering a tool is enough to make it visible — the registry feeds its schemas into the system-prompt assembly automatically. +Mount `dsh-tools` wherever agents call tools: it provides `ctx.tools`, the registry every tool plugin registers into and the selected executor resolves. Registering a tool is enough to make it visible — the registry feeds its schemas into the system-prompt assembly automatically. ### Register a tool @@ -63,22 +63,16 @@ ctx.tools.register(defineTool({ The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; `InferValue` preserves exact types through 16 container levels before widening to `JsonValue`. A raw JSON Schema (`JsonSchemaNode`) is the wire-level counterpart shared with subagents, workflows, and MCP. -### Configure the presentation mode +### Select an executor -The `mode` config decides what the model sees: `native` (every visible schema) or `ptc` (only `run_code` plus a generated SDK). The runtime supplies evaluation syntax, output syntax and state-retention guidance through `executionInstructions`; this package owns tool-call rules, SDK declarations and image-result attachment. +Mount one executor in the deployment, preset, or Agent scope. The nearest scope selects the provider; duplicate providers in one scope fail. Native exposes visible capabilities directly; PTC exposes `run_code` and a generated SDK. ```yaml - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- name: '@deepseek-ai/dsh-tool-executor-native' ``` -| Field | Default | Meaning | -|---|---|---| -| `mode` | `native` | How visible tools are presented to the model: `native` or `ptc` | -| `maxParallelSubCalls` | `10` | Concurrency cap for a `run_code` program's overlapping sub-calls; `1` restores strictly serial dispatch | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tools) is the exhaustive source for every accepted field. Non-native modes require a composed `ctx.ptcRuntime` whose language has a registered SDK renderer; an agent preset selects its own presentation with [`dsh-agent-tool-presentation`](../agent-tool-presentation/README.md), and one agent can shadow the default with `presentAs(mode)`. +Agent-step concurrency uses the [agent-loop configuration](../agent-loop/README.md). See the [native](../tool-executor-native/README.md) or [PTC](../tool-executor-ptc/README.md) package for executor settings. Custom providers implement only `describe(definitions)` and `execute(batch)`; the shared pipeline is optional. The registry selects the current scoped executor separately for prompt descriptions and each `ctx.tools.execute()` batch. An accepted batch finishes through its selected provider even if that provider unloads; new batches reject until a replacement is registered. ### Restrict tools per agent @@ -106,20 +100,19 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The registry holds typed `ToolDefinition`s in scoped layers and projects them onto the model-facing `ToolSchema` set at request time — `output`, `execute`, `finalizeContent`, `timeoutMs`, and presentation callbacks never leak onto the wire. Every call runs a fixed pipeline: `tools/pre-execute` (extensible allow/deny/ask) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace, attach context) → definition-owned `finalizeContent` → the observe-only `tools/result` event. Only the `tools/execute` view may replace the required signal, and the registry re-fuses the caller signal before the body. +The registry holds typed `ToolDefinition`s in scoped layers and projects them onto the model-facing `ToolSchema` set at request time — `output`, `execute`, `finalizeContent`, `timeoutMs`, and presentation callbacks never leak onto the wire. Built-in executors reuse the shared pipeline: `tools/pre-execute` (extensible allow/deny/ask) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace, attach context) → definition-owned `finalizeContent` → the observe-only `tools/result` event. Only the `tools/execute` view may replace the required signal, and the registry re-fuses the caller signal before the body. ### Source map -| File | Role | -|---|---| -| [`src/index.ts`](src/index.ts) | Plugin entry: `ToolRuntime` service, config, registry, execution pipeline | -| [`src/types.ts`](src/types.ts) | `ToolDefinition`, `ToolExecution`, `ToolExecutionResult`, guard and decision types | -| [`src/schema.ts`](src/schema.ts) | The `defineTool` DSL: `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs` | -| [`src/json-schema.ts`](src/json-schema.ts) | The enforced raw JSON Schema subset and validation | -| [`src/presentation.ts`](src/presentation.ts) | The `card`-tagged UI render intents | -| [`src/ptc.ts`](src/ptc.ts) | PTC mode: SDK generation, `run_code` dispatch bridge, settlement | -| [`src/ts-types.ts`](src/ts-types.ts) | TypeScript SDK type rendering | -| [`src/py-types.ts`](src/py-types.ts) | Python SDK type rendering | +- [`src/index.ts`](src/index.ts) — Plugin entry and public tool APIs; scoped tool service and executor selection. +- [`src/contracts.ts`](src/contracts.ts) — Tool definitions, invocation results, guards and decisions. +- [`src/executor.ts`](src/executor.ts) — Executor descriptions and batch input/output. +- [`src/pipeline.ts`](src/pipeline.ts) — Shared `prepare`, `dispatch`, and `complete` stages, their result types, and the batch entry point. +- [`src/batch.ts`](src/batch.ts) — Concurrency, ordered results and optional Session logging. +- [`src/events.ts`](src/events.ts) — Shared execution policy events. +- [`src/schema.ts`](src/schema.ts) — Typed tool definitions and model-facing schema projection. +- [`src/json-schema.ts`](src/json-schema.ts) — Supported JSON Schema validation. +- [`src/presentation.ts`](src/presentation.ts) — Tool-card render intents. ### Execution and cancellation @@ -127,13 +120,7 @@ Each typed invocation materializes and freezes parsed arguments, assigns an opaq ### PTC mode -Under `ptc`, the registry exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding captures a frozen ToolSchema and passes it through the scheduler to its execution context. Before policy, a started call records only pairing ids, name, and normalized arguments; its settle event preserves the rendered result, optional presentation metadata, and structured error. Metadata is projected from that call’s canonical value and frozen; it changes neither the program value nor model content. Description and parameters remain transient and never enter Session events or SDK output. Calls are scheduled through a per-run pool that reuses the native concurrency contract. Under `ptc` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. - -New sub-calls use `:ptc:` ids. Consumers treat these ids as opaque and correlate events by exact equality; restored historical ids retain their original bytes. The [PTC mode decision](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) owns durable naming and restoration rules. - -Successful image-bearing subcall results become deferred user-message context with `source.kind` set to `ptc-mode`. Other additional contexts retain their producing tool's attribution. - -`run_code` accepts `timeoutMs` when the mounted runtime supports an override; its schema reports the configured default and maximum, and the runtime's usage instructions. The Node default is 120,000 ms with a 600,000 ms cap, including nested tool and approval waits. A wider `sandbox_permissions` mode requires a non-empty `justification` and approval before the program starts. The schema asks the model to write that reason in the current user's language. The grant applies to that complete execution; standing Session policy and nested tools retain their own authority. Programs are never replayed automatically: inspect earlier effects before explicitly retrying a denied program. Retained PTC environments follow the standing file policy. PTC observes committed Session mode changes and immediately cancels affected calls in both Node modes. Later PTC calls wait for their nested-tool cleanup before resolving the latest policy. The runtime owns process invalidation and cleanup; permission setters remain synchronous. One-time escalations omit the state owner and complete in a temporary environment; standing variables remain available to ordinary calls. +The [PTC executor](../tool-executor-ptc/README.md) owns program transport, SDK generation, nested dispatch, and image context. ### Extension points @@ -150,9 +137,10 @@ Tool plugins call `ctx.tools.register()` and their schemas flow into prompt asse The package-level contract is enough for most consumers; read these when you need the surrounding domain. - [Tools subsystem](../../../docs/subsystems/tools.md) — the full pipeline types, schema DSL, and generated service API. -- [Generated tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tools) — the shipped tools' schemas the model receives. +- [Generated tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc) — the shipped tools' schemas the model receives. - [Tool execution pipeline](../../../docs/tool-execution-pipeline.md) — the pipeline visualized. - [Adding a tool cookbook](../../../docs/cookbook/adding-a-tool.md) — step-by-step tool authoring. +- [Tool testkit](../../test-support/tool-testkit/README.md) — tool-definition and execution fixtures for tests and benchmarks. - [Cooperative cancellation Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md) — the full cancellation contract. - [Core group map](../README.md) — how the core packages compose. @@ -165,7 +153,7 @@ The package-level contract is enough for most consumers; read these when you nee #### What the model sees -In normal mode the model sees each visible definition's exact name, description, and JSON Schema; the shipped definitions are recorded in the generated [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tools). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. +In normal mode the model sees each visible definition's exact name, description, and JSON Schema; the shipped definitions are recorded in the generated [tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-executor-ptc). Agent-scoped restrictions, shadows, and extension registrations change that agent's end-tool set. #### Token effect @@ -175,39 +163,6 @@ Fixed per-request cost proportional to the visible definitions. Restrictions tha Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token. -### PTC mode schema and system prompt - -#### What the model sees - -PTC mode exposes the generated [`run_code` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tools), the SDK instructions below, and the generated exact SDK block for the loaded runtime's language. Both runtime languages list `code` before `description`; this is presentation order, not an enforced JSON member order. The TypeScript instructions use a return-value example for function-body runtimes and a printed-output example for REPL runtimes. They identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000 and disables prompt-variable interpolation, preserving literal `{{…}}` text in tool descriptions and schemas for both runtime languages. Under `ptc`, the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. - -##### TypeScript PTC mode SDK instructions with bash - -```markdown -## Writing code for run_code - -`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: - -`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` - -Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. - -Program-only SDK bindings: -``` - -#### Token effect - -Fixed per-request cost proportional to the visible definitions. PTC mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. - -#### KV Cache effect - -Prefix-stable while the PTC mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. - ### Tool-call history and results #### What the model sees @@ -233,9 +188,6 @@ These limits define when the registry needs special care. They are current packa - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-tool-call-timeout-policy` wrapper. -- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: ptc` rejects prompt assembly unless `ctx.ptcRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only. -- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. -- **State depends on the runtime**: the default backend starts fresh; a REPL backend retains memory per live Agent. Agent disposal awaits release. Restart and execution failures can clear memory; model-visible guidance explains how to recreate missing variables. Programs and results remain in the Session log, without serializing live objects. `defineTool()`, registry schema projection, and system-prompt assembly preserve `deferLoading: true`. The marker requests deferred definition loading and does not imply a `tool-addition` record; the [LLM package](../../../packages/llm/llm/README.md#known-limitations-and-deferred-work) documents provider enforcement limits. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 6316543088..c7f07ab753 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-tools` 可向模型公开类型化能力、校验调用、执行允许/拒绝/询问策略,并在普通工具失败时返回最终结果而不中止当前轮次。通过 `mode` 选择原生 Function Calling(函数调用)或 [PTC mode](#ptc-mode);单个 agent(智能体)可用 `presentAs` 覆盖默认值。工具作者使用 `defineTool` 声明类型化参数与输出、协作式超时、并行安全属性和可选 UI 展示。模型会看到每个获准工具声明的名称、描述与参数 schema;按 agent 设置的限制可缩小该可见集合。 +使用 `dsh-tools` 可向模型公开类型化能力、校验调用、执行允许/拒绝/询问策略,并在普通工具失败时返回最终结果而不中止当前轮次。挂载原生或 PTC 执行器以选择模型交互和批次执行行为。工具作者使用 `defineTool` 声明类型化参数与输出、协作式超时、并行安全属性和可选 UI 展示。模型会看到每个获准工具声明的名称、描述与参数 schema;按 agent 设置的限制可缩小该可见集合。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -每个 `run_code` 程序启动时会固定使用 Session 当时的工作目录。嵌套目录切换会影响后续工具调用,而运行中的程序仍保留启动目录。 +`ctx.tools.execute()` 接受完整的原始调用批次。独立服务调用没有 `agent`;模型步骤之外的程序调用(包括初始化钩子)没有 `log`。没有 `log` 的 Agent 仍提供工具作用域,但不会产生根调用/结果事件。 在任何 agent 调用工具的地方挂载 `dsh-tools`:它提供 `ctx.tools`,即每个工具插件注册进去、循环分发所经过的注册表。注册一个工具就足以让它可见——注册表会自动把其 schema 送入系统提示词组装。 @@ -63,22 +63,16 @@ ctx.tools.register(defineTool({ 统一 schema DSL 支持 `string`、`number`、`integer`、`boolean`、`null`、`array`、`object`、仅供作者使用的 `json` 与恰好匹配一个分支的 `oneOf`;`InferValue` 在 16 层容器内保留精确类型,之后加宽为 `JsonValue`。原始 JSON Schema(`JsonSchemaNode`)是与 subagent、工作流和 MCP 共享的协议级对应类型。 -### 配置呈现模式 +### 选择执行器 -`mode` 配置决定模型看到什么:`native`(每个可见 schema)或 `ptc`(只有 `run_code` 加一份生成 SDK)。运行时通过 `executionInstructions` 提供求值语法、输出方式和状态保留指引;本包负责工具调用规则、SDK 声明和图片结果附加。 +在部署、预设或 Agent 作用域中挂载一个执行器。最近的作用域决定提供者,同一作用域的重复提供者会报错。原生执行器直接公开可见能力;PTC 执行器公开 `run_code` 和生成的 SDK。 ```yaml - name: '@deepseek-ai/dsh-tools' - config: - mode: native +- name: '@deepseek-ai/dsh-tool-executor-native' ``` -| 字段 | 默认值 | 含义 | -|---|---|---| -| `mode` | `native` | 可见工具向模型呈现的方式:`native` 或 `ptc` | -| `maxParallelSubCalls` | `10` | `run_code` 程序重叠子调用的并发上限;`1` 恢复严格串行分发 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tools)是每个受支持字段的穷尽式真源。非原生模式要求已组合的 `ctx.ptcRuntime` 且其语言有已注册的 SDK 渲染器;agent preset 通过 [`dsh-agent-tool-presentation`](../agent-tool-presentation/README.zh.md) 自行选择呈现方式,单个 agent 可用 `presentAs(mode)` 遮蔽默认值。 +Agent 步骤的并发上限使用 [agent-loop 配置](../agent-loop/README.zh.md)。执行器设置参见[原生](../tool-executor-native/README.zh.md)或 [PTC](../tool-executor-ptc/README.zh.md) 包。自定义提供者只需实现 `describe(definitions)` 和 `execute(batch)`,共享流水线为可选项。注册表分别为提示词描述和每次 `ctx.tools.execute()` 批次选择当前作用域的执行器。已接纳的批次由所选提供者完成,即使该提供者已卸载;注册替代提供者之前,新批次会被拒绝。 ### 按 agent 限制工具 @@ -110,16 +104,15 @@ ctx.tools.register(defineTool({ ### 源码地图 -| 文件 | 职责 | -|---|---| -| [`src/index.ts`](src/index.ts) | 插件入口:`ToolRuntime` 服务、配置、注册表、执行流水线 | -| [`src/types.ts`](src/types.ts) | `ToolDefinition`、`ToolExecution`、`ToolExecutionResult`、守卫与决策类型 | -| [`src/schema.ts`](src/schema.ts) | `defineTool` DSL:`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue`、`InferArgs` | -| [`src/json-schema.ts`](src/json-schema.ts) | 强制执行的原始 JSON Schema 子集与校验 | -| [`src/presentation.ts`](src/presentation.ts) | 带 `card` 标签的 UI 呈现意图 | -| [`src/ptc.ts`](src/ptc.ts) | PTC mode:SDK 生成、`run_code` 分发桥接层、结算 | -| [`src/ts-types.ts`](src/ts-types.ts) | TypeScript SDK 类型渲染 | -| [`src/py-types.ts`](src/py-types.ts) | Python SDK 类型渲染 | +- [`src/index.ts`](src/index.ts) — 插件入口与公共工具 API;作用域工具服务与执行器选择. +- [`src/contracts.ts`](src/contracts.ts) — 工具定义、调用结果、守卫与策略决策. +- [`src/executor.ts`](src/executor.ts) — 执行器描述与批量输入输出. +- [`src/pipeline.ts`](src/pipeline.ts) — 共享的 `prepare`、`dispatch`、`complete` 阶段、阶段结果类型与批次入口。 +- [`src/batch.ts`](src/batch.ts) — 并发调度、结果顺序与可选 Session 日志. +- [`src/events.ts`](src/events.ts) — 共享执行策略事件. +- [`src/schema.ts`](src/schema.ts) — 类型化工具定义与面向模型的 schema 投影。 +- [`src/json-schema.ts`](src/json-schema.ts) — 支持的 JSON Schema 校验. +- [`src/presentation.ts`](src/presentation.ts) — 工具卡片呈现意图. ### 执行与取消 @@ -127,13 +120,7 @@ ctx.tools.register(defineTool({ ### PTC mode -在 `ptc` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定捕获冻结的 ToolSchema,经由调度器传入该次执行上下文。已开始的调用在策略之前只记录配对 id、名称和规范化参数;其结算事件保留渲染结果、可选展示元数据与结构化错误;元数据由该次调用的规范值投影并冻结,不改变程序读取的值或模型内容。描述与参数 schema 仅临时存活,不进入 Session 事件或 SDK 输出。调用通过复用原生并发约定的每次运行独有池调度。在纯 `ptc` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。 - -新子调用使用 `:ptc:` 标识。消费方将这些标识视为不透明值,并通过精确相等关联事件;恢复的历史标识保留原始字节。[PTC mode 决策](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 负责持久化命名与恢复规则。 - -成功且包含图片的子调用结果会成为延后的 user-message 上下文,其 `source.kind` 为 `ptc-mode`。其他 additional context 保留其生产工具的归属。 - -当已挂载运行时支持覆盖时,`run_code` 接受 `timeoutMs`;其 schema 报告配置的默认值和上限以及运行时使用说明。Node 默认值为 120,000 ms,上限为 600,000 ms,包含嵌套工具和审批等待。更宽的 `sandbox_permissions` 模式要求非空 `justification`,并在程序启动前获得审批。schema 提示模型使用当前用户的语言撰写理由。授权仅用于该次完整执行;常驻 Session 策略与嵌套工具保留各自权限。程序不会自动重放:显式重试被拒程序前,应检查先前已发生的效果。 保留状态的 PTC 环境遵循会话文件策略。PTC 观察已提交的 Session 模式变化,并立即取消两种 Node 模式下受影响的调用。后续 PTC 调用等待这些调用的嵌套工具清理后,再解析最新策略。运行时负责进程失效和清理,权限设置保持同步。单次提权省略状态所有者,在临时环境中执行;普通调用仍可访问原环境的变量。 +[PTC 执行器](../tool-executor-ptc/README.zh.md) 负责程序传输、SDK 生成、嵌套分发和图片上下文。 ### 扩展点 @@ -150,9 +137,10 @@ ctx.tools.register(defineTool({ 包级约定对大多数消费方已经足够;需要周边领域时再阅读以下页面。 - [工具子系统](../../../docs/subsystems/tools.zh.md)——完整流水线类型、schema DSL 与生成的服务 API。 -- [生成工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)——模型收到的已交付工具 schema。 +- [生成工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)——模型收到的已交付工具 schema。 - [工具执行流水线](../../../docs/tool-execution-pipeline.zh.md)——可视化流水线。 - [添加工具实操手册](../../../docs/cookbook/adding-a-tool.zh.md)——分步骤的工具编写指南。 +- [工具测试工具包](../../test-support/tool-testkit/README.zh.md)——测试和基准测试使用的工具定义与执行 fixture。 - [协作式取消 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md)——完整取消约定。 - [core 分组地图](../README.zh.md)——core 各包如何组合。 @@ -165,7 +153,7 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -在普通模式下,模型会看到每个可见定义的确切名称、描述与 JSON Schema;已交付定义记录在生成的[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)中。agent 作用域的限制、遮蔽与扩展注册会改变该 agent 的最终工具集合。 +在普通模式下,模型会看到每个可见定义的确切名称、描述与 JSON Schema;已交付定义记录在生成的[工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-executor-ptc)中。agent 作用域的限制、遮蔽与扩展注册会改变该 agent 的最终工具集合。 #### Token 影响 @@ -175,39 +163,6 @@ ctx.tools.register(defineTool({ 只要可见定义及其顺序不变,前缀就保持稳定。注册、dispose 或作用域限制可能从第一个改变的 schema token 起使复用失效。 -### PTC mode schema 与系统提示词 - -#### 模型看到什么 - -PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。两种运行时语言都将 `code` 列在 `description` 前;这是呈现顺序,不强制 JSON 成员顺序。TypeScript 说明对函数体运行时使用返回值示例,对 REPL 运行时使用打印输出示例,并把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000,并关闭提示词变量插值,使两种运行时语言都原样保留工具描述和 schema 中的 `{{…}}` 文本。在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 - -##### 带 bash 的 TypeScript PTC mode SDK 说明 - -```markdown -## Writing code for run_code - -`run_code` takes two required arguments: `code`, the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped), and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: - -`run_code({ code: "return await tools.bash({ description: 'Show current directory', command: 'pwd' })", description: "Show current directory" })` - -Inside the program: - -- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. -- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. -- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. -- Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. - -Program-only SDK bindings: -``` - -#### Token 影响 - -每次请求的固定成本与可见定义成正比。PTC mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 - -#### KV Cache 影响 - -只要 PTC mode 选择、生成的 SDK、传输 schema 与可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 - ### 工具调用历史与结果 #### 模型看到什么 @@ -233,9 +188,6 @@ Program-only SDK bindings: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录与呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇与工具输出支持任意 JSON 根。 - **定义中的 `timeoutMs` 仅作声明之用**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。 -- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: ptc` 会拒绝组装提示词,除非 `ctx.ptcRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。 -- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 -- **状态由运行时决定**:默认后端每次重新开始;REPL 后端按存活 Agent 保留内存。Agent 销毁时等待环境释放。重启与执行失败可能清除内存;模型说明会指导重新创建缺失变量。程序与结果仍写入 Session 日志,不序列化运行中的对象。 `defineTool()`、注册表模式投影和系统提示组装会保留 `deferLoading: true`。该标记请求延迟加载工具定义,并不意味着存在 `tool-addition` 记录;提供方执行语义的限制见 [LLM 包](../../../packages/llm/llm/README.zh.md#known-limitations-and-deferred-work)。 diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 87b03eba27..b1ad87862a 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -153,10 +153,12 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-agent` | no | Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness | | `@deepseek-ai/dsh-agent-default-model` | yes | Default model selection shared by Agent entry points | | `@deepseek-ai/dsh-agent-loop` | yes | The concrete agent loop plugin for the DeepSeek Harness | -| `@deepseek-ai/dsh-agent-tool-presentation` | yes | Agent-plane presentation selector: composes one agent's tools as PTC mode or native tool calls | +| `@deepseek-ai/dsh-agent-tool-presentation` | yes | Deprecated tool presentation selector; use dsh-tool-executor-native or dsh-tool-executor-ptc directly | | `@deepseek-ai/dsh-session` | no | Event-sourced session store for the DeepSeek Harness | | `@deepseek-ai/dsh-system-prompt` | yes | System prompt assembly registry for the DeepSeek Harness | -| `@deepseek-ai/dsh-tools` | yes | Tool registry and execution pipeline for the DeepSeek Harness | +| `@deepseek-ai/dsh-tool-executor-native` | yes | Native tool execution plugin | +| `@deepseek-ai/dsh-tool-executor-ptc` | yes | PTC program execution, tool bindings, and generated SDK prompts | +| `@deepseek-ai/dsh-tools` | no | Tool registry and execution pipeline for the DeepSeek Harness | ## credentials diff --git a/packages/test-support/README.i18n.yaml b/packages/test-support/README.i18n.yaml index ac02c6c827..4075580b2a 100644 --- a/packages/test-support/README.i18n.yaml +++ b/packages/test-support/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /packages-test-support/packages: - en: 94395ff952ce77ee - zh: 4ae6afc3fe0c71dd + en: 15c602cea9f84f94 + zh: d806d9eb4d958356 /packages-test-support/related-documentation: en: 938bb087afd96c3e zh: 0d51d0d7c0915602 diff --git a/packages/test-support/README.md b/packages/test-support/README.md index 7608dc8696..a87f0a18fc 100644 --- a/packages/test-support/README.md +++ b/packages/test-support/README.md @@ -26,6 +26,7 @@ The test-support group gives repository tests deterministic, keyless ways to exe |---|---| | [`session-snapshot`](session-snapshot/README.md) | Provides session-log snapshot support and protocol adapters for profile-driven tests | | [`agent-loop-testkit`](agent-loop-testkit/README.md) | Provides the shared prerequisite services for tests that exercise the concrete AgentLoop | +| [`tool-testkit`](tool-testkit/README.md) | Defines tool fixtures and exercises invocation or executor behavior in tests | | [`client-runtime`](client-runtime/README.md) | Provides the jsdom slot test bench for browser feature specs | | [`remote-mock`](remote-mock/README.md) | Provides the endpoint-named Typert Remote mock and the Connection carrier face whole-client specs install | | [`loader-smoke`](loader-smoke/README.md) | Boots Loader-composed applications and drives fixture turns for smoke tests | diff --git a/packages/test-support/README.zh.md b/packages/test-support/README.zh.md index f82c47d835..0138f77e63 100644 --- a/packages/test-support/README.zh.md +++ b/packages/test-support/README.zh.md @@ -26,6 +26,7 @@ test-support 组为仓库测试提供确定且无须密钥的真实产品测试 |---|---| | [`session-snapshot`](session-snapshot/README.zh.md) | 为 profile 驱动的测试提供 session-log 快照支持与协议适配器 | | [`agent-loop-testkit`](agent-loop-testkit/README.zh.md) | 为运行具体 AgentLoop 的测试提供共享先决服务 | +| [`tool-testkit`](tool-testkit/README.zh.md) | 定义工具 fixture,并在测试中执行工具调用或执行器行为 | | [`client-runtime`](client-runtime/README.zh.md) | 为浏览器功能测试提供 jsdom slot 测试台 | | [`remote-mock`](remote-mock/README.zh.md) | 为整体客户端测试提供端点具名的 Typert Remote mock 与它们安装的 Connection 载体面 | | [`loader-smoke`](loader-smoke/README.zh.md) | 启动由 Loader 组合的应用并驱动 fixture(测试前置数据)轮次以执行冒烟测试 | diff --git a/packages/test-support/tool-testkit/README.i18n.yaml b/packages/test-support/tool-testkit/README.i18n.yaml new file mode 100644 index 0000000000..378182bb6c --- /dev/null +++ b/packages/test-support/tool-testkit/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/test-support/tool-testkit/README.md +/: + en: 6c56a58c1397154b + zh: 7e37b4f4915f0a75 +/deepseek-ai-dsh-tool-testkit: + en: 6f5f9186228a4b87 + zh: e05d9c94ad8a6c2e +/deepseek-ai-dsh-tool-testkit/summary: + en: 36a296389a56ac64 + zh: f1aecbfb8f0ce528 +/deepseek-ai-dsh-tool-testkit/table-of-contents: + en: d152484eb41ac6b4 + zh: 1db86285abf6c499 +/deepseek-ai-dsh-tool-testkit/use-this-package: + en: 0489d9c434d64281 + zh: 02e72be3ec225898 +/deepseek-ai-dsh-tool-testkit/understand-the-implementation: + en: 236c624cc2445cd1 + zh: 60d9dbafe03332b9 +/deepseek-ai-dsh-tool-testkit/further-exploration: + en: db619f3ee3d024ef + zh: 02e35fcebaebd40e +/deepseek-ai-dsh-tool-testkit/model-experience: + en: 478835c82631e8c3 + zh: cb4f938bcaa046be +/deepseek-ai-dsh-tool-testkit/model-experience/kv-cache-effect: + en: be3fa044c8f27bf0 + zh: 35fdb79b58ba91f6 +/deepseek-ai-dsh-tool-testkit/known-limitations-and-deferred-work: + en: c21236a743177477 + zh: 8cc8678c2133a8be +/deepseek-ai-dsh-tool-testkit/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: e88aeb617ffb74ea diff --git a/packages/test-support/tool-testkit/README.md b/packages/test-support/tool-testkit/README.md new file mode 100644 index 0000000000..adb881d574 --- /dev/null +++ b/packages/test-support/tool-testkit/README.md @@ -0,0 +1,91 @@ +--- +description: "Define test tools and invoke tool pipelines or selected executors in repository tests and benchmarks." +kind: "package-library" +--- + +# @deepseek-ai/dsh-tool-testkit + +English | [中文](README.zh.md) + +## Summary + +Use this package to define content-producing test tools and exercise tool execution without running an Agent turn. Tests and benchmarks import its helpers as a development dependency. Callers own the Cordis context, plugin composition, and teardown. + +## 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 + +Add `@deepseek-ai/dsh-tool-testkit` to the consuming package's `devDependencies`. Define a fixture when the test needs rendered content without a domain-specific result value: + +```ts +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' + +const tool = defineContentToolFixture({ + name: 'echo', + description: 'Return a test message.', + parameters: {}, + execute: async () => [{ type: 'text', text: 'hello' }], +}) +``` + +Register the returned definition through the caller's tool registry. Use `executeToolFixture` for an already-parsed invocation, or `executeModelToolFixture` to pass one JSON-encoded call through the selected executor. The latter requires the caller to mount that executor and rejects arguments that cannot be JSON-encoded or an executor response without a result. See [`src/index.ts`](src/index.ts) for invocation fields. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +[`src/index.ts`](src/index.ts) adapts content blocks into a tool's canonical JSON value and delegates execution to the production pipeline or registry. It creates no context and owns no plugin registrations. + +
+ +----- + + +## Further Exploration + +- [Tools package](../../core/tools/README.md) — tool definitions, registry, and execution policy. +- [Testing policy](../../../docs/testing.md) — choosing verification for production behavior. +- [Test-support group map](../README.md) — sibling test utilities. + +----- + + +## Model Experience + +None, as these test helpers do not assemble or send model requests. + +#### KV Cache effect + +None; the package itself sends no provider request. + +## Known Limitations and Deferred Work + + + +- Content-array values are test fixtures; product tools declare domain-owned output schemas. +- Direct pipeline execution omits model-batch scheduling. Both execution helpers omit root Session logging; use an Agent turn when verifying the recorded conversation. +- Plugin composition and context disposal remain caller-owned. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/test-support/tool-testkit/README.zh.md b/packages/test-support/tool-testkit/README.zh.md new file mode 100644 index 0000000000..7af0575834 --- /dev/null +++ b/packages/test-support/tool-testkit/README.zh.md @@ -0,0 +1,91 @@ +--- +description: "在仓库测试和基准测试中定义测试工具,并调用工具流水线或选定的执行器。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-tool-testkit + +[English](README.md) | 中文 + +## 概述 + +使用本包定义生成内容的测试工具,并在不运行 Agent 回合的情况下测试工具执行。测试和基准测试将这些辅助函数作为开发依赖导入。调用方负责 Cordis context、插件组合和资源清理。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +将 `@deepseek-ai/dsh-tool-testkit` 加入消费包的 `devDependencies`。测试只需要渲染内容而不需要领域结果值时,可以定义以下 fixture: + +```ts +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' + +const tool = defineContentToolFixture({ + name: 'echo', + description: 'Return a test message.', + parameters: {}, + execute: async () => [{ type: 'text', text: 'hello' }], +}) +``` + +通过调用方的工具注册表注册返回的定义。使用 `executeToolFixture` 执行已解析的调用,或使用 `executeModelToolFixture` 将一次 JSON 编码的调用交给选定的执行器。后者要求调用方挂载该执行器,并在参数无法编码为 JSON 或执行器响应缺少结果时拒绝。调用字段见 [`src/index.ts`](src/index.ts)。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +[`src/index.ts`](src/index.ts) 将内容块适配为工具的规范 JSON 值,并将执行委托给生产流水线或注册表。它不创建 context,也不持有插件注册。 + +
+ +----- + + +## 进一步探索 + +- [工具包](../../core/tools/README.zh.md)——工具定义、注册表和执行策略。 +- [测试策略](../../../docs/testing.zh.md)——为生产行为选择验证方式。 +- [test-support 分组地图](../README.zh.md)——同组测试工具。 + +----- + + +## 模型体验 + +无,这些测试辅助函数不组装或发送模型请求。 + +#### KV Cache 影响 + +无;本包自身不发送 provider 请求。 + +## 已知限制与后续工作 + + + +- 内容数组值仅用于测试 fixture;产品工具声明领域自己的输出 schema。 +- 直接执行流水线不包含模型批次调度。两种执行辅助函数都不记录根 Session 日志;验证会话记录时应运行 Agent 回合。 +- 插件组合和 context 释放仍由调用方负责。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
From c0661b84083ccc9af1698e874090e4140c530922 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:04:34 +0800 Subject: [PATCH 11/73] fix(tools): retain the service subject while accepted calls settle --- packages/core/tools/src/pipeline.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index fad3b19f02..d39e49feef 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -56,7 +56,12 @@ interface FusedToolSignal { * for complete calls, including a single-call batch. */ export class ToolExecutionPipeline { - constructor(private readonly ctx: Context, private readonly source: ToolExecutionSource) {} + private readonly tools: Context['tools'] + + constructor(private readonly ctx: Context, private readonly source: ToolExecutionSource) { + // Accepted calls retain their event subject while the service is withdrawn during shutdown. + this.tools = ctx.tools + } private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { const tool = this.source.get(name, scope) @@ -177,7 +182,7 @@ export class ToolExecutionPipeline { return { kind: 'final-result', exec, result: toolAbortedBeforeDispatchResult() } } try { - const carrier = scopeTarget(this.ctx.tools, exec.agent) + const carrier = scopeTarget(this.tools, exec.agent) const gate = await this.ctx.waterfall( carrier, 'tools/pre-execute', exec, () => Promise.resolve({ kind: 'allow' }), @@ -192,7 +197,7 @@ export class ToolExecutionPipeline { if (decision.kind === 'cancel') { return { kind: 'post-result', exec, result: toolAbortedBeforeDispatchResult() } } - const denialReason = decision.kind === 'allow' ? this.ctx.tools.checkGuards(exec) : decision.reason + const denialReason = decision.kind === 'allow' ? this.tools.checkGuards(exec) : decision.reason const denialInfo = decision.kind === 'deny' ? decision.info : undefined if (denialReason !== undefined) { return { @@ -277,7 +282,7 @@ export class ToolExecutionPipeline { async dispatch(exec: ToolRunContext): Promise { try { const mutableExec = exec as MutableToolRunContext - const carrier = scopeTarget(this.ctx.tools, exec.agent) + const carrier = scopeTarget(this.tools, exec.agent) const result = await this.ctx.waterfall( carrier, 'tools/execute', mutableExec, () => this.dispatchToolBody(mutableExec), @@ -388,7 +393,7 @@ export class ToolExecutionPipeline { this.ctx.logger.warn(`tool "${toolName}" (${callId}): tools/result observer failed: ${toolErrorMessage(error)}`) } const callbacks = this.ctx.events.dispatch('emit', [ - scopeTarget(this.ctx.tools, exec.agent), 'tools/result', exec, result, + scopeTarget(this.tools, exec.agent), 'tools/result', exec, result, ]) for (const callback of callbacks) { try { @@ -467,7 +472,7 @@ export class ToolExecutionPipeline { */ private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise { const decision = await this.ctx.waterfall( - scopeTarget(this.ctx.tools, exec.agent), 'tools/post-execute', exec, result, + scopeTarget(this.tools, exec.agent), 'tools/post-execute', exec, result, () => Promise.resolve({ kind: 'accept' }), ) const decisionContexts = decision.additionalContexts ?? [] From 4a84bc3f7e333821df8cb8dedfbc9cb73293ec29 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:04:34 +0800 Subject: [PATCH 12/73] test(tools): migrate remaining executor fixtures and compatibility checks --- package.json | 2 ++ .../core/agent-tool-presentation/src/index.ts | 2 ++ .../tests/agent-tool-presentation.spec.ts | 3 +++ .../tests/loader-composition.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../tests/composition.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- packages/goal/tool-goal/tests/tool-goal.spec.ts | 3 ++- .../llm-deepseek/tests/account-routing.spec.ts | 4 +++- packages/llm/llm-pi-ai/package.json | 1 + .../tests/completed-tool-arguments.spec.ts | 2 +- .../llm-retry/tests/loader-composition.spec.ts | 2 +- .../tests/tool-workspace-dependencies.spec.ts | 3 ++- .../tool-todo/tests/loader-composition.spec.ts | 4 ++-- pnpm-lock.yaml | 9 +++++++++ scripts/smoke-python-runtime.py | 3 ++- snapshots/sdk/sdk.snapshot.ts | 2 +- snapshots/sdk/tool-error-details/deny.mjs | 4 +++- .../scheduler-failure.mjs | 17 ++++++++--------- .../session/advanced-toolchain/presentation.mjs | 6 +++++- 20 files changed, 51 insertions(+), 24 deletions(-) diff --git a/package.json b/package.json index 39c7f94f95..c6de85db40 100644 --- a/package.json +++ b/package.json @@ -221,6 +221,8 @@ "devDependencies": { "@deepseek-ai/dsh-hooks-claude-code": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-terminal": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index 7531ba183e..cc621472e3 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -38,6 +38,7 @@ export interface Config { * Runtime schema. * @deprecated Select the native or PTC executor plugin directly instead of configuring a mode. */ +// oxlint-disable-next-line typescript/no-deprecated -- The compatibility schema validates its own deprecated configuration. export const Config: z = z.object({ mode: z.union(['native', 'ptc'] as const).required(), }) @@ -48,6 +49,7 @@ export const Config: z = z.object({ * @param config - the selected presentation. * @deprecated Mount @deepseek-ai/dsh-tool-executor-native or @deepseek-ai/dsh-tool-executor-ptc directly. */ +// oxlint-disable-next-line typescript/no-deprecated -- The compatibility plugin still accepts this configuration. export function apply(ctx: Context, config: Config): void { ctx.plugin(config.mode === 'native' ? NativeToolExecutor : PtcToolExecutor) } diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index c301280d1c..3dcbbc747c 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -48,6 +48,7 @@ async function host(options: { runtime?: boolean } = {}) { } /** Mount the row under one agent's scope, as a preset subtree does. */ +// oxlint-disable-next-line typescript/no-deprecated -- Exercise the supported compatibility plugin configuration. async function mount(ctx: Context, config: Config, id = 'agent') { const agent = { id: SessionId(id) } as Agent let inner!: Context @@ -62,6 +63,7 @@ async function mount(ctx: Context, config: Config, id = 'agent') { describe('the tool-presentation row', () => { it('rejects an unsupported presentation mode in configuration', () => { + // oxlint-disable-next-line typescript/no-deprecated -- The compatibility schema must reject unsupported modes. expect(() => Config({ mode: 'both' } as never)).toThrow() }) @@ -119,6 +121,7 @@ describe('the tool-presentation row', () => { it('requires a mode rather than defaulting one', () => { // An omitted value would mean the row was composed for nothing: a preset // without this row already gets the deployment default. + // oxlint-disable-next-line typescript/no-deprecated -- The compatibility schema still requires an explicit mode. expect(() => Config({} as never)).toThrow() }) }) diff --git a/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts b/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts index c02ece4f6f..88dfbf6897 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/loader-composition.spec.ts @@ -81,7 +81,7 @@ it('loads browser tools from cordis.yml, logs browser results, and admits the sc ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-attachment-local', LocalAttachmentStore], diff --git a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts index 4c1fb0d546..21cf21c7ad 100644 --- a/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts +++ b/packages/experimental/claude-code-mods/tests/loader-composition.spec.ts @@ -46,7 +46,7 @@ it('loads from cordis.yml, counts the model\'s tool calls, and answers /tally th ['@deepseek-ai/dsh-fs-local', LocalFileSystem], ['@deepseek-ai/dsh-working-directory', WorkingDirectory], ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-commands', CommandRuntime], diff --git a/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts b/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts index f23deaa812..e2b123829a 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts +++ b/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts @@ -65,7 +65,7 @@ async function load(mode?: string): Promise<{ ctx: Context; root: string; model: ['@deepseek-ai/dsh-computer-use', ComputerUse], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-llm', LlmRuntime], ['@deepseek-ai/dsh-session', SessionStore], ['@deepseek-ai/dsh-agent', AgentRegistry], diff --git a/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts index df447f6810..f3bb7e05b3 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts @@ -67,7 +67,7 @@ it('loads from cordis.yml and logs the native screenshot before the next model r ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], ['@deepseek-ai/dsh-attachment-local', LocalAttachmentStore], diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index e4468c642c..5ef28599a9 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -157,8 +157,9 @@ describe('goal tool registration and presentation', () => { const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 }) expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) .toEqual(['create_goal', 'get_goal', 'update_goal']) + const pipeline = new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }) for (const name of ['create_goal', 'get_goal', 'update_goal']) { - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId(name), name, arguments: {} })) + expect(pipeline.executionMode({ signal: testToolSignal, callId: ToolCallId(name), name, arguments: {} })) .toEqual({ kind: 'exclusive' }) } const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') diff --git a/packages/llm/llm-deepseek/tests/account-routing.spec.ts b/packages/llm/llm-deepseek/tests/account-routing.spec.ts index 72e46ed54c..83285250d4 100644 --- a/packages/llm/llm-deepseek/tests/account-routing.spec.ts +++ b/packages/llm/llm-deepseek/tests/account-routing.spec.ts @@ -33,7 +33,9 @@ async function harness() { const ctx = new Context() contexts.push(ctx) installAccountTaskCancellation(ctx) - for (const plugin of [LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, NativeToolExecutor, AgentRegistry]) { + for (const plugin of [ + LlmRuntime, SessionStore, SessionProjectionRegistry, SystemPrompt, ToolRuntime, NativeToolExecutor, AgentRegistry, + ]) { await ctx.plugin(plugin) } await ctx.plugin(AgentLoop, { agents: [] }) diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 572c90e9fb..22210a134d 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" }, "peerDependenciesMeta": { diff --git a/packages/llm/llm-pi-ai/tests/completed-tool-arguments.spec.ts b/packages/llm/llm-pi-ai/tests/completed-tool-arguments.spec.ts index 9bc6c42757..16b143a4e0 100644 --- a/packages/llm/llm-pi-ai/tests/completed-tool-arguments.spec.ts +++ b/packages/llm/llm-pi-ai/tests/completed-tool-arguments.spec.ts @@ -7,7 +7,7 @@ import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' import { SessionId } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { resolveProfiles } from '../src/config.ts' import { memoryAuth } from './auth-double.ts' import { anthropicFrame, anthropicMessageStart, closeMockServers, mockServer } from './mock-server.ts' diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 851cb0868a..a83ec7f3cb 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -65,7 +65,7 @@ async function loadYaml(lines: readonly string[]): Promise { ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-llm-retry', retry], ['@deepseek-ai/dsh-agent-loop', AgentLoop], diff --git a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts index 19d1748725..4e1640c95f 100644 --- a/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts +++ b/packages/skill/tool-workspace-dependencies/tests/tool-workspace-dependencies.spec.ts @@ -251,7 +251,8 @@ it('loads the real tool through Cordis, exposes payload paths in place, and unre await ctx.plugin(Loader) ctx.loader.builtins.include = Include Object.assign(ctx.loader.builtins, { - agents: AgentRegistry, systemPrompt: SystemPrompt, tools: ToolRuntime, toolExecutor: NativeToolExecutor, dependencies: workspaceDependencies, + agents: AgentRegistry, systemPrompt: SystemPrompt, tools: ToolRuntime, + toolExecutor: NativeToolExecutor, dependencies: workspaceDependencies, }) const config = join(directory, 'cordis.yml') await writeFile(config, `- name: cordis:agents\n- name: cordis:systemPrompt\n- name: cordis:tools\n- name: cordis:toolExecutor\n- name: cordis:dependencies\n config:\n source: ${JSON.stringify(source)}\n`) diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index cc0acfebfa..8f636531ec 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -62,7 +62,7 @@ async function boot(configLines: readonly string[]): Promise { "- name: '@deepseek-ai/dsh-agent'", "- name: '@deepseek-ai/dsh-system-prompt'", "- name: '@deepseek-ai/dsh-tools'", - "- name: '@deepseek-ai/dsh-tool-executor-native'", + "- name: '@deepseek-ai/dsh-tool-executor-native'", "- name: '@deepseek-ai/dsh-session-projection'", "- name: '@deepseek-ai/dsh-tool-todo'", ...configLines.length > 0 ? [' config:', ...configLines] : [], @@ -78,7 +78,7 @@ async function boot(configLines: readonly string[]): Promise { ['@deepseek-ai/dsh-agent', AgentRegistry], ['@deepseek-ai/dsh-system-prompt', SystemPrompt], ['@deepseek-ai/dsh-tools', ToolRuntime], - ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], + ['@deepseek-ai/dsh-tool-executor-native', NativeToolExecutor], ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry], ['@deepseek-ai/dsh-tool-todo', ToolTodo], ]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 108496405b..5fe40ee5bd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,12 @@ importers: '@deepseek-ai/dsh-skill-office': specifier: workspace:* version: link:packages/skill/skill-office + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:packages/core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:packages/core/tool-executor-ptc '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:packages/experimental/tool-ralph @@ -11685,6 +11691,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-testkit': + specifier: workspace:* + version: link:../../test-support/tool-testkit '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 0d6260b79c..3f4cee6fe4 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -252,7 +252,8 @@ def write_advanced_profile_patch( ) -> Path: """Write the shared custom, snapshot, and restart profile patch.""" return write_profile_patch(root, name, sessions, [ - {"id": "tools", "config": {"mode": mode}}, + {"id": "tool-executor-native", "disabled": mode != "native"}, + {"id": "tool-executor-ptc", "disabled": mode != "ptc"}, { "id": "system-prompt", "config": { diff --git a/snapshots/sdk/sdk.snapshot.ts b/snapshots/sdk/sdk.snapshot.ts index 051cfb6026..6f05d3a773 100644 --- a/snapshots/sdk/sdk.snapshot.ts +++ b/snapshots/sdk/sdk.snapshot.ts @@ -1068,7 +1068,7 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { const events = results.flatMap(result => result.events) const headers = events.filter(event => event.type === 'request/header') .map(event => (event.data.header as { tools: { name: string }[] }).tools.map(tool => tool.name)) - expect(headers).toHaveLength(2) + expect(headers, JSON.stringify(events.filter(event => event.type === 'turn/end' || event.type === 'tool/result'))).toHaveLength(2) expect(headers[0]).toContain('bash') expect(headers[0]).not.toContain('run_code') expect(headers[1]).toEqual(['run_code']) diff --git a/snapshots/sdk/tool-error-details/deny.mjs b/snapshots/sdk/tool-error-details/deny.mjs index 62313c5abe..053818812e 100644 --- a/snapshots/sdk/tool-error-details/deny.mjs +++ b/snapshots/sdk/tool-error-details/deny.mjs @@ -1,4 +1,6 @@ /** A fixed error producer for SDK transport coverage; it does not enable Auto. */ +import { PtcExecutor } from '@deepseek-ai/dsh-tool-executor-ptc' + export const name = 'structured-denial-fixture' export const inject = ['tools'] export function apply(ctx) { @@ -18,6 +20,6 @@ export function apply(ctx) { ctx.on('tools/result', (exec, result) => { if (exec.name !== 'bash' || exec.parent !== undefined || result.error?.info?.code !== 'AUTO_REVIEW_DENIED' || exec.agent === undefined) return - exec.agent.ctx.tools.presentAs('ptc') + exec.agent.ctx.tools.setExecutor(new PtcExecutor(exec.agent.ctx)) }) } diff --git a/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs b/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs index 46761aa98a..765d7f1d23 100644 --- a/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs +++ b/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs @@ -1,15 +1,14 @@ /** Reject one scheduler preparation to exercise the terminal internal-failure path. */ +import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native' + export const inject = ['tools'] /** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned runtime. */ export function apply(ctx) { - // Inspect the active instance's key so the fixture cannot introduce a second tools module. - const key = Object.getOwnPropertySymbols(ctx.tools) - .find(symbol => symbol.description === '@deepseek-ai/dsh-tools.scheduler') - if (key === undefined) throw new Error('Scheduler failure fixture requires the active tool scheduler') - const scheduler = ctx.tools[key] - const prepare = scheduler.prepare - ctx.effect(() => { + ctx.on('agent/created', ({ agent }) => { + const executor = new NativeToolExecutor(agent.ctx) + const scheduler = executor.pipeline + const prepare = scheduler.prepare scheduler.prepare = async input => { if (input.callId === 'scheduler-fail') { scheduler.prepare = prepare @@ -17,6 +16,6 @@ export function apply(ctx) { } return prepare.call(scheduler, input) } - return () => { scheduler.prepare = prepare } - }, 'scheduler failure fixture') + agent.ctx.tools.setExecutor(executor) + }) } diff --git a/snapshots/session/advanced-toolchain/presentation.mjs b/snapshots/session/advanced-toolchain/presentation.mjs index 13096eabea..073616092b 100644 --- a/snapshots/session/advanced-toolchain/presentation.mjs +++ b/snapshots/session/advanced-toolchain/presentation.mjs @@ -1,4 +1,7 @@ /** Select PTC for the parent program, then restore native calls before child creation. */ +import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native' +import { PtcExecutor } from '@deepseek-ai/dsh-tool-executor-ptc' + export const name = 'advanced-toolchain-presentation' export const inject = ['tools'] @@ -7,9 +10,10 @@ export function apply(ctx) { ctx.on('tools/result', (exec, result) => { if (exec.agent === undefined || exec.parent !== undefined || result.error !== undefined) return if (exec.name === 'cordis_inspect_list' && !selections.has(exec.agent)) { - selections.set(exec.agent, exec.agent.ctx.tools.presentAs('ptc')) + selections.set(exec.agent, exec.agent.ctx.tools.setExecutor(new PtcExecutor(exec.agent.ctx))) } else if (exec.name === 'run_code') { selections.get(exec.agent)?.() + exec.agent.ctx.tools.setExecutor(new NativeToolExecutor(exec.agent.ctx)) selections.set(exec.agent, null) } }) From 82417accb84ff635389e57dde9dac28878623298 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:04:35 +0800 Subject: [PATCH 13/73] docs(tools): refresh merged catalog and translation records --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 33cfbb29e4..ee53ca268d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3611,7 +3611,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:260`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c8a78b7e6f..7da2fed7a5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3613,7 +3613,7 @@ export interface Config { ## `@deepseek-ai/dsh-system-prompt` -- `source`: [`packages/core/system-prompt/src/index.ts:260`](../packages/core/system-prompt/src/index.ts) +- `source`: [`packages/core/system-prompt/src/index.ts:258`](../packages/core/system-prompt/src/index.ts) ```ts config-catalog /** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */ diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml index ed97c6a3df..492aa657ff 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -1,13 +1,13 @@ # Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading # section, a hash of its English and Chinese blocks outside code blocks and generated regions. # After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.4/tool-executor-plugins/guide.md +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md /: en: 3b1ca9e0f78709e0 zh: 13f23adcc722f319 /mount-a-tool-executor: - en: de4970d25f6e3cf0 - zh: 1c4d11dcb0d32322 + en: 73002a2963b9ea27 + zh: a67ea382d7c70c61 /mount-a-tool-executor/change: en: 2adab658221d48a2 zh: 91573940e81bcba2 From b907ff53d96ab93b87c5fd6cec7d82268d6a2d0e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:06:41 +0800 Subject: [PATCH 14/73] test(cli): probe the public tool batch entry in profile resolution --- apps/cli/tests/profiles/headless/tests/profile-resolution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts index 3068045800..137941c43c 100644 --- a/apps/cli/tests/profiles/headless/tests/profile-resolution.ts +++ b/apps/cli/tests/profiles/headless/tests/profile-resolution.ts @@ -214,7 +214,7 @@ export function testProfileResolution(mode: ExampleMode): void { ' sourceToolsInstance: ctx.tools instanceof SourceTools,', ` profileToolsCjs: ${toolsCjsExpression},`, ' sourceToolsCjs, sourceLeafCjs, sourceLeafExplicitCjs,', - " executor: typeof ctx.tools.prepare().execute === 'function',", + " executor: typeof ctx.tools.execute === 'function',", ' modules: [...ctx.loader.internal.loadCache.keys()]', ' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),', ' }', From 5935b6b2cc8c1aa4d52a4140942482e41555bb50 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:20:48 +0800 Subject: [PATCH 15/73] fix(runtime): include the PTC executor in Python carriers --- pnpm-lock.yaml | 3 +++ python/sdk-runtime/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fe40ee5bd..ea116afa03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16504,6 +16504,9 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../../packages/extensions/tool-cordis + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../packages/core/tool-executor-ptc '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../packages/fs/tool-fs diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 19f2de7e0c..34dca4c14f 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -126,6 +126,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-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", "@deepseek-ai/dsh-tool-goal": "workspace:*", From ebd6a9cf792de2df659669ac45491a7eaa6c1baf Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:20:48 +0800 Subject: [PATCH 16/73] test(tools): migrate remaining executor smoke fixtures --- apps/cli/tests/built-bin.e2e.ts | 1 + .../tool-present/tests/built-errors.e2e.ts | 1 + .../tests/fixtures/loader/cordis.yml | 3 ++ .../generator/tests/tools-catalog.spec.ts | 36 +++++++++++-------- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 5174026bc4..46e9e7ac77 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1268,6 +1268,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', ['session-title', '@deepseek-ai/dsh-session-title'], ['system-prompt', '@deepseek-ai/dsh-system-prompt'], ['tools', '@deepseek-ai/dsh-tools'], + ['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'], ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index 7ac3083124..d28f1b3d6a 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -15,6 +15,7 @@ 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 { executeToolFixture } from './packages/test-support/tool-testkit/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' import * as Present from './packages/deliverables/tool-present/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' diff --git a/packages/shell/tool-pwsh/tests/fixtures/loader/cordis.yml b/packages/shell/tool-pwsh/tests/fixtures/loader/cordis.yml index 39204f9ef7..0a2a07d627 100644 --- a/packages/shell/tool-pwsh/tests/fixtures/loader/cordis.yml +++ b/packages/shell/tool-pwsh/tests/fixtures/loader/cordis.yml @@ -15,6 +15,9 @@ - id: tools name: '@deepseek-ai/dsh-tools' +- id: tool-executor-native + name: '@deepseek-ai/dsh-tool-executor-native' + - id: subprocess name: '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts index 430968f909..0ff6d60c5a 100644 --- a/packages/typert/generator/tests/tools-catalog.spec.ts +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -16,29 +16,31 @@ afterEach(() => { for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }) }) -describe('model-driven dsh-tools generation', () => { - it('round-trips the complete service and event structure through the runtime registry', { timeout: 30_000 }, async () => { +describe('model-driven tool package generation', () => { + it('round-trips the complete service and event structure through the runtime registry', { timeout: 30_000 }, async ({ onTestFinished }) => { const workspace = new WorkspaceAnalyzer({ root: workspaceRoot, faces: ['host'], - packages: ['@deepseek-ai/dsh-tools'], + packages: ['@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-tool-executor-ptc'], }).analyze() const host = workspace.faces.find(candidate => candidate.face === 'host') if (host === undefined) throw new Error('dsh-tools has no analyzed host face') - const artifact = new FaceModelEmitter(host).emit('@deepseek-ai/dsh-tools') - const root = mkdtempSync(join(import.meta.dirname, '.generated-tools-')) temporaryRoots.push(root) - const modulePath = join(root, 'host.mjs') - writeFileSync(modulePath, artifact.js) - const generated = await import(`${pathToFileURL(modulePath).href}?test=${Date.now()}`) as { - TYPERT: TypertContribution - } - const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(TypertRegistry) - const dispose = ctx.typert.register(generated.TYPERT) + const registry = ctx.typert + for (const name of ['tools', 'tool-executor-ptc']) { + const artifact = new FaceModelEmitter(host).emit(`@deepseek-ai/dsh-${name}`) + const modulePath = join(root, `${name}.mjs`) + writeFileSync(modulePath, artifact.js) + const generated = await import(pathToFileURL(modulePath).href) as { TYPERT: TypertContribution } + registry.register(generated.TYPERT) + } const record = ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host') + const executor = ctx.typert.getPackage('@deepseek-ai/dsh-tool-executor-ptc', 'host') + expect(executor?.model.events.map(event => event.name)).toEqual(['tools/ptc-dispatch-log']) const service = record?.model.services.find(candidate => candidate.key === 'tools') expect(service).toBeDefined() expect({ @@ -55,7 +57,10 @@ describe('model-driven dsh-tools generation', () => { methods: api?.methods.map(method => method.signature), } })()) - expect(record?.model.events.filter(event => event.name.startsWith('tools/')).map(event => ({ + const events = [...record?.model.events ?? [], ...executor?.model.events ?? []] + .filter(event => event.name.startsWith('tools/')) + .sort((a, b) => a.name.localeCompare(b.name)) + expect(events.map(event => ({ name: event.name, mode: event.mode, signature: event.signature, @@ -70,7 +75,8 @@ describe('model-driven dsh-tools generation', () => { TYPE_API.find(type => type.name === 'ToolDefinition'), ) - await dispose() - expect(ctx.typert.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() + await ctx.fiber.dispose() + expect(registry.getPackage('@deepseek-ai/dsh-tools', 'host')).toBeUndefined() + expect(registry.getPackage('@deepseek-ai/dsh-tool-executor-ptc', 'host')).toBeUndefined() }) }) From f0987e7f31fdebd486319dbc4171a7f0cd934170 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:20:48 +0800 Subject: [PATCH 17/73] fix(gates): recognize scoped executor composition --- scripts/product-package-policy.ts | 1 + scripts/verify-cordis-config.spec.ts | 12 ++++++++++++ scripts/verify-cordis-config.ts | 18 +++++++++++++----- 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/scripts/product-package-policy.ts b/scripts/product-package-policy.ts index 8606ff62c9..08171b05b8 100644 --- a/scripts/product-package-policy.ts +++ b/scripts/product-package-policy.ts @@ -16,6 +16,7 @@ export interface ProductPackagePolicy { * Optional integrations may be reached by an optional bundle without becoming default capabilities. */ export const PRODUCT_PACKAGE_POLICY: Readonly> = { + 'packages/core/agent-tool-presentation': { category: 'optional', reason: 'Deprecated selector retained for existing executor configurations.' }, 'packages/browser-use/browser-use': { category: 'optional', reason: 'Service definition used by explicitly selected browser providers.' }, 'packages/computer-use/computer-use': { category: 'optional', reason: 'Service definition used by explicitly selected computer providers.' }, 'packages/lsp/lsp': { category: 'optional', reason: 'Service definition for deployment-configured language servers.' }, diff --git a/scripts/verify-cordis-config.spec.ts b/scripts/verify-cordis-config.spec.ts index ec0385af10..497729b2ac 100644 --- a/scripts/verify-cordis-config.spec.ts +++ b/scripts/verify-cordis-config.spec.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest' import { bundleManifestPaths, bundlePluginDependencyErrors, + exclusivePlaneRowIds, metadataExpressionErrors, packageTestFixtureDependencyErrors, packageTestPluginDependencyErrors, @@ -48,6 +49,17 @@ describe('verify-cordis-config metadata expressions', () => { }) describe('workspace Bundle discovery and product dependency closures', () => { + it('allows scoped executors on both planes while retaining other plugin rows', () => { + expect([...exclusivePlaneRowIds([{ insert: [ + { id: 'native', name: '@deepseek-ai/dsh-tool-executor-native' }, + { id: 'ptc', name: '@deepseek-ai/dsh-tool-executor-ptc' }, + { id: 'tools', name: '@deepseek-ai/dsh-tools' }, + { id: 'group', name: 'cordis:group', group: true, config: [ + { id: 'tool-executor-ptc', name: '@deepseek-ai/dsh-tool-fs' }, + ] }, + ] }])]).toEqual(['tools', 'group', 'tool-executor-ptc']) + }) + it('discovers a Bundle outside packages/bundle from its manifest declaration', () => { const fixture = mkdtempSync(join(tmpdir(), 'dsh-bundle-discovery-')) try { diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 76e9bdba87..1017c7e8e1 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -119,7 +119,8 @@ function validateClientHalvesDeclared(): string[] { } /** - * No shipped agent preset may repeat a row the host composition still runs. + * No shipped agent preset may repeat an unscoped row the host composition still runs. + * Tool executors select through the mounting scope and may appear on both planes. * * A preset contributes what ONE session adds to the host's registries. A row * active on both planes is therefore mounted twice — once per process and once @@ -157,7 +158,7 @@ function validatePresetPlaneSeparation(): string[] { const active = new Set([...rowIds(hostFile), ...overlayRows].filter(id => !disabled.has(id))) for (const file of overlayFiles) { for (const definition of presetDefinitions(loadEntries(file))) { - for (const id of collectRowIds(definition.plugins)) { + for (const id of exclusivePlaneRowIds(definition.plugins)) { if (active.has(id)) problems.push(`${file}#${definition.id}: row "${id}" is also active in the host composition; a row belongs to exactly one plane`) } } @@ -178,10 +179,15 @@ function loadEntries(file: string): unknown[] { * @returns the declared ids. */ function rowIds(file: string): Set { - return collectRowIds(loadEntries(file)) + return exclusivePlaneRowIds(loadEntries(file)) } -function collectRowIds(rows: unknown): Set { +/** + * Collect rows that cannot be shared between the host and a preset. + * @param rows - parsed Loader entries, including inserts and groups. + * @returns ids excluding the executor plugins whose registrations are scope-owned. + */ +export function exclusivePlaneRowIds(rows: unknown): Set { const ids = new Set() const walk = (value: unknown): void => { if (isUnknownArray(value)) { @@ -189,7 +195,9 @@ function collectRowIds(rows: unknown): Set { return } if (!isRecord(value)) return - if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id) + if (typeof value.id === 'string' && typeof value.name === 'string' + && value.name !== '@deepseek-ai/dsh-tool-executor-native' + && value.name !== '@deepseek-ai/dsh-tool-executor-ptc') ids.add(value.id) walk(value.insert) if (isCordisGroupEntry(value)) walk(value.config) } From 7ebe78d2317973380e48b92d7a6ae8669c4ae8f7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:20:48 +0800 Subject: [PATCH 18/73] test(tools): cover executor cancellation and model fixtures --- packages/core/tools/src/batch.ts | 6 ++- packages/core/tools/tests/tools.spec.ts | 20 +++++++++ .../test-support/tool-testkit/package.json | 1 + .../tool-testkit/tests/model-call.spec.ts | 41 +++++++++++++++++++ pnpm-lock.yaml | 3 ++ 5 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 packages/test-support/tool-testkit/tests/model-call.spec.ts diff --git a/packages/core/tools/src/batch.ts b/packages/core/tools/src/batch.ts index ea744a9d8b..d1b0e1a6f4 100644 --- a/packages/core/tools/src/batch.ts +++ b/packages/core/tools/src/batch.ts @@ -257,7 +257,11 @@ class BatchSettlement { message: createToolResultMessage({ callId: block.id, content: result.content, isError: result.isError }), ...result.error?.info ? { error: result.error.info } : {}, ...result.meta !== undefined ? { meta: result.meta } : {}, - }, { surfaceOp: 'append', ...callSeq === undefined ? {} : { sourceEventSeqs: [callSeq] } }) + }, { + surfaceOp: 'append', + // oxlint-disable-next-line typescript/no-non-null-assertion -- start records a call seq whenever this batch logs results + sourceEventSeqs: [callSeq!], + }) } this.results.push({ callId: block.id, result }) this.contexts.push(...result.additionalContexts ?? []) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index ca90e1bd72..7b12f80037 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,4 +1,5 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -1761,6 +1762,25 @@ describe('ToolRuntime', () => { expect(observed).toBe(1) }) + it('reports cancellation before an executor refusal without entering policy', async ({ onTestFinished }) => { + const ctx = await setup() + onTestFinished(() => ctx.fiber.dispose()) + let policyCalls = 0 + ctx.on('tools/pre-execute', async (_exec, next) => { policyCalls += 1; return next() }) + const pipeline = new ToolExecutionPipeline(ctx, { + get: () => echoTool, + reject: () => new Error('executor refuses direct calls'), + }) + const prepared = await pipeline.prepare({ + callId: ToolCallId('cancelled-refusal'), name: 'echo', arguments: {}, signal: AbortSignal.abort(), + }) + expect(prepared.kind).toBe('final-result') + if (prepared.kind === 'dispatch') throw new Error('cancelled call was dispatched') + const result = await pipeline.complete(prepared.exec, prepared) + expect(result.error?.info?.code).toBe(TOOL_ABORTED_BEFORE_DISPATCH) + expect(policyCalls).toBe(0) + }) + it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => { const ctx = await setup() ctx.tools.register(echoTool) diff --git a/packages/test-support/tool-testkit/package.json b/packages/test-support/tool-testkit/package.json index eb3221b5f6..f0835ab2ea 100644 --- a/packages/test-support/tool-testkit/package.json +++ b/packages/test-support/tool-testkit/package.json @@ -34,6 +34,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/test-support/tool-testkit/tests/model-call.spec.ts b/packages/test-support/tool-testkit/tests/model-call.spec.ts new file mode 100644 index 0000000000..3638f83965 --- /dev/null +++ b/packages/test-support/tool-testkit/tests/model-call.spec.ts @@ -0,0 +1,41 @@ +/** Model-call fixtures preserve raw JSON input and reject incomplete execution. */ +import { Context } from '@deepseek-ai/cordis' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import Tools, { type ToolExecutor } from '@deepseek-ai/dsh-tools' +import { expect, it, onTestFinished } from 'vitest' +import { executeModelToolFixture } from '../src/index.ts' + +async function setup(execute: ToolExecutor['execute']): Promise { + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + await ctx.plugin(SystemPrompt) + await ctx.plugin(Tools) + ctx.tools.setExecutor({ describe: () => ({ schemas: [], knownNames: [] }), execute }) + return ctx +} + +const call = { callId: ToolCallId('fixture'), name: 'echo', signal: new AbortController().signal } + +it('serializes model arguments and returns the selected executor result without an Agent', async () => { + const result = { isError: false as const, value: 'done', content: [{ type: 'text' as const, text: 'done' }] } + const ctx = await setup(async (input) => { + expect(input.calls).toEqual([{ type: 'tool-call', id: call.callId, name: 'echo', arguments: '{"text":"hello"}' }]) + expect(input.signal).toBe(call.signal) + expect(input.agent).toBeUndefined() + return { results: [{ callId: call.callId, result }], additionalContexts: [], concluded: false } + }) + expect(await executeModelToolFixture(ctx, { ...call, arguments: { text: 'hello' } })).toBe(result) +}) + +it('rejects unencodable model arguments before invoking the executor', async () => { + const ctx = await setup(async () => { throw new Error('executor must not run') }) + await expect(executeModelToolFixture(ctx, { ...call, arguments: undefined })) + .rejects.toThrow('Model tool fixture arguments must encode as JSON') +}) + +it('reports when an executor omits the requested result', async () => { + const ctx = await setup(async () => ({ results: [], additionalContexts: [], concluded: false })) + await expect(executeModelToolFixture(ctx, { ...call, arguments: {} })) + .rejects.toThrow('Executor omitted the model tool fixture result') +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea116afa03..8913024452 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15481,6 +15481,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:* + version: link:../../core/system-prompt '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools From ef9ed3fec187515279d5e1ba89842b7364107c2a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:24:06 +0800 Subject: [PATCH 19/73] test(agent-loop): preserve context after partial batch failure --- packages/core/agent-loop/tests/tool-calls.spec.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 0d0b1168b1..a57106751d 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -699,8 +699,11 @@ describe('tool-call scheduler: failure quiescence', () => { const executed: string[] = [] ctx.tools.register(defineContentToolFixture({ name: 'p', description: 'exclusive', parameters: { id: { type: 'string', required: true } }, - async execute(args) { + async execute(args, exec) { executed.push(args.id) + if (args.id === '1') { + exec.deferContext(createUserMessage({ content: [{ type: 'text', text: 'committed context' }], source: { kind: 'p' } })) + } return [{ type: 'text', text: `done-${args.id}` }] }, })) @@ -770,7 +773,10 @@ describe('tool-call scheduler: failure quiescence', () => { if (message.role === 'user' && block.type === 'text') return [`user:${block.text}`] return [] }) - })).toEqual(['user:go', 'call:c1', 'call:c2', 'call:c3', 'result:c1', 'result:c2', 'result:c3', 'user:continue']) + })).toEqual([ + 'user:go', 'call:c1', 'call:c2', 'call:c3', 'result:c1', 'result:c2', 'result:c3', + 'user:committed context', 'user:continue', + ]) expect(events(agent).filter(event => event.type === 'tool/result')).toHaveLength(3) expect(events(agent).findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed') expect(listenerCount()).toBe(idleListenerCount) From 1db86655af7b3a5e9b8c8b699fdcb1ab1d21ec20 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:24:06 +0800 Subject: [PATCH 20/73] test(sdk-minimal): expect the native tool executor --- packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts index ce122661ae..2a96ffc076 100644 --- a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -53,6 +53,7 @@ describe('dsh-sdk-minimal bundle', () => { ['session-title', '@deepseek-ai/dsh-session-title'], ['system-prompt', '@deepseek-ai/dsh-system-prompt'], ['tools', '@deepseek-ai/dsh-tools'], + ['tool-executor-native', '@deepseek-ai/dsh-tool-executor-native'], ['mcp-resources', '@deepseek-ai/dsh-mcp-resources'], ['agent', '@deepseek-ai/dsh-agent'], ['llm-retry', '@deepseek-ai/dsh-llm-retry'], From 2f69e7f181e0bba5f956662aab45da0fbf3ea8d3 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:41:31 +0800 Subject: [PATCH 21/73] test: finish executor fixture migration and await readiness --- .../tests/profiles/headless/tests/ptc.e2e.ts | 2 +- .../tests/fixtures/creator-plugin-manager.mjs | 6 +++-- apps/cli/tests/web-agent-presets.e2e.ts | 4 ++-- .../spill-policy-terminal.client.spec.ts | 4 ++-- .../auto-review/tests/auto-review.e2e.ts | 4 ++-- .../tests/fixtures/built-attachment.mjs | 3 ++- .../tool-ralph/tests/tool-ralph.spec.ts | 1 + .../tool-terminal/tests/tools.spec.ts | 2 +- packages/fs/tool-fs/tests/integration.spec.ts | 2 +- packages/fs/tool-fs/tests/read-image.spec.ts | 5 ++-- .../mcp/mcp-resources/tests/resources.spec.ts | 5 ++-- .../tests/session-checkpoint-policy.spec.ts | 14 +++++++---- .../shell/tool-bash/tests/integration.spec.ts | 2 +- .../subagent/tests/structured.spec.ts | 4 ++-- .../agent-loop-testkit/package.json | 1 + .../tests/agent-loop-testkit.spec.ts | 24 +++++++++++++++++++ pnpm-lock.yaml | 3 +++ 17 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index d10bf5e532..0eb7a187b0 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -309,7 +309,7 @@ describe('PTC mode typed values: keyless real-process contracts', () => { return { provider: provider.id, names: inspected.data.tools.map(tool => tool.name) }; `, testToolSignal, agent)) - expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query', 'run_code'] }) + expect(value).toEqual({ provider: 'Tool', names: ['cordis_inspect_list', 'cordis_inspect_query'] }) }) }) diff --git a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs index b13cc06455..3b0f7bdc32 100644 --- a/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs +++ b/apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs @@ -1,4 +1,6 @@ /** Test-only IPC assertions over real Creator presets and profile management. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' + export const inject = ['agents', 'agentPresets', 'tools', 'pluginManager', 'permissionPresets'] export function apply(ctx, config) { @@ -30,7 +32,7 @@ export function apply(ctx, config) { try { const first = await make(`${phase}-first`) const before = names(first) - const manage = args => ctx.tools.execute({ name: 'plugin_manager', arguments: args, agent: first, + const manage = args => executeModelToolFixture(ctx, { name: 'plugin_manager', arguments: args, agent: first, callId: `${phase}-manage`, signal: new AbortController().signal }) ctx.permissionPresets.set(first.session, 'workspace-write') first.session.append('turn/start', { turn: 1 }) @@ -45,7 +47,7 @@ export function apply(ctx, config) { const second = await make(`${phase}-second`) const after = names(first) const other = names(second) - const ping = await ctx.tools.execute({ name: 'mcp__demo__ping', arguments: {}, agent: first, + const ping = await executeModelToolFixture(ctx, { name: 'mcp__demo__ping', arguments: {}, agent: first, callId: `${phase}-ping`, signal: new AbortController().signal }) const removed = phase === 'restart' ? JSON.parse((await manage({ action: 'remove_bundle', target: '@test/creator-mcp' })).value) : undefined diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 5ecb83fe60..5944476fb2 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -831,7 +831,7 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(page.isError, resultText(page)).toBe(false) return (JSON.parse(resultText(page)) as ConfigPage).data } - const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-tool-presentation' }, 'copied-preset-inspect-configs') + const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tool-executor-native' }, 'copied-preset-inspect-configs') const toolsRow = toolsRows.entries[0] expect(toolsRow).toMatchObject({ patchId: 'tool-executor-native', status: 'schema' }) expect(toolsRows).toMatchObject({ total: 1, nextOffset: null }) @@ -852,7 +852,7 @@ describe('a user preset declared from the shipped cordis rows', () => { const { data } = JSON.parse(resultText(projected)) as { data: Projected } expect(data.status).toBe('schema') expect(data.schema.$defs).toHaveProperty('loaderExpression') - expect(JSON.stringify(data.schema)).toContain('"mode"') + expect(JSON.stringify(data.schema)).toContain('"maxParallelToolCalls"') } finally { await copied.dispose() await shipped.dispose() diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 4e68ff9997..4b229a1543 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -1,4 +1,4 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Producer-to-UI regression for shell results bounded by the real spill policy. */ import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' @@ -80,7 +80,7 @@ async function executeShell(text: string, nested: boolean, name = 'bash', maxInl // Nested PTC registers retained-environment cleanup on the owning context. const agent = { ctx, session } as Agent const callId = ToolCallId('shell-call') - const result = await executeToolFixture(ctx, { + const result = await executeModelToolFixture(ctx, { callId, agent, signal: new AbortController().signal, diff --git a/packages/experimental/auto-review/tests/auto-review.e2e.ts b/packages/experimental/auto-review/tests/auto-review.e2e.ts index 381b9f79fb..8fe8ac3924 100644 --- a/packages/experimental/auto-review/tests/auto-review.e2e.ts +++ b/packages/experimental/auto-review/tests/auto-review.e2e.ts @@ -315,7 +315,7 @@ it('certifies eight Auto risk/authorization cases with zero retries and zero ski sessionId: SessionId(randomUUID()), meta: { cwd: workspace }, agentOptions: { provider: PROVIDER, model }, }) try { - if (path === 'ptc-inner') handle.agent.ctx.plugin(PtcToolExecutor) + if (path === 'ptc-inner') await handle.agent.ctx.plugin(PtcToolExecutor) selectFinalAuto(ctx, handle.agent.session) const command = `rm -- ${quote(target)}` await runCase(handle.agent, 'M01-unauthorized', path, command, @@ -376,7 +376,7 @@ it.each(['native', 'ptc-inner'] as const)('feeds denial back, re-reviews a new c sessionId: SessionId(randomUUID()), meta: { cwd: root }, agentOptions: { provider: PROVIDER, model: FLASH }, }) try { - if (path === 'ptc-inner') handle.agent.ctx.plugin(PtcToolExecutor) + if (path === 'ptc-inner') await handle.agent.ctx.plugin(PtcToolExecutor) selectFinalAuto(ctx, handle.agent.session) const rawReason = ` TEST_ONLY_SECRET_${'x'.repeat(16_384)}\nexact deletion was not authorized ` for (let attempt = 0; attempt < 2; attempt += 1) { diff --git a/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs b/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs index 7ed3e7e07c..5ddbe44748 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs +++ b/packages/experimental/browser-use-stagehand-native/tests/fixtures/built-attachment.mjs @@ -4,6 +4,7 @@ import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deep import BrowserUseRegistry from '@deepseek-ai/dsh-browser-use' import { ToolCallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as Provider from '../../lib/index.js' const [endpoint, url] = process.argv.slice(2) @@ -19,7 +20,7 @@ try { const harness = await mountAgentLoopTestHarness(ctx) const agent = await harness.create(SessionId('built-stagehand')) const call = async (method, args) => { - const result = await ctx.tools.execute({ agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`built-${method}`), signal: new AbortController().signal }) + const result = await executeModelToolFixture(ctx, { agent, name: `stagehand_${method}`, arguments: args, callId: ToolCallId(`built-${method}`), signal: new AbortController().signal }) if (result.isError) throw new Error(JSON.stringify(result.content)) return result } diff --git a/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts b/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts index 6572520f6f..ffa84f5e73 100644 --- a/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/experimental/tool-ralph/tests/tool-ralph.spec.ts @@ -238,6 +238,7 @@ describe('dsh-tool-ralph', () => { expect((firstResult.content[0] as { text: string }).text).toContain('No previous handoff was available.') const later = execute(ctx, { objective: 'Ship it.', maxRounds: 2 }, { agent: parent }) + await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) }) const laterResult = await settleCompleted(engine, later, { status: 'round-failed', roundsStarted: 2, diff --git a/packages/experimental/tool-terminal/tests/tools.spec.ts b/packages/experimental/tool-terminal/tests/tools.spec.ts index 7403b2fa42..93848f5337 100644 --- a/packages/experimental/tool-terminal/tests/tools.spec.ts +++ b/packages/experimental/tool-terminal/tests/tools.spec.ts @@ -497,7 +497,7 @@ describe('tool-terminal task integration', () => { stub.sessions[0]!.autoSettle = false const controller = new AbortController() const pending = callWithSignal(ctx, 'terminal_send', { sessionId: 'pty-1', text: 'sleep' }, agent, controller.signal) - await Promise.resolve() + await expect.poll(() => stub.sessions[0]!.operation).toBeDefined() controller.abort() stub.sessions[0]!.operation?.cancel() expect((await pending).isError).toBe(true) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 7576d95f45..65fa6be7f0 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -623,7 +623,7 @@ describe('current working directory', () => { } } await ctx.plugin(ReadBindingRuntime) - agent.ctx.plugin(PtcToolExecutor) + await agent.ctx.plugin(PtcToolExecutor) const nested = await executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('ptc-read-current'), name: 'run_code', arguments: { code: 'return await tools.read({file_path: "note.txt"})', description: 'Read the current note' }, agent, diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 19dbc3cc12..cd66e7cf6d 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -1,5 +1,5 @@ import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * The `read_image` tool over the REAL local filesystem and attachment store: * extension routing, extension-less content sniffing (attachment object paths @@ -150,7 +150,8 @@ function agentOn(model: string | undefined, provider = 'visual', messages: reado let callCounter = 0 function call(ctx: Context, name: string, args: unknown, agent?: object) { - return executeToolFixture(ctx, { + const execute = name === RUN_CODE_NAME ? executeModelToolFixture : executeToolFixture + return execute(ctx, { signal: testToolSignal, callId: ToolCallId(`img-call-${++callCounter}`), name, diff --git a/packages/mcp/mcp-resources/tests/resources.spec.ts b/packages/mcp/mcp-resources/tests/resources.spec.ts index 4f63331eee..c731a843ba 100644 --- a/packages/mcp/mcp-resources/tests/resources.spec.ts +++ b/packages/mcp/mcp-resources/tests/resources.spec.ts @@ -1,4 +1,4 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { setImmediate as nextEventLoopTurn } from 'node:timers/promises' @@ -59,7 +59,8 @@ function visibleResourceTools(ctx: Context, agent?: Agent): string[] { } function call(ctx: Context, name: string, args: unknown, agent?: Agent) { - return executeToolFixture(ctx, { + const execute = name === 'run_code' ? executeModelToolFixture : executeToolFixture + return execute(ctx, { name, arguments: args, callId: ToolCallId('resource-test'), signal: new AbortController().signal, ...agent === undefined ? {} : { agent }, diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index a9ddf50053..9845368142 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -110,14 +110,17 @@ describe('session-checkpoint-policy request boundary', () => { }) describe('session-checkpoint-policy tool and step boundaries', () => { - it('awaits the checkpoint before a top-level tool body', async () => { + it('awaits the checkpoint before a top-level tool body', async ({ onTestFinished }) => { const ctx = await setup() const session = ctx.sessions.create(SessionId('tool-checkpoint')) const agent = { session } as Agent const gate = Promise.withResolvers() + onTestFinished(() => { gate.resolve(undefined) }) + const entered = Promise.withResolvers() const order: string[] = [] ctx.on('session/flush', async () => { order.push('flush:start') + entered.resolve(undefined) await gate.promise order.push('flush:end') }) @@ -131,22 +134,25 @@ describe('session-checkpoint-policy tool and step boundaries', () => { callId: ToolCallId('write-1'), name: 'write', arguments: {}, agent, signal: new AbortController().signal, }) - await Promise.resolve() + await entered.promise expect(order).toEqual(['flush:start']) gate.resolve(undefined) await expect(pending).resolves.toMatchObject({ isError: false }) expect(order).toEqual(['flush:start', 'flush:end', 'tool']) }) - it('does not dispatch when cancellation lands during the tool checkpoint', async () => { + it('does not dispatch when cancellation lands during the tool checkpoint', async ({ onTestFinished }) => { const ctx = await setup() const session = ctx.sessions.create(SessionId('tool-checkpoint-cancel')) const agent = { session } as Agent const controller = new AbortController() const gate = Promise.withResolvers() + onTestFinished(() => { gate.resolve(undefined) }) + const entered = Promise.withResolvers() const order: string[] = [] ctx.on('session/flush', async () => { order.push('flush:start') + entered.resolve(undefined) await gate.promise order.push('flush:end') }) @@ -160,7 +166,7 @@ describe('session-checkpoint-policy tool and step boundaries', () => { callId: ToolCallId('write-cancelled'), name: 'write', arguments: {}, agent, signal: controller.signal, }) - await Promise.resolve() + await entered.promise expect(order).toEqual(['flush:start']) controller.abort('cancelled during checkpoint') gate.resolve(undefined) diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts index 07ef7d2d0e..0ad254ed8b 100644 --- a/packages/shell/tool-bash/tests/integration.spec.ts +++ b/packages/shell/tool-bash/tests/integration.spec.ts @@ -120,7 +120,7 @@ describe('bash tool through the agent loop', () => { } } await ctx.plugin(BashBindingRuntime) - agent.ctx.plugin(PtcToolExecutor) + await agent.ctx.plugin(PtcToolExecutor) const result = await executeModelToolFixture(ctx, { callId: ToolCallId('nested-shell-root'), agent, signal: new AbortController().signal, name: 'run_code', arguments: { code: 'return await tools.bash({ command: "pwd", description: "Print launch directory" })', description: 'Inspect current directory' }, diff --git a/packages/subagent/subagent/tests/structured.spec.ts b/packages/subagent/subagent/tests/structured.spec.ts index 47a7ef1b1b..666ff504cb 100644 --- a/packages/subagent/subagent/tests/structured.spec.ts +++ b/packages/subagent/subagent/tests/structured.spec.ts @@ -1,4 +1,4 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' @@ -171,7 +171,7 @@ describe('in-process structured output', () => { return blocked ? { kind: 'block', feedback: [{ type: 'text', text: 'Result policy rejected capture.' }] } : next() }) const run = await startTestActivation(ctx, 'spawn', structuredRequest(parent)) - const execution = executeToolFixture(ctx, { + const execution = executeModelToolFixture(ctx, { signal: testToolSignal, callId: ToolCallId('capture'), name: toolName, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index b9f2eacdf1..ee628370d1 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-ptc-runtime": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index 932912b4e4..7c172737ab 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import FsLocal from '@deepseek-ai/dsh-fs-local' +import { PtcRuntime, type PtcRunRequest, type PtcRunResult, type PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' @@ -18,7 +19,30 @@ function message(text: string) { return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) } +class FixtureRuntime extends PtcRuntime { + readonly language = 'typescript' + readonly isolation = 'fixture' + resolve(request: PtcRunRequest): PtcRunSpec { + return { ...request, cwd: request.cwd ?? process.cwd(), timeoutMs: request.timeoutMs ?? 1_000 } + } + run(): Promise { + return Promise.resolve({ logs: [] }) + } +} + describe('dsh-agent-loop-testkit', () => { + it('rejects an invalid program concurrency limit during prerequisite mounting', async () => { + const ctx = new Context() + try { + await ctx.plugin(FixtureRuntime) + await expect(mountAgentLoopTestDependencies(ctx, { + tools: { mode: 'ptc', maxParallelSubCalls: 0 }, + })).rejects.toThrow('maxParallelSubCalls') + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects mutations through an unsupported Agent stub Inbox', () => { const inbox = unsupportedInbox() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 100f588f73..9b2b9da3e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15194,6 +15194,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:* version: link:../../llm/llm + '@deepseek-ai/dsh-ptc-runtime': + specifier: workspace:* + version: link:../../ptc-runtime/ptc-runtime '@deepseek-ai/dsh-session': specifier: workspace:* version: link:../../core/session From 44391530735dc5a0057d189f26f06d004dbd12dc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:41:32 +0800 Subject: [PATCH 22/73] fix(boot): require enabled tool executors at startup --- .../v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml | 4 ++-- docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md | 2 +- .../v0.2.1-rc.2/tool-executor-plugins/guide.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 ++-- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 2 ++ packages/boot/app-boot/tests/app-boot.spec.ts | 2 ++ 8 files changed, 12 insertions(+), 8 deletions(-) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml index 492aa657ff..bf3b31aa5b 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 73002a2963b9ea27 zh: a67ea382d7c70c61 /mount-a-tool-executor/change: - en: 2adab658221d48a2 - zh: 91573940e81bcba2 + en: b47c04b407b204a9 + zh: f3f54c9b25eb1951 /mount-a-tool-executor/migration: en: 411860d06f1013ea zh: 615e044545d89b55 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md index aac2697ee9..9c3fc0bc4a 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but no longer accepts `mode` or `maxParallelSubCalls`. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. +`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but no longer accepts `mode` or `maxParallelSubCalls`. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. ## Migration diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md index f8161ebf9c..0d9e484c8e 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md @@ -9,7 +9,7 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 ## 变更 -`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但不再接受 `mode` 或 `maxParallelSubCalls`。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。 +`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但不再接受 `mode` 或 `maxParallelSubCalls`。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。 启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。 ## 迁移 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index c13fb647d1..3a1296c53e 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2fdfe02f2ed4a258 zh: d748228ece579c4a /deepseek-ai-dsh-app-boot/use-this-package/starting-the-app: - en: 4a424cd401556f20 - zh: 0b34dd18e0db6cea + en: 65bf2834b81f87da + zh: c864ac60a3005b99 /deepseek-ai-dsh-app-boot/use-this-package/profiles: en: d2d520668668292c zh: b7deeab27032a3ad diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 59c38476da..aa41460585 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. +`installFailLoud` writes one labelled `util.inspect` diagnostic to stderr for an unhandled rejection or an uncaught exception, awaits the surface's release hook under a fixed timeout, and exits 1; control never returns to the failed operation, because only the throw site knows which state is intact, and the event loop runs only until the release settles or times out. With that entry point, startup keeps every plugin that can activate. An enabled failed plugin produces a labelled warning. A failed required entry makes startup dispose the whole app and exit nonzero; required ids absent from a profile and disabled required entries do not affect startup. The global required list covers shared Agent execution, application endpoints, and Web bootstrap/transport: `agent-loop`, `tool-executor-native`, `tool-executor-ptc`, `webserver`, `web-runtime`, `modules`, `connection`, `headless-runner`, `acp`, and `sdk-jsonrpc-server`. ### Profiles diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 73e383e04a..d5a2a49b15 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -40,7 +40,7 @@ installFailLoud('dsh') const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) ``` -`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 +`installFailLoud` 会为未处理 rejection 或未捕获异常向 stderr 写一条带标签的 `util.inspect` 诊断,在固定超时内等待界面的 release 钩子,然后以 1 退出;控制流不会回到失败的操作,因为只有抛出点知道哪些状态仍然完整,事件循环只运行到 release 结束或超时。有了这个入口,启动会保留所有能够激活的插件。启用但失败的插件会产生带标签的警告。required entry 失败时,启动会拆卸整个应用并以非零码退出;profile 中不存在的 required id 和已禁用的 required entry 不影响启动。全局 required list 覆盖共享 Agent 执行、应用 endpoint,以及 Web 启动与传输:`agent-loop`、`tool-executor-native`、`tool-executor-ptc`、`webserver`、`web-runtime`、`modules`、`connection`、`headless-runner`、`acp` 和 `sdk-jsonrpc-server`。 ### Profile diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fded02c9c8..67c8618b9d 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -950,6 +950,8 @@ const FIBER_DISPOSED = 4 as FiberState.DISPOSED */ const requiredStartupEntryIds = new Set([ 'agent-loop', + 'tool-executor-native', + 'tool-executor-ptc', 'webserver', 'web-runtime', 'modules', diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 5e83da0b17..48f2e4456c 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -557,6 +557,8 @@ describe('installFailLoud', () => { describe('auditStartupEntries', () => { const requiredIds = [ 'agent-loop', + 'tool-executor-native', + 'tool-executor-ptc', 'webserver', 'web-runtime', 'modules', From a63c3ba39af4c04a898a8c59c06126571185a636 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:44:05 +0800 Subject: [PATCH 23/73] test(sdk): resolve scheduler fixture from the runtime profile --- .../sdk/tool-scheduler-recovery/scheduler-failure.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs b/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs index 765d7f1d23..5a813743fe 100644 --- a/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs +++ b/snapshots/sdk/tool-scheduler-recovery/scheduler-failure.mjs @@ -1,10 +1,10 @@ /** Reject one scheduler preparation to exercise the terminal internal-failure path. */ -import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native' - -export const inject = ['tools'] +export const inject = ['tools', 'loader'] /** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned runtime. */ -export function apply(ctx) { +export async function apply(ctx) { + // Resolve through the running profile so packaged runs share the bundled error classes. + const { NativeToolExecutor } = await ctx.loader.import('@deepseek-ai/dsh-tool-executor-native') ctx.on('agent/created', ({ agent }) => { const executor = new NativeToolExecutor(agent.ctx) const scheduler = executor.pipeline From 2f113f54cfd4295bafda32eb4c19817d0b844de2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 15:51:57 +0800 Subject: [PATCH 24/73] test: refresh Tools API inspection snapshot --- snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl index a165bcdee1..6fb94c5c26 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778145981,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778145981,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":1788778145981,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Concrete tool registry and scoped executor dispatcher.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"definitions(scope?: ScopeKey): readonly ToolDefinition[]\",\n \"description\": \"Read the visible capability definitions for descriptions and program bindings.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"viewing Agent or preset; omitted selects the global view.\"\n }\n ],\n \"returns\": \"definitions after scoped shadowing and restrictions.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"setExecutor(executor: ToolExecutor): () => void\",\n \"description\": \"Provide the executor for the calling scope; an unscoped contribution is the deployment default.\",\n \"parameters\": [\n {\n \"name\": \"executor\",\n \"description\": \"initialized provider owned by the contributing plugin.\"\n }\n ],\n \"returns\": \"the effect disposer. Unloading leaves the selection unavailable until it is replaced.\"\n },\n {\n \"signature\": \"async execute(input: ToolBatchInput): Promise\",\n \"description\": \"Delegate a raw call batch to its Agent's provider or the deployment default. Select at dispatch; accepted batches settle through that provider even if it unloads.\",\n \"parameters\": [\n {\n \"name\": \"input\",\n \"description\": \"original calls, optional owning Agent, and optional logging step.\"\n }\n ],\n \"returns\": \"the selected executor's settled batch; unavailable selections reject.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic denial in the calling scope.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"evaluated after extensible pre-execute policy by the shared pipeline.\"\n }\n ],\n \"returns\": \"the effect disposer that removes this guard.\"\n },\n {\n \"signature\": \"checkGuards(exec: ToolExecution): string | undefined\",\n \"description\": \"Check global and inherited guards after pre-execute policy and approval resolve.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"invocation whose guards are checked before dispatch.\"\n }\n ],\n \"returns\": \"the first denial reason, or undefined when no guard denies.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssembleContext\",\n \"declaration\": \"export interface AssembleContext {\\n scope?: ScopeKey;\\n signal?: AbortSignal;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"PromptSection\",\n \"declaration\": \"export interface PromptSection {\\n readonly name: string;\\n readonly order: number;\\n readonly text: string | ((context: AssembleContext) => string);\\n readonly interpolate?: boolean;\\n readonly complete?: boolean;\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolBatchInput\",\n \"declaration\": \"export interface ToolBatchInput {\\n readonly calls: readonly ToolCallBlock[];\\n readonly signal: AbortSignal;\\n readonly maxParallelToolCalls?: number;\\n readonly agent?: Agent;\\n readonly log?: {\\n readonly turn: number;\\n readonly step: number;\\n };\\n}\"\n },\n {\n \"name\": \"ToolBatchResult\",\n \"declaration\": \"export interface ToolBatchResult {\\n readonly results: readonly ToolCallResult[];\\n readonly additionalContexts: readonly UserMessage[];\\n readonly concluded: boolean;\\n}\"\n },\n {\n \"name\": \"ToolCallBlock\",\n \"declaration\": \"export interface ToolCallBlock {\\n type: 'tool-call';\\n id: ToolCallId;\\n name: string;\\n arguments: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolExecutor\",\n \"declaration\": \"export interface ToolExecutor {\\n readonly sections?: readonly ToolPromptSection[];\\n describe(tools: readonly ToolDefinition[]): ToolModelInput;\\n execute(input: ToolBatchInput): Promise;\\n}\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolModelInput\",\n \"declaration\": \"export type ToolModelInput = Pick;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPromptSection\",\n \"declaration\": \"export interface ToolPromptSection extends Pick {\\n readonly text: string | ((tools: readonly ToolDefinition[]) => string);\\n}\"\n },\n {\n \"name\": \"ToolProviderResult\",\n \"declaration\": \"export interface ToolProviderResult {\\n readonly schemas: readonly ToolSchema[];\\n readonly knownNames?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"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":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} From 3b978adcec944495124044fe20496863a27824e7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 16:32:04 +0800 Subject: [PATCH 25/73] test(tools): tidy migrated fixture imports and comments --- apps/cli/tests/optional-bundle-transitions.e2e.ts | 2 +- apps/web/tests/jobs-menu-layering.e2e.ts | 2 +- apps/web/tests/schedule-after.e2e.ts | 2 +- packages/boot/plugin-manager/tests/tools.spec.ts | 2 +- packages/boot/plugin-market/tests/tools.spec.ts | 2 +- .../client/ui-tool/tests/spill-policy-terminal.client.spec.ts | 2 +- .../session-reference/tests/loader-composition.spec.ts | 2 +- packages/core/agent-loop/tests/cancel.spec.ts | 4 ++-- packages/core/agent-loop/tests/tool-order.spec.ts | 4 ++-- packages/deliverables/tool-present/tests/present.spec.ts | 2 +- .../experimental/browser-use-runtime/tests/mcp-upstream.ts | 2 +- .../browser-use-stagehand-native/tests/native.e2e.ts | 2 +- .../browser-use-stagehand-native/tests/provider.spec.ts | 2 +- .../computer-use-cua-driver-mcp/tests/composition.spec.ts | 2 +- .../computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts | 2 +- .../computer-use-cua-driver-native/tests/cancellation.spec.ts | 2 +- .../tests/loader-composition.spec.ts | 2 +- .../computer-use-cua-driver-native/tests/native.e2e.ts | 2 +- .../computer-use-cua-driver-native/tests/provider.spec.ts | 2 +- .../experimental/cua-macos/tests/loader-composition.spec.ts | 2 +- .../cua-macos/tests/persistent-bridge.built.e2e.ts | 2 +- .../experimental/cua-macos/tests/provider-lifecycle.spec.ts | 2 +- packages/fs/tool-fs-search/tests/integration.spec.ts | 4 ++-- packages/fs/tool-fs-search/tests/tools.spec.ts | 4 ++-- packages/fs/tool-fs/tests/integration.spec.ts | 4 ++-- packages/guard/timeout-policy/tests/timeout-policy.spec.ts | 2 +- packages/mcp/mcp-client/tests/mcp-client.e2e.ts | 2 +- packages/mcp/mcp-client/tests/protocol.spec.ts | 2 +- packages/mcp/mcp-client/tests/reconnect.spec.ts | 2 +- packages/mcp/mcp-resources/tests/resources.spec.ts | 2 +- packages/shell/tool-pwsh/tests/integration.spec.ts | 4 ++-- packages/shell/tool-pwsh/tests/tools.spec.ts | 4 ++-- packages/skill/skill-office/tests/progressive-loading.spec.ts | 2 +- packages/spill/spill-policy/tests/multimodal-recovery.spec.ts | 4 ++-- packages/spill/spill-policy/tests/multimodal.spec.ts | 4 ++-- packages/spill/spill-policy/tests/spill-policy.spec.ts | 2 +- .../tool-subagent/tests/model-selection-settings.spec.ts | 2 +- packages/web/tool-web/tests/integration.spec.ts | 4 ++-- packages/web/tool-web/tests/spill.spec.ts | 4 ++-- 39 files changed, 50 insertions(+), 50 deletions(-) diff --git a/apps/cli/tests/optional-bundle-transitions.e2e.ts b/apps/cli/tests/optional-bundle-transitions.e2e.ts index 17863719d2..b5a5eecbaf 100644 --- a/apps/cli/tests/optional-bundle-transitions.e2e.ts +++ b/apps/cli/tests/optional-bundle-transitions.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Optional bundles through the shipped Web Loader, live package manager, and real Agent loop. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { randomUUID } from 'node:crypto' import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' diff --git a/apps/web/tests/jobs-menu-layering.e2e.ts b/apps/web/tests/jobs-menu-layering.e2e.ts index f8be48f444..7388c9f4b3 100644 --- a/apps/web/tests/jobs-menu-layering.e2e.ts +++ b/apps/web/tests/jobs-menu-layering.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Background-job menu geometry over the normal Browser dock in the shipped Web composition. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index a4978a6b98..1fc1ef2395 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Keyless assembled-Web evidence for conversational Schedule delivery. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' diff --git a/packages/boot/plugin-manager/tests/tools.spec.ts b/packages/boot/plugin-manager/tests/tools.spec.ts index 64257882b1..4bc87fed73 100644 --- a/packages/boot/plugin-manager/tests/tools.spec.ts +++ b/packages/boot/plugin-manager/tests/tools.spec.ts @@ -1,7 +1,7 @@ +/** Agent controls use the same manager methods as the Web and bound inventory reads. */ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -/** Agent controls use the same manager methods as the Web and bound inventory reads. */ import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/boot/plugin-market/tests/tools.spec.ts b/packages/boot/plugin-market/tests/tools.spec.ts index 98eb0fe16c..8fadfd222a 100644 --- a/packages/boot/plugin-market/tests/tools.spec.ts +++ b/packages/boot/plugin-market/tests/tools.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** The read-only market listing tool over the same service the Discover page uses. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { Context, Service } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts index 4b229a1543..1b92e65a1f 100644 --- a/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts +++ b/packages/client/ui-tool/tests/spill-policy-terminal.client.spec.ts @@ -1,5 +1,5 @@ -import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Producer-to-UI regression for shell results bounded by the real spill policy. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/context/session-reference/tests/loader-composition.spec.ts b/packages/context/session-reference/tests/loader-composition.spec.ts index 113bccccc8..09d051bfb5 100644 --- a/packages/context/session-reference/tests/loader-composition.spec.ts +++ b/packages/context/session-reference/tests/loader-composition.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real Loader composition preserves retrievable source text outside the bounded preview. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 8c2b935edb..c702b64e89 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -1,5 +1,3 @@ -import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' -import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm' /** * Tests for the queue-aware `Agent.cancel()` primitive. The default clears * queued and steering work, while `keepInbox` preserves pending input for a @@ -8,6 +6,8 @@ import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek- * the cause each cancelled turn records. * @module dsh-agent-loop/tests/cancel */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { ToolCallId, createUserMessage, expandAssistantStream } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 6e267d03d1..8767b485e8 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -1,5 +1,3 @@ -import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' -import { createUserMessage } from '@deepseek-ai/dsh-llm' /** * Loop-level tool-order determinism: the request/header event — and therefore the frozen * request the adapter receives — carries the assembly's canonical tool order (system-prompt's @@ -7,6 +5,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' * happened to register in. Registration order is a concurrent loading artifact * and must not leak downstream. */ +import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' +import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index 1ae239ec92..a0fc709c9d 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Explicit deliveries commit only after a successful final tool result. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture, unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' import { mkdtemp, rm, writeFile, symlink, realpath } from 'node:fs/promises' diff --git a/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts b/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts index bc6e18eb51..be8fd7ead9 100644 --- a/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts +++ b/packages/experimental/browser-use-runtime/tests/mcp-upstream.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real upstream MCP browser checks against private loopback pages and disposable Chromium. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { spawn } from 'node:child_process' import { once } from 'node:events' diff --git a/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts b/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts index c8657985a7..ce6226b1a9 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/native.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Opt-in installed Stagehand smoke against an owned local page and optional real model. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { createServer } from 'node:http' import { execFile, spawn } from 'node:child_process' diff --git a/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts b/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts index 43992071c6..abe6bd38be 100644 --- a/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts +++ b/packages/experimental/browser-use-stagehand-native/tests/provider.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Native browser ownership through real Agent, Session, and ToolRuntime services. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { StagehandDrainError } from '../src/native.ts' diff --git a/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts b/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts index e2b123829a..2213212b19 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts +++ b/packages/experimental/computer-use-cua-driver-mcp/tests/composition.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Loader composition with a real stdio child and a scripted external model. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts b/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts index 0f03725add..90f5849a3a 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts +++ b/packages/experimental/computer-use-cua-driver-mcp/tests/installed-driver.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Explicit opt-in compatibility check against an installed Cua Driver executable. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { isAbsolute } from 'node:path' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts index 6f65e0a574..a1155a1903 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/cancellation.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Provider unload owns screenshot admission as well as the native call. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts index f3bb7e05b3..3d3f34328e 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/loader-composition.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real Loader and AgentLoop composition with only external native/model fixtures. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, rm, writeFile } from 'node:fs/promises' diff --git a/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts b/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts index bc3cd015c1..8d838facaf 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/native.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Opt-in native SDK compatibility check without screenshots, input, or permission prompts. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { expect, it } from 'vitest' diff --git a/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts b/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts index 2a453439f8..d5f14a52e1 100644 --- a/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts +++ b/packages/experimental/computer-use-cua-driver-native/tests/provider.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Native SDK lifecycle and catalog behavior without desktop access. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/experimental/cua-macos/tests/loader-composition.spec.ts b/packages/experimental/cua-macos/tests/loader-composition.spec.ts index 5c9db14dae..0671eb4437 100644 --- a/packages/experimental/cua-macos/tests/loader-composition.spec.ts +++ b/packages/experimental/cua-macos/tests/loader-composition.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real Loader, AgentLoop and helper-process transport; only desktop and model responses are fixtures. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts b/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts index ccb14a01b6..107ea362e2 100644 --- a/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts +++ b/packages/experimental/cua-macos/tests/persistent-bridge.built.e2e.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Built bridge imports and retained objects from a project outside the Harness installation. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts index eb460ef9e4..0060b4cab7 100644 --- a/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts +++ b/packages/experimental/cua-macos/tests/provider-lifecycle.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Native helper replacement is synchronized with Agent and provider disposal. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index 1de7cac662..f80d99fbbf 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -1,7 +1,6 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Integration tests: the REAL local subprocess service plus the PACKAGED - * ripgrep binary (`@vscode/ripgrep`), exercised through `executeToolFixture(ctx, )`. + * ripgrep binary (`@vscode/ripgrep`), exercised through `executeToolFixture()`. * These verify the WORLD — actual files on disk are discovered and grepped, * hostile patterns stay inert (they are plain argv elements; there is no * shell layer to escape), and real `rg` stderr classifies into the @@ -9,6 +8,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' * suite runs on every platform without a system `rg` install; the * fake-service suite (tools.spec.ts) carries the coverage gate. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index bdf0fe2ef9..679e6094bc 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -1,7 +1,6 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Consumer-surface tests for the search tools over a FAKE subprocess service - * and a FAKE spill backend, exercised through `executeToolFixture(ctx, )` so nothing + * and a FAKE spill backend, exercised through `executeToolFixture()` so nothing * bypasses the tool registry. The fake service makes every seam outcome * scriptable — spawn failure, truncated stdout with/without a raw spill path, * abort/timeout kills, signal kills, ripgrep exit codes — so these tests @@ -10,6 +9,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' * formatted-result spill handoff, and the no-background-job invariant. * Real-`rg` behavior is pinned separately in integration.spec.ts. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 65fa6be7f0..bb8286d185 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -1,10 +1,10 @@ -import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * End-to-end tool-registry tests against the real local backend. The policy deployment verifies * observed-state and guarded mutation; the bare deployment proves unconditional tools have no * policy-service dependency. Assertions read files back byte-for-byte rather than trusting tool * messages. */ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' @@ -451,7 +451,7 @@ describe('per-session cwd', () => { // -------------------------------------------------------------------------- // Abort-through-the-tool, tool-tier concurrency, and the fs/observed contract — -// all through executeToolFixture(ctx, ) against the REAL backend + policy. +// all through executeToolFixture() against the REAL backend + policy. // -------------------------------------------------------------------------- describe('signal, concurrency, and the fs/observed contract', () => { beforeEach(async () => { diff --git a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts index 0d1476030d..5a67f91259 100644 --- a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts @@ -1,4 +1,3 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Unit + real-load-path coverage for @deepseek-ai/dsh-tool-call-timeout-policy. The * timeout-wins cases drive the deadline under fake timers (deterministic — no @@ -6,6 +5,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' * `exec.signal` aborts, mirroring how a real capability forwards the signal and * reaches quiescence. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 8a7c64ab18..0478bc7d16 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -1,4 +1,3 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * End-to-end tests for dsh-mcp-client. Exercises the REAL MCP protocol against: * 1. A self-written fixture server over stdio (controlled edge cases) @@ -8,6 +7,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' * * No API key needed — all servers are local/keyless. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' diff --git a/packages/mcp/mcp-client/tests/protocol.spec.ts b/packages/mcp/mcp-client/tests/protocol.spec.ts index 0c068d6b6c..e4fb58b8fc 100644 --- a/packages/mcp/mcp-client/tests/protocol.spec.ts +++ b/packages/mcp/mcp-client/tests/protocol.spec.ts @@ -1,7 +1,7 @@ +/** Real SDK negotiation, subscription, and cancellation through the connection supervisor. */ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { z } from 'zod' -/** Real SDK negotiation, subscription, and cancellation through the connection supervisor. */ import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 73b3b089f8..a25e1e8683 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -1,10 +1,10 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Tests for the mcp-client connection supervisor: crash-driven reconnection * with bounded backoff, generation-safe tool re-registration, the failure * cap, the stability-window budget reset, and disposal stopping reconnection. * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, it, vi, beforeEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/mcp/mcp-resources/tests/resources.spec.ts b/packages/mcp/mcp-resources/tests/resources.spec.ts index c731a843ba..ed70b89233 100644 --- a/packages/mcp/mcp-resources/tests/resources.spec.ts +++ b/packages/mcp/mcp-resources/tests/resources.spec.ts @@ -40,7 +40,7 @@ async function setup(mode: ('native' | 'ptc') = 'native') { roots.push(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) + await ctx.plugin(mode === 'ptc' ? PtcToolExecutor : NativeToolExecutor) if (mode !== 'native') await ctx.plugin(BindingProbe) await ctx.plugin(McpResources) return ctx diff --git a/packages/shell/tool-pwsh/tests/integration.spec.ts b/packages/shell/tool-pwsh/tests/integration.spec.ts index ed07287a97..64b6542159 100644 --- a/packages/shell/tool-pwsh/tests/integration.spec.ts +++ b/packages/shell/tool-pwsh/tests/integration.spec.ts @@ -1,7 +1,6 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Integration tests: the REAL `@deepseek-ai/dsh-pwsh-local` executor plus the - * `pwsh` tool, exercised through `executeToolFixture(ctx, )` with a real PowerShell + * `pwsh` tool, exercised through `executeToolFixture()` with a real PowerShell * process. These verify the world — actual commands run, stdout/stderr come * back, exit codes render, timeouts abort, background jobs settle through the * generic job runtime, and per-session cwd resolution works. The suite @@ -9,6 +8,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' * PowerShell); the fake-executor suite (tools.spec.ts) carries the coverage * gate. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index 295b2f1c39..d430607080 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -1,7 +1,6 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Consumer-surface tests for the `pwsh` tool over a FAKE bash executor, - * exercised through `executeToolFixture(ctx, )` so nothing bypasses the tool + * exercised through `executeToolFixture()` so nothing bypasses the tool * registry. The fake executor makes every seam outcome scriptable — output * text, truncation, timeout, abort, nonzero exits, background handles — so * these tests verify the schema, argument validation, workdir derivation, @@ -10,6 +9,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' * background job wiring, and the UI presenters. Real-pwsh behavior * is pinned separately in integration.spec.ts. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/skill/skill-office/tests/progressive-loading.spec.ts b/packages/skill/skill-office/tests/progressive-loading.spec.ts index 85bcc5c3ca..0a7b330ec0 100644 --- a/packages/skill/skill-office/tests/progressive-loading.spec.ts +++ b/packages/skill/skill-office/tests/progressive-loading.spec.ts @@ -38,7 +38,7 @@ async function setup(mode: 'native' | 'ptc', names: string[]) { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt, { personaPrefix: '', personaSuffix: '' }) await ctx.plugin(ToolRuntime) - await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) + await ctx.plugin(mode === 'ptc' ? PtcToolExecutor : NativeToolExecutor) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SkillRegistry) diff --git a/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts b/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts index dd9b2fd422..3b34a521dc 100644 --- a/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts +++ b/packages/spill/spill-policy/tests/multimodal-recovery.spec.ts @@ -1,5 +1,5 @@ -import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Real attachments, filesystem recovery, Node PTC, and DeepSeek image request bytes without a desktop or API key. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -54,7 +54,7 @@ async function setup(mode: 'native' | 'ptc' = 'native') { }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) + await ctx.plugin(mode === 'ptc' ? PtcToolExecutor : NativeToolExecutor) await ctx.plugin(FileSystem, { cwd: root }) await ctx.plugin(LocalAttachments, { dshHome: join(root, 'home') }) await ctx.plugin(ToolFs) diff --git a/packages/spill/spill-policy/tests/multimodal.spec.ts b/packages/spill/spill-policy/tests/multimodal.spec.ts index b522bb3a15..343d2d8076 100644 --- a/packages/spill/spill-policy/tests/multimodal.spec.ts +++ b/packages/spill/spill-policy/tests/multimodal.spec.ts @@ -1,5 +1,5 @@ -import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** MCP admission, token retention, recovery files, and PTC forwarding together. */ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' @@ -75,7 +75,7 @@ async function setup(content: JsonValue[], maxInlineTokens: number, mode: 'nativ }) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) + await ctx.plugin(mode === 'ptc' ? PtcToolExecutor : NativeToolExecutor) const fsFiber = await ctx.plugin(FileSystem, { cwd: root }) await ctx.plugin(TestAttachments, root) await ctx.plugin(LocalSpillStore, { root: join(root, 'spill'), cleanupPeriodDays: 0 }) diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 6880ec7d6f..11b5f0adb4 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -1,4 +1,3 @@ -import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Tests for the spill-policy PLUGIN. It registers no service, only the * `tools/post-execute` transformer. We drive real tools through @@ -8,6 +7,7 @@ import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-to * `saveText` failure / missing backend / missing owner all preserve the original * result without an `isError`. */ +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index f8a0691bc9..bca03cf951 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -1,6 +1,6 @@ +/** Default-off settings and per-session model-selection decisions. */ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mountWorkingDirectoryFixture } from '../../subagent/tests/working-directory-fixture.ts' -/** Default-off settings and per-session model-selection decisions. */ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index 2de835910f..b56ecf7c76 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -1,12 +1,12 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Integration: the real fetch backend (`dsh-web-fetch-http`) + a real search provider * (`dsh-web-search-exa`) + the real seam (`dsh-web`) + the model tool (`dsh-tool-web`) + the - * tool-call timeout policy (`dsh-tool-call-timeout-policy`), exercised through `executeToolFixture(ctx, )` — + * tool-call timeout policy (`dsh-tool-call-timeout-policy`), exercised through `executeToolFixture()` — * nothing bypasses the tool registry. Fetch verifies world effects against loopback HTTP with * public-address resolution replaced by the fixture address; search uses the real Exa provider * with only its network boundary stubbed. */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts index 85ca0be5a8..5f0231375e 100644 --- a/packages/web/tool-web/tests/spill.spec.ts +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -1,12 +1,12 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Showcase integration: the real `web_fetch` tool + the real spill stack * (`dsh-spill-local` backend + `dsh-spill-policy`), exercised through - * `executeToolFixture(ctx, )`. Proves the Agent Note's default local-backend path — a large + * `executeToolFixture()`. Proves the Agent Note's default local-backend path — a large * formatted fetch result is automatically retained and spilled with NO * tool-specific spill code, and the model-facing text changes ONLY by the * deliberate spill notice (the full formatted result lands in the spill file). */ +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' From e9784c073be2c73da9963dbfea503279ce9aaf0d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 16:32:29 +0800 Subject: [PATCH 26/73] fix(tools): settle executor calls after unload and validate configuration --- .../tests/profiles/sdk/keyless-smoke.e2e.ts | 2 +- apps/cli/tests/web-agent-presets.e2e.ts | 6 +- apps/web/tests/shipped-composition.e2e.ts | 4 +- docs/config-catalog.md | 30 +----- docs/config-catalog.zh.md | 30 +----- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 2 +- docs/persistence-catalog.zh.md | 2 +- docs/persistence-schema.json | 2 +- docs/subsystems/tools.md | 3 +- docs/subsystems/tools.zh.md | 3 +- .../tool-executor-plugins/guide.i18n.yaml | 4 +- .../tool-executor-plugins/guide.md | 2 +- .../tool-executor-plugins/guide.zh.md | 2 +- .../context/session-reference/package.json | 2 +- .../core/agent-loop/tests/tool-calls.spec.ts | 10 +- .../agent-tool-presentation/README.i18n.yaml | 8 +- .../core/agent-tool-presentation/README.md | 4 +- .../core/agent-tool-presentation/README.zh.md | 4 +- .../core/agent-tool-presentation/src/index.ts | 5 +- .../tests/agent-tool-presentation.spec.ts | 16 +--- .../tool-executor-native/README.i18n.yaml | 8 +- packages/core/tool-executor-native/README.md | 8 +- .../core/tool-executor-native/README.zh.md | 8 +- .../core/tool-executor-native/package.json | 5 - .../core/tool-executor-native/src/index.ts | 20 +--- .../core/tool-executor-ptc/README.i18n.yaml | 12 +-- packages/core/tool-executor-ptc/README.md | 9 +- packages/core/tool-executor-ptc/README.zh.md | 9 +- packages/core/tool-executor-ptc/package.json | 2 - packages/core/tool-executor-ptc/src/config.ts | 13 +++ packages/core/tool-executor-ptc/src/index.ts | 39 ++------ .../core/tool-executor-ptc/src/ptc-calls.ts | 8 +- packages/core/tool-executor-ptc/src/ptc.ts | 39 ++++---- .../core/tool-executor-ptc/src/py-types.ts | 16 ++-- .../tool-executor-ptc/tests/ptc-calls.spec.ts | 6 +- .../core/tool-executor-ptc/tests/ptc.spec.ts | 93 +++++++++++++------ packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/contracts.ts | 2 +- packages/core/tools/src/index.ts | 6 ++ packages/core/tools/src/pipeline.ts | 17 ++-- .../core/tools/tests/execution-mode.spec.ts | 20 ++-- packages/core/tools/tests/tools.spec.ts | 42 +++++++++ .../examples/persistent-ptc.cordis.yml | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 4 +- packages/fs/tool-fs/tests/read-image.spec.ts | 6 +- packages/fs/tool-fs/tests/tools.spec.ts | 12 +-- .../goal/tool-goal/tests/tool-goal.spec.ts | 2 +- packages/llm/llm-deepseek/package.json | 2 +- .../tests/tool-executors.spec.ts | 15 ++- .../references/packages.md | 2 +- packages/spill/spill-policy/package.json | 1 - .../tool-subagent/tests/tool-subagent.spec.ts | 6 +- .../tests/agent-loop-testkit.spec.ts | 11 +++ .../test-support/tool-testkit/src/index.ts | 2 +- packages/web/tool-web/tests/tool-web.spec.ts | 4 +- pnpm-lock.yaml | 22 ++--- scripts/gen-cordis-catalog.ts | 1 - .../cordis-inspect-jsdoc/session.v4.jsonl | 2 +- 63 files changed, 327 insertions(+), 310 deletions(-) create mode 100644 packages/core/tool-executor-ptc/src/config.ts diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 4d3bb617b8..5c51bd36ae 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -399,7 +399,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const patch = join(root, 'failure.yml') await mkdir(home) if (blocked) await writeFile(join(home, 'logs'), 'blocked') - await writeFile(patch, '- id: tool-executor-native\n config:\n maxParallelToolCalls: 0\n') + await writeFile(patch, '- id: agent-loop\n config:\n maxParallelToolCalls: 0\n') const child = execa(launch.command, [ ...launch.args, '--profile', 'sdk', '--patch', patch, ], { diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 5944476fb2..43620ba878 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -816,7 +816,7 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(queriedText).toContain(`"name": "${toolName}"`) } - // The `Config` provider reads the booted profile tree: the executor selector declares a Config, + // The `Config` provider reads the booted profile tree: the Agent loop declares a Config, // and the bootstrap include row is a carrier. The name filter keeps each page small. type ConfigRow = { id: string; patchId: string; name: string; status: string } type ConfigPage = { data: { entries: ConfigRow[]; total: number; nextOffset: number | null } } @@ -831,9 +831,9 @@ describe('a user preset declared from the shipped cordis rows', () => { expect(page.isError, resultText(page)).toBe(false) return (JSON.parse(resultText(page)) as ConfigPage).data } - const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-tool-executor-native' }, 'copied-preset-inspect-configs') + const toolsRows = await listConfigs({ name: '@deepseek-ai/dsh-agent-loop' }, 'copied-preset-inspect-configs') const toolsRow = toolsRows.entries[0] - expect(toolsRow).toMatchObject({ patchId: 'tool-executor-native', status: 'schema' }) + expect(toolsRow).toMatchObject({ patchId: 'agent-loop', status: 'schema' }) expect(toolsRows).toMatchObject({ total: 1, nextOffset: null }) const includes = await listConfigs({ name: 'cordis:include' }, 'copied-preset-inspect-includes') expect(includes.entries.find(entry => entry.patchId === 'include')).toMatchObject({ status: 'tree' }) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 669a48faf1..9868bc8968 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -1,4 +1,4 @@ -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' // Boots the shipped Web composition over the built dist this lane already uses // and asserts its catalog, defaults, Loader lifecycle, and one complete Auto // producer-to-tool path. Browser scenarios in this lane own visual behavior. @@ -680,7 +680,7 @@ it('lets a preset producer reach the background-job registry', async () => { // `tool-bash` is a preset row and `tasks` is a host registry; the producer // resolves it with `ctx.get`, so a registry hidden behind a preset realm // fails here — with every task control still listed in the catalog above. - const started = await executeToolFixture(ctx, { + const started = await executeModelToolFixture(ctx, { signal, callId: ToolCallId('shipped-bash-background'), name: 'bash', diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ee53ca268d..33ab75b9e4 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -162,7 +162,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-tool-presentation` - `inject`: `tools` -- `source`: [`packages/core/agent-tool-presentation/src/index.ts:26`](../packages/core/agent-tool-presentation/src/index.ts) +- `source`: [`packages/core/agent-tool-presentation/src/index.ts:25`](../packages/core/agent-tool-presentation/src/index.ts) ```ts config-catalog /** @@ -3822,40 +3822,19 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-tool-executor-native` - -- `inject`: `tools` -- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/tool-executor-native/src/index.ts:14`](../packages/core/tool-executor-native/src/index.ts) - -```ts config-catalog -/** Native batch concurrency configuration. */ -export interface Config { - /** Maximum overlapping concurrency-safe calls in one batch. */ - maxParallelToolCalls: Volatile -} -``` - - ## `@deepseek-ai/dsh-tool-executor-ptc` -- `inject`: `tools` · `systemPrompt` · `ptcRuntime` -- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/tool-executor-ptc/src/index.ts:19`](../packages/core/tool-executor-ptc/src/index.ts) +- `inject`: `tools` · `systemPrompt` +- `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts) ```ts config-catalog /** Program execution limits. */ export interface Config { - /** Maximum overlapping concurrency-safe calls in one model batch. */ - maxParallelToolCalls: Volatile /** Maximum overlapping concurrency-safe program subcalls. */ - maxParallelSubCalls?: number + maxParallelSubCalls: number } ``` @@ -4769,6 +4748,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) | | `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) | | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | +| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | | `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7da2fed7a5..1e2e6f7a05 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -164,7 +164,7 @@ export interface Config { ## `@deepseek-ai/dsh-agent-tool-presentation` - `inject`: `tools` -- `source`: [`packages/core/agent-tool-presentation/src/index.ts:26`](../packages/core/agent-tool-presentation/src/index.ts) +- `source`: [`packages/core/agent-tool-presentation/src/index.ts:25`](../packages/core/agent-tool-presentation/src/index.ts) ```ts config-catalog /** @@ -3824,40 +3824,19 @@ export interface Config { ``` - - - -## `@deepseek-ai/dsh-tool-executor-native` - -- `inject`: `tools` -- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/tool-executor-native/src/index.ts:14`](../packages/core/tool-executor-native/src/index.ts) - -```ts config-catalog -/** Native batch concurrency configuration. */ -export interface Config { - /** Maximum overlapping concurrency-safe calls in one batch. */ - maxParallelToolCalls: Volatile -} -``` - - ## `@deepseek-ai/dsh-tool-executor-ptc` -- `inject`: `tools` · `systemPrompt` · `ptcRuntime` -- `refs`: `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/tool-executor-ptc/src/index.ts:19`](../packages/core/tool-executor-ptc/src/index.ts) +- `inject`: `tools` · `systemPrompt` +- `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts) ```ts config-catalog /** Program execution limits. */ export interface Config { - /** Maximum overlapping concurrency-safe calls in one model batch. */ - maxParallelToolCalls: Volatile /** Maximum overlapping concurrency-safe program subcalls. */ - maxParallelSubCalls?: number + maxParallelSubCalls: number } ``` @@ -4771,6 +4750,7 @@ export interface Config { | `@deepseek-ai/dsh-terminal` | — | [`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts) | | `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) | | `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) | +| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) | | `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) | | `@deepseek-ai/dsh-tool-subagent-control` | `tools` · `subagents` · `agents` | [`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts) | | `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index c5ba1734a8..233e731000 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1194,7 +1194,6 @@ flowchart TD pkg_spill_policy --> pkg_session pkg_spill_policy --> pkg_spill pkg_spill_policy --> pkg_token_meter - pkg_spill_policy --> pkg_tool_executor_ptc pkg_spill_policy --> pkg_tools pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1769,7 +1768,7 @@ flowchart TD | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`fs-local`](../packages/fs/fs-local), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tools`](../packages/core/tools) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6a7f781d4a..ba174b22f4 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1196,7 +1196,6 @@ flowchart TD pkg_spill_policy --> pkg_session pkg_spill_policy --> pkg_spill pkg_spill_policy --> pkg_token_meter - pkg_spill_policy --> pkg_tool_executor_ptc pkg_spill_policy --> pkg_tools pkg_session_query_sqlite --> pkg_session pkg_session_query_sqlite --> pkg_session_persistence @@ -1771,7 +1770,7 @@ flowchart TD | [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | `ssh` | [`ssh`](../packages/ssh/ssh), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) | | [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`fs-local`](../packages/fs/fs-local), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | -| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tools`](../packages/core/tools) | +| [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 634a3a5b47..8112d1933e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1830,8 +1830,8 @@ en: 08be4349548406a9 zh: 8e7cf9966198ef78 /session-persistence-event-catalog/resolved-persistence-types/kind-tool-registry: - en: 4fde95c569339141 - zh: 6fa54e19c24ab7f3 + en: c59d020828d8211f + zh: 154a30cb69e79489 /session-persistence-event-catalog/resolved-persistence-types/kind-user: en: a58f339332877b15 zh: b7406af7d8197b64 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index ad03afd422..54674899b3 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -7542,7 +7542,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -Sources: [`packages/core/tools/src/index.ts:17`](../packages/core/tools/src/index.ts) +Sources: [`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index ac37597541..f4833af8b8 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -7544,7 +7544,7 @@ SHA-256: `c5228b7a55498d84f529591744a0ab26e0b93ade18a70a26db1377a1ecfa6b49` SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` -来源:[`packages/core/tools/src/index.ts:17`](../packages/core/tools/src/index.ts) +来源:[`packages/core/tools/src/index.ts:18`](../packages/core/tools/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 935980f278..c1aed265be 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -24599,7 +24599,7 @@ "digest": "8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543", "names": [], "sources": [ - "packages/core/tools/src/index.ts:17" + "packages/core/tools/src/index.ts:18" ] }, { diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index a43e3f29e0..a92f41d6df 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -293,7 +293,7 @@ interface ToolExecutionInput { * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may + * model-direct call: with the PTC executor selected, only calls with a parent may * execute a native tool name — a model-direct call (no parent) is denied as * `UNKNOWN_TOOL` before the policy pipeline. */ @@ -635,6 +635,7 @@ schemas(scope?: ScopeKey): ToolSchema[] /** * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * Rendering any executor-owned section requires the viewing scope's selection to remain available. * @param executor - initialized provider owned by the contributing plugin. * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index a0fecf88cc..187d61c3f7 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -293,7 +293,7 @@ interface ToolExecutionInput { * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may + * model-direct call: with the PTC executor selected, only calls with a parent may * execute a native tool name — a model-direct call (no parent) is denied as * `UNKNOWN_TOOL` before the policy pipeline. */ @@ -635,6 +635,7 @@ schemas(scope?: ScopeKey): ToolSchema[] /** * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * Rendering any executor-owned section requires the viewing scope's selection to remain available. * @param executor - initialized provider owned by the contributing plugin. * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml index bf3b31aa5b..60370bde67 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 73002a2963b9ea27 zh: a67ea382d7c70c61 /mount-a-tool-executor/change: - en: b47c04b407b204a9 - zh: f3f54c9b25eb1951 + en: f732d85027bf0236 + zh: d6597cde7ba31e47 /mount-a-tool-executor/migration: en: 411860d06f1013ea zh: 615e044545d89b55 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md index 9c3fc0bc4a..9ea22ca563 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but no longer accepts `mode` or `maxParallelSubCalls`. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. +`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but rejects the removed `mode` and `maxParallelSubCalls` configuration fields at load time. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. ## Migration diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md index 0d9e484c8e..6e0f4c9a24 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md @@ -9,7 +9,7 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 ## 变更 -`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但不再接受 `mode` 或 `maxParallelSubCalls`。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。 启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。 +`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但加载时会拒绝已移除的 `mode` 和 `maxParallelSubCalls` 配置字段。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。 ## 迁移 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index e7ffcca708..8cefe88f74 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -47,7 +47,6 @@ "license": "MIT", "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "zod": "4.4.3" @@ -95,6 +94,7 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index a57106751d..d08e9f6bca 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -1,8 +1,8 @@ -import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' /** * Exercises scheduler ordering and cancellation with deterministic gated tools. * ACP expected outputs own transcript-facing coverage. */ +import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' @@ -37,13 +37,13 @@ async function harness(adapter: MockAdapter, maxParallelToolCalls?: number, exec await ctx.plugin(ToolRuntime) let toolPipeline!: ToolExecutionPipeline await ctx.plugin({ - inject: ['tools'], Config: NativeToolExecutor.Config, - apply(owner: Context, config: NativeToolExecutor.Config) { - const executor = new NativeToolExecutor.NativeToolExecutor(owner, () => config.maxParallelToolCalls.get()) + inject: ['tools'], + apply(owner: Context) { + const executor = new NativeToolExecutor.NativeToolExecutor(owner, () => executorLimit ?? 10) toolPipeline = executor['pipeline'] owner.tools.setExecutor(executor) }, - }, { ...executorLimit === undefined ? {} : { maxParallelToolCalls: executorLimit } }) + }) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [], diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index 2a2ed58937..b39ccedaf7 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 1550e3dcc5a3aa1e zh: 47b2d1e0e3e3cbf2 /deepseek-ai-dsh-agent-tool-presentation/use-this-package/what-ptc-mode-requires: - en: d5004b2caa31b245 - zh: 32619d7e03c7c2ac + en: 3ec33f18b3ac0859 + zh: 92bf371bfbbc511d /deepseek-ai-dsh-agent-tool-presentation/use-this-package/one-presentation-per-agent: en: 46bf80f051175567 zh: 8f5e1623da5d1520 @@ -36,8 +36,8 @@ en: 89ecdb3d5d032e7f zh: 64ae2feb4344c679 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/behavior-notes: - en: 7996925328f0e771 - zh: 40146455aac119d2 + en: 4a44ac4057732829 + zh: 813d3ec7627146fd /deepseek-ai-dsh-agent-tool-presentation/further-exploration: en: 6a1d0096e4da25e4 zh: 9d5de770addb69d2 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index 569ee37d63..b3c22d9bfe 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -45,7 +45,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What PTC mode requires -Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tool-executor-ptc`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. +Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tool-executor-ptc`. Selection takes effect even when the runtime is missing; prompt assembly or program execution then reports the missing runtime instead of inheriting native execution. ### One presentation per agent @@ -73,7 +73,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane ### Behavior notes -`native` applies immediately. A PTC mode instead waits for `ctx.ptcRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-preset-registry` refuses the mount naming this id. `setExecutor` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it. +Both modes mount their executor immediately. PTC checks runtime availability when describing or executing a program. The mounted executor owns its scoped registration, which is withdrawn with this selector's plugin lifetime.
diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index e30fc01cb5..f54c3799a6 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -45,7 +45,7 @@ kind: "package-reference" ### PTC 模式需要什么 -选择 `ptc` 需要已组合的 PTC 运行时(`ctx.ptcRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tools` 中。针对未组装此类运行时的部署选择 PTC 模式的 preset 会拒绝挂载并点名这一行,使失败落在操作者可以行动的地方,而不是落在会话的第一次请求上。 +选择 `ptc` 需要已组合的 PTC 运行时(`ctx.ptcRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tool-executor-ptc` 中。即使缺少运行时,选择也会生效;随后提示词组装或程序执行会报告缺少运行时,而不会继承原生执行方式。 ### 每个 agent 只声明一次呈现方式 @@ -73,7 +73,7 @@ kind: "package-reference" ### 行为说明 -`native` 立即生效。PTC 模式则等待 `ctx.ptcRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-preset-registry` 会指名此 id 拒绝挂载。`setExecutor` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 +两种模式都会立即挂载各自的执行器。PTC 在描述或执行程序时检查运行时是否可用。挂载的执行器拥有作用域注册,并随此选择器的插件生命周期一同撤销。
diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index cc621472e3..ce986fce5f 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -13,9 +13,8 @@ import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' export const name = 'tool-presentation' /** - * Required services. `ptcRuntime` is NOT listed: a `native` row must mount in - * a deployment that composes no runtime, and the mode-dependent wait is - * declared inside {@link apply} instead. + * Tool selection does not require a program runtime; the PTC executor + * reports a missing runtime when its description or execution is requested. */ export const inject = ['tools'] diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index 3dcbbc747c..4481f31788 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -1,9 +1,8 @@ /** * The row an agent preset carries to pick its tool presentation. What it owes * its caller: the choice reaches THIS agent and no other, it unwinds with the - * agent, and a code mode composed against a deployment with no PTC runtime - * stops at mount — where a preset's activation audit can name it — rather - * than at the first prompt assembly. + * agent, and a missing PTC runtime rejects the selected scope's prompt + * instead of inheriting the native executor. */ import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' @@ -68,8 +67,7 @@ describe('the tool-presentation row', () => { }) it('declares the services it uses without holding a PTC runtime hostage', () => { - // A `native` row must mount where no runtime is composed, so the wait is - // conditional inside apply rather than static metadata. + // Native execution does not require a program runtime. expect(inject).toEqual(['tools']) }) @@ -95,17 +93,13 @@ describe('the tool-presentation row', () => { await expect(ctx.systemPrompt.assemble({ scope: agent })).rejects.toThrow('selected tool executor is unavailable') }) - it('waits for a PTC runtime the deployment does not compose', async () => { + it('rejects PTC assembly when the deployment does not compose its runtime', async () => { const ctx = await host({ runtime: false }) const { agent, row } = await mount(ctx, { mode: 'ptc' }) - // Pending, not applied: `dsh-agent-preset-registry` rejects a mount holding a row - // that never reached a usable state, naming this id — so the preset fails - // where the operator can act, instead of at the first request. expect(row.ctx.get('ptcRuntime')).toBeUndefined() - const assembly = await ctx.systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + await expect(ctx.systemPrompt.assemble({ scope: agent })).rejects.toThrow('requires a PTC runtime') }) it('applies once the runtime arrives', async () => { diff --git a/packages/core/tool-executor-native/README.i18n.yaml b/packages/core/tool-executor-native/README.i18n.yaml index 118a6d84fb..8d8ed7b889 100644 --- a/packages/core/tool-executor-native/README.i18n.yaml +++ b/packages/core/tool-executor-native/README.i18n.yaml @@ -15,11 +15,11 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-executor-native/use-this-package: - en: 176f69450bacc28e - zh: fc3c2d876195a4a4 + en: 0e73b1a49308a0f6 + zh: 968b3ee68284d849 /deepseek-ai-dsh-tool-executor-native/understand-the-implementation: - en: d005036097c11788 - zh: 65fcaec551dc1697 + en: 6a06c6b318f58436 + zh: 4f572feca095e3a6 /deepseek-ai-dsh-tool-executor-native/further-exploration: en: e8ae407b0ecdc5d3 zh: 022a8a714d24da4b diff --git a/packages/core/tool-executor-native/README.md b/packages/core/tool-executor-native/README.md index 5d6103c5fc..0841540ba6 100644 --- a/packages/core/tool-executor-native/README.md +++ b/packages/core/tool-executor-native/README.md @@ -29,13 +29,9 @@ Add this row to a composition with `dsh-tools` mounted. The nearest scoped execu ```yaml - name: '@deepseek-ai/dsh-tool-executor-native' - config: - maxParallelToolCalls: 10 ``` -For Agent steps, configure [agent-loop concurrency](../agent-loop/README.md); the loop always forwards its resolved limit to `execute()`. The executor's `maxParallelToolCalls` supplies the limit only when the caller omits it. This fallback is volatile, defaults to `10`, and is sampled for the next tool group. It must be a positive integer; `1` selects serial execution. - -[Configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-executor-native) +Configure [agent-loop concurrency](../agent-loop/README.md) for Agent steps; the loop reads the limit once per batch. This plugin has no configuration fields. Direct `NativeToolExecutor` callers may pass a constructor callback, defaulting to `10`, which is sampled for each tool group when the batch omits `maxParallelToolCalls`. Limits must be positive integers; `1` selects serial execution. ----- @@ -47,7 +43,7 @@ For Agent steps, configure [agent-loop concurrency](../agent-loop/README.md); th The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. -- [`src/index.ts`](src/index.ts) — Validated configuration and scoped registration; tool descriptions and batch execution. +- [`src/index.ts`](src/index.ts) — Scoped executor registration; tool descriptions and batch execution.
diff --git a/packages/core/tool-executor-native/README.zh.md b/packages/core/tool-executor-native/README.zh.md index 82f71691e6..4c9ea6ee19 100644 --- a/packages/core/tool-executor-native/README.zh.md +++ b/packages/core/tool-executor-native/README.zh.md @@ -29,13 +29,9 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-tool-executor-native' - config: - maxParallelToolCalls: 10 ``` -Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循环始终向 `execute()` 传入解析后的上限。只有调用方未传入上限时,才使用执行器的 `maxParallelToolCalls`。这个后备配置是 volatile 字段,默认值为 `10`,在下一个工具组开始时读取。值必须是正整数;`1` 表示串行执行。 - -[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-executor-native) +Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循环在每个批次开始时读取一次上限。本插件没有配置字段。直接构造 `NativeToolExecutor` 的调用方可以传入回调,默认返回 `10`;批次未指定 `maxParallelToolCalls` 时,每个工具组读取该回调。上限必须是正整数;`1` 表示串行执行。 ----- @@ -47,7 +43,7 @@ Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循 执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。 -- [`src/index.ts`](src/index.ts) — 配置校验和作用域注册;工具描述和批次执行。 +- [`src/index.ts`](src/index.ts) — 作用域执行器注册;工具描述和批次执行。 diff --git a/packages/core/tool-executor-native/package.json b/packages/core/tool-executor-native/package.json index 3b733d5e63..e027d0e9bb 100644 --- a/packages/core/tool-executor-native/package.json +++ b/packages/core/tool-executor-native/package.json @@ -28,17 +28,12 @@ ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" }, - "dependencies": { - "@deepseek-ai/schemastery": "workspace:~" - }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" diff --git a/packages/core/tool-executor-native/src/index.ts b/packages/core/tool-executor-native/src/index.ts index 24685a696b..457749152c 100644 --- a/packages/core/tool-executor-native/src/index.ts +++ b/packages/core/tool-executor-native/src/index.ts @@ -3,15 +3,13 @@ import type { Context } from '@deepseek-ai/cordis' import { projectToolSchema } from '@deepseek-ai/dsh-tools' import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools' import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' -import type { Volatile } from '@deepseek-ai/cosmokit' -import z from '@deepseek-ai/schemastery' /** Executes visible capabilities directly and owns their complete batch settlement. */ export class NativeToolExecutor implements ToolExecutor { private readonly pipeline: ToolExecutionPipeline constructor(ctx: Context, private readonly maxParallel: () => number = () => 10) { - this.pipeline = new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }) + this.pipeline = new ToolExecutionPipeline(ctx) } describe(tools: readonly ToolDefinition[]): ToolModelInput { @@ -28,22 +26,10 @@ export const name = 'native-tool-executor' /** Tool registration and scoped dispatch. */ export const inject = ['tools'] -/** Native batch concurrency configuration. */ -export interface Config { - /** Maximum overlapping concurrency-safe calls in one batch. */ - maxParallelToolCalls: Volatile -} - -/** Validated live concurrency limit; one makes each batch serial. */ -export const Config: z<{ maxParallelToolCalls?: number }, Config> = z.object({ - maxParallelToolCalls: z.number().step(1).min(1).default(10).volatile(), -}) - /** * Select native execution in the mounting scope. * @param ctx - context owning this executor's selection and lifetime. - * @param config - live batch concurrency configuration. */ -export function apply(ctx: Context, config: Config): void { - ctx.tools.setExecutor(new NativeToolExecutor(ctx, () => config.maxParallelToolCalls.get())) +export function apply(ctx: Context): void { + ctx.tools.setExecutor(new NativeToolExecutor(ctx)) } diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml index 3a3836c1d0..e6d35ea2bc 100644 --- a/packages/core/tool-executor-ptc/README.i18n.yaml +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -9,17 +9,17 @@ en: e253ed9cec03a396 zh: 922fde282e2b0fd2 /deepseek-ai-dsh-tool-executor-ptc/summary: - en: 60fb16b4d443d265 - zh: 6b0df6471c370c6b + en: 66e159c308afb6c1 + zh: 29b1a646ae7f918a /deepseek-ai-dsh-tool-executor-ptc/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-executor-ptc/use-this-package: - en: 5cfc28690358a419 - zh: d066d078ca649022 + en: 488f394426b3aaa0 + zh: a15185f91a1c342c /deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation: - en: f054dc0faabb468f - zh: 10f13508bfa9ef84 + en: 9d4f61c351945d30 + zh: 1c575526230b0e02 /deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation/ptc-mode: en: 11d32e05ba397f86 zh: 4e071ec4e6222d9d diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md index b7b56146a5..7519301f2a 100644 --- a/packages/core/tool-executor-ptc/README.md +++ b/packages/core/tool-executor-ptc/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Use this executor when models should call tools through `run_code` programs. Mount a compatible PTC runtime and tool registry first. Scoped restrictions determine the generated SDK; only `run_code` is directly callable. Configure root-call and program-subcall concurrency independently. +Use this executor when models should call tools through `run_code` programs. Mount a compatible PTC runtime and tool registry first. Scoped restrictions determine the generated SDK; only `run_code` is directly callable. Configure concurrency for parallel-safe program subcalls. ## Table of Contents @@ -30,13 +30,12 @@ Add this row to a composition with `dsh-tools` mounted. The nearest scoped execu ```yaml - name: '@deepseek-ai/dsh-tool-executor-ptc' config: - maxParallelToolCalls: 10 maxParallelSubCalls: 10 ``` -For Agent steps, configure [agent-loop concurrency](../agent-loop/README.md); the loop always forwards its resolved limit to `execute()`. The executor's `maxParallelToolCalls` supplies the limit only when the caller omits it. This fallback is volatile, defaults to `10`, and is sampled for the next tool group. It must be a positive integer; `1` selects serial execution. +Root `run_code` calls are exclusive and execute serially. `maxParallelSubCalls` limits parallel-safe nested calls within each program and defaults to `10`; it must be a positive integer, with `1` selecting serial execution. Exclusive calls form barriers. -This package also requires `ctx.ptcRuntime` and `ctx.systemPrompt`; Agent-owned programs need `ctx.workingDirectory`. `maxParallelSubCalls` limits overlapping parallel-safe calls within each program and defaults to `10`. +This package also requires `ctx.ptcRuntime` and `ctx.systemPrompt`; Agent-owned programs need `ctx.workingDirectory`. A missing runtime rejects prompt assembly or execution; the selected PTC executor never falls back to native execution. Import durable event payloads from `@deepseek-ai/dsh-tool-executor-ptc/types`; this entry is safe for detached readers and client compilation. Host policy extensions import `PtcDispatchLog`, `tools/ptc-dispatch-log`, and the `ptc-mode` message-source declaration through a type-only import of the package entry, without mounting an executor. @@ -50,7 +49,7 @@ Import durable event payloads from `@deepseek-ai/dsh-tool-executor-ptc/types`; t
Implementation internals — click to expand -The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. +The executor receives the entire raw call batch and owns parsing, scheduling, cancellation, and optional model-step logging. It reuses the shared invocation pipeline for policy, tool bodies, and result notifications. After abort, it waits for started work and returns ordered error results for skipped calls. Unloading the executor aborts active sandboxed programs and waits for their nested calls to settle. Accepted batches still settle through that executor, potentially with cancellation errors. ### PTC mode diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md index f4d60631ea..8625183568 100644 --- a/packages/core/tool-executor-ptc/README.zh.md +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -当模型应通过 `run_code` 程序调用工具时,使用此执行器。先挂载兼容的 PTC 运行时和工具注册表。作用域限制决定生成的 SDK;只有 `run_code` 能被直接调用。根调用和程序子调用的并发限制分别配置。 +当模型应通过 `run_code` 程序调用工具时,使用此执行器。先挂载兼容的 PTC 运行时和工具注册表。作用域限制决定生成的 SDK;只有 `run_code` 能被直接调用。可配置并行安全的程序子调用并发上限。 ## 目录 @@ -30,13 +30,12 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-tool-executor-ptc' config: - maxParallelToolCalls: 10 maxParallelSubCalls: 10 ``` -Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循环始终向 `execute()` 传入解析后的上限。只有调用方未传入上限时,才使用执行器的 `maxParallelToolCalls`。这个后备配置是 volatile 字段,默认值为 `10`,在下一个工具组开始时读取。值必须是正整数;`1` 表示串行执行。 +根 `run_code` 调用是独占调用,按串行执行。`maxParallelSubCalls` 限制每个程序中并行安全的嵌套调用数,默认值为 `10`;值必须是正整数,`1` 表示串行执行。独占调用构成屏障。 -本包还要求 `ctx.ptcRuntime` 和 `ctx.systemPrompt`;带 Agent 的程序要求 `ctx.workingDirectory`。`maxParallelSubCalls` 限制每个程序内重叠的并行安全调用,默认值为 `10`。 +本包还要求 `ctx.ptcRuntime` 和 `ctx.systemPrompt`;带 Agent 的程序要求 `ctx.workingDirectory`。缺少运行时时,提示词组装或执行会失败;选中的 PTC 执行器不会回退到原生执行。 持久化事件载荷从 `@deepseek-ai/dsh-tool-executor-ptc/types` 导入;该入口可用于独立读取者与 Client 编译。Host 策略扩展通过包主入口的仅类型导入获取 `PtcDispatchLog`、`tools/ptc-dispatch-log` 与 `ptc-mode` 消息来源声明,无需挂载执行器。 @@ -50,7 +49,7 @@ Agent 步骤使用 [agent-loop 并发配置](../agent-loop/README.zh.md),循
实现细节——点击展开 -执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。 +执行器接收完整的原始调用批次,负责解析、调度、取消和可选的模型步骤日志。它复用共享调用流水线来执行策略、工具和结果通知。中止后,它等待已开始的工作完成,并为跳过的调用返回有序错误结果。卸载执行器会中止运行中的沙箱程序,并等待其嵌套调用结束。已接纳批次仍由该执行器结算,但可能返回取消错误。 ### PTC mode diff --git a/packages/core/tool-executor-ptc/package.json b/packages/core/tool-executor-ptc/package.json index 89c8973e84..c2dc63b7e1 100644 --- a/packages/core/tool-executor-ptc/package.json +++ b/packages/core/tool-executor-ptc/package.json @@ -33,7 +33,6 @@ ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-ptc-runtime": "workspace:*", @@ -52,7 +51,6 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", diff --git a/packages/core/tool-executor-ptc/src/config.ts b/packages/core/tool-executor-ptc/src/config.ts new file mode 100644 index 0000000000..b2710764e2 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/config.ts @@ -0,0 +1,13 @@ +/** Configuration shared by plugin mounting and direct executor construction. @module */ +import z from '@deepseek-ai/schemastery' + +/** Program execution limits. */ +export interface Config { + /** Maximum overlapping concurrency-safe program subcalls. */ + maxParallelSubCalls: number +} + +/** Validated program subcall concurrency; one makes subcalls serial. */ +export const Config: z, Config> = z.object({ + maxParallelSubCalls: z.natural().min(1).default(10), +}) diff --git a/packages/core/tool-executor-ptc/src/index.ts b/packages/core/tool-executor-ptc/src/index.ts index 74de8722b0..fa6c00086b 100644 --- a/packages/core/tool-executor-ptc/src/index.ts +++ b/packages/core/tool-executor-ptc/src/index.ts @@ -1,14 +1,12 @@ /** PTC execution plugin for the mounting Agent, preset, or deployment scope. @module */ import type { Context } from '@deepseek-ai/cordis' -import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { RUN_CODE_NAME, ToolNotFoundError, projectToolSchema } from '@deepseek-ai/dsh-tools' import type { ToolDefinition, ToolExecutor, ToolModelInput, ToolPromptSection, ToolBatchInput, ToolBatchResult } from '@deepseek-ai/dsh-tools' import { ToolExecutionPipeline, executeToolBatch } from '@deepseek-ai/dsh-tools/pipeline' import { createRunCodeTool, requirePtcRuntime, type PtcSdkLanguage } from './ptc.ts' import { renderToolsSdk, type ToolSdkSchema } from './ts-types.ts' import { renderToolsSdkPy } from './py-types.ts' -import type { Volatile } from '@deepseek-ai/cosmokit' -import z from '@deepseek-ai/schemastery' +import { Config } from './config.ts' const PTC_ONLY_INSTRUCTION = `\`${RUN_CODE_NAME}\` is the only tool you can call directly — a tool call naming any other tool fails. Reach every tool the SDK declares below from inside the program.` @@ -24,17 +22,15 @@ export class PtcExecutor implements ToolExecutor { private readonly maxParallelSubCalls: number private ptcTransport: ToolDefinition | undefined - constructor(private readonly ctx: Context, config: Pick = {}, private readonly maxParallel: () => number = () => 10) { + constructor(private readonly ctx: Context, config: Partial = {}, private readonly maxParallel: () => number = () => 10) { + this.maxParallelSubCalls = Config(config).maxParallelSubCalls + const tools = ctx.tools this.sections = [ { name: 'tools:ptc-only', order: ctx.systemPrompt.getSectionOrder('PTC_ONLY'), text: PTC_ONLY_INSTRUCTION, interpolate: false }, { name: 'tools:sdk', order: ctx.systemPrompt.getSectionOrder('TOOLS_SDK'), text: tools => this.renderSdk(tools), interpolate: false }, ] - this.maxParallelSubCalls = config.maxParallelSubCalls ?? 10 - if (!Number.isInteger(this.maxParallelSubCalls) || this.maxParallelSubCalls < 1) { - throw new Error('maxParallelSubCalls must be a positive integer') - } this.pipeline = new ToolExecutionPipeline(ctx, { - get: (name, scope) => this.get(name, scope), + get: (name, scope) => name === RUN_CODE_NAME ? this.requirePtcTransport() : tools.get(name, scope), reject: (name, nested) => !nested && name !== RUN_CODE_NAME ? new ToolNotFoundError(name, `only \`${RUN_CODE_NAME}\` is callable directly — call \`${name}\` from inside a \`${RUN_CODE_NAME}\` program instead`) : undefined, @@ -54,10 +50,6 @@ export class PtcExecutor implements ToolExecutor { })), runtime.programFormat) } - private get(name: string, scope?: ScopeKey): ToolDefinition | undefined { - return name === RUN_CODE_NAME ? this.requirePtcTransport() : this.ctx.tools.get(name, scope) - } - execute(input: ToolBatchInput): Promise { return executeToolBatch(input, this.pipeline, this.maxParallel) } @@ -69,6 +61,7 @@ export class PtcExecutor implements ToolExecutor { } } +export { Config } from './config.ts' export { CodeRunFailedError } from './errors.ts' export type { PtcDispatchLog } from './types.ts' export type { PtcDispatchStartEventData, PtcDispatchEventData } from './session-types.ts' @@ -77,22 +70,8 @@ export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' /** Cordis plugin name. */ export const name = 'ptc-tool-executor' -/** The program provider activates with its runtime. */ -export const inject = ['tools', 'systemPrompt', 'ptcRuntime'] - -/** Program execution limits. */ -export interface Config { - /** Maximum overlapping concurrency-safe calls in one model batch. */ - maxParallelToolCalls: Volatile - /** Maximum overlapping concurrency-safe program subcalls. */ - maxParallelSubCalls?: number -} - -/** Validated program execution configuration. */ -export const Config: z<{ maxParallelToolCalls?: number; maxParallelSubCalls?: number }, Config> = z.object({ - maxParallelToolCalls: z.number().step(1).min(1).default(10).volatile(), - maxParallelSubCalls: z.natural().min(1).default(10), -}) +/** Register the selected executor even when its runtime is unavailable. */ +export const inject = ['tools', 'systemPrompt'] /** * Select PTC execution in the mounting scope. @@ -100,5 +79,5 @@ export const Config: z<{ maxParallelToolCalls?: number; maxParallelSubCalls?: nu * @param config - program subcall concurrency limits. */ export function apply(ctx: Context, config: Config): void { - ctx.tools.setExecutor(new PtcExecutor(ctx, config, () => config.maxParallelToolCalls.get())) + ctx.tools.setExecutor(new PtcExecutor(ctx, config)) } diff --git a/packages/core/tool-executor-ptc/src/ptc-calls.ts b/packages/core/tool-executor-ptc/src/ptc-calls.ts index 16de96380c..c088ff92b4 100644 --- a/packages/core/tool-executor-ptc/src/ptc-calls.ts +++ b/packages/core/tool-executor-ptc/src/ptc-calls.ts @@ -15,8 +15,8 @@ export class PtcCalls { private disposed = false /** - * Observe policy changes for the owning tool registry. - * @param ctx - Registry lifecycle and Session events. + * Observe policy changes for the owning executor. + * @param ctx - Executor lifecycle and Session events. */ constructor(ctx: Context) { ctx.on('session/event', (session, event) => { @@ -28,7 +28,7 @@ export class PtcCalls { ctx.effect(() => async () => { this.disposed = true const calls = [...this.calls.values()].flatMap(calls => [...calls]) - for (const call of calls) call.controller.abort(new Error('PTC tool registry disposed')) + for (const call of calls) call.controller.abort(new Error('PTC tool executor disposed')) await Promise.all(calls.map(call => call.done)) }, 'PTC calls') } @@ -48,7 +48,7 @@ export class PtcCalls { await Promise.all(pending.map(call => call.done)) } signal.throwIfAborted() - if (this.disposed) throw new Error('PTC tool registry disposed') + if (this.disposed) throw new Error('PTC tool executor disposed') const controller = new AbortController() const done = Promise.withResolvers() const call = { mode: mode(), controller, done: done.promise } diff --git a/packages/core/tool-executor-ptc/src/ptc.ts b/packages/core/tool-executor-ptc/src/ptc.ts index be45a469f8..899b89dd71 100644 --- a/packages/core/tool-executor-ptc/src/ptc.ts +++ b/packages/core/tool-executor-ptc/src/ptc.ts @@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-working-directory' import type {} from '@deepseek-ai/dsh-sandbox-policy' import { deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' -import { defineTool, parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' +import { defineTool, parameterSchemaSpecToJsonSchema, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' import type { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import { toolErrorMessage } from '@deepseek-ai/dsh-tools/pipeline' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -29,8 +29,7 @@ import type { ToolDefinition, ToolExecutionResult, ToolRunContext } from '@deeps import type {} from './session-types.ts' /** The model-facing name of the PTC mode tool. */ -export { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +export { RUN_CODE_NAME } /** Language-specific tool and code-parameter descriptions. */ interface RunCodeFlavor { @@ -264,19 +263,6 @@ function resolveSandboxPolicy(ctx: Context, exec: ToolRunContext): SandboxExecut return policy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session }) } -/** A failed log-content listener preserves the settled content and cannot fail the dispatch. */ -async function shapeDispatchLog(ctx: Context, dispatch: PtcDispatchLog): Promise { - try { - return await ctx.waterfall( - scopeTarget(ctx.tools, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, - () => Promise.resolve(dispatch.content), - ) - } catch (error: unknown) { - ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${toolErrorMessage(error)}; logging the original settled content`) - return dispatch.content - } -} - /** * Build the reserved `run_code` transport outside registered capabilities. * @param ctx - executor context owning policy listeners and active program cleanup. @@ -289,6 +275,23 @@ export function createRunCodeTool( executor: ToolExecutionPipeline, maxParallel: number, ): ToolDefinition { + // Retain services for log settlement after the executor context becomes inactive. + const tools = ctx.tools + const logger = ctx.logger + + /** A failed log-content listener preserves the settled content and cannot fail the dispatch. */ + async function shapeDispatchLog(dispatch: PtcDispatchLog): Promise { + try { + return await ctx.waterfall( + scopeTarget(tools, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, + () => Promise.resolve(dispatch.content), + ) + } catch (error: unknown) { + logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${toolErrorMessage(error)}; logging the original settled content`) + return dispatch.content + } + } + const stateOwners = new WeakMap>() const calls = new PtcCalls(ctx) const definition = defineTool({ @@ -536,7 +539,7 @@ export function createRunCodeTool( // The listener may replace the durable copy with a preview and // locator; the program's value and model-visible result are // untouched. - const logged = await shapeDispatchLog(ctx, { + const logged = await shapeDispatchLog({ exec, agent, subCallId, name, isError: result.isError, // The registry deep-froze this projection at result // finalization; append snapshots the final copy again, so @@ -645,7 +648,7 @@ export function createRunCodeTool( // restricted globals vanish) — the same view the SDK section declared, // so a program can bind exactly what its prompt promised; sub-dispatch // re-resolves per call through the same view (exec.agent threads down). - for (const schema of ctx.tools.schemas(exec.agent)) { + for (const schema of tools.schemas(exec.agent)) { Object.defineProperty(functions, schema.name, { enumerable: true, value: binding(deepFreeze(schema)) }) } diff --git a/packages/core/tool-executor-ptc/src/py-types.ts b/packages/core/tool-executor-ptc/src/py-types.ts index 9634099bd3..2d5e9d9e5a 100644 --- a/packages/core/tool-executor-ptc/src/py-types.ts +++ b/packages/core/tool-executor-ptc/src/py-types.ts @@ -5,12 +5,12 @@ * store, keyed by the loaded {@link @deepseek-ai/dsh-ptc-runtime#PtcRuntime.language | PTC * runtime's language}. * - * Under `mode: 'ptc'` the native tool schemas are omitted from the request, so this generated - * SDK is the model's ONLY source for each tool's argument names, required fields, types, + * With the PTC executor selected, native tool schemas are omitted from the request. This generated + * SDK is the model's only source for each tool's argument names, required fields, types, * descriptions, and canonical output types. Object-shaped arguments and outputs render as one * named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the * shape survives into the program under the mode that has nothing else to carry it. - * @module @deepseek-ai/dsh-tools/src/py-types + * @module */ import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' @@ -31,7 +31,7 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * Python identifiers are not ASCII: `路径` is as legal a field name as `path`, * and rejecting it would degrade the whole enclosing object, dropping every * field's name, requiredness, and type — information whose only source under - * `mode: 'ptc'` is this generated text. + * PTC execution is this generated text. * * NFKC stability is a second and separate condition, because CPython * normalizes identifiers at compile time while JSON keys are compared as @@ -162,7 +162,7 @@ interface RenderState { * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema * description would make the whole generated SDK unparseable — under - * `mode: 'ptc'`, the model's only declaration of the tools. The rest are + * PTC execution, the model's only declaration of the tools. The rest are * legal but invisible; escaping them with the same rule keeps the emitted text * readable and the treatment uniform. * @@ -578,7 +578,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } // TypedDict syntax cannot express openness, so an open object states it - // in-band: the annotation is advisory either way, and `mode: 'ptc'` + // in-band: the annotation is advisory either way, and PTC execution // omits the native schemas, making this line the model's only signal // that extra keys are accepted. if (node.additionalProperties !== false) { @@ -777,8 +777,8 @@ export function renderToolsSdkPy(schemas: readonly ToolSdkSchema[]): string { // of that method's body. Emitted before the `async def` it would instead // become the `Tools` class docstring (for the first tool) or a dead // expression (for every later one), leaving every method undocumented — - // and under `mode: 'ptc'` this SDK is the model's only description of - // what a tool does. A docstring is a complete body, so the `...` stub is + // and with PTC selected, this SDK is the model's only description of what + // a tool does. A docstring is a complete body, so the `...` stub is // only for the description-less case. const doc = docLines(schema.description, 2) members.push(doc.length > 0 diff --git a/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts b/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts index bf5dce527a..5fe9fd7ec8 100644 --- a/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc-calls.spec.ts @@ -67,7 +67,7 @@ it('honors cancellation while waiting for a canceled predecessor', async () => { expect(next).not.toHaveBeenCalled() }) -it('unloads the registry only after calls stop and refuses late admission', async () => { +it('unloads the executor only after calls stop and refuses late admission', async () => { const { ctx, session, calls, mode, signal } = await setup() const cleanup = Promise.withResolvers() const stopped = Promise.withResolvers() @@ -79,11 +79,11 @@ it('unloads the registry only after calls stop and refuses late admission', asyn try { await stopped.promise const next = calls.run(session.id, signal, mode, async () => 'unexpected') - const rejected = expect(next).rejects.toThrow('PTC tool registry disposed') + const rejected = expect(next).rejects.toThrow('PTC tool executor disposed') cleanup.resolve(undefined) await first await rejected await disposed - await expect(calls.run(session.id, signal, mode, async () => 'unexpected')).rejects.toThrow('PTC tool registry disposed') + await expect(calls.run(session.id, signal, mode, async () => 'unexpected')).rejects.toThrow('PTC tool executor disposed') } finally { cleanup.resolve(undefined); await first; await disposed } }) diff --git a/packages/core/tool-executor-ptc/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts index c42b502e2a..e3cbd422e0 100644 --- a/packages/core/tool-executor-ptc/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -83,7 +83,7 @@ async function setup(options: SetupOptions = {}) { runtime = ctx.ptcRuntime as FakeRuntime } return { ctx, tools: { - get: (name: string, scope?: ScopeKey) => executor instanceof PtcExecutor ? executor['get'](name, scope) : ctx.tools.get(name, scope), + get: (name: string, scope?: ScopeKey) => executor instanceof PtcExecutor && name === RUN_CODE_NAME ? executor['requirePtcTransport']() : ctx.tools.get(name, scope), execute: (input: ToolExecutionInput) => executeCall(ctx, input), }, systemPrompt: ctx.systemPrompt, runtime: runtime! } } @@ -490,7 +490,7 @@ describe('mode-aware wire contribution', () => { it.each(['typescript', 'python'])('lists code before description for a %s runtime', async (language) => { const { ctx, systemPrompt } = await setup({ mode: 'ptc', runtime: { language } }) try { - const parameters = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)!.parameters + const parameters = new PtcExecutor(ctx)['requirePtcTransport']().parameters expect(Object.keys(parameters.properties as Record).slice(0, 2)) .toEqual(['code', 'description']) expect(parameters).toHaveProperty('required', ['code', 'description']) @@ -558,17 +558,17 @@ describe('mode-aware wire contribution', () => { it.each(['ruby', 'constructor'])('rejects transport schema reads for unsupported runtime language %s', async (language) => { const { ctx } = await setup({ mode: 'ptc', runtime: { language } }) - const definition = new PtcExecutor(ctx)['get'](RUN_CODE_NAME) + const definition = new PtcExecutor(ctx)['requirePtcTransport']() const message = `no SDK renderer registered for runtime language "${language}" (known: "typescript", "python")` - expect(() => definition?.description).toThrow(message) - expect(() => definition?.parameters).toThrow(message) + expect(() => definition.description).toThrow(message) + expect(() => definition.parameters).toThrow(message) }) it('rejects transport schema reads when no runtime is mounted', async () => { const { ctx } = await setup({ mode: 'ptc', runtime: false }) - const definition = new PtcExecutor(ctx)['get'](RUN_CODE_NAME) - expect(() => definition?.description).toThrow('requires a PTC runtime') - expect(() => definition?.parameters).toThrow('requires a PTC runtime') + const definition = new PtcExecutor(ctx)['requirePtcTransport']() + expect(() => definition.description).toThrow('requires a PTC runtime') + expect(() => definition.parameters).toThrow('requires a PTC runtime') }) it("rejects the assembly when toolOrder names a native tool that mode 'ptc' no longer contributes", async () => { @@ -584,7 +584,7 @@ describe('mode-aware wire contribution', () => { await ctx.plugin(FakeRuntime, {}) const fiber = await ctx.plugin(ToolRuntime) await ctx.plugin(PtcToolExecutor) - expect(new PtcExecutor(ctx)['get'](RUN_CODE_NAME)).toBeDefined() + expect(new PtcExecutor(ctx)['requirePtcTransport']()).toBeDefined() await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() expect(assembly.tools).toEqual([]) @@ -718,14 +718,8 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { const gated = registerGated(ctx, 'safe_read', true) runtime.behavior = async (request) => { const tools = request.bindings[0]!.functions - const all = Promise.all([ - tools.safe_read!({ id: 'a' }), - tools.safe_read!({ id: 'b' }), - tools.safe_read!({ id: 'c' }), - ]) + const all = Promise.all(Array.from({ length: 3 }, (_, i) => tools.safe_read!({ id: String(i) }))) await expect.poll(() => gated.pending()).toBe(2) - // The third call waits for a slot. - expect(gated.pending()).toBe(2) gated.release() await expect.poll(() => gated.pending()).toBe(2) gated.releaseAll() @@ -733,7 +727,6 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { return { logs: [], value: 'capped' } } const result = await runCode(ctx, 'program') - if (result.isError) console.error('CAP-FAIL:', (result.content[0] as { text: string }).text) expect(result.isError).toBe(false) expect(gated.peakLive()).toBe(2) }) @@ -1659,7 +1652,7 @@ describe('the run_code dispatch bridge', () => { it('presents the model-authored description as the execute-card title over the program input', async () => { const { ctx } = await setup({ mode: 'ptc' }) - const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() // The description labels the card (the bash description precedent); the // program itself remains the expanded raw input. expect(tool.presentCall?.({ code: 'return 1', description: 'Return the constant one' })).toEqual({ @@ -1687,7 +1680,7 @@ describe('the run_code dispatch bridge', () => { runtime.behavior = () => Promise.resolve(output) const result = await runCode(ctx, 'return 1') - const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() expect(result.content).toEqual([{ type: 'text', text }]) // Presenters keep the pending program title and render this durable content @@ -1706,7 +1699,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'return 1') - const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() expect(result.content).toEqual([{ type: 'text', text: preview }]) expect('presentResult' in tool).toBe(false) @@ -1720,7 +1713,7 @@ describe('the run_code dispatch bridge', () => { }) const result = await runCode(ctx, 'return 1') - const tool = new PtcExecutor(ctx)['get'](RUN_CODE_NAME)! + const tool = new PtcExecutor(ctx)['requirePtcTransport']() expect(result.isError).toBe(true) expect(result.content).toEqual([{ @@ -2000,21 +1993,21 @@ describe('the run_code dispatch bridge', () => { expect(derived[0]?.role).toBe('user') }) - it('direct construction rejects a non-positive parallel sub-call cap at load', async () => { + it('direct construction rejects a non-positive parallel subcall limit', async () => { const ctx = new Context() - provideWorkingDirectoryFixture(ctx) - await ctx.plugin(SystemPrompt, {}) - expect(() => new PtcExecutor(ctx, { maxParallelSubCalls: 0 })) - .toThrow('maxParallelSubCalls must be a positive integer') + try { + await ctx.plugin(SystemPrompt) + expect(() => new PtcExecutor(ctx, { maxParallelSubCalls: 0 })).toThrow('maxParallelSubCalls') + } finally { await ctx.fiber.dispose() } }) - it('direct construction in PTC mode defaults the parallel sub-call cap', async () => { + it('direct construction exposes the PTC transport without configuration', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) const registry = new PtcExecutor(ctx) - expect(registry['get'](RUN_CODE_NAME)).toBeDefined() + expect(registry['requirePtcTransport']()).toBeDefined() }) it('defaults to native mode under direct construction with no config', async () => { @@ -2542,3 +2535,47 @@ describe('PTC state ownership', () => { } finally { format.mockRestore(); instructions.mockRestore(); await ctx.fiber.dispose() } }) }) + +it.each([false, true])('settles nested calls after the PTC executor fiber unloads (Agent: %s)', async (owned) => { + const ctx = new Context() + provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + await ctx.plugin(FakeRuntime) + const provider = ctx.plugin(PtcToolExecutor) + await provider + ctx.tools.register(defineTool({ + name: 'echo', description: 'Retained capability.', parameters: {}, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, + execute: async () => 'settled', + })) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + const runtime = ctx.ptcRuntime as FakeRuntime + runtime.behavior = async (request) => { + entered.resolve(undefined) + await release.promise + return { logs: [], value: await request.bindings[0]!.functions.echo!({}) } + } + const agent = { ctx, id: SessionId('unloading-ptc'), session: Session.create(SessionId('unloading-ptc')) } as Agent + const input = { + calls: [{ type: 'tool-call' as const, id: ToolCallId('unloading-program'), name: RUN_CODE_NAME, + arguments: JSON.stringify({ code: 'return await tools.echo({})', description: 'Invoke retained tool after unload' }) }], + signal: new AbortController().signal, + ...owned ? { agent } : {}, + } + const pending = ctx.tools.execute(input) + try { + await entered.promise + await provider.dispose() + await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') + release.resolve(undefined) + expect((await pending).results[0]?.result).toMatchObject({ isError: false, value: { logs: [], result: 'settled' } }) + if (owned) expect(agent.session.snapshotEvents().filter(event => event.type === 'tool/ptc-dispatch')) + .toMatchObject([{ data: { name: 'echo', isError: false, content: [{ type: 'text', text: 'settled' }] } }]) + } finally { + release.resolve(undefined) + await Promise.allSettled([pending]) + await ctx.fiber.dispose() + } +}) diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index ff0a69e461..687c39c221 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9d2e6caa39c53d62 zh: 096a777f828c7565 /deepseek-ai-dsh-tools/use-this-package/select-an-executor: - en: f3efeb3d996cb9c5 - zh: 442cf4ca6d68fea0 + en: 1ddfa584f9280cb7 + zh: b0899fcb8163569e /deepseek-ai-dsh-tools/use-this-package/restrict-tools-per-agent: en: c6f98b2283cc2e47 zh: c2cdce5d13473f15 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index bcd25bc40f..cee98fd3f7 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -72,7 +72,7 @@ Mount one executor in the deployment, preset, or Agent scope. The nearest scope - name: '@deepseek-ai/dsh-tool-executor-native' ``` -Agent-step concurrency uses the [agent-loop configuration](../agent-loop/README.md). See the [native](../tool-executor-native/README.md) or [PTC](../tool-executor-ptc/README.md) package for executor settings. Custom providers implement only `describe(definitions)` and `execute(batch)`; the shared pipeline is optional. The registry selects the current scoped executor separately for prompt descriptions and each `ctx.tools.execute()` batch. An accepted batch finishes through its selected provider even if that provider unloads; new batches reject until a replacement is registered. +Agent-step concurrency uses the [agent-loop configuration](../agent-loop/README.md). See the [native](../tool-executor-native/README.md) or [PTC](../tool-executor-ptc/README.md) package for executor settings. Custom providers implement only `describe(definitions)` and `execute(batch)`; the shared pipeline is optional. The registry selects the current scoped executor separately for prompt descriptions and each `ctx.tools.execute()` batch. An accepted batch settles through its selected provider even if that provider unloads; settlement may include cancellation errors according to the provider’s disposal policy. New batches reject until a replacement is registered. ### Restrict tools per agent diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index c7f07ab753..fe97c14c4a 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -72,7 +72,7 @@ ctx.tools.register(defineTool({ - name: '@deepseek-ai/dsh-tool-executor-native' ``` -Agent 步骤的并发上限使用 [agent-loop 配置](../agent-loop/README.zh.md)。执行器设置参见[原生](../tool-executor-native/README.zh.md)或 [PTC](../tool-executor-ptc/README.zh.md) 包。自定义提供者只需实现 `describe(definitions)` 和 `execute(batch)`,共享流水线为可选项。注册表分别为提示词描述和每次 `ctx.tools.execute()` 批次选择当前作用域的执行器。已接纳的批次由所选提供者完成,即使该提供者已卸载;注册替代提供者之前,新批次会被拒绝。 +Agent 步骤的并发上限使用 [agent-loop 配置](../agent-loop/README.zh.md)。执行器设置参见[原生](../tool-executor-native/README.zh.md)或 [PTC](../tool-executor-ptc/README.zh.md) 包。自定义提供者只需实现 `describe(definitions)` 和 `execute(batch)`,共享流水线为可选项。注册表分别为提示词描述和每次 `ctx.tools.execute()` 批次选择当前作用域的执行器。已接纳批次仍由所选提供者结算,即使该提供者已卸载;根据提供者的卸载策略,结果可能包含取消错误。注册替代提供者之前,新批次会被拒绝。 ### 按 agent 限制工具 diff --git a/packages/core/tools/src/contracts.ts b/packages/core/tools/src/contracts.ts index abf652fd9e..8ca74bcc65 100644 --- a/packages/core/tools/src/contracts.ts +++ b/packages/core/tools/src/contracts.ts @@ -142,7 +142,7 @@ export interface ToolExecutionInput { * mode sets this on SDK sub-dispatches so commit-style observers can wait for * the outer `run_code` outcome without receiving its live mutable execution. * The token also marks the call as a transport sub-dispatch rather than a - * model-direct call: under `mode: 'ptc'`, only calls WITH a parent may + * model-direct call: with the PTC executor selected, only calls with a parent may * execute a native tool name — a model-direct call (no parent) is denied as * `UNKNOWN_TOOL` before the policy pipeline. */ diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index ab301264c0..92f0ca612a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -1,5 +1,6 @@ /** Tool definitions and scoped dispatch to plugin-owned executors. @module */ import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeChainOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' import { ToolExecutorUnavailableError } from './executor.ts' @@ -79,6 +80,10 @@ class GuardLayer { /** Concrete tool registry and scoped executor dispatcher. */ export class ToolsService extends Service { static inject = ['systemPrompt'] + static Config = z.object({ + mode: z.never().description('Removed. Mount a native or PTC tool executor instead.'), + maxParallelSubCalls: z.never().description('Moved to the PTC tool executor configuration.'), + }) private readonly layers = new ScopedLayers(scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }) private readonly scoped = new WeakMap() @@ -231,6 +236,7 @@ export class ToolsService extends Service { } /** * Provide the executor for the calling scope; an unscoped contribution is the deployment default. + * Rendering any executor-owned section requires the viewing scope's selection to remain available. * @param executor - initialized provider owned by the contributing plugin. * @returns the effect disposer. Unloading leaves the selection unavailable until it is replaced. */ diff --git a/packages/core/tools/src/pipeline.ts b/packages/core/tools/src/pipeline.ts index d39e49feef..db5f0321d2 100644 --- a/packages/core/tools/src/pipeline.ts +++ b/packages/core/tools/src/pipeline.ts @@ -9,12 +9,13 @@ import type {} from '@deepseek-ai/dsh-user-approval' import { assertNever, deepFreeze, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values' import { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH, ToolNotFoundError, ToolOutputError } from './contracts.ts' import { validateJsonSchemaValue } from './json-schema.ts' +import type { ToolsService } from './index.ts' import type { ToolDefinition, ToolExecutionInput, ToolExecutionMode, ToolExecution, ToolRunContext, ToolExecutionResult, ToolExecutionSuccess, ToolErrorInfo, ToolExecutionToken, PreToolDecision, PostToolDecision } from './contracts.ts' /** Executor-owned lookup and restrictions applied before extensible execution policy. */ export interface ToolExecutionSource { - /** Read the live definition visible to the calling scope. */ - get(name: string, scope?: ScopeKey): ToolDefinition | undefined + /** Override registry lookup for executor-owned capabilities; omitted uses the retained registry. */ + get?(name: string, scope?: ScopeKey): ToolDefinition | undefined /** Reject an otherwise visible invocation before policy; omitted permits it. */ reject?(name: string, nested: boolean): Error | undefined } @@ -56,15 +57,19 @@ interface FusedToolSignal { * for complete calls, including a single-call batch. */ export class ToolExecutionPipeline { - private readonly tools: Context['tools'] + private readonly tools: ToolsService - constructor(private readonly ctx: Context, private readonly source: ToolExecutionSource) { + constructor(private readonly ctx: Context, private readonly source: ToolExecutionSource = {}) { // Accepted calls retain their event subject while the service is withdrawn during shutdown. this.tools = ctx.tools } + private get(name: string, scope?: ScopeKey): ToolDefinition | undefined { + return this.source.get === undefined ? this.tools.get(name, scope) : this.source.get(name, scope) + } + private resolveExecution(name: string, scope: ScopeKey | undefined, nested: boolean): ToolDefinition | undefined { - const tool = this.source.get(name, scope) + const tool = this.get(name, scope) return tool !== undefined && this.source.reject?.(name, nested) === undefined ? tool : undefined } @@ -111,7 +116,7 @@ export class ToolExecutionPipeline { const parent = exec.parent const signal = exec.signal // Executor refusals precede extensible policy; unknown tools retain dispatch-stage handling. - const visible = this.source.get(name, agent) + const visible = this.get(name, agent) const rejection = visible === undefined ? undefined : this.source.reject?.(name, parent !== undefined) const rejected = rejection !== undefined const concludingExecutions = this.concludingExecutions diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index f8dbf21f9f..d506ad08a8 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -1,5 +1,5 @@ -import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' /** Covers fail-closed per-call classification and model-schema isolation. */ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import { describe, expect, expectTypeOf, it } from 'vitest' @@ -33,7 +33,7 @@ describe('ToolExecutionPipeline.executionMode', () => { isConcurrencySafe: () => true, async execute() { return [] }, })) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' }) }) it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => { @@ -44,12 +44,12 @@ describe('ToolExecutionPipeline.executionMode', () => { parameters: {}, async execute() { return [] }, })) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' }) }) it('returns exclusive for an unknown tool', async () => { const ctx = await setup() - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' }) }) it('returns exclusive when the classifier returns false for these args', async () => { @@ -61,8 +61,8 @@ describe('ToolExecutionPipeline.executionMode', () => { isConcurrencySafe: args => args.mode === 'read', async execute() { return [] }, })) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' }) }) it('classifies invalid defineContentToolFixture arguments as exclusive without throwing', async () => { @@ -74,7 +74,7 @@ describe('ToolExecutionPipeline.executionMode', () => { isConcurrencySafe: () => true, async execute() { return [] }, })) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' }) }) it('treats a throwing raw classifier as exclusive', async () => { @@ -88,7 +88,7 @@ describe('ToolExecutionPipeline.executionMode', () => { async execute() { return null }, } ctx.tools.register(raw) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' }) }) it('treats a truthy non-boolean raw result as exclusive', async () => { @@ -102,7 +102,7 @@ describe('ToolExecutionPipeline.executionMode', () => { async execute() { return null }, } as unknown as ToolDefinition ctx.tools.register(raw) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' }) }) it('passes parsed arguments directly to a raw definition', async () => { @@ -116,7 +116,7 @@ describe('ToolExecutionPipeline.executionMode', () => { isConcurrencySafe(args) { seen = args; return true }, async execute() { return null }, }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) + expect(new ToolExecutionPipeline(ctx).executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' }) expect(seen).toEqual({ anything: 1 }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 7b12f80037..785927d780 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -52,6 +52,48 @@ const echoTool = defineTool({ }) describe('ToolRuntime', () => { + it.each([{ mode: 'ptc' }, { maxParallelSubCalls: 4 }])('rejects removed execution configuration %j during plugin mounting', async (config) => { + const ctx = new Context() + try { + await ctx.plugin(SystemPrompt) + // @ts-expect-error Removed fields can still arrive from untyped configuration files. + await expect(ctx.plugin(ToolRuntime, config).await()).rejects.toThrow('invalid config') + } finally { await ctx.fiber.dispose() } + }) + + it('settles accepted calls after the actual native executor fiber unloads', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const provider = ctx.plugin(NativeToolExecutor) + await provider + ctx.tools.register(echoTool) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('tools/pre-execute', async (exec, next) => { + if (exec.callId === 'first') { entered.resolve(undefined); await release.promise } + return next() + }) + const input = { + calls: ['first', 'second'].map(id => ({ type: 'tool-call' as const, id: ToolCallId(id), name: 'echo', arguments: JSON.stringify({ text: id }) })), + signal: new AbortController().signal, + } + const pending = ctx.tools.execute(input) + try { + await entered.promise + await provider.dispose() + await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') + release.resolve(undefined) + expect((await pending).results.map(({ result }) => result)).toMatchObject([ + { isError: false, value: 'first' }, { isError: false, value: 'second' }, + ]) + } finally { + release.resolve(undefined) + await Promise.allSettled([pending]) + await ctx.fiber.dispose() + } + }) + it('preserves deferred loading through registry and prompt schema projection', async () => { const ctx = await setup() try { diff --git a/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml b/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml index 1efa35f829..5ab779eb29 100644 --- a/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml +++ b/packages/experimental/cua-macos/examples/persistent-ptc.cordis.yml @@ -2,4 +2,6 @@ config: executionMode: persistent-in-memory - id: tool-executor-native - name: '@deepseek-ai/dsh-tool-executor-ptc' + disabled: true +- id: tool-executor-ptc + disabled: false diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index ce795f4305..08df76b3a1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3489,7 +3489,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'setExecutor(executor: ToolExecutor): () => void', - description: 'Provide the executor for the calling scope; an unscoped contribution is the deployment default.', + description: 'Provide the executor for the calling scope; an unscoped contribution is the deployment default. Rendering any executor-owned section requires the viewing scope\'s selection to remain available.', parameters: [{ name: 'executor', description: 'initialized provider owned by the contributing plugin.' }], returns: 'the effect disposer. Unloading leaves the selection unavailable until it is replaced.', }, @@ -8394,7 +8394,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolsService', - declaration: 'export class ToolsService extends Service {\n static inject;\n constructor(ctx: Context);\n register(definition: ToolDefinition): () => void;\n restrict(filter: ToolRestriction): () => void;\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined;\n definitions(scope?: ScopeKey): readonly ToolDefinition[];\n schemas(scope?: ScopeKey): ToolSchema[];\n setExecutor(executor: ToolExecutor): () => void;\n async execute(input: ToolBatchInput): Promise;\n guard(guard: ToolGuard): () => void;\n checkGuards(exec: ToolExecution): string | undefined;\n}', + declaration: 'export class ToolsService extends Service {\n static inject;\n static Config;\n constructor(ctx: Context);\n register(definition: ToolDefinition): () => void;\n restrict(filter: ToolRestriction): () => void;\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined;\n definitions(scope?: ScopeKey): readonly ToolDefinition[];\n schemas(scope?: ScopeKey): ToolSchema[];\n setExecutor(executor: ToolExecutor): () => void;\n async execute(input: ToolBatchInput): Promise;\n guard(guard: ToolGuard): () => void;\n checkGuards(exec: ToolExecution): string | undefined;\n}', }, { name: 'ToolUpdate', diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index cd66e7cf6d..97087edf7e 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -1,5 +1,3 @@ -import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' -import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * The `read_image` tool over the REAL local filesystem and attachment store: * extension routing, extension-less content sniffing (attachment object paths @@ -7,6 +5,8 @@ import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-to * commit + image-block rendering, attachment admission failures, and the * regression that `read` keeps its text-only contract. */ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture, executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' @@ -840,7 +840,7 @@ describe('registration surface', () => { it('declares read_image parallel-safe and presents a read-family card', async () => { const ctx = await setup() - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('img-parallel'), name: 'read_image', arguments: { file_path: 'a.png' }, })).toEqual({ kind: 'parallel' }) expect(ctx.tools.get('read_image')?.presentCall?.({ file_path: 'shot.png' })).toEqual({ diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index a3d3384374..abc9ff4787 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -1,9 +1,9 @@ -import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' -import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** * Consumer API tests over a fake provider and the real policy collaborator: schemas, * validation, formatting, typed errors, intent dispatch, and observation-driven authorization. */ +import { ToolExecutionPipeline } from '@deepseek-ai/dsh-tools/pipeline' +import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit' import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' @@ -198,11 +198,11 @@ describe('registration', () => { it('declares read parallel-safe while write/edit remain exclusive', async () => { const { ctx } = await setup() - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('read-safe'), name: 'read', arguments: { file_path: 'a.txt' } })) .toEqual({ kind: 'parallel' }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('write-exclusive'), name: 'write', arguments: { file_path: 'a.txt', content: 'x' } })) .toEqual({ kind: 'exclusive' }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('edit-exclusive'), name: 'edit', arguments: { file_path: 'a.txt', old_string: 'x', new_string: 'y' } })) .toEqual({ kind: 'exclusive' }) }) @@ -1149,7 +1149,7 @@ describe('scope-aware PTC guidance', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(GuidancePtcRuntime) await ctx.plugin(ToolRuntime) - await ctx.plugin((mode) === 'ptc' ? PtcToolExecutor : NativeToolExecutor) + await ctx.plugin(mode === 'ptc' ? PtcToolExecutor : NativeToolExecutor) await ctx.plugin(FakeFs) await ctx.plugin(ToolFs) const { key, scope } = await guidanceScope(ctx) diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 5ef28599a9..40bcef44b9 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -157,7 +157,7 @@ describe('goal tool registration and presentation', () => { const { ctx, fiber } = await harness({ blockedAfterConsecutiveRounds: 5 }) expect(['create_goal', 'get_goal', 'update_goal'].map(name => ctx.tools.get(name)?.name)) .toEqual(['create_goal', 'get_goal', 'update_goal']) - const pipeline = new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }) + const pipeline = new ToolExecutionPipeline(ctx) for (const name of ['create_goal', 'get_goal', 'update_goal']) { expect(pipeline.executionMode({ signal: testToolSignal, callId: ToolCallId(name), name, arguments: {} })) .toEqual({ kind: 'exclusive' }) diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 37c0cae4d3..e23cf8f784 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -42,7 +42,6 @@ "dependencies": { "@deepseek-ai/cosmokit": "workspace:~", "@deepseek-ai/dsh-brand": "workspace:*", - "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "eventsource-parser": "^3.1.0" @@ -72,6 +71,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", diff --git a/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts b/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts index f2d2dedef2..2c0c0b9088 100644 --- a/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts +++ b/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts @@ -1,5 +1,5 @@ -import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' /** Executor plugins follow the Loader-owned preset revision lifetime. */ +import { executeModelToolFixture } from '@deepseek-ai/dsh-tool-testkit' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -91,7 +91,7 @@ class ProgramFixture extends PtcRuntime { } } -it('loads the separate executor packages through cordis.yml presets and logs PTC subcalls', async () => { +it.each([true, false])('loads cordis.yml executor presets with runtime availability %s', async (hasRuntime) => { const root = await mkdtemp(join(tmpdir(), 'dsh-executor-packages-')) const ctx = await harness() onTestFinished(async () => { @@ -99,7 +99,7 @@ it('loads the separate executor packages through cordis.yml presets and logs PTC }) await ctx.plugin(FsLocal) await ctx.plugin(WorkingDirectory, { defaultDirectory: root }) - await ctx.plugin(ProgramFixture) + if (hasRuntime) await ctx.plugin(ProgramFixture) ctx.tools.register(defineTool({ name: 'echo', description: 'Shared capability.', parameters: { value: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }] }, @@ -127,6 +127,15 @@ it('loads the separate executor packages through cordis.yml presets and logs PTC const native = await agentOn(ctx, 'native-package', 'standard') const ptc = await agentOn(ctx, 'ptc-package', 'ptc') expect((await ctx.systemPrompt.assemble({ scope: native })).tools.map(tool => tool.name)).toEqual(['echo']) + if (!hasRuntime) { + await expect(ctx.systemPrompt.assemble({ scope: ptc })).rejects.toThrow('requires a PTC runtime') + const batch = await ctx.tools.execute({ + agent: ptc, calls: [{ type: 'tool-call', id: ToolCallId('missing-runtime'), name: 'echo', arguments: '{}' }], + signal: new AbortController().signal, + }) + expect(batch.results[0]?.result).toMatchObject({ isError: true, error: { info: { code: 'UNKNOWN_TOOL' } } }) + return + } expect((await ctx.systemPrompt.assemble({ scope: ptc })).tools.map(tool => tool.name)).toEqual(['run_code']) const signal = new AbortController().signal expect(await executeModelToolFixture(ctx, { diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index b4ee30714b..625fcf2a51 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -157,7 +157,7 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | `@deepseek-ai/dsh-agent-tool-presentation` | yes | Deprecated tool presentation selector; use dsh-tool-executor-native or dsh-tool-executor-ptc directly | | `@deepseek-ai/dsh-session` | no | Event-sourced session store for the DeepSeek Harness | | `@deepseek-ai/dsh-system-prompt` | yes | System prompt assembly registry for the DeepSeek Harness | -| `@deepseek-ai/dsh-tool-executor-native` | yes | Native tool execution plugin | +| `@deepseek-ai/dsh-tool-executor-native` | no | Native tool execution plugin | | `@deepseek-ai/dsh-tool-executor-ptc` | yes | PTC program execution, tool bindings, and generated SDK prompts | | `@deepseek-ai/dsh-tools` | no | Tool registry and execution pipeline for the DeepSeek Harness | diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 9dea684ef9..ee98142aff 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -38,7 +38,6 @@ "@deepseek-ai/dsh-output-retention": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-spill": "workspace:*", - "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-attachment": "workspace:*", diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index c685976692..df6bdcf362 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -111,13 +111,13 @@ describe('dsh-tool-subagent', () => { it('classifies delegations as concurrency-safe', async () => { const ctx = await setup({ provider: 'mock' }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('subagent-first'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, })).toEqual({ kind: 'parallel' }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('subagent-second'), name: 'subagent', @@ -696,7 +696,7 @@ describe('dsh-tool-subagent local activation', () => { it('classifies local activations concurrency-safe', async () => { const { ctx } = await continuableSetup() - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('subagent-continuable'), name: 'subagent', diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index 7c172737ab..d76c97d70f 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -43,6 +43,17 @@ describe('dsh-agent-loop-testkit', () => { } }) + it('mounts the PTC executor with its runtime', async () => { + const ctx = new Context() + try { + await ctx.plugin(FixtureRuntime) + await mountAgentLoopTestDependencies(ctx, { tools: { mode: 'ptc' } }) + expect((await ctx.systemPrompt.assemble()).tools.map(tool => tool.name)).toEqual(['run_code']) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects mutations through an unsupported Agent stub Inbox', () => { const inbox = unsupportedInbox() diff --git a/packages/test-support/tool-testkit/src/index.ts b/packages/test-support/tool-testkit/src/index.ts index ded44c27bf..d9d7435658 100644 --- a/packages/test-support/tool-testkit/src/index.ts +++ b/packages/test-support/tool-testkit/src/index.ts @@ -60,7 +60,7 @@ export function defineContentToolFixture( * @internal */ export async function executeToolFixture(ctx: Context, input: ToolExecutionInput): Promise { - const pipeline = new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }) + const pipeline = new ToolExecutionPipeline(ctx) const prepared = await pipeline.prepare(input) const outcome = prepared.kind === 'dispatch' ? await pipeline.dispatch(prepared.exec) : prepared return pipeline.complete(prepared.exec, outcome) diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 03ed9993bf..a036e10b51 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -457,9 +457,9 @@ describe('tool-web registration', () => { const names = ctx.tools.schemas().map(s => s.name) expect(names).toContain('web_search') expect(names).toContain('web_fetch') - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } })) + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('search-safe'), name: 'web_search', arguments: { queries: ['q'] } })) .toEqual({ kind: 'parallel' }) - expect(new ToolExecutionPipeline(ctx, { get: (name, scope) => ctx.tools.get(name, scope) }).executionMode({ signal: testToolSignal, callId: ToolCallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) + expect(new ToolExecutionPipeline(ctx).executionMode({ signal: testToolSignal, callId: ToolCallId('fetch-safe'), name: 'web_fetch', arguments: { url: 'https://a.test' } })) .toEqual({ kind: 'parallel' }) await fiber.dispose() expect(ctx.tools.schemas().map(s => s.name)).not.toContain('web_search') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b2b9da3e6..e130663bd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7035,9 +7035,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand - '@deepseek-ai/dsh-tool-executor-native': - specifier: workspace:* - version: link:../../core/tool-executor-native '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -7102,6 +7099,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:* version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-fs': specifier: workspace:* version: link:../../fs/tool-fs @@ -7411,13 +7411,6 @@ importers: version: link:../scope packages/core/tool-executor-native: - dependencies: - '@deepseek-ai/cosmokit': - specifier: link:../../../vendor/cosmokit - version: link:../../../vendor/cosmokit - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -7434,9 +7427,6 @@ importers: packages/core/tool-executor-ptc: dependencies: - '@deepseek-ai/cosmokit': - specifier: link:../../../vendor/cosmokit - version: link:../../../vendor/cosmokit '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand @@ -11468,9 +11458,6 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:* version: link:../../util/brand - '@deepseek-ai/dsh-tool-executor-native': - specifier: workspace:* - version: link:../../core/tool-executor-native '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -11562,6 +11549,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:* version: link:../../util/timeout + '@deepseek-ai/dsh-tool-executor-native': + specifier: workspace:* + version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-testkit': specifier: workspace:* version: link:../../test-support/tool-testkit diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b7043b8538..f09c20a812 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -728,7 +728,6 @@ export const LINK_MAP: Readonly> = { ToolExecutionResult: 'tools.md', ToolExecutionToken: 'tools.md', ToolGuard: 'tools.md', - ToolPresentationMode: 'tools.md', ToolRuntime: 'tools.md', ToolsService: 'tools.md', ToolExecutor: 'tools.md', diff --git a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl index 6fb94c5c26..cbf96e8019 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.v4.jsonl @@ -14,7 +14,7 @@ {"type":"session/title","data":{"title":"Inspect the exact tools service","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778145981,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778145981,"index":0,"dt":[],"id":"inspect-tools-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"]},{"type":"chunk","time":1788778145981,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778145981,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Concrete tool registry and scoped executor dispatcher.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"definitions(scope?: ScopeKey): readonly ToolDefinition[]\",\n \"description\": \"Read the visible capability definitions for descriptions and program bindings.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"viewing Agent or preset; omitted selects the global view.\"\n }\n ],\n \"returns\": \"definitions after scoped shadowing and restrictions.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"setExecutor(executor: ToolExecutor): () => void\",\n \"description\": \"Provide the executor for the calling scope; an unscoped contribution is the deployment default.\",\n \"parameters\": [\n {\n \"name\": \"executor\",\n \"description\": \"initialized provider owned by the contributing plugin.\"\n }\n ],\n \"returns\": \"the effect disposer. Unloading leaves the selection unavailable until it is replaced.\"\n },\n {\n \"signature\": \"async execute(input: ToolBatchInput): Promise\",\n \"description\": \"Delegate a raw call batch to its Agent's provider or the deployment default. Select at dispatch; accepted batches settle through that provider even if it unloads.\",\n \"parameters\": [\n {\n \"name\": \"input\",\n \"description\": \"original calls, optional owning Agent, and optional logging step.\"\n }\n ],\n \"returns\": \"the selected executor's settled batch; unavailable selections reject.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic denial in the calling scope.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"evaluated after extensible pre-execute policy by the shared pipeline.\"\n }\n ],\n \"returns\": \"the effect disposer that removes this guard.\"\n },\n {\n \"signature\": \"checkGuards(exec: ToolExecution): string | undefined\",\n \"description\": \"Check global and inherited guards after pre-execute policy and approval resolve.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"invocation whose guards are checked before dispatch.\"\n }\n ],\n \"returns\": \"the first denial reason, or undefined when no guard denies.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssembleContext\",\n \"declaration\": \"export interface AssembleContext {\\n scope?: ScopeKey;\\n signal?: AbortSignal;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"PromptSection\",\n \"declaration\": \"export interface PromptSection {\\n readonly name: string;\\n readonly order: number;\\n readonly text: string | ((context: AssembleContext) => string);\\n readonly interpolate?: boolean;\\n readonly complete?: boolean;\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolBatchInput\",\n \"declaration\": \"export interface ToolBatchInput {\\n readonly calls: readonly ToolCallBlock[];\\n readonly signal: AbortSignal;\\n readonly maxParallelToolCalls?: number;\\n readonly agent?: Agent;\\n readonly log?: {\\n readonly turn: number;\\n readonly step: number;\\n };\\n}\"\n },\n {\n \"name\": \"ToolBatchResult\",\n \"declaration\": \"export interface ToolBatchResult {\\n readonly results: readonly ToolCallResult[];\\n readonly additionalContexts: readonly UserMessage[];\\n readonly concluded: boolean;\\n}\"\n },\n {\n \"name\": \"ToolCallBlock\",\n \"declaration\": \"export interface ToolCallBlock {\\n type: 'tool-call';\\n id: ToolCallId;\\n name: string;\\n arguments: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolExecutor\",\n \"declaration\": \"export interface ToolExecutor {\\n readonly sections?: readonly ToolPromptSection[];\\n describe(tools: readonly ToolDefinition[]): ToolModelInput;\\n execute(input: ToolBatchInput): Promise;\\n}\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolModelInput\",\n \"declaration\": \"export type ToolModelInput = Pick;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPromptSection\",\n \"declaration\": \"export interface ToolPromptSection extends Pick {\\n readonly text: string | ((tools: readonly ToolDefinition[]) => string);\\n}\"\n },\n {\n \"name\": \"ToolProviderResult\",\n \"declaration\": \"export interface ToolProviderResult {\\n readonly schemas: readonly ToolSchema[];\\n readonly knownNames?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"inspect-tools-api"},"toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Concrete tool registry and scoped executor dispatcher.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"definitions(scope?: ScopeKey): readonly ToolDefinition[]\",\n \"description\": \"Read the visible capability definitions for descriptions and program bindings.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"viewing Agent or preset; omitted selects the global view.\"\n }\n ],\n \"returns\": \"definitions after scoped shadowing and restrictions.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"setExecutor(executor: ToolExecutor): () => void\",\n \"description\": \"Provide the executor for the calling scope; an unscoped contribution is the deployment default. Rendering any executor-owned section requires the viewing scope's selection to remain available.\",\n \"parameters\": [\n {\n \"name\": \"executor\",\n \"description\": \"initialized provider owned by the contributing plugin.\"\n }\n ],\n \"returns\": \"the effect disposer. Unloading leaves the selection unavailable until it is replaced.\"\n },\n {\n \"signature\": \"async execute(input: ToolBatchInput): Promise\",\n \"description\": \"Delegate a raw call batch to its Agent's provider or the deployment default. Select at dispatch; accepted batches settle through that provider even if it unloads.\",\n \"parameters\": [\n {\n \"name\": \"input\",\n \"description\": \"original calls, optional owning Agent, and optional logging step.\"\n }\n ],\n \"returns\": \"the selected executor's settled batch; unavailable selections reject.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic denial in the calling scope.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"evaluated after extensible pre-execute policy by the shared pipeline.\"\n }\n ],\n \"returns\": \"the effect disposer that removes this guard.\"\n },\n {\n \"signature\": \"checkGuards(exec: ToolExecution): string | undefined\",\n \"description\": \"Check global and inherited guards after pre-execute policy and approval resolve.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"invocation whose guards are checked before dispatch.\"\n }\n ],\n \"returns\": \"the first denial reason, or undefined when no guard denies.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssembleContext\",\n \"declaration\": \"export interface AssembleContext {\\n scope?: ScopeKey;\\n signal?: AbortSignal;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"PromptSection\",\n \"declaration\": \"export interface PromptSection {\\n readonly name: string;\\n readonly order: number;\\n readonly text: string | ((context: AssembleContext) => string);\\n readonly interpolate?: boolean;\\n readonly complete?: boolean;\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolBatchInput\",\n \"declaration\": \"export interface ToolBatchInput {\\n readonly calls: readonly ToolCallBlock[];\\n readonly signal: AbortSignal;\\n readonly maxParallelToolCalls?: number;\\n readonly agent?: Agent;\\n readonly log?: {\\n readonly turn: number;\\n readonly step: number;\\n };\\n}\"\n },\n {\n \"name\": \"ToolBatchResult\",\n \"declaration\": \"export interface ToolBatchResult {\\n readonly results: readonly ToolCallResult[];\\n readonly additionalContexts: readonly UserMessage[];\\n readonly concluded: boolean;\\n}\"\n },\n {\n \"name\": \"ToolCallBlock\",\n \"declaration\": \"export interface ToolCallBlock {\\n type: 'tool-call';\\n id: ToolCallId;\\n name: string;\\n arguments: string;\\n}\"\n },\n {\n \"name\": \"ToolCallId\",\n \"declaration\": \"export type ToolCallId = Branded<'ToolCallId'>;\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n projectContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n reason?: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly schema?: ToolSchema;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolExecutor\",\n \"declaration\": \"export interface ToolExecutor {\\n readonly sections?: readonly ToolPromptSection[];\\n describe(tools: readonly ToolDefinition[]): ToolModelInput;\\n execute(input: ToolBatchInput): Promise;\\n}\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolModelInput\",\n \"declaration\": \"export type ToolModelInput = Pick;\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPromptSection\",\n \"declaration\": \"export interface ToolPromptSection extends Pick {\\n readonly text: string | ((tools: readonly ToolDefinition[]) => string);\\n}\"\n },\n {\n \"name\": \"ToolProviderResult\",\n \"declaration\": \"export interface ToolProviderResult {\\n readonly schemas: readonly ToolSchema[];\\n readonly knownNames?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n deferLoading?: true;\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends MessageBase {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false,"id":"{{message:5}}"}},"sourceEventSeqs":[14],"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":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"usage":{"inputTokens":3,"outputTokens":3},"stream":[{"type":"chunk","time":1788778146017,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788778146017,"index":0,"dt":[],"id":"inspect-subprocess-api","name":"cordis_inspect_query","args":["{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"]},{"type":"chunk","time":1788778146017,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-subprocess-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"subprocess\"}}"}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}},{"type":"chunk","time":1788778146017,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} From fa8e928e4f826cc5f475a43734f88579710a37d7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 17:18:08 +0800 Subject: [PATCH 27/73] fix(tools): require PTC runtime before activating executor --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tool-executor-plugins/guide.i18n.yaml | 4 +-- .../tool-executor-plugins/guide.md | 2 +- .../tool-executor-plugins/guide.zh.md | 2 +- .../core/tool-executor-ptc/README.i18n.yaml | 4 +-- packages/core/tool-executor-ptc/README.md | 2 +- packages/core/tool-executor-ptc/README.zh.md | 2 +- packages/core/tool-executor-ptc/src/index.ts | 4 +-- .../core/tool-executor-ptc/tests/ptc.spec.ts | 36 +++++++++++++++++-- .../ptc-node-workspace/cordis.snapshot.yml | 8 +++++ .../session/ptc-node-workspace/cordis.yml | 8 +++++ 12 files changed, 61 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 33ab75b9e4..62cc0e4c9a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3827,7 +3827,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-executor-ptc` -- `inject`: `tools` · `systemPrompt` +- `inject`: `tools` · `systemPrompt` · `ptcRuntime` - `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts) ```ts config-catalog diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1e2e6f7a05..0e7eb0a8e5 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3829,7 +3829,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-executor-ptc` -- `inject`: `tools` · `systemPrompt` +- `inject`: `tools` · `systemPrompt` · `ptcRuntime` - `source`: [`packages/core/tool-executor-ptc/src/config.ts:5`](../packages/core/tool-executor-ptc/src/config.ts) ```ts config-catalog diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml index 60370bde67..6a9350d082 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -12,5 +12,5 @@ en: f732d85027bf0236 zh: d6597cde7ba31e47 /mount-a-tool-executor/migration: - en: 411860d06f1013ea - zh: 615e044545d89b55 + en: 79369b132e00d7be + zh: e93523fa3369ae1c diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md index 9ea22ca563..a5a378695c 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md @@ -14,7 +14,7 @@ English | [中文](guide.zh.md) ## Migration 1. Keep the `dsh-tools` row and remove its execution configuration. Mount one native or PTC executor in the intended deployment or preset scope. For shipped profile patches, target the executor row instead of the `tools` row. -2. Move `maxParallelSubCalls` to the PTC executor configuration. PTC still requires a compatible runtime. `agent-loop.maxParallelToolCalls` and saved overrides stay in place with default `10`. The loop forwards the current limit to each batch; live edits apply to the next batch instead of the next tool group. +2. Move `maxParallelSubCalls` to the PTC executor configuration and mount a compatible `ctx.ptcRuntime` provider. The executor declares this service as a required injection; a missing runtime stops startup, and unloading the runtime withdraws the executor. `agent-loop.maxParallelToolCalls` and saved overrides stay in place with default `10`. The loop forwards the current limit to each batch; live edits apply to the next batch instead of the next tool group. 3. Custom executors implement `describe` and `execute`. Programmatic callers pass raw `calls` and `signal`, optional `agent`, and optional `log: { turn, step }`; read single-call outcomes from `results`. Omit `log` outside a committed model step. 4. Import PTC SDK renderers and `CodeRunFailedError` from `@deepseek-ai/dsh-tool-executor-ptc`. Run a task with the selected interaction mode and confirm its tool results appear in the Session. 5. Import `ToolExecutionPipeline` and `executeToolBatch` from `@deepseek-ai/dsh-tools/pipeline`. Complete calls, including single calls, use batches; custom schedulers call `prepare`, `dispatch`, and `complete` directly. Add `@deepseek-ai/dsh-tool-testkit` as a development dependency for tool fixtures and import them from that package. Neither is exported by the tool registry entry. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md index 6e0f4c9a24..ad38d78427 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md @@ -14,7 +14,7 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 ## 迁移 1. 保留 `dsh-tools` 条目并移除其执行配置。在目标部署或预设作用域挂载一个原生或 PTC 执行器。内置 profile 的补丁应修改执行器条目,而非 `tools` 条目。 -2. 把 `maxParallelSubCalls` 移到 PTC 执行器配置。PTC 仍要求兼容的运行时。`agent-loop.maxParallelToolCalls` 和已保存的覆盖值保留原位,默认值为 `10`。循环向每个批次传入当前上限;实时修改从下一个批次生效,不再从下一个工具组生效。 +2. 把 `maxParallelSubCalls` 移到 PTC 执行器配置,并挂载兼容的 `ctx.ptcRuntime` 提供方。执行器将该服务声明为必需注入;缺少运行时会阻止启动,卸载运行时会撤回执行器。`agent-loop.maxParallelToolCalls` 和已保存的覆盖值保留原位,默认值为 `10`。循环向每个批次传入当前上限;实时修改从下一个批次生效,不再从下一个工具组生效。 3. 自定义执行器实现 `describe` 和 `execute`。程序调用方传入原始 `calls` 和 `signal`,以及可选的 `agent` 和 `log: { turn, step }`;从 `results` 读取单次结果。不属于已提交模型步骤的调用应省略 `log`。 4. 从 `@deepseek-ai/dsh-tool-executor-ptc` 导入 PTC SDK 渲染器和 `CodeRunFailedError`。用所选交互模式运行任务,确认工具结果出现在 Session 中。 5. 从 `@deepseek-ai/dsh-tools/pipeline` 导入 `ToolExecutionPipeline` 和 `executeToolBatch`。完整执行(包括单次调用)使用批次;自定义调度器直接调用 `prepare`、`dispatch` 和 `complete`。把 `@deepseek-ai/dsh-tool-testkit` 添加为开发依赖,并从该包导入工具 fixture。工具注册表主入口不再导出这些成员。 diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml index e6d35ea2bc..4324c4868a 100644 --- a/packages/core/tool-executor-ptc/README.i18n.yaml +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-executor-ptc/use-this-package: - en: 488f394426b3aaa0 - zh: a15185f91a1c342c + en: 9bb7f839614606c1 + zh: 64269fc2f32c7309 /deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation: en: 9d4f61c351945d30 zh: 1c575526230b0e02 diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md index 7519301f2a..af661f6282 100644 --- a/packages/core/tool-executor-ptc/README.md +++ b/packages/core/tool-executor-ptc/README.md @@ -35,7 +35,7 @@ Add this row to a composition with `dsh-tools` mounted. The nearest scoped execu Root `run_code` calls are exclusive and execute serially. `maxParallelSubCalls` limits parallel-safe nested calls within each program and defaults to `10`; it must be a positive integer, with `1` selecting serial execution. Exclusive calls form barriers. -This package also requires `ctx.ptcRuntime` and `ctx.systemPrompt`; Agent-owned programs need `ctx.workingDirectory`. A missing runtime rejects prompt assembly or execution; the selected PTC executor never falls back to native execution. +This package declares `ctx.ptcRuntime` and `ctx.systemPrompt` as required injections; Agent-owned programs need `ctx.workingDirectory`. The executor activates only after its runtime is available and withdraws when that runtime unloads. An enabled PTC executor without a runtime stops application startup. A withdrawn PTC selection never falls back to native execution. Import durable event payloads from `@deepseek-ai/dsh-tool-executor-ptc/types`; this entry is safe for detached readers and client compilation. Host policy extensions import `PtcDispatchLog`, `tools/ptc-dispatch-log`, and the `ptc-mode` message-source declaration through a type-only import of the package entry, without mounting an executor. diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md index 8625183568..ccaa6647d6 100644 --- a/packages/core/tool-executor-ptc/README.zh.md +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" 根 `run_code` 调用是独占调用,按串行执行。`maxParallelSubCalls` 限制每个程序中并行安全的嵌套调用数,默认值为 `10`;值必须是正整数,`1` 表示串行执行。独占调用构成屏障。 -本包还要求 `ctx.ptcRuntime` 和 `ctx.systemPrompt`;带 Agent 的程序要求 `ctx.workingDirectory`。缺少运行时时,提示词组装或执行会失败;选中的 PTC 执行器不会回退到原生执行。 +本包将 `ctx.ptcRuntime` 和 `ctx.systemPrompt` 声明为必需注入;带 Agent 的程序要求 `ctx.workingDirectory`。执行器在运行时可用后才激活,并在运行时卸载时撤回。启用 PTC 执行器但未提供运行时会阻止应用启动。已撤回的 PTC 选择不会回退到原生执行。 持久化事件载荷从 `@deepseek-ai/dsh-tool-executor-ptc/types` 导入;该入口可用于独立读取者与 Client 编译。Host 策略扩展通过包主入口的仅类型导入获取 `PtcDispatchLog`、`tools/ptc-dispatch-log` 与 `ptc-mode` 消息来源声明,无需挂载执行器。 diff --git a/packages/core/tool-executor-ptc/src/index.ts b/packages/core/tool-executor-ptc/src/index.ts index fa6c00086b..5605b0c49f 100644 --- a/packages/core/tool-executor-ptc/src/index.ts +++ b/packages/core/tool-executor-ptc/src/index.ts @@ -70,8 +70,8 @@ export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' /** Cordis plugin name. */ export const name = 'ptc-tool-executor' -/** Register the selected executor even when its runtime is unavailable. */ -export const inject = ['tools', 'systemPrompt'] +/** Activate the executor only while its registry, prompt service, and runtime are available. */ +export const inject = ['tools', 'systemPrompt', 'ptcRuntime'] /** * Select PTC execution in the mounting scope. diff --git a/packages/core/tool-executor-ptc/tests/ptc.spec.ts b/packages/core/tool-executor-ptc/tests/ptc.spec.ts index e3cbd422e0..9972e5d8a0 100644 --- a/packages/core/tool-executor-ptc/tests/ptc.spec.ts +++ b/packages/core/tool-executor-ptc/tests/ptc.spec.ts @@ -198,6 +198,31 @@ it('logs detached frozen nested metadata without changing the binding value or l }) describe('mode-aware wire contribution', () => { + it('waits for its runtime and follows runtime withdrawal and replacement', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + const executor = await ctx.plugin(PtcToolExecutor) + + await expect(runCode(ctx, 'return 1')).rejects.toThrow('selected tool executor is unavailable') + + const runtime = await ctx.plugin(FakeRuntime) + await executor.await() + const first = await ctx.systemPrompt.assemble() + expect(first.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(first.tools[0]?.description).toContain('TypeScript') + + await runtime.dispose() + await expect(runCode(ctx, 'return 1')).rejects.toThrow('selected tool executor is unavailable') + + await ctx.plugin(FakeRuntime, { language: 'python' }) + await executor.await() + const replacement = await ctx.systemPrompt.assemble() + expect(replacement.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) + expect(replacement.tools[0]?.description).toContain('Python') + }) + it('rejects a non-positive PTC concurrency limit in configuration', () => { expect(() => PtcToolExecutor.Config({ maxParallelSubCalls: 0 })).toThrow() }) @@ -2536,12 +2561,17 @@ describe('PTC state ownership', () => { }) }) -it.each([false, true])('settles nested calls after the PTC executor fiber unloads (Agent: %s)', async (owned) => { +it.each([ + { owned: false, unload: 'executor' }, + { owned: true, unload: 'executor' }, + { owned: false, unload: 'runtime' }, + { owned: true, unload: 'runtime' }, +] as const)('settles nested calls after the PTC $unload fiber unloads (Agent: $owned)', async ({ owned, unload }) => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - await ctx.plugin(FakeRuntime) + const runtimeProvider = await ctx.plugin(FakeRuntime) const provider = ctx.plugin(PtcToolExecutor) await provider ctx.tools.register(defineTool({ @@ -2567,7 +2597,7 @@ it.each([false, true])('settles nested calls after the PTC executor fiber unload const pending = ctx.tools.execute(input) try { await entered.promise - await provider.dispose() + await (unload === 'runtime' ? runtimeProvider : provider).dispose() await expect(ctx.tools.execute(input)).rejects.toThrow('selected tool executor is unavailable') release.resolve(undefined) expect((await pending).results[0]?.result).toMatchObject({ isError: false, value: { logs: [], result: 'settled' } }) diff --git a/snapshots/session/ptc-node-workspace/cordis.snapshot.yml b/snapshots/session/ptc-node-workspace/cordis.snapshot.yml index c97ea0876f..2b37a72e97 100644 --- a/snapshots/session/ptc-node-workspace/cordis.snapshot.yml +++ b/snapshots/session/ptc-node-workspace/cordis.snapshot.yml @@ -34,6 +34,14 @@ - id: tool-executor-ptc disabled: false +# The runtime mounts after the executor, which waits for its required service. +- id: ptc-runtime + disabled: true + +- insert: + - id: ptc-runtime-late + name: '@deepseek-ai/dsh-ptc-runtime-node' + - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: diff --git a/snapshots/session/ptc-node-workspace/cordis.yml b/snapshots/session/ptc-node-workspace/cordis.yml index fcbab151bf..52c900cb99 100644 --- a/snapshots/session/ptc-node-workspace/cordis.yml +++ b/snapshots/session/ptc-node-workspace/cordis.yml @@ -28,6 +28,14 @@ - id: tool-executor-ptc disabled: false +# The runtime mounts after the executor, which waits for its required service. +- id: ptc-runtime + disabled: true + +- insert: + - id: ptc-runtime-late + name: '@deepseek-ai/dsh-ptc-runtime-node' + - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: From 78b577faa49fdbbb7f71d03b0634eb3ee661fc0d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 17:25:16 +0800 Subject: [PATCH 28/73] refactor(profiles): select PTC execution through overlays --- apps/cli/config/examples/ptc.yml | 5 +++++ apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- .../headless/tests/headless.expected.e2e.ts | 6 +++--- .../headless/tests/source-tool.built.e2e.ts | 1 - apps/cli/tests/profiles/sdk/office-cli.e2e.ts | 2 +- apps/web/tests/scaffold.ts | 8 ------- apps/web/tests/smoke-real.e2e.ts | 21 +++++++++++++------ .../tool-executor-plugins/guide.i18n.yaml | 8 +++---- .../tool-executor-plugins/guide.md | 16 +++++++++++++- .../tool-executor-plugins/guide.zh.md | 16 +++++++++++++- packages/bundle/headless/README.i18n.yaml | 4 ++-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/headless/cordis.patch.yml | 12 ----------- packages/bundle/web-app/README.i18n.yaml | 4 ++-- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 12 ----------- packages/experimental/auto-review/README.md | 2 +- .../experimental/auto-review/README.zh.md | 2 +- .../skill-office/tests/docx-validation.e2e.ts | 3 ++- .../skill-office/tests/render-tools.e2e.ts | 5 +++-- .../skill-office/tests/visual-intent.e2e.ts | 2 +- .../skill-office/tests/xlsx-validation.e2e.ts | 2 +- scripts/demo-ptc.mjs | 3 ++- .../session/headless-auto-review/snapshot.yml | 2 -- 28 files changed, 81 insertions(+), 71 deletions(-) create mode 100644 apps/cli/config/examples/ptc.yml diff --git a/apps/cli/config/examples/ptc.yml b/apps/cli/config/examples/ptc.yml new file mode 100644 index 0000000000..d0c2cc888a --- /dev/null +++ b/apps/cli/config/examples/ptc.yml @@ -0,0 +1,5 @@ +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 3144f47b3a..187828977b 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: 850434e8ca162221 - zh: 4893613f97a21eb8 + en: 179aa84fb1f628c0 + zh: 01da19b1a5d6aaf3 /dsh-cli-behavior-reference/shared-deployment-behavior: en: 6a7abc9339e8f37f zh: 133adb834c8e195d diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 58ffe96eb8..7ab66654e3 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native` or `ptc` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. +Web and headless inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 85312166bc..947ec64230 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -132,7 +132,7 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native` 或 `ptc`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +Web 与 headless 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index b71ffc778c..a2ae0c9f19 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -555,7 +555,7 @@ describe('headless stream-json snapshots', () => { '--permission-mode', 'auto', '--approval-policy', 'never', '--json', 'Run the CLI probe.', ], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: mode, DSH_TELEMETRY_DISABLED: '1' }, inspect: async (cwd) => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) @@ -605,7 +605,7 @@ describe('headless stream-json snapshots', () => { const first = await runLoaderSmoke({ label: 'headless Auto denied wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--permission-mode', 'auto', '--approval-policy', 'never', 'Run the CLI probe.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'deny', DSH_TELEMETRY_DISABLED: '1' }, }) const sessionId = parseJsonl(first.stdout)[0]?.sessionId if (typeof sessionId !== 'string') throw new Error('missing Auto Session identity') @@ -615,7 +615,7 @@ describe('headless stream-json snapshots', () => { const second = await runLoaderSmoke({ label: 'headless Auto authorized wake', cwd, binScript: dshBinScript, configPath: autoReviewOverlayPath, binArgs: [...args, '--session-id', sessionId, 'I authorize the exact CLI probe. Retry it.'], tsconfigPath, - env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TOOLS_MODE: 'native', DSH_TELEMETRY_DISABLED: '1' }, + env: { DSH_AUTO_REVIEW_TEST_MODE: 'allow', DSH_TELEMETRY_DISABLED: '1' }, inspect: async (directory) => { const logs = await persistedLogs(directory, join(directory, '.dsh', 'sessions')) expect(logs).toHaveLength(1) diff --git a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts index 4988ef80fd..03f94856f4 100644 --- a/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts @@ -58,7 +58,6 @@ describe.skipIf(!existsSync(join(repoRoot, 'apps/cli/lib/bin.js')))('dsh SOURCE DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: 'native', DSH_CLI_MOCK_FAILURE: '0', }, input: '', diff --git a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts index 2c90e5fc6e..7c8a94748d 100644 --- a/apps/cli/tests/profiles/sdk/office-cli.e2e.ts +++ b/apps/cli/tests/profiles/sdk/office-cli.e2e.ts @@ -58,7 +58,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || process.platform === 'win32')(`agent ], { cwd: root, env: { PATH: path, DSH_HOME: join(root, 'home'), DSH_AGENTS_HOME: join(root, 'agents'), - DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_PRIMARY_RUNTIME: carrier === 'sdk' ? undefined : '', }, timeout: 110_000, killSignal: 'SIGKILL', reject: false, diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 61af9d9e5c..f23a3d948e 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -384,13 +384,6 @@ export interface LaunchOptions { paceMs?: number /** Synthetic model capacity for UI scenarios whose seeded history must remain uncompacted. */ replayContextWindow?: number - /** - * Tool presentation mode patched onto the shipped `tools` row (`code` - * collapses the wire to run_code + the SDK prompt section). Omit for the - * yml default. The PTC runtime row is always in the tree, so no extra - * insertion is needed. - */ - toolsMode?: 'native' | 'ptc' /** * Keep the shipped DeepSeek adapter mounted while masking the process * environment's DEEPSEEK_API_KEY for this scaffold lifetime. This is the @@ -689,7 +682,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise ({ id: `preset-${config.id}`, name: '@deepseek-ai/dsh-agent-preset', config })) }, ], - ...options.toolsMode === undefined ? [] : [{ id: 'tools', config: { mode: options.toolsMode } }], ...options.deepSeekSearch === undefined ? [] : [{ diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index cf1546ccf2..a980c2d02d 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -603,7 +603,7 @@ describe('dsh web keyless CLI smoke', () => { } }, 120_000) - it('DSH_TOOLS_MODE=ptc collapses the provider wire tools to run_code with the SDK prompt section', async () => { + it.each(['native', 'ptc'] as const)('selects the %s executor through profile overlays and ignores the retired environment selector', async (mode) => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) @@ -632,14 +632,17 @@ describe('dsh web keyless CLI smoke', () => { const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href const child = spawn( process.execPath, - ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--no-open', '--port', '0'], + ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + ...mode === 'ptc' ? ['--patch', join(REPO_ROOT, 'apps/cli/config/examples/ptc.yml')] : [], + '--no-open', '--port', '0', + ], { cwd: workspace, env: { ...process.env, DEEPSEEK_API_KEY: 'keyless-web-ptc', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - DSH_TOOLS_MODE: 'ptc', + DSH_TOOLS_MODE: mode === 'native' ? 'ptc' : 'native', DSH_HOME: join(workspace, '.dsh'), DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), @@ -662,9 +665,15 @@ describe('dsh web keyless CLI smoke', () => { setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() }), ]) - expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code']) - expect(captured.system).toContain('## Writing code for run_code') - expect(captured.system).toContain('declare const tools') + if (mode === 'ptc') { + expect(captured.tools?.map(tool => tool.name)).toEqual(['run_code']) + expect(captured.system).toContain('## Writing code for run_code') + expect(captured.system).toContain('declare const tools') + } else { + expect(captured.tools?.map(tool => tool.name)).not.toContain('run_code') + expect(captured.tools?.length).toBeGreaterThan(0) + expect(captured.system).not.toContain('## Writing code for run_code') + } } finally { const closed = child.exitCode === null ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) }) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml index 6a9350d082..50a174e891 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 73002a2963b9ea27 zh: a67ea382d7c70c61 /mount-a-tool-executor/change: - en: f732d85027bf0236 - zh: d6597cde7ba31e47 + en: 6d9c056c4ec74661 + zh: 340be0eddcd30049 /mount-a-tool-executor/migration: - en: 79369b132e00d7be - zh: e93523fa3369ae1c + en: 60ea8b9dd1128dfc + zh: 7c648e9e5d75c49b diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md index a5a378695c..48a12c3078 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but rejects the removed `mode` and `maxParallelSubCalls` configuration fields at load time. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. +`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but rejects the removed `mode` and `maxParallelSubCalls` configuration fields at load time. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. Web and headless no longer read `DSH_TOOLS_MODE`; both inherit native execution from base. ## Migration @@ -19,3 +19,17 @@ English | [中文](guide.zh.md) 4. Import PTC SDK renderers and `CodeRunFailedError` from `@deepseek-ai/dsh-tool-executor-ptc`. Run a task with the selected interaction mode and confirm its tool results appear in the Session. 5. Import `ToolExecutionPipeline` and `executeToolBatch` from `@deepseek-ai/dsh-tools/pipeline`. Complete calls, including single calls, use batches; custom schedulers call `prepare`, `dispatch`, and `complete` directly. Add `@deepseek-ai/dsh-tool-testkit` as a development dependency for tool fixtures and import them from that package. Neither is exported by the tool registry entry. 6. Import `PtcDispatchLog` from the PTC executor package using `import type`. Detached Session readers use `@deepseek-ai/dsh-tool-executor-ptc/types` for durable dispatch events. Live PTC policy and message-source declarations remain on the executor package entry. + +7. Remove `DSH_TOOLS_MODE` from launch environments. For PTC, save the following as `ptc.yml` and pass it with `--patch`; native execution needs no overlay. The same patch works with `--profile web`. Confirm that the model receives only `run_code` in PTC mode. + +```yaml +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false +``` + +```bash +dsh --profile headless --patch ./ptc.yml "task" +``` diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md index ad38d78427..dd9d85657d 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md @@ -9,7 +9,7 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 ## 变更 -`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但加载时会拒绝已移除的 `mode` 和 `maxParallelSubCalls` 配置字段。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。 +`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但加载时会拒绝已移除的 `mode` 和 `maxParallelSubCalls` 配置字段。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。Web 与 headless 不再读取 `DSH_TOOLS_MODE`,均继承 base 的原生执行。 ## 迁移 @@ -19,3 +19,17 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 4. 从 `@deepseek-ai/dsh-tool-executor-ptc` 导入 PTC SDK 渲染器和 `CodeRunFailedError`。用所选交互模式运行任务,确认工具结果出现在 Session 中。 5. 从 `@deepseek-ai/dsh-tools/pipeline` 导入 `ToolExecutionPipeline` 和 `executeToolBatch`。完整执行(包括单次调用)使用批次;自定义调度器直接调用 `prepare`、`dispatch` 和 `complete`。把 `@deepseek-ai/dsh-tool-testkit` 添加为开发依赖,并从该包导入工具 fixture。工具注册表主入口不再导出这些成员。 6. 使用 `import type` 从 PTC 执行器包导入 `PtcDispatchLog`。独立 Session 读取者通过 `@deepseek-ai/dsh-tool-executor-ptc/types` 获取持久化分派事件;PTC 实时策略与消息来源声明由执行器包主入口提供。 + +7. 从启动环境中移除 `DSH_TOOLS_MODE`。需要 PTC 时,将以下内容保存为 `ptc.yml` 并通过 `--patch` 加载;原生执行无需 overlay。同一补丁也适用于 `--profile web`。确认 PTC 模式下模型只收到 `run_code`。 + +```yaml +- id: tool-executor-native + disabled: true + +- id: tool-executor-ptc + disabled: false +``` + +```bash +dsh --profile headless --patch ./ptc.yml "task" +``` diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 4c46daefd0..3ab283a4bb 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -48,8 +48,8 @@ en: fdef19c52550fdf8 zh: c4bab4fb6972cf10 /deepseek-ai-dsh-headless/understand-the-implementation/patch-surface-over-base: - en: 290f49af13bb0ae1 - zh: 5b599df1df8e6b7b + en: e192267973792ad1 + zh: ef78428a68dff9e4 /deepseek-ai-dsh-headless/understand-the-implementation/exit-mapping: en: c2466d3d8a26177a zh: b75743bfa2e7723a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index b96232b7d5..f7b4cffadb 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -125,7 +125,7 @@ The runner awaits the complete application (`ctx.get('loader')?.await()`) so the ### Patch surface over base -The patch rides over `dsh-base`: it inherits the projection cache and shared PTC runtime, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the task and invocation options, prints the app's `--help`, and provides `headlessStartup`; the runner reads its task, Session identity, and output mode from lazy config. It reads the explicit CLI permission preset and approval policy directly from `headlessStartup`, with runner config as the default. The sandbox-policy row also waits for this service, retaining the base mode and workspace defaults while receiving the explicit directory grants. +The patch rides over `dsh-base`: it inherits the projection cache and shared PTC runtime, sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row, inherits native tool execution and accepts a [PTC overlay](../../../apps/cli/config/examples/ptc.yml), disables the shared HMR row, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the task and invocation options, prints the app's `--help`, and provides `headlessStartup`; the runner reads its task, Session identity, and output mode from lazy config. It reads the explicit CLI permission preset and approval policy directly from `headlessStartup`, with runner config as the default. The sandbox-policy row also waits for this service, retaining the base mode and workspace defaults while receiving the explicit directory grants. ### Exit mapping diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index d52e539c49..72de7b6eb1 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -125,7 +125,7 @@ runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组 ### 基于 base 的 patch 内容 -patch 叠加在 `dsh-base` 之上:继承投影缓存与共享 PTC 运行时,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR(热模块替换)行,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取任务和调用选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 从延迟配置读取任务、Session 标识与输出模式。它直接从 `headlessStartup` 读取显式 CLI 权限预设与审批策略,以 runner 配置作为默认值。sandbox-policy 行也等待此服务,在保留基础模式和工作区默认值的同时接收显式目录授权。 +patch 叠加在 `dsh-base` 之上:继承投影缓存与共享 PTC 运行时,在基础 `system-prompt` 行上设置编码 persona 前缀与独立的 cwd 后缀,继承原生工具执行,并支持 [PTC overlay](../../../apps/cli/config/examples/ptc.yml),禁用共享的 HMR(热模块替换)行,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 连续段。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取任务和调用选项、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 从延迟配置读取任务、Session 标识与输出模式。它直接从 `headlessStartup` 读取显式 CLI 权限预设与审批策略,以 runner 配置作为默认值。sandbox-policy 行也等待此服务,在保留基础模式和工作区默认值的同时接收显式目录授权。 ### 退出映射 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 7d366b3fe8..0a0ea09b7f 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -11,18 +11,6 @@ personaPrefix: >- You are a coding agent powered by the {{model}} model. -- id: tool-executor-native - name: '@deepseek-ai/dsh-tool-executor-native' - disabled: !!js | - (() => { - const mode = process.env.DSH_TOOLS_MODE ?? 'native' - if (mode !== 'native' && mode !== 'ptc') throw new Error('DSH_TOOLS_MODE must be native or ptc') - return mode === 'ptc' - })() - -- id: tool-executor-ptc - disabled: !!js process.env.DSH_TOOLS_MODE !== 'ptc' - - insert: - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 1d8e07a01e..6dc5f64ac6 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -36,8 +36,8 @@ en: 323742a00cfe67b2 zh: eff5245a82fc16e7 /deepseek-ai-dsh-web-app/understand-the-implementation/patch-semantics: - en: 8a2554b3d17daa1f - zh: 66f18946791cabb6 + en: a6d126f30b1594d7 + zh: 7f0ccc8a55d6f743 /deepseek-ai-dsh-web-app/understand-the-implementation/advertised-application-url: en: 0b22e03e28d41420 zh: fd45a97dd4336c25 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 2305d402cd..67448234b9 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -102,7 +102,7 @@ The bundle is one patch layer of five files plus one runtime glue plugin: `cordi ### Patch semantics -A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. +A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona prefix template and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The `webserver`, `web-runtime`, and `connection` rows inject the `webStartup` provider and read their invocation values directly; Connection composes its accepted authorities from `ctx.webStartup.trustedHosts`, and the Host/Origin fence accepts the listener's bind IP independently of that list. The bundle inherits native tool execution; a [PTC overlay](../../../apps/cli/config/examples/ptc.yml) changes the deployment executor. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. ### Advertised application URL diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 8d08ede8af..ef5e029f53 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -102,7 +102,7 @@ dsh --profile web --no-open --host "$(hostname -i | awk '{print $1}')" --public- ### patch 语义 -patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 +patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona 前缀模板与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。`webserver`、`web-runtime` 与 `connection` 行都注入 `webStartup` 提供方并直接读取本次调用的取值;Connection 的被接受 authority 全部来自 `ctx.webStartup.trustedHosts`,Host/Origin 栅栏则独立于该列表接受监听器的绑定 IP。bundle 继承原生工具执行;[PTC overlay](../../../apps/cli/config/examples/ptc.yml) 可切换部署执行器。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 ### 公告应用 URL diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 06d5a8424a..9eda9fccde 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -29,18 +29,6 @@ path: ':memory:' openAt: never -- id: tool-executor-native - name: '@deepseek-ai/dsh-tool-executor-native' - disabled: !!js | - (() => { - const mode = process.env.DSH_TOOLS_MODE ?? 'native' - if (mode !== 'native' && mode !== 'ptc') throw new Error('DSH_TOOLS_MODE must be native or ptc') - return mode === 'ptc' - })() - -- id: tool-executor-ptc - disabled: !!js process.env.DSH_TOOLS_MODE !== 'ptc' - # ── web-only host rows, the transport layer, and the browser roster ───────── # `dsh.client` rows are the browser roster the modules node half scans into diff --git a/packages/experimental/auto-review/README.md b/packages/experimental/auto-review/README.md index 3404f3b887..d37bd56ee8 100644 --- a/packages/experimental/auto-review/README.md +++ b/packages/experimental/auto-review/README.md @@ -50,7 +50,7 @@ Enable the experimental layer explicitly for the headless profile: ```sh dsh plugin --profile headless add @deepseek-ai/dsh-experimental-auto-review -DSH_TOOLS_MODE=native dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl +dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl ``` For a one-off run from this checkout, pass `--patch /absolute/path/to/packages/experimental/auto-review/cordis.patch.yml` instead of installing a profile layer. An unavailable Auto integration fails preset selection before reading stdin or starting model work. Auto uses full host access; `--add-dir` is unnecessary in this mode. Native tools keep every tool body on the review path; direct Node effects in PTC remain outside it. diff --git a/packages/experimental/auto-review/README.zh.md b/packages/experimental/auto-review/README.zh.md index 4f7551f7cd..32602f6874 100644 --- a/packages/experimental/auto-review/README.zh.md +++ b/packages/experimental/auto-review/README.zh.md @@ -50,7 +50,7 @@ pnpm dsh plugin --profile web remove @deepseek-ai/dsh-experimental-auto-review ```sh dsh plugin --profile headless add @deepseek-ai/dsh-experimental-auto-review -DSH_TOOLS_MODE=native dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl +dsh headless --permission-mode auto --approval-policy never --json - < task.txt > run.jsonl ``` 如需从此 checkout 单次运行,可传入 `--patch /absolute/path/to/packages/experimental/auto-review/cordis.patch.yml`,无需安装 profile 层。Auto integration 不可用时,权限预设选择会在读取 stdin 或开始模型工作前失败。Auto 使用完全主机访问权限;此模式不需要 `--add-dir`。原生工具让每个工具 body 都经过审查;PTC 的直接 Node 效果仍不在审查范围内。 diff --git a/packages/skill/skill-office/tests/docx-validation.e2e.ts b/packages/skill/skill-office/tests/docx-validation.e2e.ts index a9a7a60fe9..6d2a2d8130 100644 --- a/packages/skill/skill-office/tests/docx-validation.e2e.ts +++ b/packages/skill/skill-office/tests/docx-validation.e2e.ts @@ -19,8 +19,9 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc'])('ch binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm', tsconfigPath: join(repo, 'tsconfig.base.json'), configPath: 'office.patch.json', processTimeoutMs: 110_000, binArgs: ['--profile', 'headless', '--patch', 'office.patch.json', + ...mode === 'ptc' ? ['--patch', join(repo, 'apps/cli/config/examples/ptc.yml')] : [], 'In input.docx, change the title paragraph text to Quarterly results without changing its formatting; save result.docx. Do not render or visually inspect the finished file. Respond briefly in English.'], - env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: mode, + env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY }, async prepare(cwd) { await execa(python!, ['-c', ` diff --git a/packages/skill/skill-office/tests/render-tools.e2e.ts b/packages/skill/skill-office/tests/render-tools.e2e.ts index f2dc0cfbf2..7d7653e555 100644 --- a/packages/skill/skill-office/tests/render-tools.e2e.ts +++ b/packages/skill/skill-office/tests/render-tools.e2e.ts @@ -15,7 +15,7 @@ const python = runtime && join(runtime, 'dependencies/python', process.platform const launch = { binScript: join(repo, 'apps/cli/src/bin.ts'), sourceImport: 'tsx/esm' as const, tsconfigPath: join(repo, 'tsconfig.base.json'), configPath: 'office.patch.json', processTimeoutMs: 110_000, - env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY }, } @@ -39,8 +39,9 @@ async function sessionEvents(cwd: string) { it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each([{ format: 'docx', mode: 'native' }, { format: 'pdf', mode: 'native' }, { format: 'docx', mode: 'ptc' }])('loads the operation skill and renders $format through the shell in $mode mode', async ({ format, mode }) => { await runLoaderSmoke({ label: 'Office CLI rendering', tempDirPrefix: 'dsh-office-render-', - ...launch, env: { ...launch.env, DSH_TOOLS_MODE: mode }, + ...launch, binArgs: ['--profile', 'headless', '--patch', 'office.patch.json', + ...mode === 'ptc' ? ['--patch', join(repo, 'apps/cli/config/examples/ptc.yml')] : [], `Export page 1 of input.${format} as PNG into preview and check that the exported image is readable. Report briefly in English.`], async prepare(cwd) { await execa(python!, ['-c', "from docx import Document\nd=Document()\nd.add_heading('Quarterly revenue',0)\nd.add_paragraph('Q1: 12; Q2: 18; Total: 30')\nd.save('input.docx')"], { cwd }) diff --git a/packages/skill/skill-office/tests/visual-intent.e2e.ts b/packages/skill/skill-office/tests/visual-intent.e2e.ts index 55419e5ad1..d37377813d 100644 --- a/packages/skill/skill-office/tests/visual-intent.e2e.ts +++ b/packages/skill/skill-office/tests/visual-intent.e2e.ts @@ -76,7 +76,7 @@ it.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(cases)('$format $kind // The CLI fixture and its log live outside the workspace; bwrap's private /tmp hides both. env: { DSH_PRIMARY_RUNTIME: runtime, DSH_PERMISSION_MODE: 'danger-full-access', DSH_HOME: join(controlRoot, 'home'), DSH_AGENTS_HOME: join(controlRoot, 'agents'), - DSH_TELEMETRY_DISABLED: '1', DSH_TOOLS_MODE: 'native', + DSH_TELEMETRY_DISABLED: '1', TMPDIR: previewRoot, TMP: previewRoot, TEMP: previewRoot, HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY }, async prepare(cwd) { diff --git a/packages/skill/skill-office/tests/xlsx-validation.e2e.ts b/packages/skill/skill-office/tests/xlsx-validation.e2e.ts index b6036ac505..bf3cdf218e 100644 --- a/packages/skill/skill-office/tests/xlsx-validation.e2e.ts +++ b/packages/skill/skill-office/tests/xlsx-validation.e2e.ts @@ -80,6 +80,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc' tsconfigPath: join(repo, 'tsconfig.base.json'), configPath, binArgs: ['--profile', 'headless', '--patch', configPath, + ...mode === 'ptc' ? ['--patch', join(repo, 'apps/cli/config/examples/ptc.yml')] : [], `${task} Use input.xlsx and save the finished workbook as result.xlsx. Preserve the Notes sheet and leave input.xlsx unchanged. Respond briefly in English.`, ], processTimeoutMs: 110_000, @@ -89,7 +90,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !runtime).each(['native', 'ptc' DSH_HOME: join(controlRoot, 'home'), DSH_AGENTS_HOME: join(controlRoot, 'agents'), DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: mode, HTTP_PROXY: process.env.DSH_OFFICE_TEST_PROXY, HTTPS_PROXY: process.env.DSH_OFFICE_TEST_PROXY, }, diff --git a/scripts/demo-ptc.mjs b/scripts/demo-ptc.mjs index d0c5c059bc..61702824af 100644 --- a/scripts/demo-ptc.mjs +++ b/scripts/demo-ptc.mjs @@ -10,9 +10,10 @@ const child = spawn(process.execPath, [ 'apps/cli/src/bin.ts', '--profile', 'headless', + '--patch', + 'apps/cli/config/examples/ptc.yml', task, ], { stdio: 'inherit', - env: { ...process.env, DSH_TOOLS_MODE: 'ptc' }, }) child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) }) diff --git a/snapshots/session/headless-auto-review/snapshot.yml b/snapshots/session/headless-auto-review/snapshot.yml index fca50a171e..196aac20e3 100644 --- a/snapshots/session/headless-auto-review/snapshot.yml +++ b/snapshots/session/headless-auto-review/snapshot.yml @@ -10,5 +10,3 @@ header: toolSchemasSource: text-turn workspace: final: true -environment: - DSH_TOOLS_MODE: native From 0fd34b3abb2a0c2761608ff4bf27feb410ebec5a Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 17:27:42 +0800 Subject: [PATCH 29/73] chore(packages): trim redundant executor dependencies --- packages/bundle/headless/package.json | 2 -- packages/bundle/web-app/package.json | 2 -- .../core/tool-executor-native/package.json | 4 ---- .../core/tool-executor-native/tsconfig.json | 6 ----- packages/experimental/cua-macos/package.json | 2 +- pnpm-lock.yaml | 24 +++---------------- 6 files changed, 4 insertions(+), 36 deletions(-) diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index b923295e41..2247beae8e 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -42,8 +42,6 @@ "dependencies": { "@deepseek-ai/dsh-brand": "workspace:*", "@deepseek-ai/dsh-cmdline": "workspace:*", - "@deepseek-ai/dsh-tool-executor-native": "workspace:*", - "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", "commander": "^15.0.0" diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 784a4b42e6..6f24e17e90 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -159,7 +159,6 @@ "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", - "@deepseek-ai/dsh-tool-executor-native": "workspace:*", "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", @@ -175,7 +174,6 @@ "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-web-frontend": "workspace:*", "@deepseek-ai/dsh-workflow-ptc": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", diff --git a/packages/core/tool-executor-native/package.json b/packages/core/tool-executor-native/package.json index e027d0e9bb..ccf044e39b 100644 --- a/packages/core/tool-executor-native/package.json +++ b/packages/core/tool-executor-native/package.json @@ -28,14 +28,10 @@ ], "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", - "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*" } } diff --git a/packages/core/tool-executor-native/tsconfig.json b/packages/core/tool-executor-native/tsconfig.json index 4ba2702b24..527fb0d8f8 100644 --- a/packages/core/tool-executor-native/tsconfig.json +++ b/packages/core/tool-executor-native/tsconfig.json @@ -11,15 +11,9 @@ { "path": "../../../vendor/cordis" }, - { - "path": "../scope" - }, { "path": "../tools" }, - { - "path": "../../llm/llm" - }, { "path": "../../../vendor/cosmokit" }, diff --git a/packages/experimental/cua-macos/package.json b/packages/experimental/cua-macos/package.json index 459a554ab7..96a5498237 100644 --- a/packages/experimental/cua-macos/package.json +++ b/packages/experimental/cua-macos/package.json @@ -46,7 +46,6 @@ "@deepseek-ai/dsh-user-questions": "workspace:*" }, "dependencies": { - "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "zod": "^4.4.3", "@deepseek-ai/dsh-atomic-write": "workspace:*", "@deepseek-ai/dsh-cua": "workspace:*" @@ -67,6 +66,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tool-executor-native": "workspace:*", + "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-tool-testkit": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e130663bd5..c769bdf1af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2554,12 +2554,6 @@ importers: '@deepseek-ai/dsh-cmdline': specifier: workspace:* version: link:../../boot/cmdline - '@deepseek-ai/dsh-tool-executor-native': - specifier: workspace:* - version: link:../../core/tool-executor-native - '@deepseek-ai/dsh-tool-executor-ptc': - specifier: workspace:* - version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-util-values': specifier: workspace:* version: link:../../util/values @@ -3094,9 +3088,6 @@ importers: '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../../extensions/tool-cordis - '@deepseek-ai/dsh-tool-executor-native': - specifier: workspace:* - version: link:../../core/tool-executor-native '@deepseek-ai/dsh-tool-executor-ptc': specifier: workspace:* version: link:../../core/tool-executor-ptc @@ -3142,9 +3133,6 @@ importers: '@deepseek-ai/dsh-tool-workflow': specifier: workspace:* version: link:../../workflow/tool-workflow - '@deepseek-ai/dsh-tools': - specifier: workspace:* - version: link:../../core/tools '@deepseek-ai/dsh-web-frontend': specifier: workspace:* version: link:../../../apps/web @@ -7415,12 +7403,6 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-llm': - specifier: workspace:* - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:* - version: link:../scope '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../tools @@ -8824,9 +8806,6 @@ importers: '@deepseek-ai/dsh-cua': specifier: workspace:* version: link:../cua - '@deepseek-ai/dsh-tool-executor-ptc': - specifier: workspace:* - version: link:../../core/tool-executor-ptc zod: specifier: ^4.4.3 version: 4.4.3 @@ -8912,6 +8891,9 @@ importers: '@deepseek-ai/dsh-tool-executor-native': specifier: workspace:* version: link:../../core/tool-executor-native + '@deepseek-ai/dsh-tool-executor-ptc': + specifier: workspace:* + version: link:../../core/tool-executor-ptc '@deepseek-ai/dsh-tool-testkit': specifier: workspace:* version: link:../../test-support/tool-testkit From 2cc5d541fbd4317a5c03c2117e31526b01482aac Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 19 Oct 2026 22:45:01 +0800 Subject: [PATCH 30/73] test(cua): use executor batches in permission flow --- apps/web/tests/cua-permissions.e2e.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 18072ae8b1..b66390a9bf 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -56,9 +56,9 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr await scaffold.ctx.settings.mutate('cua', [{ op: 'set', path: ['allowedApps'], value: [] }]) // Application consent remains independent of the session's tool-approval policy. const grant = scaffold.ctx.tools.execute({ agent, signal: new AbortController().signal, - callId: ToolCallId('cua-app-approval'), name: 'run_code', - arguments: { code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })', - description: 'Request application access' } }) + calls: [{ type: 'tool-call', id: ToolCallId('cua-app-approval'), name: 'run_code', + arguments: JSON.stringify({ code: 'return await tools.cua({ method: "getApp", params: { selector: "Fixture" } })', + description: 'Request application access' }) }] }) const approval = page.locator('[data-question-key]') await Promise.race([approval.waitFor(), grant.then((result) => { throw new Error(`Application selection completed before consent: ${JSON.stringify(result)}`) @@ -66,7 +66,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr expect(await approval.textContent()).toContain('Fixture') await approval.getByRole('radio', { name: 'Always allow', exact: true }).click() await approval.getByRole('button', { name: 'Submit', exact: true }).click() - expect((await grant).isError).toBe(false) + expect((await grant).results).toMatchObject([{ result: { isError: false } }]) await approval.waitFor({ state: 'hidden' }) await page.getByRole('button', { name: 'Plugins', exact: true }).click() const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') From 6c1ae74d1652b961b7d9bf6bf6b1e8afb6bc90e2 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 12:13:50 +0800 Subject: [PATCH 31/73] refactor(ptc): separate client declarations from host policy types --- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 28 +++--- docs/persistence-catalog.md | 16 ++-- docs/persistence-catalog.zh.md | 16 ++-- docs/persistence-schema.json | 12 +-- docs/subsystems/tools.md | 2 +- docs/subsystems/tools.zh.md | 2 +- .../src/client/conversation-nodes/tool.ts | 2 +- .../client/conversation-nodes/turn-process.ts | 2 +- .../src/client/model/tool-call-tree.ts | 2 +- .../ui-plan/src/client/plan-definition.ts | 2 +- packages/client/ui-plan/src/client/plan.ts | 2 +- .../src/client/tool/models/todo-history.ts | 2 +- .../src/client/trajectory-tool-definition.ts | 2 +- .../core/tool-executor-ptc/README.i18n.yaml | 4 +- packages/core/tool-executor-ptc/README.md | 2 +- packages/core/tool-executor-ptc/README.zh.md | 2 +- packages/core/tool-executor-ptc/package.json | 8 +- packages/core/tool-executor-ptc/src/client.ts | 2 + packages/core/tool-executor-ptc/src/index.ts | 4 +- packages/core/tool-executor-ptc/src/ptc.ts | 4 +- .../tool-executor-ptc/src/runtime-types.ts | 48 ++++++++++ .../tool-executor-ptc/src/session-types.ts | 63 ------------ packages/core/tool-executor-ptc/src/types.ts | 95 +++++++++++-------- scripts/type-equiv.manifest.json | 2 +- tsconfig.base.json | 3 +- 27 files changed, 169 insertions(+), 162 deletions(-) create mode 100644 packages/core/tool-executor-ptc/src/client.ts create mode 100644 packages/core/tool-executor-ptc/src/runtime-types.ts delete mode 100644 packages/core/tool-executor-ptc/src/session-types.ts diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 24ad02fb23..2e790175d7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -80,7 +80,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/types.ts:46`](../packages/core/tool-executor-ptc/src/types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 044e8fe6a1..879e079950 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -82,7 +82,7 @@ | `tools/execute` | `waterfall` | [`packages/core/tools/src/events.ts:29`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/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/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/types.ts:46`](../packages/core/tool-executor-ptc/src/types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 8b939f6188..8c8a386408 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -249,11 +249,11 @@ en: 03f36237cbf9e267 zh: 83fe2e69bbbfd3c1 /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-log-only: - en: e808d7ba03c24dad - zh: c67b9189e60e4986 + en: a62b38c00afed655 + zh: 793bef93e856eafa /session-persistence-event-catalog/events/tool/tool-ptc-dispatch-start-log-only: - en: 5ae8f1bd938d0cdb - zh: 8f3fee21c023a246 + en: da841b1ed3b22c87 + zh: 4c9a4d4bc5bd76b6 /session-persistence-event-catalog/events/tool/tool-result-surface: en: 283e3cf1fdd57f05 zh: 0bc3a15ae4f47aaf @@ -1164,14 +1164,14 @@ en: bb11552744e477b8 zh: afb881f3ebf97e68 /session-persistence-event-catalog/resolved-persistence-types/principal: - en: 6a590e6880db0242 - zh: 2078c7e558a4cfad + en: 6d0d81e0e833403e + zh: fa47cd75ee75f948 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatcheventdata: - en: cf94e6efcd7b19ba - zh: a66d50affd2b6f98 + en: c0f7b22b41cb0608 + zh: de14a1b5cac17665 /session-persistence-event-catalog/resolved-persistence-types/ptcdispatchstarteventdata: - en: 4ba5104118842c3f - zh: d9b487902f8c4f47 + en: aca35dac4f9d2add + zh: 6a4bdb95e3c7b0ce /session-persistence-event-catalog/resolved-persistence-types/reasoningblock: en: 07e04c3e336f880f zh: 6f8986ede9994fa1 @@ -1443,8 +1443,8 @@ en: a1f188c9058cf621 zh: eb43c0f045fb9afb /session-persistence-event-catalog/resolved-persistence-types/code-name-reason: - en: 3f78ce454b333a0a - zh: 3c414c1510b59caf + en: 39c67256532a22f9 + zh: 59f823e98630c5e2 /session-persistence-event-catalog/resolved-persistence-types/commandid-kind-sourceeventseq-text: en: 87b03e1c73d2a326 zh: d7b9f06863cad891 @@ -1674,8 +1674,8 @@ en: c53129cf7214286c zh: 8c5183b6dabbf714 /session-persistence-event-catalog/resolved-persistence-types/kind-ptc-mode: - en: 4ed6a03496090056 - zh: c040b7247425c57b + en: b4fd9b219667fb26 + zh: bc56baa64fd7ffd8 /session-persistence-event-catalog/resolved-persistence-types/kind-repeat-tool-reminder: en: e3a0ef98a197374e zh: 95e9363529a62cc3 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 388dca7b0d..99f341a41d 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -1116,7 +1116,7 @@ Source: [`packages/core/session/src/types.ts:362`](../packages/core/session/src/ 'tool/ptc-dispatch': PtcDispatchEventData ``` -Source: [`packages/core/tool-executor-ptc/src/session-types.ts:61`](../packages/core/tool-executor-ptc/src/session-types.ts) +Source: [`packages/core/tool-executor-ptc/src/types.ts:61`](../packages/core/tool-executor-ptc/src/types.ts) @@ -1139,7 +1139,7 @@ Source: [`packages/core/tool-executor-ptc/src/session-types.ts:61`](../packages/ 'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` -Source: [`packages/core/tool-executor-ptc/src/session-types.ts:45`](../packages/core/tool-executor-ptc/src/session-types.ts) +Source: [`packages/core/tool-executor-ptc/src/types.ts:45`](../packages/core/tool-executor-ptc/src/types.ts) @@ -4442,7 +4442,7 @@ Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protoc - + @@ -4450,7 +4450,7 @@ Sources: [`packages/typert/protocol/src/types.ts:457`](../packages/typert/protoc SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` -Sources: [`packages/core/tool-executor-ptc/src/session-types.ts:21`](../packages/core/tool-executor-ptc/src/session-types.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:21`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4466,7 +4466,7 @@ Sources: [`packages/core/tool-executor-ptc/src/session-types.ts:21`](../packages - + @@ -4474,7 +4474,7 @@ Sources: [`packages/core/tool-executor-ptc/src/session-types.ts:21`](../packages SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f` -Sources: [`packages/core/tool-executor-ptc/src/session-types.ts:12`](../packages/core/tool-executor-ptc/src/session-types.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:12`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -5979,7 +5979,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/session-types.ts:27`](../packages/core/tool-executor-ptc/src/session-types.ts) +Sources: [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -6960,7 +6960,7 @@ Sources: [`packages/session/session-title/src/types.ts:30`](../packages/session/ SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -Sources: [`packages/core/tool-executor-ptc/src/types.ts:53`](../packages/core/tool-executor-ptc/src/types.ts) +Sources: [`packages/core/tool-executor-ptc/src/types.ts:68`](../packages/core/tool-executor-ptc/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index af5278663e..f1eb84be24 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -1118,7 +1118,7 @@ export type SessionEvent = { 'tool/ptc-dispatch': PtcDispatchEventData ``` -来源:[`packages/core/tool-executor-ptc/src/session-types.ts:61`](../packages/core/tool-executor-ptc/src/session-types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:61`](../packages/core/tool-executor-ptc/src/types.ts) @@ -1141,7 +1141,7 @@ export type SessionEvent = { 'tool/ptc-dispatch-start': PtcDispatchStartEventData ``` -来源:[`packages/core/tool-executor-ptc/src/session-types.ts:45`](../packages/core/tool-executor-ptc/src/session-types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:45`](../packages/core/tool-executor-ptc/src/types.ts) @@ -4444,7 +4444,7 @@ SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` - + @@ -4452,7 +4452,7 @@ SHA-256: `deebfc7c62177ea840f23c6362b8615bc78ffa93377dd5d243c04e541c413af5` SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` -来源:[`packages/core/tool-executor-ptc/src/session-types.ts:21`](../packages/core/tool-executor-ptc/src/session-types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:21`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4468,7 +4468,7 @@ SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` - + @@ -4476,7 +4476,7 @@ SHA-256: `cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6` SHA-256: `2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f` -来源:[`packages/core/tool-executor-ptc/src/session-types.ts:12`](../packages/core/tool-executor-ptc/src/session-types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:12`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -5981,7 +5981,7 @@ SHA-256: `cbbe8377ee744964537dbafd83607219e58ba79a13f6db0d0645c7ad3d5e8a09` SHA-256: `a81059ca06999da1e712414520c2c65e491d052fcf9640fae20aa38f0a5dc735` -来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/session-types.ts:27`](../packages/core/tool-executor-ptc/src/session-types.ts) +来源:[`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) · [`packages/core/tool-executor-ptc/src/types.ts:27`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -6962,7 +6962,7 @@ SHA-256: `b81e9de81a481b24e1e1f9adf038bfc59e57b4f35ce62f8f1e45284186f4f812` SHA-256: `dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd` -来源:[`packages/core/tool-executor-ptc/src/types.ts:53`](../packages/core/tool-executor-ptc/src/types.ts) +来源:[`packages/core/tool-executor-ptc/src/types.ts:68`](../packages/core/tool-executor-ptc/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index b01edbb5af..6e810f1e46 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23147,10 +23147,10 @@ { "digest": "2fe5026daf14bd4ff2b7bfb7cf9791c302d9cbba06802178b6dfec00b892698f", "names": [ - "packages/core/tool-executor-ptc/src/session-types.ts#PtcDispatchStartEventData" + "packages/core/tool-executor-ptc/src/types.ts#PtcDispatchStartEventData" ], "sources": [ - "packages/core/tool-executor-ptc/src/session-types.ts:12" + "packages/core/tool-executor-ptc/src/types.ts:12" ] }, { @@ -24920,7 +24920,7 @@ "names": [], "sources": [ "packages/core/session/src/types.ts:384", - "packages/core/tool-executor-ptc/src/session-types.ts:27" + "packages/core/tool-executor-ptc/src/types.ts:27" ] }, { @@ -25509,10 +25509,10 @@ { "digest": "cef171adf021a1867d244618a3dbbaafc22beb17c7f3df3ee203393271c8c6f6", "names": [ - "packages/core/tool-executor-ptc/src/session-types.ts#PtcDispatchEventData" + "packages/core/tool-executor-ptc/src/types.ts#PtcDispatchEventData" ], "sources": [ - "packages/core/tool-executor-ptc/src/session-types.ts:21" + "packages/core/tool-executor-ptc/src/types.ts:21" ] }, { @@ -25626,7 +25626,7 @@ "digest": "dba54c09c6e7aafc9acbed7792ecc920d99f92d0784aeeed10af55f7fe0bc8cd", "names": [], "sources": [ - "packages/core/tool-executor-ptc/src/types.ts:53" + "packages/core/tool-executor-ptc/src/types.ts:68" ] }, { diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index a92f41d6df..98c0faca51 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -788,7 +788,7 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) -Source: [`packages/core/tool-executor-ptc/src/types.ts`](../../packages/core/tool-executor-ptc/src/types.ts) +Source: [`packages/core/tool-executor-ptc/src/runtime-types.ts`](../../packages/core/tool-executor-ptc/src/runtime-types.ts) diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 187d61c3f7..daaabb8988 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -788,7 +788,7 @@ Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` su Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) -Source: [`packages/core/tool-executor-ptc/src/types.ts`](../../packages/core/tool-executor-ptc/src/types.ts) +Source: [`packages/core/tool-executor-ptc/src/runtime-types.ts`](../../packages/core/tool-executor-ptc/src/runtime-types.ts) diff --git a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts index a94131b8e2..3ed7b86d13 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/tool.ts @@ -4,7 +4,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import type { ChatNode, ToolChatData } from '../contract/chat-nodes.ts' import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode, contextLocation } from './common.ts' diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts index aead6ad087..a0ad62091b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-llm-retry/types' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import type { AssistantChatData, ChatNode, FinalAssistantChatData } from '../contract/chat-nodes.ts' import { diff --git a/packages/client/ui-chat/src/client/model/tool-call-tree.ts b/packages/client/ui-chat/src/client/model/tool-call-tree.ts index 0d1a76cc0e..d9f3244abf 100644 --- a/packages/client/ui-chat/src/client/model/tool-call-tree.ts +++ b/packages/client/ui-chat/src/client/model/tool-call-tree.ts @@ -1,5 +1,5 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ConversationNode, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts index 634f285910..3bb675b3d9 100644 --- a/packages/client/ui-plan/src/client/plan-definition.ts +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -1,5 +1,5 @@ /** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */ -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client' import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client' import { submittedPlan, type SubmittedPlan } from './plan.ts' diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index 494b441dda..6ec1937f5c 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -1,5 +1,5 @@ /** Plan text and resource identities derived from logged native or PTC calls. */ -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types' diff --git a/packages/client/ui-tool/src/client/tool/models/todo-history.ts b/packages/client/ui-tool/src/client/tool/models/todo-history.ts index c552e75577..c6873ab56d 100644 --- a/packages/client/ui-tool/src/client/tool/models/todo-history.ts +++ b/packages/client/ui-tool/src/client/tool/models/todo-history.ts @@ -1,5 +1,5 @@ /** Indexed recorded todo predecessors for root and nested Tool calls. */ -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, ConversationViewDefinition, ConversationViewNode, TodoItem, diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 79b17178f9..ba06b40f9b 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -3,7 +3,7 @@ import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, StartedToolCall, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types' +import type {} from '@deepseek-ai/dsh-tool-executor-ptc/client' import { PartialArguments } from '@deepseek-ai/dsh-util-values' import { trajectoryNode } from './trajectory-definition-common.ts' diff --git a/packages/core/tool-executor-ptc/README.i18n.yaml b/packages/core/tool-executor-ptc/README.i18n.yaml index 4324c4868a..8b9724e5c1 100644 --- a/packages/core/tool-executor-ptc/README.i18n.yaml +++ b/packages/core/tool-executor-ptc/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-tool-executor-ptc/use-this-package: - en: 9bb7f839614606c1 - zh: 64269fc2f32c7309 + en: b6b7ffab35e1a725 + zh: fc5db2524e1507d6 /deepseek-ai-dsh-tool-executor-ptc/understand-the-implementation: en: 9d4f61c351945d30 zh: 1c575526230b0e02 diff --git a/packages/core/tool-executor-ptc/README.md b/packages/core/tool-executor-ptc/README.md index af661f6282..5a1e781989 100644 --- a/packages/core/tool-executor-ptc/README.md +++ b/packages/core/tool-executor-ptc/README.md @@ -37,7 +37,7 @@ Root `run_code` calls are exclusive and execute serially. `maxParallelSubCalls` This package declares `ctx.ptcRuntime` and `ctx.systemPrompt` as required injections; Agent-owned programs need `ctx.workingDirectory`. The executor activates only after its runtime is available and withdraws when that runtime unloads. An enabled PTC executor without a runtime stops application startup. A withdrawn PTC selection never falls back to native execution. -Import durable event payloads from `@deepseek-ai/dsh-tool-executor-ptc/types`; this entry is safe for detached readers and client compilation. Host policy extensions import `PtcDispatchLog`, `tools/ptc-dispatch-log`, and the `ptc-mode` message-source declaration through a type-only import of the package entry, without mounting an executor. +Shared dispatch events and the `ptc-mode` message-source declaration live in `@deepseek-ai/dsh-tool-executor-ptc/types`; Client code imports the same declarations through `@deepseek-ai/dsh-tool-executor-ptc/client`. Both entries exclude Host service declarations. Host policy extensions use `import type` from the package entry for `PtcDispatchLog` and `tools/ptc-dispatch-log` without mounting the executor. [Configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tool-executor-ptc) diff --git a/packages/core/tool-executor-ptc/README.zh.md b/packages/core/tool-executor-ptc/README.zh.md index ccaa6647d6..c2b0ad1356 100644 --- a/packages/core/tool-executor-ptc/README.zh.md +++ b/packages/core/tool-executor-ptc/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" 本包将 `ctx.ptcRuntime` 和 `ctx.systemPrompt` 声明为必需注入;带 Agent 的程序要求 `ctx.workingDirectory`。执行器在运行时可用后才激活,并在运行时卸载时撤回。启用 PTC 执行器但未提供运行时会阻止应用启动。已撤回的 PTC 选择不会回退到原生执行。 -持久化事件载荷从 `@deepseek-ai/dsh-tool-executor-ptc/types` 导入;该入口可用于独立读取者与 Client 编译。Host 策略扩展通过包主入口的仅类型导入获取 `PtcDispatchLog`、`tools/ptc-dispatch-log` 与 `ptc-mode` 消息来源声明,无需挂载执行器。 +共享分派事件与 `ptc-mode` 消息来源声明位于 `@deepseek-ai/dsh-tool-executor-ptc/types`;Client 代码通过 `@deepseek-ai/dsh-tool-executor-ptc/client` 导入同一组声明。两个入口都不引入 Host 服务声明。Host 策略扩展通过包主入口的 `import type` 获取 `PtcDispatchLog` 与 `tools/ptc-dispatch-log`,无需挂载执行器。 [配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tool-executor-ptc) diff --git a/packages/core/tool-executor-ptc/package.json b/packages/core/tool-executor-ptc/package.json index c2dc63b7e1..0216185868 100644 --- a/packages/core/tool-executor-ptc/package.json +++ b/packages/core/tool-executor-ptc/package.json @@ -20,8 +20,12 @@ "default": "./lib/index.js" }, "./types": { - "types": "./lib/types/session-types.d.ts", - "default": "./lib/types/session-types.js" + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client.d.ts", + "default": "./lib/types/client.js" }, "./src/*": "./src/*", "./package.json": "./package.json" diff --git a/packages/core/tool-executor-ptc/src/client.ts b/packages/core/tool-executor-ptc/src/client.ts new file mode 100644 index 0000000000..540306c5a5 --- /dev/null +++ b/packages/core/tool-executor-ptc/src/client.ts @@ -0,0 +1,2 @@ +/** Client entry for shared PTC event and message-source declarations. @module */ +export type * from './types.ts' diff --git a/packages/core/tool-executor-ptc/src/index.ts b/packages/core/tool-executor-ptc/src/index.ts index 5605b0c49f..319c109d60 100644 --- a/packages/core/tool-executor-ptc/src/index.ts +++ b/packages/core/tool-executor-ptc/src/index.ts @@ -63,8 +63,8 @@ export class PtcExecutor implements ToolExecutor { export { Config } from './config.ts' export { CodeRunFailedError } from './errors.ts' -export type { PtcDispatchLog } from './types.ts' -export type { PtcDispatchStartEventData, PtcDispatchEventData } from './session-types.ts' +export type { PtcDispatchLog } from './runtime-types.ts' +export type { PtcDispatchStartEventData, PtcDispatchEventData } from './types.ts' export { jsonSchemaToTs, renderToolsSdk, type ToolSdkSchema } from './ts-types.ts' export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' diff --git a/packages/core/tool-executor-ptc/src/ptc.ts b/packages/core/tool-executor-ptc/src/ptc.ts index 899b89dd71..c24cd0984c 100644 --- a/packages/core/tool-executor-ptc/src/ptc.ts +++ b/packages/core/tool-executor-ptc/src/ptc.ts @@ -6,7 +6,7 @@ * @module */ -import type { PtcDispatchLog } from './types.ts' +import type { PtcDispatchLog } from './runtime-types.ts' import { symbols, type Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -26,7 +26,7 @@ import { scopeTarget } from '@deepseek-ai/dsh-scope' import { PtcCalls } from './ptc-calls.ts' import { CodeRunFailedError } from './errors.ts' import type { ToolDefinition, ToolExecutionResult, ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from './session-types.ts' +import type {} from './types.ts' /** The model-facing name of the PTC mode tool. */ export { RUN_CODE_NAME } diff --git a/packages/core/tool-executor-ptc/src/runtime-types.ts b/packages/core/tool-executor-ptc/src/runtime-types.ts new file mode 100644 index 0000000000..b36b2731bc --- /dev/null +++ b/packages/core/tool-executor-ptc/src/runtime-types.ts @@ -0,0 +1,48 @@ +/** Host PTC dispatch policy declarations with live Agent and tool execution types. @module */ +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, ToolCallId } from '@deepseek-ai/dsh-llm' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { ToolExecution, ToolsService } from '@deepseek-ai/dsh-tools' + +/** + * One settled `run_code` sub-dispatch about to be logged, as seen by the + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, + * outer call identity), the sub-call identity, and the outcome whose durable + * copy a listener may reshape. `content` is the RENDERED result projection + * (what a native `tool/result` would carry) — the program itself received + * the structured `value` (or just the error message on failure); only the + * `tool/ptc-dispatch` event's copy changes. + */ +export interface PtcDispatchLog { + /** The outer `run_code` execution. */ + readonly exec: ToolExecution + /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ + readonly agent?: Agent + /** Opaque sub-call id; new calls use `:ptc:`. */ + readonly subCallId: ToolCallId + /** The dispatched sub-tool name. */ + readonly name: string + /** Whether the sub-call settled as an error. */ + readonly isError: boolean + /** The sub-call's complete model-facing content (the settle event's default payload). */ + readonly content: ContentBlock[] +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/ptc-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ + 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise + } +} diff --git a/packages/core/tool-executor-ptc/src/session-types.ts b/packages/core/tool-executor-ptc/src/session-types.ts deleted file mode 100644 index 8047a127db..0000000000 --- a/packages/core/tool-executor-ptc/src/session-types.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Durable PTC dispatch events shared with type-only readers. - * - * @module @deepseek-ai/dsh-tool-executor-ptc/types - */ - -import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -import type { JsonValue } from '@deepseek-ai/dsh-util-values' - -/** Payload recorded when one nested PTC mode Tool dispatch starts. */ -export interface PtcDispatchStartEventData { - rootCallId: ToolCallId - parentCallId: ToolCallId - subCallId: ToolCallId - name: string - arguments: unknown -} - -/** Payload recorded when one nested PTC mode Tool dispatch settles. */ -export interface PtcDispatchEventData extends PtcDispatchStartEventData { - isError: boolean - content: ContentBlock[] - /** Replayable tool-owned presentation metadata, separate from model content. */ - meta?: JsonValue - /** Optional failure identity and raw user-facing detail, outside model-facing content. */ - error?: { name: string; code: string; reason?: string } -} - -declare module '@deepseek-ai/dsh-session/types' { - interface SessionEventMap { - /** - * One sub-dispatch STARTING inside a `run_code` program: the parent - * `run_code` call id, the opaque sub-call id (new calls use - * `:ptc:`, numbered in submission order), and the tool `name` with its - * JSON-normalized `arguments` — the exact value dispatched, normalized - * BEFORE dispatch, so this append can never fail on payload shape. - * Appended when the scheduler actually starts the call (not at - * submission), so a start means the tool body pipeline was entered; a - * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` - * ignores it; UIs use it for live per-sub-call running state and pair it - * with `tool/ptc-dispatch` by `subCallId` (timing = the two events' - * `time` fields). - */ - 'tool/ptc-dispatch-start': PtcDispatchStartEventData - /** - * One bridged sub-dispatch SETTLING: the pairing ids (matching the - * `tool/ptc-dispatch-start` with the same `subCallId`), the tool `name` - * with the same JSON-normalized `arguments`, and the sub-call's complete - * durable outcome in `tool/result`'s own vocabulary (`content` + `isError` - * + optional presentation `meta` and structured `error`), so UIs and SDKs - * render a sub-call through the exact path used for a native call. Every started sub-call settles - * with exactly one of these (abort included: the aborted pipeline result - * is an `isError` outcome). - * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter - * model context; persistence and UIs get every call. Appended inside the - * parent `run_code`'s execution (the bridge drains in-flight dispatches - * before returning), so its execution-enclosure relation holds by - * construction. - */ - 'tool/ptc-dispatch': PtcDispatchEventData - } -} diff --git a/packages/core/tool-executor-ptc/src/types.ts b/packages/core/tool-executor-ptc/src/types.ts index 07d2c960c5..861ff1ff15 100644 --- a/packages/core/tool-executor-ptc/src/types.ts +++ b/packages/core/tool-executor-ptc/src/types.ts @@ -1,49 +1,64 @@ -/** Live PTC dispatch policy declarations; importing types does not mount the executor. @module */ -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { ContentBlock, ToolCallId } from '@deepseek-ai/dsh-llm' -import type { Scoped } from '@deepseek-ai/dsh-scope' -import type { ToolExecution, ToolsService } from '@deepseek-ai/dsh-tools' - /** - * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, - * outer call identity), the sub-call identity, and the outcome whose durable - * copy a listener may reshape. `content` is the RENDERED result projection - * (what a native `tool/result` would carry) — the program itself received - * the structured `value` (or just the error message on failure); only the - * `tool/ptc-dispatch` event's copy changes. + * Client-safe PTC dispatch events and message-source declarations. + * + * @module @deepseek-ai/dsh-tool-executor-ptc/types */ -export interface PtcDispatchLog { - /** The outer `run_code` execution. */ - readonly exec: ToolExecution - /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ - readonly agent?: Agent - /** Opaque sub-call id; new calls use `:ptc:`. */ - readonly subCallId: ToolCallId - /** The dispatched sub-tool name. */ - readonly name: string - /** Whether the sub-call settled as an error. */ - readonly isError: boolean - /** The sub-call's complete model-facing content (the settle event's default payload). */ - readonly content: ContentBlock[] + +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' + +/** Payload recorded when one nested PTC mode Tool dispatch starts. */ +export interface PtcDispatchStartEventData { + rootCallId: ToolCallId + parentCallId: ToolCallId + subCallId: ToolCallId + name: string + arguments: unknown } -declare module '@deepseek-ai/cordis' { - interface Events { +/** Payload recorded when one nested PTC mode Tool dispatch settles. */ +export interface PtcDispatchEventData extends PtcDispatchStartEventData { + isError: boolean + content: ContentBlock[] + /** Replayable tool-owned presentation metadata, separate from model content. */ + meta?: JsonValue + /** Optional failure identity and raw user-facing detail, outside model-facing content. */ + error?: { name: string; code: string; reason?: string } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { /** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/ptc-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall + * One sub-dispatch STARTING inside a `run_code` program: the parent + * `run_code` call id, the opaque sub-call id (new calls use + * `:ptc:`, numbered in submission order), and the tool `name` with its + * JSON-normalized `arguments` — the exact value dispatched, normalized + * BEFORE dispatch, so this append can never fail on payload shape. + * Appended when the scheduler actually starts the call (not at + * submission), so a start means the tool body pipeline was entered; a + * call abandoned in the queue logs nothing. Log-only: `deriveMessages()` + * ignores it; UIs use it for live per-sub-call running state and pair it + * with `tool/ptc-dispatch` by `subCallId` (timing = the two events' + * `time` fields). */ - 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise + 'tool/ptc-dispatch-start': PtcDispatchStartEventData + /** + * One bridged sub-dispatch SETTLING: the pairing ids (matching the + * `tool/ptc-dispatch-start` with the same `subCallId`), the tool `name` + * with the same JSON-normalized `arguments`, and the sub-call's complete + * durable outcome in `tool/result`'s own vocabulary (`content` + `isError` + * + optional presentation `meta` and structured `error`), so UIs and SDKs + * render a sub-call through the exact path used for a native call. Every started sub-call settles + * with exactly one of these (abort included: the aborted pipeline result + * is an `isError` outcome). + * Log-only: `deriveMessages()` ignores it, so sub-calls never re-enter + * model context; persistence and UIs get every call. Appended inside the + * parent `run_code`'s execution (the bridge drains in-flight dispatches + * before returning), so its execution-enclosure relation holds by + * construction. + */ + 'tool/ptc-dispatch': PtcDispatchEventData } } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 276b04035d..5126c5fa1d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -974,7 +974,7 @@ { "doc": "docs/subsystems/tools.md", "symbol": "PtcDispatchLog", - "source": "packages/core/tool-executor-ptc/src/types.ts" + "source": "packages/core/tool-executor-ptc/src/runtime-types.ts" }, { "doc": "docs/subsystems/tools.md", diff --git a/tsconfig.base.json b/tsconfig.base.json index 56eb39074e..2b71dea2f3 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -198,7 +198,8 @@ "@deepseek-ai/dsh-file-reference/grammar": ["./packages/context/file-reference/src/grammar.ts"], "@deepseek-ai/dsh-file-reference/types": ["./packages/context/file-reference/src/types.ts"], "@deepseek-ai/dsh-session-reference/types": ["./packages/context/session-reference/src/types.ts"], - "@deepseek-ai/dsh-tool-executor-ptc/types": ["./packages/core/tool-executor-ptc/src/session-types.ts"], + "@deepseek-ai/dsh-tool-executor-ptc/types": ["./packages/core/tool-executor-ptc/src/types.ts"], + "@deepseek-ai/dsh-tool-executor-ptc/client": ["./packages/core/tool-executor-ptc/src/client.ts"], "@deepseek-ai/dsh-tools/pipeline": ["./packages/core/tools/src/pipeline.ts"], "@deepseek-ai/dsh-tool-testkit": ["./packages/test-support/tool-testkit/src/index.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], From e727a3b16141ff342cf75deb18b6f436655d8ce8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 12:13:57 +0800 Subject: [PATCH 32/73] fix(tools): keep executor changes out of registry notifications --- docs/subsystems/tools.md | 4 ++-- docs/subsystems/tools.zh.md | 4 ++-- packages/core/agent-loop/tests/tool-executors.spec.ts | 10 ++++++++-- packages/core/tools/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 2 +- packages/core/tools/README.zh.md | 2 +- packages/core/tools/src/events.ts | 2 +- packages/core/tools/src/index.ts | 2 -- packages/extensions/tool-cordis/src/api-catalog.ts | 4 ++-- 9 files changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 98c0faca51..bff827d584 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -676,11 +676,11 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/change` — emit -A tool or executor was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. +A tool was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. ```ts cordis-catalog /** - * A tool or executor was registered or unregistered, or a scoped restriction changed. + * A tool was registered or unregistered, or a scoped restriction changed. * Unfiltered registry notification: scoped listeners receive changes from every * scope because a global contribution can affect every agent's next assembly. * @mode emit diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index daaabb8988..53dbd43fb7 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -676,11 +676,11 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index #### `tools/change` — emit -A tool or executor was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. +A tool was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent's next assembly. ```ts cordis-catalog /** - * A tool or executor was registered or unregistered, or a scoped restriction changed. + * A tool was registered or unregistered, or a scoped restriction changed. * Unfiltered registry notification: scoped listeners receive changes from every * scope because a global contribution can affect every agent's next assembly. * @mode emit diff --git a/packages/core/agent-loop/tests/tool-executors.spec.ts b/packages/core/agent-loop/tests/tool-executors.spec.ts index 900eba6031..eaf3a4726d 100644 --- a/packages/core/agent-loop/tests/tool-executors.spec.ts +++ b/packages/core/agent-loop/tests/tool-executors.spec.ts @@ -191,14 +191,20 @@ it('rejects new batches after unload while an accepted batch settles on its orig expect(trace).toEqual(['old_eval:execute', 'replacement_eval:execute']) }) -it('refreshes the selected projection when the same assembly context is reused', async () => { +it('refreshes executor projections without emitting tool registry changes', async () => { const ctx = await setup() + let registryChanges = 0 + ctx.on('tools/change', () => { registryChanges++ }) const key = { preset: 'refresh' } const scope = await scoped(ctx, key) const context: AssembleContext = { scope: key } expect((await ctx.systemPrompt.assemble(context)).tools.map(tool => tool.name)).toEqual(['echo']) - scope.ctx.tools.setExecutor(independent(scope.ctx, 'new_eval', [])) + const dispose = scope.ctx.tools.setExecutor(independent(scope.ctx, 'new_eval', [])) expect((await ctx.systemPrompt.assemble(context)).tools.map(tool => tool.name)).toEqual(['new_eval']) + expect(registryChanges).toBe(0) + dispose() + await expect(ctx.systemPrompt.assemble(context)).rejects.toThrow('selected tool executor is unavailable') + expect(registryChanges).toBe(0) }) it.each([ diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 687c39c221..1f9c8098ed 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9d2e6caa39c53d62 zh: 096a777f828c7565 /deepseek-ai-dsh-tools/use-this-package/select-an-executor: - en: 1ddfa584f9280cb7 - zh: b0899fcb8163569e + en: bdf27b6f339ea866 + zh: b89205eedea1db0a /deepseek-ai-dsh-tools/use-this-package/restrict-tools-per-agent: en: c6f98b2283cc2e47 zh: c2cdce5d13473f15 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index cee98fd3f7..3f0db3fcf6 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -65,7 +65,7 @@ The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null` ### Select an executor -Mount one executor in the deployment, preset, or Agent scope. The nearest scope selects the provider; duplicate providers in one scope fail. Native exposes visible capabilities directly; PTC exposes `run_code` and a generated SDK. +Mount one executor in the deployment, preset, or Agent scope. The nearest scope selects the provider; duplicate providers in one scope fail. Native exposes visible capabilities directly; PTC exposes `run_code` and a generated SDK. Executor registration and disposal do not emit `tools/change`; prompt assemblies and batches resolve the current executor when they run. ```yaml - name: '@deepseek-ai/dsh-tools' diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index fe97c14c4a..012ff9798a 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -65,7 +65,7 @@ ctx.tools.register(defineTool({ ### 选择执行器 -在部署、预设或 Agent 作用域中挂载一个执行器。最近的作用域决定提供者,同一作用域的重复提供者会报错。原生执行器直接公开可见能力;PTC 执行器公开 `run_code` 和生成的 SDK。 +在部署、预设或 Agent 作用域中挂载一个执行器。最近的作用域决定提供者,同一作用域的重复提供者会报错。原生执行器直接公开可见能力;PTC 执行器公开 `run_code` 和生成的 SDK。执行器注册和卸载不触发 `tools/change`;提示词组装和批次执行在运行时解析当前执行器。 ```yaml - name: '@deepseek-ai/dsh-tools' diff --git a/packages/core/tools/src/events.ts b/packages/core/tools/src/events.ts index cb890d2df7..f736de8908 100644 --- a/packages/core/tools/src/events.ts +++ b/packages/core/tools/src/events.ts @@ -48,7 +48,7 @@ declare module '@deepseek-ai/cordis' { */ 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined /** - * A tool or executor was registered or unregistered, or a scoped restriction changed. + * A tool was registered or unregistered, or a scoped restriction changed. * Unfiltered registry notification: scoped listeners receive changes from every * scope because a global contribution can affect every agent's next assembly. * @mode emit diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 92f0ca612a..3bbf45ad6a 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -260,11 +260,9 @@ export class ToolsService extends Service { } if (scope === undefined) this.root = record else this.scoped.set(scope, record) - this.ctx.emit('tools/change') yield () => { // Keep an unavailable selection so a program cannot fall through to a different executor. record.executor = undefined - this.ctx.emit('tools/change') } }.bind(this), 'tools.setExecutor()') } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 11338a3f37..75f0e3ffe1 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4691,8 +4691,8 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'tools/change', mode: 'emit', signature: '\'tools/change\'(): void', - summary: 'A tool or executor was registered or unregistered, or a scoped restriction changed.', - description: 'A tool or executor was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent\'s next assembly.', + summary: 'A tool was registered or unregistered, or a scoped restriction changed.', + description: 'A tool was registered or unregistered, or a scoped restriction changed. Unfiltered registry notification: scoped listeners receive changes from every scope because a global contribution can affect every agent\'s next assembly.', parameters: [], }, { From 2be80e36195f2ec6a10ed61fd0302feda62c5628 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 12:13:58 +0800 Subject: [PATCH 33/73] fix(sdk): select tool executors through profile overlays --- apps/cli/reference/README.i18n.yaml | 4 ++-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts | 1 - apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 5 +++-- apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts | 2 +- apps/cli/tests/profiles/sdk/user-questions.e2e.ts | 4 +++- .../v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml | 4 ++-- .../v0.2.1-rc.2/sdk-desktop-context/guide.md | 2 +- .../v0.2.1-rc.2/sdk-desktop-context/guide.zh.md | 2 +- .../v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml | 8 ++++---- .../v0.2.1-rc.2/tool-executor-plugins/guide.md | 6 +++--- .../v0.2.1-rc.2/tool-executor-plugins/guide.zh.md | 6 +++--- packages/bundle/sdk-app/README.i18n.yaml | 4 ++-- packages/bundle/sdk-app/README.md | 2 +- packages/bundle/sdk-app/README.zh.md | 2 +- packages/bundle/sdk-app/cordis.patch.yml | 4 ---- 17 files changed, 29 insertions(+), 31 deletions(-) diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b38d5ca39a..ffd79b8daa 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -21,8 +21,8 @@ en: 8d2cfff9bab7327f zh: 21515ab6e6798504 /dsh-cli-behavior-reference/web-profile: - en: 179aa84fb1f628c0 - zh: 01da19b1a5d6aaf3 + en: 149c5571acd51baf + zh: 300e3c2425273a92 /dsh-cli-behavior-reference/shared-deployment-behavior: en: f40edcf903a51f3f zh: 8a9a915451047c8b diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 1d6b364cae..a375450048 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -130,7 +130,7 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -Web and headless inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. +Web, headless, and SDK inherit native tool execution from the base bundle; use a [PTC overlay](../config/examples/ptc.yml) to select PTC. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and provides the platform-selected persistent shell, `read_image`, and `working_directory`. It supports automatic context compaction and the human `/compact` command. Select 极简模式 when creating a Web session; the shared browser, workspace, persistence, sandbox, and permission host remains in place. ## Shared deployment behavior diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index c18b585042..642fa18bc5 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -132,7 +132,7 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -Web 与 headless 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC overlay](../config/examples/ptc.yml) 可选择 PTC。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并提供按平台选择的持久 shell、`read_image` 和 `working_directory`。它支持自动上下文压缩和用户 `/compact` 命令。创建 Web 会话时请选择极简模式;共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 diff --git a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts index b1b2c88128..ec5fe6ad94 100644 --- a/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts +++ b/apps/cli/tests/profiles/sdk/dynamic-tool-cache.e2e.ts @@ -85,7 +85,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('SDK native tool updates with rea retryPolicy: { mode: 'normal', maxRetries: 0 } satisfies RetryPolicyConfig, models: [{ id: 'deepseek-flash', systemPromptUpdate: 'in-history', toolUpdate: 'addition-only' }], } }, - { id: 'tools', config: { mode: 'native' } }, { id: 'session-log-deepseek', disabled: true }, { id: 'plugin-package-inventory-deepseek', disabled: true }, { insert: [{ id: 'sdk-dynamic-tool-cache-fixture', name: fixturePath, config: { evidencePath, callsPath, sampleLabel, updatePrompt } }] }, diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index a65f11c29e..32e43b8be8 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -58,7 +58,7 @@ describe('Python SDK dsh profile keyless smoke', () => { { label: 'reports max-token turns with mapping enabled through env', envValue: 'true', editorEnabled: false }, { label: 'reports max-token turns with mapping disabled through env', envValue: 'false', editorEnabled: false }, { label: 'allows an explicit patch to enable str_replace_editor', envValue: undefined, editorEnabled: true }, - { label: 'uses desktop tools through the PTC environment switch', envValue: undefined, editorEnabled: false, ptc: true }, + { label: 'uses the PTC executor overlay despite the retired environment selector', envValue: undefined, editorEnabled: false, ptc: true }, ])('$label', async (scenario) => { const { envValue, editorEnabled } = scenario const ptc = 'ptc' in scenario && scenario.ptc @@ -92,6 +92,7 @@ describe('Python SDK dsh profile keyless smoke', () => { '--profile', 'sdk', ...(editorEnabled ? ['--patch', editorPatch] : []), + ...(ptc ? ['--patch', join(repoRoot, 'apps/cli/config/examples/ptc.yml')] : []), ], { cwd: repoRoot, env: { @@ -99,7 +100,7 @@ describe('Python SDK dsh profile keyless smoke', () => { DSH_HOME: join(root, '.dsh'), DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', - DSH_TOOLS_MODE: ptc ? 'ptc' : 'native', + DSH_TOOLS_MODE: ptc ? 'native' : 'ptc', DEEPSEEK_API_KEY: 'keyless-smoke-no-call', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, ...(envValue === undefined ? {} : { DSH_MAX_TOKENS_AS_SUCCESS: envValue }), diff --git a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts index 46b880cb78..f3efcf6dd1 100644 --- a/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts +++ b/apps/cli/tests/profiles/sdk/schedule-resume.e2e.ts @@ -49,7 +49,7 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: 'native', DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const first = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), patches: [patch], diff --git a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts index 3185cf4aa9..b13b0dd542 100644 --- a/apps/cli/tests/profiles/sdk/user-questions.e2e.ts +++ b/apps/cli/tests/profiles/sdk/user-questions.e2e.ts @@ -3,6 +3,7 @@ import { createServer } from 'node:http' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { DeepSeekHarness, type SdkUserQuestionRequest } from '@deepseek-ai/dsh-sdk-client' import { clearAmbientProxyEnv } from '../../../../../scripts/test-proxy-environment.ts' import { expect, it, onTestFinished } from 'vitest' @@ -47,13 +48,14 @@ it.each([ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('model fixture has no port') - const env = { ...process.env, DSH_TOOLS_MODE: mode, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', + const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: 'keyless-question-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` } clearAmbientProxyEnv(env) const seen: SdkUserQuestionRequest[] = [] const entered = Promise.withResolvers() const harness = state.harness = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'), env, model: 'deepseek-v4-pro', maxTokens: 1234, initializeTimeoutMs: 30_000, + patches: mode === 'ptc' ? [fileURLToPath(new URL('../../../config/examples/ptc.yml', import.meta.url))] : [], onUserQuestion: async (request, { signal }) => { expect(signal.aborted).toBe(false) seen.push(request) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml index 54c20e070f..a0657e1ef4 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.i18n.yaml @@ -9,8 +9,8 @@ en: d202616bf6e03fa4 zh: cc276adda4b952be /sdk-desktop-tools-and-context/change: - en: 85ac2825c52e2503 - zh: fd6fdbdca131c778 + en: ef89799953ecb457 + zh: 86fdb487b9a15a6f /sdk-desktop-tools-and-context/migration: en: 9caef4f8b153622a zh: 1b4901f5b18b72fd diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md index 2f0ae5051e..23a9efdd57 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -The existing `sdk` profile now includes `present`, `ask_user_question`, schedule tools, file-reference guidance and time context. It does not start a Web server. Standard tool calling remains the default; `DSH_TOOLS_MODE=ptc` selects programmatic tool calling in the same profile. `sdk-minimal` is unchanged. +The existing `sdk` profile now includes `present`, `ask_user_question`, schedule tools, file-reference guidance and time context. It does not start a Web server. Native tool execution remains the default; select PTC through an [executor overlay](../tool-executor-plugins/guide.md). `sdk-minimal` is unchanged. ## Migration diff --git a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md index 463d9d947d..719b105344 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/sdk-desktop-context/guide.zh.md @@ -9,7 +9,7 @@ description: 现有 SDK profile 包含原生桌面工具和模型上下文。 ## 变更 -现有 `sdk` profile 现在包含 `present`、`ask_user_question`、定时任务工具、文件引用说明和时间上下文。它不启动 Web server。默认仍使用标准工具调用;`DSH_TOOLS_MODE=ptc` 在同一 profile 中选择程序化工具调用。`sdk-minimal` 保持不变。 +现有 `sdk` profile 现在包含 `present`、`ask_user_question`、定时任务工具、文件引用说明和时间上下文。它不启动 Web server。默认仍使用原生工具执行;通过[执行器 overlay](../tool-executor-plugins/guide.zh.md) 选择 PTC。`sdk-minimal` 保持不变。 ## 迁移 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml index 50a174e891..8c3acb8f44 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 73002a2963b9ea27 zh: a67ea382d7c70c61 /mount-a-tool-executor/change: - en: 6d9c056c4ec74661 - zh: 340be0eddcd30049 + en: fac0b27c9452c4f2 + zh: 2f53fd013ff460ba /mount-a-tool-executor/migration: - en: 60ea8b9dd1128dfc - zh: 7c648e9e5d75c49b + en: 4d91c0177d06f4bd + zh: e93047b58a5aef4c diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md index 48a12c3078..17045a20b3 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.md @@ -9,7 +9,7 @@ English | [中文](guide.zh.md) ## Change -`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but rejects the removed `mode` and `maxParallelSubCalls` configuration fields at load time. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. Web and headless no longer read `DSH_TOOLS_MODE`; both inherit native execution from base. +`@deepseek-ai/dsh-tools` keeps tool registration and executor selection, but rejects the removed `mode` and `maxParallelSubCalls` configuration fields at load time. Native and PTC execution require `@deepseek-ai/dsh-tool-executor-native` or `@deepseek-ai/dsh-tool-executor-ptc`. Shipped profiles mount an executor; custom compositions must add one. The existing `dsh-agent-tool-presentation` selector remains supported but is deprecated. Enabled `tool-executor-native` and `tool-executor-ptc` rows are required at startup; invalid executor configuration now stops startup. Web, headless, and SDK no longer read `DSH_TOOLS_MODE`; all inherit native execution from base. ## Migration @@ -18,9 +18,9 @@ English | [中文](guide.zh.md) 3. Custom executors implement `describe` and `execute`. Programmatic callers pass raw `calls` and `signal`, optional `agent`, and optional `log: { turn, step }`; read single-call outcomes from `results`. Omit `log` outside a committed model step. 4. Import PTC SDK renderers and `CodeRunFailedError` from `@deepseek-ai/dsh-tool-executor-ptc`. Run a task with the selected interaction mode and confirm its tool results appear in the Session. 5. Import `ToolExecutionPipeline` and `executeToolBatch` from `@deepseek-ai/dsh-tools/pipeline`. Complete calls, including single calls, use batches; custom schedulers call `prepare`, `dispatch`, and `complete` directly. Add `@deepseek-ai/dsh-tool-testkit` as a development dependency for tool fixtures and import them from that package. Neither is exported by the tool registry entry. -6. Import `PtcDispatchLog` from the PTC executor package using `import type`. Detached Session readers use `@deepseek-ai/dsh-tool-executor-ptc/types` for durable dispatch events. Live PTC policy and message-source declarations remain on the executor package entry. +6. Import shared dispatch events and the `ptc-mode` message-source declaration from `@deepseek-ai/dsh-tool-executor-ptc/types`; Client code uses its `/client` re-export. Host policy extensions use `import type` from `@deepseek-ai/dsh-tool-executor-ptc` for `PtcDispatchLog` and `tools/ptc-dispatch-log`. -7. Remove `DSH_TOOLS_MODE` from launch environments. For PTC, save the following as `ptc.yml` and pass it with `--patch`; native execution needs no overlay. The same patch works with `--profile web`. Confirm that the model receives only `run_code` in PTC mode. +7. Remove `DSH_TOOLS_MODE` from launch environments. For PTC, save the following as `ptc.yml` and pass it with `--patch`; native execution needs no overlay. The same patch works with `--profile web` and `--profile sdk`; TypeScript and Python SDK clients pass its path through `patches`. Confirm that the model receives only `run_code` in PTC mode. ```yaml - id: tool-executor-native diff --git a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md index dd9d85657d..132e886e31 100644 --- a/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-rc.2/tool-executor-plugins/guide.zh.md @@ -9,7 +9,7 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 ## 变更 -`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但加载时会拒绝已移除的 `mode` 和 `maxParallelSubCalls` 配置字段。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。Web 与 headless 不再读取 `DSH_TOOLS_MODE`,均继承 base 的原生执行。 +`@deepseek-ai/dsh-tools` 保留工具注册和执行器选择,但加载时会拒绝已移除的 `mode` 和 `maxParallelSubCalls` 配置字段。原生和 PTC 执行分别要求 `@deepseek-ai/dsh-tool-executor-native` 或 `@deepseek-ai/dsh-tool-executor-ptc`。内置 profile 已挂载执行器,自定义组合必须添加一个。现有 `dsh-agent-tool-presentation` 选择器仍受支持,但已弃用。启用的 `tool-executor-native` 和 `tool-executor-ptc` 条目属于启动必需项;无效的执行器配置会终止启动。Web、headless 与 SDK 不再读取 `DSH_TOOLS_MODE`,均继承 base 的原生执行。 ## 迁移 @@ -18,9 +18,9 @@ description: "工具执行模式从 dsh-tools 配置移至显式执行器插件 3. 自定义执行器实现 `describe` 和 `execute`。程序调用方传入原始 `calls` 和 `signal`,以及可选的 `agent` 和 `log: { turn, step }`;从 `results` 读取单次结果。不属于已提交模型步骤的调用应省略 `log`。 4. 从 `@deepseek-ai/dsh-tool-executor-ptc` 导入 PTC SDK 渲染器和 `CodeRunFailedError`。用所选交互模式运行任务,确认工具结果出现在 Session 中。 5. 从 `@deepseek-ai/dsh-tools/pipeline` 导入 `ToolExecutionPipeline` 和 `executeToolBatch`。完整执行(包括单次调用)使用批次;自定义调度器直接调用 `prepare`、`dispatch` 和 `complete`。把 `@deepseek-ai/dsh-tool-testkit` 添加为开发依赖,并从该包导入工具 fixture。工具注册表主入口不再导出这些成员。 -6. 使用 `import type` 从 PTC 执行器包导入 `PtcDispatchLog`。独立 Session 读取者通过 `@deepseek-ai/dsh-tool-executor-ptc/types` 获取持久化分派事件;PTC 实时策略与消息来源声明由执行器包主入口提供。 +6. 从 `@deepseek-ai/dsh-tool-executor-ptc/types` 导入共享分派事件与 `ptc-mode` 消息来源声明;Client 代码使用其 `/client` 重导出。Host 策略扩展通过 `@deepseek-ai/dsh-tool-executor-ptc` 的 `import type` 获取 `PtcDispatchLog` 与 `tools/ptc-dispatch-log`。 -7. 从启动环境中移除 `DSH_TOOLS_MODE`。需要 PTC 时,将以下内容保存为 `ptc.yml` 并通过 `--patch` 加载;原生执行无需 overlay。同一补丁也适用于 `--profile web`。确认 PTC 模式下模型只收到 `run_code`。 +7. 从启动环境中移除 `DSH_TOOLS_MODE`。需要 PTC 时,将以下内容保存为 `ptc.yml` 并通过 `--patch` 加载;原生执行无需 overlay。同一补丁也适用于 `--profile web` 和 `--profile sdk`;TypeScript 和 Python SDK 客户端通过 `patches` 传入其路径。确认 PTC 模式下模型只收到 `run_code`。 ```yaml - id: tool-executor-native diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ac256bac4f..ed528810fc 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: b76bd7ea9880f529 - zh: e80c7b9fa331dd9b + en: b9a0e01492ffe278 + zh: 7c6ffd1f9e9c0906 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index b07ddae97b..d1be289939 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -23,7 +23,7 @@ The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/RE ## Use this package -The `sdk` profile includes native desktop tools and model context. Standard tool calling is the default; set `DSH_TOOLS_MODE=ptc` for programmatic tool calling. Both modes use the same plugins. +The `sdk` profile includes native desktop tools and model context. Native tool execution is the default. For PTC, pass the [executor overlay](../../../apps/cli/config/examples/ptc.yml) through `--patch` or the `patches` option in the TypeScript or Python SDK. `DSH_TOOLS_MODE` is ignored. The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints SDK-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin. diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index fa9e3a8702..70523123d7 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -23,7 +23,7 @@ kind: "package-bundle" ## 使用本包 -`sdk` profile 包含原生桌面工具和模型上下文。默认使用标准工具调用;设置 `DSH_TOOLS_MODE=ptc` 可使用程序化工具调用。两种模式使用相同插件。 +`sdk` profile 包含原生桌面工具和模型上下文。默认使用原生工具执行。需要 PTC 时,通过 `--patch` 或 TypeScript、Python SDK 的 `patches` 选项传入[执行器 overlay](../../../apps/cli/config/examples/ptc.yml)。`DSH_TOOLS_MODE` 不生效。 启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的会话标题;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 SDK 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。 diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 52e258605d..afcbfd2c0d 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -41,10 +41,6 @@ node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" # Share native desktop tools and model context without starting a Web server. -- id: tools - config: - mode: !!js process.env.DSH_TOOLS_MODE - - insert: - id: connection name: '@deepseek-ai/dsh-client-connection' From 81a132d1c04dcbd7f419f203a19a47af908ec359 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 12:13:58 +0800 Subject: [PATCH 34/73] test(tools): align missing-runtime expectations with plugin activation --- .../agent-tool-presentation/README.i18n.yaml | 16 ++++++++-------- packages/core/agent-tool-presentation/README.md | 8 ++++---- .../core/agent-tool-presentation/README.zh.md | 8 ++++---- .../core/agent-tool-presentation/src/index.ts | 4 ++-- .../tests/agent-tool-presentation.spec.ts | 13 ++++++++----- .../tests/tool-executors.spec.ts | 11 ++++------- 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index b39ccedaf7..ca8b3a37ca 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/README.i18n.yaml @@ -9,8 +9,8 @@ en: db5b1de2af3a6f37 zh: e4b10a1484df8b29 /deepseek-ai-dsh-agent-tool-presentation/summary: - en: ae29d1ab4c22566f - zh: c363959591dce513 + en: 4e7556ae08aa7259 + zh: 1d178d483a426267 /deepseek-ai-dsh-agent-tool-presentation/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -21,8 +21,8 @@ en: 1550e3dcc5a3aa1e zh: 47b2d1e0e3e3cbf2 /deepseek-ai-dsh-agent-tool-presentation/use-this-package/what-ptc-mode-requires: - en: 3ec33f18b3ac0859 - zh: 92bf371bfbbc511d + en: 4bc61559223f7d60 + zh: 3e11c92b13292d54 /deepseek-ai-dsh-agent-tool-presentation/use-this-package/one-presentation-per-agent: en: 46bf80f051175567 zh: 8f5e1623da5d1520 @@ -36,8 +36,8 @@ en: 89ecdb3d5d032e7f zh: 64ae2feb4344c679 /deepseek-ai-dsh-agent-tool-presentation/understand-the-implementation/behavior-notes: - en: 4a44ac4057732829 - zh: 813d3ec7627146fd + en: d9300d0a040f3e70 + zh: 11969cc8c9e60ad0 /deepseek-ai-dsh-agent-tool-presentation/further-exploration: en: 6a1d0096e4da25e4 zh: 9d5de770addb69d2 @@ -48,8 +48,8 @@ en: c75b16e38577f026 zh: f802d115af4bf9a1 /deepseek-ai-dsh-agent-tool-presentation/known-limitations-and-deferred-work: - en: 17a0a213ec65c95f - zh: ff96d985f6360338 + en: 583b046bb6d61acf + zh: 155601a35aac8ca8 /deepseek-ai-dsh-agent-tool-presentation/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index b3c22d9bfe..b20d954b04 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -11,7 +11,7 @@ English | [中文](README.zh.md) ## Summary -Existing [agent presets](../../preset/agent-preset-registry/README.md) can keep using `dsh-agent-tool-presentation` to select whether models see every native tool schema or only `run_code` with a generated SDK. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` requires a compatible PTC runtime; a deployment without one rejects the preset at mount time before its first prompt. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. +Existing [agent presets](../../preset/agent-preset-registry/README.md) can keep using `dsh-agent-tool-presentation` to select whether models see every native tool schema or only `run_code` with a generated SDK. Each preset can choose independently, so native and PTC agents can share one process without sharing tool catalogs. Selecting `ptc` requires a compatible PTC runtime. The `mode` field is required when this package is present, while omitting the package keeps the deployment default. ## Table of Contents @@ -45,7 +45,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### What PTC mode requires -Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tool-executor-ptc`. Selection takes effect even when the runtime is missing; prompt assembly or program execution then reports the missing runtime instead of inheriting native execution. +Selecting `ptc` needs a composed PTC runtime (`ctx.ptcRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tool-executor-ptc`. Before the runtime first becomes available, this compatibility selector leaves the inherited executor active. Mount `dsh-tool-executor-ptc` directly when preset activation must reject a missing runtime. ### One presentation per agent @@ -73,7 +73,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane ### Behavior notes -Both modes mount their executor immediately. PTC checks runtime availability when describing or executing a program. The mounted executor owns its scoped registration, which is withdrawn with this selector's plugin lifetime. +The selected child plugin registers its executor when its required services are available. After registration, unloading the child or its runtime makes the selected executor unavailable until a replacement registers; calls do not fall back to an inherited executor.
@@ -108,7 +108,7 @@ No direct invalidation; the presentation is fixed when the agent is composed, so These limits define when this row needs special care. They are current package constraints, not a task backlog. -- **The runtime stays host-plane** — a preset can select PTC mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no PTC preset. +- **The runtime stays host-plane** — a preset can select PTC mode but cannot supply the TypeScript runtime it needs; PTC selection remains pending until the deployment supplies a runtime. ### Dev Note diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index f54c3799a6..2b8bfc6ce7 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -11,7 +11,7 @@ kind: "package-reference" ## 概述 -现有 [agent preset](../../preset/agent-preset-registry/README.zh.md) 可以继续使用 `dsh-agent-tool-presentation`,选择模型看到全部原生工具 schema,还是只有带生成 SDK 的 `run_code`。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 需要兼容的 PTC 运行时;没有该运行时的部署会在挂载时拒绝 preset,不会等到收到第一条提示词。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 +现有 [agent preset](../../preset/agent-preset-registry/README.zh.md) 可以继续使用 `dsh-agent-tool-presentation`,选择模型看到全部原生工具 schema,还是只有带生成 SDK 的 `run_code`。每个 preset 可独立选择,因此 native 与 PTC agent 可以共享同一进程,而不共享工具目录。选择 `ptc` 需要兼容的 PTC 运行时。使用本包时 `mode` 字段为必填;省略本包则沿用部署默认值。 ## 目录 @@ -45,7 +45,7 @@ kind: "package-reference" ### PTC 模式需要什么 -选择 `ptc` 需要已组合的 PTC 运行时(`ctx.ptcRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tool-executor-ptc` 中。即使缺少运行时,选择也会生效;随后提示词组装或程序执行会报告缺少运行时,而不会继承原生执行方式。 +选择 `ptc` 需要已组合的 PTC 运行时(`ctx.ptcRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-ptc-runtime-node`](../../ptc-runtime/ptc-runtime-node/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tool-executor-ptc` 中。在运行时首次可用之前,此兼容选择器会保留继承的执行器。若需要在 preset 激活时拒绝缺少运行时的配置,请直接挂载 `dsh-tool-executor-ptc`。 ### 每个 agent 只声明一次呈现方式 @@ -73,7 +73,7 @@ kind: "package-reference" ### 行为说明 -两种模式都会立即挂载各自的执行器。PTC 在描述或执行程序时检查运行时是否可用。挂载的执行器拥有作用域注册,并随此选择器的插件生命周期一同撤销。 +选中的子插件在所需服务可用时注册执行器。注册后,卸载子插件或其运行时会使选中的执行器不可用,直到替代执行器完成注册;调用不会回退到继承的执行器。
@@ -108,7 +108,7 @@ kind: "package-reference" 这些限制说明这一行何时需要特别留意。它们是当前包约束,不是任务积压。 -- **运行时仍在宿主平面**——preset 可以选择 PTC mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 PTC 模式的 preset。 +- **运行时仍在宿主平面**——preset 可以选择 PTC mode,却无法自带它所需的 TypeScript 运行时;在部署提供运行时之前,PTC 选择会保持等待状态。 ### 开发备注 diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index ce986fce5f..59d36daf76 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -13,8 +13,8 @@ import * as PtcToolExecutor from '@deepseek-ai/dsh-tool-executor-ptc' export const name = 'tool-presentation' /** - * Tool selection does not require a program runtime; the PTC executor - * reports a missing runtime when its description or execution is requested. + * Native selection does not require a program runtime; the PTC child waits + * for its runtime before registering an executor in this scope. */ export const inject = ['tools'] diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index 4481f31788..4f43b87f08 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -1,12 +1,12 @@ /** * The row an agent preset carries to pick its tool presentation. What it owes * its caller: the choice reaches THIS agent and no other, it unwinds with the - * agent, and a missing PTC runtime rejects the selected scope's prompt - * instead of inheriting the native executor. + * agent, and PTC selection waits for a runtime before replacing the + * inherited executor. */ import * as NativeToolExecutor from '@deepseek-ai/dsh-tool-executor-native' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -32,6 +32,7 @@ class StubRuntime extends PtcRuntime { /** A host plane with one tool, optionally carrying a PTC runtime. */ async function host(options: { runtime?: boolean } = {}) { const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SystemPrompt, {}) await ctx.plugin(ToolRuntime) await ctx.plugin(NativeToolExecutor) @@ -93,13 +94,15 @@ describe('the tool-presentation row', () => { await expect(ctx.systemPrompt.assemble({ scope: agent })).rejects.toThrow('selected tool executor is unavailable') }) - it('rejects PTC assembly when the deployment does not compose its runtime', async () => { + it('keeps the inherited executor while waiting for a PTC runtime', async () => { const ctx = await host({ runtime: false }) const { agent, row } = await mount(ctx, { mode: 'ptc' }) expect(row.ctx.get('ptcRuntime')).toBeUndefined() - await expect(ctx.systemPrompt.assemble({ scope: agent })).rejects.toThrow('requires a PTC runtime') + const assembly = await ctx.systemPrompt.assemble({ scope: agent }) + expect(assembly.tools.map(tool => tool.name)).toEqual(['echo']) + expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) it('applies once the runtime arrives', async () => { diff --git a/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts b/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts index 2c0c0b9088..fdcc5d6150 100644 --- a/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts +++ b/packages/preset/agent-preset-registry/tests/tool-executors.spec.ts @@ -125,17 +125,14 @@ it.each([true, false])('loads cordis.yml executor presets with runtime availabil await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(path).href } }) await ctx.loader.await() const native = await agentOn(ctx, 'native-package', 'standard') - const ptc = await agentOn(ctx, 'ptc-package', 'ptc') expect((await ctx.systemPrompt.assemble({ scope: native })).tools.map(tool => tool.name)).toEqual(['echo']) if (!hasRuntime) { - await expect(ctx.systemPrompt.assemble({ scope: ptc })).rejects.toThrow('requires a PTC runtime') - const batch = await ctx.tools.execute({ - agent: ptc, calls: [{ type: 'tool-call', id: ToolCallId('missing-runtime'), name: 'echo', arguments: '{}' }], - signal: new AbortController().signal, - }) - expect(batch.results[0]?.result).toMatchObject({ isError: true, error: { info: { code: 'UNKNOWN_TOOL' } } }) + const pending = agentOn(ctx, 'ptc-package', 'ptc') + await expect(pending).rejects.toMatchObject({ code: 'agent-preset/invalid' }) + await expect(pending).rejects.toThrow('executor (cordis:ptc): waiting for ptcRuntime') return } + const ptc = await agentOn(ctx, 'ptc-package', 'ptc') expect((await ctx.systemPrompt.assemble({ scope: ptc })).tools.map(tool => tool.name)).toEqual(['run_code']) const signal = new AbortController().signal expect(await executeModelToolFixture(ctx, { From 302fc0119fdc5b3d7f0fc48beaa8997dcea7bce8 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 15:56:44 +0800 Subject: [PATCH 35/73] fix(conversation): add missing Japanese quote removal label --- packages/client/ui-conversation/src/client/locales.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', From ab173e9d09e83bc79b4120a7b12c1f27bf9065ab Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 16:09:26 +0800 Subject: [PATCH 36/73] chore: remove unrelated changes from executor PR --- packages/client/ui-conversation/src/client/locales.ts | 1 - .../compaction-basic/tests/compaction-loop-repro.spec.ts | 2 +- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 36edb38e56..f908a94233 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,7 +772,6 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { - 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', diff --git a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts index 89df330da3..2777a6e29b 100644 --- a/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts +++ b/packages/compaction/compaction-basic/tests/compaction-loop-repro.spec.ts @@ -5,7 +5,7 @@ import { createUserMessage, createSystemMessage, CONTEXT_WINDOW_EXCEEDED_CODE, L import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' import { ToolCallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineContentToolFixture } from '@deepseek-ai/dsh-tool-testkit' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic' diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index c9fb03068a..90228febab 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From f53d476790bf0b8ca597d96b3bcec57b37267dcc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 16:09:26 +0800 Subject: [PATCH 37/73] chore(tools): drop unused testing export policy --- .../package-surface.i18n.yaml | 4 +-- docs/defensive-patterns/package-surface.md | 2 +- docs/defensive-patterns/package-surface.zh.md | 2 +- scripts/check-workspace-constraints.spec.ts | 26 +++++-------------- scripts/check-workspace-constraints.ts | 6 ----- 5 files changed, 11 insertions(+), 29 deletions(-) diff --git a/docs/defensive-patterns/package-surface.i18n.yaml b/docs/defensive-patterns/package-surface.i18n.yaml index 8d5c9525ed..6b216a0443 100644 --- a/docs/defensive-patterns/package-surface.i18n.yaml +++ b/docs/defensive-patterns/package-surface.i18n.yaml @@ -12,8 +12,8 @@ en: bf79c6acf35c90a5 zh: 9f2ce6c7f6493b17 /package-surface-rules/package-json-exports-uses-the-standard-key-set: - en: 8a2a50f2876b6fc9 - zh: ca693a3bf91e16f9 + en: 692ded0ff903609d + zh: c871ab58ca92ad70 /package-surface-rules/dependency-declarations-and-version-consistency-are-enforced-by-the-dependency-scan: en: e68bfcde70f845b4 zh: 73e5c4fb067c4fa1 diff --git a/docs/defensive-patterns/package-surface.md b/docs/defensive-patterns/package-surface.md index a01b50aa5d..5876dfa463 100644 --- a/docs/defensive-patterns/package-surface.md +++ b/docs/defensive-patterns/package-surface.md @@ -76,7 +76,7 @@ A hand-written `external` list — and a `tsconfig.foo.json` invented beside it A `package.json` `exports` map uses only the standard key set, in which every key already has one meaning across the repository, and a package never opens a key for its own local purpose. A key that only a handful of packages use without a documenting owner is not a precedent: formalizing a key, including one of the undocumented keys below, means changing the owning document and the check in the same change. The small shared key set is what lets a consumer infer a key's purpose from its name and lets a scan judge the export map at all. -- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./testing` (shared test fixtures at `lib/testing.js` or `lib/types/testing.js`, with declarations at `lib/types/testing.d.ts`), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. +- Standard keys, each with one meaning repo-wide, across `packages/*/*/package.json` and `apps/*/package.json`: `.` (the package entry), `./package.json` (toolchain access, not a semantic export), `./src/*` (source passthrough for tests and tools, whose documented status is still open), `./client` (the browser entry, paired with the `dsh.client` declaration), `./types` (the type entry), `./typert` (the typert type runtime), `./remote` (the `@Remote` call surface), `./locale/*.json` (locale resources), `./cordis.patch.yml` (the bundle patch layer declared by `dsh.bundle`). Counts are not listed here because they rot on the next package; the recompute command below prints them. - Keys outside that set (`./brand` in 5 packages, `./worker` in 4, `./startup`, `./runner`, and `./protocol` in 2 each, plus many one-off keys) have no documented meaning; they are pending review, not a pattern to copy. - `dsh-tools` additionally exposes `./pipeline` for reusable invocation stages and batch scheduling. Its main and pipeline bundles share runtime chunks to preserve error-class identity; workspace constraints check the export targets and publication files. - Judged condition: does a change add an export key outside the standard set? diff --git a/docs/defensive-patterns/package-surface.zh.md b/docs/defensive-patterns/package-surface.zh.md index 362a4a9385..8452120e4c 100644 --- a/docs/defensive-patterns/package-surface.zh.md +++ b/docs/defensive-patterns/package-surface.zh.md @@ -76,7 +76,7 @@ import { isSurfaceEligibleType } from '../../core/session/src/surface.ts' `package.json` 的 `exports` 只使用标准键集合,集合中每个键在全仓已经只有一个含义,包不得为自己的局部用途开键。只有少数几个包在用、又没有文档正家的键不构成先例:把某个键正式化(包括下面那些未记录在案的键)意味着在同一次改动里改正家文档与检查。键集合小且共享,消费者才能从键名推断用途,扫描也才有可能判定导出映射。 -- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./testing`(共享测试 fixture,位于 `lib/testing.js` 或 `lib/types/testing.js`,声明位于 `lib/types/testing.d.ts`)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 +- 标准键,每个键在全仓只有一个含义,统计口径是 `packages/*/*/package.json` 与 `apps/*/package.json`:`.`(包主入口)、`./package.json`(工具链访问,不是语义导出)、`./src/*`(测试与工具的源码透传,文档状态仍待定)、`./client`(浏览器入口,与 `dsh.client` 声明配对)、`./types`(类型入口)、`./typert`(typert 类型运行时)、`./remote`(`@Remote` 调用面)、`./locale/*.json`(本地化资源)、`./cordis.patch.yml`(`dsh.bundle` 声明的 bundle 补丁层)。这里不列各键的包数,因为下一个包就会让它过期;下面的复算命令会打印当前数字。 - 集合之外的键(`./brand` 5 个包、`./worker` 4 个、`./startup`、`./runner`、`./protocol` 各 2 个,另有大量一次性键)没有文档含义,属待核项,不是可照抄的先例。 - `dsh-tools` 另行提供 `./pipeline`,用于复用调用阶段与批量调度。主入口与 pipeline bundle 共享运行时 chunk,以保持错误类的身份一致;工作区检查验证导出目标与发布文件。 - 判定条件:改动是否新增了标准集合以外的 export 键? diff --git a/scripts/check-workspace-constraints.spec.ts b/scripts/check-workspace-constraints.spec.ts index 25ce4941fa..7ef20ad965 100644 --- a/scripts/check-workspace-constraints.spec.ts +++ b/scripts/check-workspace-constraints.spec.ts @@ -332,30 +332,18 @@ describe('package payload constraints', () => { const dir = 'packages/core/tools' const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest'] expect(checkWorkspaceManifest({ dir, manifest })).toEqual([]) - const emittedTesting = { - ...manifest, - exports: { - ...manifest.exports, - './testing': { types: './lib/types/testing.d.ts', default: './lib/types/testing.js' }, - }, - } - expect(checkWorkspaceManifest({ dir, manifest: { - ...emittedTesting, files: [...expectedDshPackageFiles(emittedTesting)], - } })).toEqual([]) for (const omitted of ['lib/pipeline.js', 'lib/shared-*.js']) { expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== omitted), } })).toEqual([expect.stringContaining('package.json files must be')]) } - for (const entry of ['testing', 'pipeline']) { - expect(checkWorkspaceManifest({ dir, manifest: { - ...manifest, - exports: { - ...manifest.exports, - [`./${entry}`]: { types: `./lib/types/${entry}.d.ts`, default: './lib/index.js' }, - }, - } })).toContainEqual(expect.stringContaining(`exports["./${entry}"] must expose`)) - } + expect(checkWorkspaceManifest({ dir, manifest: { + ...manifest, + exports: { + ...manifest.exports, + './pipeline': { types: './lib/types/pipeline.d.ts', default: './lib/index.js' }, + }, + } })).toContainEqual(expect.stringContaining('exports["./pipeline"] must expose')) }) it('publishes the directory of each locale detail document after the locale resources', () => { diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f4e507f5fc..b9d9e380e3 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -325,7 +325,6 @@ export function expectedDshPackageFiles(manifest: PackageManifest, detailFiles: ...detailFiles, 'lib/index.js', ...manifest.bin ? ['lib/bin.js'] : [], - ...exportDefault(manifest, './testing') === './lib/testing.js' ? ['lib/testing.js'] : [], // Worker-thread packages ship a CJS worker entry; the browser worker // bundle is an ES module a page loads with `new Worker(type: 'module')`. // Keyed on the artifact path, like ./client below. @@ -580,11 +579,6 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st if (rootEntry?.default !== './lib/index.js') { errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) } - if (manifest.exports?.['./testing'] !== undefined - && !hasExportPair(manifest, './testing', './lib/types/testing.d.ts', './lib/testing.js') - && !hasExportPair(manifest, './testing', './lib/types/testing.d.ts', './lib/types/testing.js')) { - errors.push(`${label}: exports["./testing"] must expose lib/types/testing.d.ts with lib/testing.js or lib/types/testing.js`) - } if (manifest.name === '@deepseek-ai/dsh-tools' && !hasExportPair(manifest, './pipeline', './lib/types/pipeline.d.ts', './lib/pipeline.js')) { errors.push(`${label}: exports["./pipeline"] must expose lib/types/pipeline.d.ts and lib/pipeline.js`) From aa099a9b4069cdef1a487ac5bbe1098cbdc3f0cc Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 21:47:44 +0800 Subject: [PATCH 38/73] fix(ui-user-questions): keep long questions answerable --- apps/web/tests/question-composer.e2e.ts | 176 +++++++++++++++--- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../client/ui-primitives/src/HoverCard.tsx | 119 +++++++++--- .../tests/hover-card.client.spec.tsx | 140 ++++++++++++++ .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 100 ++++++++-- .../src/client/QuestionComposer.tsx | 116 ++++++++---- .../ui-user-questions/src/client/locales.ts | 2 + .../question-composer-styles.client.spec.ts | 37 ++++ .../user-questions-composer.client.spec.tsx | 81 +++++++- .../question-composer/composed.expected.md | 4 +- .../late-answered.expected.md | 7 +- .../web/question-composer/ui.expected.md | 4 +- 16 files changed, 682 insertions(+), 116 deletions(-) create mode 100644 packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index eda964da3e..70fa1fd1b6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -69,8 +69,8 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: } // The options carry long descriptions on purpose: the squeeze assertion below -// needs option copy that WRAPS, which is the only text layout that reproduces a -// collapsed row painting its copy outside its own box. +// verifies that each description remains a single ellipsized line while the +// complete text stays reachable through its information action. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' function isRecord(value: unknown): value is Record { @@ -343,48 +343,64 @@ describe('web e2e: resident question composer round trip', () => { await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } - // Squeezed card: the option rows are the capped card's scroll content, so - // shrinking the seat must push overflow into the option list, never - // collapse a row below the height its own copy needs — a collapsed row - // paints its centered copy outside the row box, over the title and the - // neighbouring rows. Measured on the live composer at seat heights that - // force the cap, then restored for the answer gesture below. Replay only: - // record mode must reach the recording write below, not abort on layout. + // Squeezed card: the question and compact option rows share one scrollport, + // while navigation and submission stay outside it. Measured on the live + // composer at seat heights that force the cap, then restored for the answer + // gesture below. Replay only: record mode must reach the recording write + // below, not abort on layout. if (MODE !== 'record') { const original = page.viewportSize() ?? { width: 1680, height: 1000 } + const scrollStates: boolean[] = [] for (const height of [520, 440, 380]) { - await page.setViewportSize({ width: 900, height }) + await page.setViewportSize({ width: 640, height }) const squeeze = await composer.evaluate((card) => { - // Role/ARIA selectors, not the CSS-module class names: the built - // client hashes those. - const rows = [...card.querySelectorAll( - '[role="radio"], [role="checkbox"], [aria-expanded]', - )] + const rows = [...card.querySelectorAll('[data-option-row]')] + const descriptions = [...card.querySelectorAll('[data-option-description]')] const spill = rows.map(row => Math.max(...[...row.children].map((child) => { const box = row.getBoundingClientRect() const inner = child.getBoundingClientRect() return Math.max(box.top - inner.top, inner.bottom - box.bottom) }))) const list = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') return { rows: rows.length, spill: Math.max(...spill), - // Wrapped option text is what overflows a collapsed row, and a - // scrolling list proves the seat is genuinely capped. Without both, - // the spill assertion would hold vacuously. - wrappedRows: rows.filter(row => row.getBoundingClientRect().height > 42).length, + ellipsized: descriptions.filter(item => item.scrollWidth > item.clientWidth).length, + descriptionLines: Math.max(...descriptions.map((item) => { + const style = getComputedStyle(item) + return item.clientHeight / parseFloat(style.lineHeight) + })), scrolls: list === null ? false : list.scrollHeight > list.clientHeight, + titleInScrollport: list?.contains(title) === true, } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.wrappedRows).toBeGreaterThan(0) - expect(squeeze.scrolls).toBe(true) + expect(squeeze.ellipsized).toBeGreaterThan(0) + expect(squeeze.descriptionLines).toBeLessThan(1.1) + scrollStates.push(squeeze.scrolls) + expect(squeeze.titleInScrollport).toBe(true) + expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) // Sub-pixel tolerance: every row's copy stays inside its border box. expect(squeeze.spill).toBeLessThan(0.6) } + expect(scrollStates).toContain(true) await page.setViewportSize(original) } + const blue = composer.getByRole('checkbox', { name: 'Blue' }) + const blueDetail = 'A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.' + const blueInfo = composer.getByRole('button', { name: 'Show details for “Blue”' }) + await blueInfo.hover() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) + await composer.getByRole('heading', { name: 'Which color do you prefer?' }).hover() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + await blueInfo.click() + expect(await blue.getAttribute('aria-checked')).toBe('false') + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) + await composer.getByRole('heading', { name: 'Which color do you prefer?' }).click() + await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + // Multi-line custom answer: the field is a textarea whose hidden mirror // owns the box height, so a soft-wrapped or line-broken draft GROWS the // field instead of scrolling one line, and Shift+Enter breaks the line @@ -415,7 +431,6 @@ describe('web e2e: resident question composer round trip', () => { await custom.fill('') } - const blue = composer.getByRole('checkbox', { name: 'Blue' }) await blue.click() await custom.fill('Include accessibility notes') expect(await blue.getAttribute('aria-checked')).toBe('true') @@ -536,6 +551,123 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) + it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const question = 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?' + const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' + const description = 'Remove the experimental client from every shipped template and require users to install it explicitly in the profile where they need it.' + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'release', + header: 'Release decision', + question, + detail, + multiSelect: true, + options: [ + { label: 'Explicit install', description }, + { label: 'Keep the default edge', description: 'Retain the current dependency and exempt it from the release graph invariant.' }, + { label: 'Move the package', description: 'Rename the package outside the experimental namespace without changing how profiles acquire it.' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await composer.waitFor({ timeout: 30_000 }) + // Browser zoom reduces the CSS-pixel viewport. These dimensions model + // the same 640x420 window at 100%, 125%, and 150% without relying on a + // browser-global zoom preference that Playwright cannot isolate per page. + for (const viewport of [ + { width: 640, height: 420 }, + { width: 512, height: 336 }, + { width: 427, height: 280 }, + ]) { + await page.setViewportSize(viewport) + await expect.poll(() => composer.evaluate((card) => { + const scroll = card.querySelector('[data-question-scroll]') + const title = card.querySelector('h2') + const submit = [...card.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + if (scroll === null || title === null || submit === undefined) return null + const submitBox = submit.getBoundingClientRect() + return { + footerFits: submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + scrolls: scroll.scrollHeight > scroll.clientHeight, + titleInScrollport: scroll.contains(title), + } + })).toEqual({ + footerFits: true, + scrolls: true, + titleInScrollport: true, + }) + const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { + const style = getComputedStyle(title) + return title.getBoundingClientRect().height / parseFloat(style.lineHeight) + }) + expect(questionLines).toBeGreaterThan(2) + } + + const detailText = composer.getByText(detail, { exact: true }) + expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) + await detailText.scrollIntoViewIfNeeded() + expect(await detailText.isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) + + const info = composer.getByRole('button', { name: 'Show details for “Explicit install”' }) + const descriptionId = await info.getAttribute('aria-describedby') + expect(descriptionId).not.toBeNull() + expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).textContent()).toBe(description) + await info.click() + expect(await composer.getByRole('checkbox', { name: 'Explicit install' }).getAttribute('aria-checked')).toBe('false') + expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) + const preview = page.locator('[data-pinned="true"]') + await preview.getByText(description, { exact: true }).waitFor() + const previewBox = await preview.boundingBox() + const constrainedViewport = page.viewportSize() + expect(constrainedViewport).not.toBeNull() + expect(previewBox).not.toBeNull() + expect(previewBox?.x).toBeGreaterThanOrEqual(7.5) + expect(previewBox?.y).toBeGreaterThanOrEqual(7.5) + expect((previewBox?.x ?? 0) + (previewBox?.width ?? 0)) + .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) + expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) + .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) + await page.keyboard.press('Escape') + + await composer.getByRole('checkbox', { name: 'Explicit install' }).click() + await composer.getByRole('textbox').fill('Keep the migration steps explicit.') + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ + value: { + answers: [{ + id: 'release', + selected: ['Explicit install'], + custom: 'Keep the migration steps explicit.', + }], + }, + }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 9e054e13cc..ab03603969 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -92,6 +92,8 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. +Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep it open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. + `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 96b9db405a..f02e0e6b22 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -92,6 +92,8 @@ kind: "package-library" `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 +显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 + `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index e30d671c36..218d86afc6 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,11 +1,12 @@ -import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' +import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' +import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -16,10 +17,37 @@ const INLINE_PREVIEW_WIDTH = 300 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 +interface DescribedAnchorProps { + 'aria-describedby'?: string | undefined +} + +type HoverCardProps = { + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ + anchor: ReactElement + openOnClick: true + describeAnchor?: boolean +} | { + anchor: ReactNode + openOnClick?: false | undefined + describeAnchor?: never +}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +}) + /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * Touch entry does not open a preview or intercept the anchor's action. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.anchor - the hover target (rendered in place inside a wrapper span). With + * openOnClick, pass one focusable element that forwards ARIA props. * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). @@ -30,30 +58,23 @@ const VIEWPORT_MARGIN = 8 * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. + * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, + * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. + * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible + * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard({ - anchor, content, openDelayMs = 500, disabled = false, grouped = false, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, -}: { - anchor: ReactNode - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' - widthAnchorRef?: RefObject -} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -})) { +export function HoverCard(props: HoverCardProps) { + const { + content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, + } = props + const openOnClick = props.openOnClick === true + const id = useId() const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) @@ -71,6 +92,7 @@ export function HoverCard({ const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) + const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -84,10 +106,12 @@ export function HoverCard({ const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() + setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) + useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -140,7 +164,7 @@ export function HoverCard({ }, []) useEffect(() => { - if (!open || (variant !== 'preview' && !inline)) return + if (!open || openOnClick || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -150,7 +174,27 @@ export function HoverCard({ } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, variant, inline, cancelClose, close]) + }, [open, openOnClick, variant, inline, cancelClose, close]) + + useEffect(() => { + if (!openOnClick || !open) return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' && event.key !== 'Tab') return + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + } + clearTimer() + cancelClose() + close() + } + document.addEventListener('keydown', dismiss, true) + return () => { document.removeEventListener('keydown', dismiss, true) } + }, [open, openOnClick, cancelClose, close]) + + useEffect(() => { + if (pinned && !openOnClick) close() + }, [close, openOnClick, pinned]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -238,18 +282,32 @@ export function HoverCard({ } const copyable = copyText !== undefined + const describedAnchor = props.openOnClick === true && describeAnchor + ? cloneElement(props.anchor, { + 'aria-describedby': open + ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') + : props.anchor.props['aria-describedby'], + }) + : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return clearTimer() cancelClose() + if (openOnClick && !disabled && !pinned) { + setPinned(true) + setPhase('open') + return + } close() } const card = open && pos !== null && (
{ - if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } + if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -304,13 +362,16 @@ export function HoverCard({ onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. - if (open) armClose() + // open, matching Menu's shape. Keyboard focus remains an independent + // open trigger after the pointer leaves. + if (open && !pinned + && rootRef.current?.contains(document.activeElement) !== true + && cardRef.current?.contains(document.activeElement) !== true) armClose() }} - onPointerDownCapture={dismissFromAnchor} + onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {anchor} + {describedAnchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 65fbad0a64..1059631263 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -225,6 +225,130 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('pins on click for touch access and keeps the card through pointer leave and blur', () => { + render( + <> +

Existing description

+ details} + content={
card body
} + /> + , + ) + const anchor = screen.getByRole('button', { name: 'details' }) + const wrapper = anchor.parentElement as HTMLElement + const touch = new Event('pointerover', { bubbles: true }) + Object.defineProperty(touch, 'pointerType', { value: 'touch' }) + fireEvent(wrapper, touch) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.getAttribute('data-pinned')).toBe('true') + expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + fireEvent.blur(anchor, { relatedTarget: document.body }) + expect(screen.getByText('card body')).toBeTruthy() + + fireEvent.pointerDown(card) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(document.body) + expect(screen.queryByText('card body')).toBeNull() + expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') + }) + + it('preserves a caller-owned accessible description while click-pinned', () => { + render(<> +

Stable description

+ details} + content={
card body
} + /> + ) + const anchor = screen.getByRole('button', { name: 'details' }) + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + fireEvent.pointerDown(document.body) + expect(screen.queryByText('card body')).toBeNull() + expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') + }) + + it('dismisses a click-open card on another click, Escape, or Tab', () => { + const ownerKey = vi.fn() + render(
+ details} content={
card body
} /> +
) + const anchor = screen.getByRole('button', { name: 'details' }) + + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.click(anchor) + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + fireEvent.keyDown(anchor, { key: 'a' }) + expect(screen.getByText('card body')).toBeTruthy() + ownerKey.mockClear() + expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) + expect(ownerKey).not.toHaveBeenCalled() + expect(screen.queryByText('card body')).toBeNull() + + fireEvent.click(anchor) + expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('drops a click pin when click opening is removed or the card is disabled', () => { + const pinnedCard = (disabled = false) => ( + details} content={
card body
} /> + ) + const hoverCard = details} content={
card body
} /> + const view = render(pinnedCard()) + const anchor = screen.getByRole('button', { name: 'details' }) + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(pinnedCard(true)) + expect(screen.queryByText('card body')).toBeNull() + + view.rerender(pinnedCard()) + fireEvent.click(anchor) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(hoverCard) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('removes click-pin dismissal listeners on unmount', () => { + const view = render( + details} content={
card body
} />, + ) + const registrations = vi.spyOn(document, 'addEventListener') + const removals = vi.spyOn(document, 'removeEventListener') + fireEvent.click(screen.getByRole('button', { name: 'details' })) + expect(screen.getByText('card body')).toBeTruthy() + + const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') + expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) + view.unmount() + + for (const [type, listener, options] of listeners) { + const removed = removals.mock.calls.find(([removedType, removedListener]) => + removedType === type && removedListener === listener) + expect(removed).toBeDefined() + expect(removed?.[2]).toBe(options) + } + expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) + fireEvent.pointerDown(document.body) + }) + it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -656,6 +780,22 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) +it('keeps an inline preview open when the pointer leaves its focused anchor', () => { + render(details} content={thumbnail} />) + const anchor = screen.getByRole('button', { name: 'details' }) + const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) + fireEvent.focus(anchor) + expect(screen.getByText('thumbnail')).toBeTruthy() + const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) + fireEvent.pointerLeave(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('thumbnail')).toBeTruthy() + activeElement.mockRestore() + fireEvent.blur(anchor, { relatedTarget: document.body }) + expect(screen.queryByText('thumbnail')).toBeNull() + matches.mockRestore() +}) + it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 78ff5697f1..b161fa2b89 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: cc19c065d0aeac1e - zh: 91c8cbe08b438e7f + en: 05caa3aa1846dc07 + zh: 8055a578d71d3f58 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index d57e359de7..410455e5a8 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps its title, navigation, and submission actions fixed while long detail and choices share an internal scroll region. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2145663976..9a20a9e5f4 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持标题、导航与提交动作固定,超长的详情与选项共享内部滚动区。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会打开可选择文本的完整说明。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 8de8c84028..5c6d8c013b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -60,6 +60,7 @@ align-items: center; gap: 4px; flex-shrink: 0; + margin-left: auto; } .waitButton { @@ -85,6 +86,7 @@ } .headingBlock { + flex: 1; min-width: 0; } @@ -97,14 +99,24 @@ } .title { - margin: 0; + flex-shrink: 0; + margin: 5px 24px 0; font-size: 16px; line-height: 22px; font-weight: 500; } +.cardMinimized .title { + display: -webkit-box; + margin: 0; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + .detail { - margin: 0 2px 8px; + flex-shrink: 0; + margin: 0 14px 8px; } .footerActions { @@ -175,29 +187,35 @@ padding: 4px 12px; } +.optionRow { + position: relative; + width: 100%; + min-height: 40px; + flex-shrink: 0; + border: 0.5px solid transparent; + border-radius: var(--dsw-radius-md); + background: transparent; + transition: background-color 120ms ease, border-color 120ms ease; +} + .option { display: flex; - /* flex-start, not center: with a wrapped description the indicator must - stay on the FIRST line (centering drifts it down the taller copy block). - The 8px padding makes a single-line row 40px exactly, so nothing reads - as top-heavy; .number/.checkbox re-center against the first line box. */ + /* flex-start keeps the indicator aligned with the first label line. */ align-items: flex-start; gap: 8px; width: 100%; + min-width: 0; min-height: 40px; - /* Rows are the scroll content, never the slack absorber: a shrinkable row - collapses to min-height while its wrapped copy keeps the taller - intrinsic height, and centered content then paints outside the row box — - over the title and the next row. Overflow belongs to .options. */ - flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; - border-radius: var(--dsw-radius-md); + border: 0; background: transparent; color: inherit; text-align: left; cursor: pointer; - transition: background-color 120ms ease, border-color 120ms ease; +} + +.optionWithDescription { + padding-right: 42px; } .optionSelected { @@ -205,7 +223,7 @@ } @media (hover: hover) { - .option:hover:not(:disabled) { + .optionRowInteractive:hover { background: var(--dsw-alias-interactive-bg-hover); } } @@ -305,10 +323,48 @@ } .description { + display: block; + min-width: 0; + overflow: hidden; color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; + font-size: 13px; + line-height: 20px; font-weight: 400; + text-overflow: ellipsis; + white-space: nowrap; +} + +.descriptionAction { + position: absolute; + right: 12px; + bottom: 6px; + width: 24px; + height: 24px; +} + +.descriptionButton { + display: grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 999px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.descriptionButton:hover, +.descriptionButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.descriptionPreview { + font-size: 13px; + line-height: 20px; } /* Read-only note standing in for a question the user skipped: it takes the @@ -477,6 +533,14 @@ padding: 8px 6px; } + .optionWithDescription { + padding-right: 36px; + } + + .descriptionAction { + right: 6px; + } + .footer { align-items: flex-end; padding: 0 10px; @@ -488,7 +552,7 @@ } @media (prefers-reduced-motion: reduce) { - .option, + .optionRow, .customRow { transition: none; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 8d3ad551ba..d447f96840 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,12 +1,12 @@ import { - useEffect, useMemo, useRef, useState, + useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + HoverCard, IconEditOutlineRegular, IconInfoOutlineRegular, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -157,6 +157,7 @@ type QuestionFlowProps = { pending: PendingQuestion } & Pick function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: QuestionFlowProps) { + const descriptionIdPrefix = useId() const questions = pending.questions // A read-only card built from a settled call's transcript: the same panel // over the recorded answers, with nothing left to submit. @@ -436,12 +437,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-labelledby={`question-${pending.key}-${String(index)}`} >
-
- {question.header !== undefined &&
{question.header}
} -

- {question.question} -

-
+ {(question.header !== undefined || minimized) && ( +
+ {question.header !== undefined &&
{question.header}
} + {minimized && ( +

+ {question.question} +

+ )} +
+ )}
{countdown !== undefined && card.waitState !== 'waiting' && card.waitState !== 'editing' && card.waitState !== 'continued' && ( @@ -486,12 +491,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {!minimized && ( <>
+

+ {question.question} +

{question.detail !== undefined && (
)} @@ -499,40 +508,73 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {(question.options ?? []).map((option, optionIndex) => { const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) + const hasDescription = option.description !== undefined && option.description.trim() !== '' + const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return ( - + + {hasDescription && ( + + + + + )} + content={} + /> + + )} +
) })} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index d45fd56b4a..26aa017cc9 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,7 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'option.details': '查看“{option}”的选项说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -63,6 +64,7 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', + 'option.details': 'Show details for “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts new file mode 100644 index 0000000000..1bb0bb553e --- /dev/null +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -0,0 +1,37 @@ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const stylesheet = readFileSync( + fileURLToPath(new URL('../src/client/QuestionComposer.module.css', import.meta.url)), + 'utf8', +).replace(/\/\*[\s\S]*?\*\//g, ' ') + +function declarations(selector: string): string[] { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +} + +describe('QuestionComposer compact long-copy layout', () => { + it('keeps the option description to one ellipsized line', () => { + expect(declarations('.description')).toEqual(expect.arrayContaining([ + 'display: block', + 'min-width: 0', + 'overflow: hidden', + 'font-size: 13px', + 'line-height: 20px', + 'text-overflow: ellipsis', + 'white-space: nowrap', + ])) + }) + + it('keeps the question and choices in the card scrollport', () => { + expect(declarations('.body')).toEqual(expect.arrayContaining([ + 'min-height: 0', + 'overflow-y: auto', + ])) + expect(declarations('.title')).toContain('flex-shrink: 0') + }) +}) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 8ece0da777..c5ce1dc757 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -24,7 +24,13 @@ afterEach(cleanup) const SID = 's1' as SessionId const seatOver = (dict: Record, common: Record): QuestionComposerProps['t'] => - (key => dict[key] ?? common[key] ?? key) + (key, params) => { + const template = dict[key] ?? common[key] ?? key + return params === undefined + ? template + : template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match) + } type SessionState = Parameters[0]>[0] type ConversationState = Parameters[0]>[0] @@ -257,6 +263,7 @@ describe('QuestionComposer', () => { const detail = screen.getByText('按当前空缺岗位的优先级选择。') const scrollRegion = detail.closest('[data-question-scroll]') expect(scrollRegion).toBeTruthy() + expect(scrollRegion?.contains(screen.getByRole('heading', { name: '选择候选人类型' }))).toBe(true) expect(scrollRegion?.contains(screen.getByRole('radio', { name: /工程落地型/ }))).toBe(true) expect(scrollRegion?.contains(screen.getByText('下一题').closest('button'))).toBe(false) fireEvent.click(screen.getByRole('radio', { name: /工程落地型/ })) @@ -293,6 +300,77 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('opens a full option description without choosing the option', () => { + const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' + const { carrier } = wait([{ + id: 'release', question: 'How should the release graph change?', + options: [ + { label: 'Explicit install', description }, + { label: 'Optional bundle' }, + { label: 'Empty description', description: '' }, + { label: 'Whitespace description', description: ' ' }, + ], + }]) + render() + + const option = screen.getByRole('radio', { name: 'Explicit install' }) + const details = screen.getByRole('button', { name: '查看“Explicit install”的选项说明' }) + expect(screen.getAllByRole('button', { name: /选项说明/ })).toHaveLength(1) + expect(screen.getAllByText(description)).toHaveLength(1) + const describedBy = details.getAttribute('aria-describedby') + expect(describedBy).not.toBeNull() + expect(document.getElementById(describedBy as string)?.textContent).toBe(description) + fireEvent.click(details) + + expect(option.getAttribute('aria-checked')).toBe('false') + expect(details.getAttribute('aria-describedby')).toBe(describedBy) + expect(screen.getAllByText(description)).toHaveLength(2) + fireEvent.pointerDown(document.body) + expect(screen.getAllByText(description)).toHaveLength(1) + }) + + it('resets scrolling and an open option description when the question changes', () => { + const firstDescription = 'First question detail that was pinned open.' + const secondDescription = 'Second question detail starts closed.' + const { carrier } = wait([ + { + id: 'first', question: 'First question?', + options: [{ label: 'Continue', description: firstDescription }], + }, + { + id: 'second', question: 'Second question?', + options: [{ label: 'Continue', description: secondDescription }], + }, + ]) + render() + + expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() + const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement + firstScroll.scrollTop = 80 + const firstDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) + fireEvent.click(firstDetails) + expect(screen.getAllByText(firstDescription)).toHaveLength(2) + const firstDescribedIds = (firstDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) + expect(firstDescribedIds.length).toBeGreaterThan(0) + expect(firstDescribedIds.every(id => document.getElementById(id) !== null)).toBe(true) + expect(firstDescribedIds.map(id => document.getElementById(id)?.textContent)).toContain(firstDescription) + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + + const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement + expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull() + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(secondScroll).not.toBe(firstScroll) + expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByText(firstDescription)).toBeNull() + expect(screen.getAllByText(secondDescription)).toHaveLength(1) + const secondDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) + const describedIds = (secondDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) + expect(describedIds.length).toBeGreaterThan(0) + expect(describedIds.every(id => document.getElementById(id) !== null)).toBe(true) + expect(describedIds.map(id => document.getElementById(id)?.textContent)).toContain(secondDescription) + expect(describedIds.map(id => document.getElementById(id)?.textContent)).not.toContain(firstDescription) + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', @@ -863,6 +941,7 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) + expect(screen.getByRole('button', { name: '查看“工程落地型”的选项说明' }).disabled).toBe(false) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index 2b62217f60..e5a7b73860 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -1,11 +1,13 @@ - region "Which color do you prefer?": - text: Pick one - - heading "Which color do you prefer?" [level=2] - button "Collapse the question card" [expanded] - button "Dismiss all questions" + - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer": Include accessibility notes - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/late-answered.expected.md b/snapshots/web/question-composer/late-answered.expected.md index a2e1961f8d..49ac47eeb4 100644 --- a/snapshots/web/question-composer/late-answered.expected.md +++ b/snapshots/web/question-composer/late-answered.expected.md @@ -29,14 +29,15 @@ - text: {{clock}} - button "Copy" - region "Which color do you prefer?": - - text: Pick one - - heading "Which color do you prefer?" [level=2] - - text: Answered + - text: Pick one Answered - button "Collapse the question card" [expanded] - button "Close the panel — reopen it from the tool call" + - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [disabled]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - button "Show details for “Blue”" - checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer" [disabled]: Answered after the timeout - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index e975922565..3188d73c5b 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -1,11 +1,13 @@ - region "Which color do you prefer?": - text: Pick one - - heading "Which color do you prefer?" [level=2] - button "Collapse the question card" [expanded] - button "Dismiss all questions" + - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. + - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. + - button "Show details for “Green”" - textbox "Type your answer" - button "Previous question" [disabled] - text: 1 / 1 From 1cbf50d03e536ce322873bfa17bdbe992842184d Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 22:31:04 +0800 Subject: [PATCH 39/73] test(web): stress long question layouts --- apps/web/tests/question-composer.e2e.ts | 49 ++++++++++++++++++++----- 1 file changed, 40 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 70fa1fd1b6..73b77e9fc5 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -559,7 +559,8 @@ describe('web e2e: resident question composer round trip', () => { expect(agent).toBeDefined() const question = 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?' const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' - const description = 'Remove the experimental client from every shipped template and require users to install it explicitly in the profile where they need it.' + const description = Array.from({ length: 48 }, (_, index) => + `Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ') const controller = new AbortController() const asked = scaffold.ctx.userQuestions.ask({ agent: agent as NonNullable, @@ -584,16 +585,18 @@ describe('web e2e: resident question composer round trip', () => { let bodyCompleted = false try { await composer.waitFor({ timeout: 30_000 }) - // Browser zoom reduces the CSS-pixel viewport. These dimensions model - // the same 640x420 window at 100%, 125%, and 150% without relying on a - // browser-global zoom preference that Playwright cannot isolate per page. + // Cover common desktop sizes plus the CSS-pixel equivalents of a + // progressively zoomed 640x420 window without changing browser-global zoom. + const scrollStates: boolean[] = [] for (const viewport of [ + { width: 1440, height: 900 }, + { width: 900, height: 600 }, { width: 640, height: 420 }, { width: 512, height: 336 }, { width: 427, height: 280 }, ]) { await page.setViewportSize(viewport) - await expect.poll(() => composer.evaluate((card) => { + const readMetrics = () => composer.evaluate((card) => { const scroll = card.querySelector('[data-question-scroll]') const title = card.querySelector('h2') const submit = [...card.querySelectorAll('button')] @@ -605,17 +608,20 @@ describe('web e2e: resident question composer round trip', () => { scrolls: scroll.scrollHeight > scroll.clientHeight, titleInScrollport: scroll.contains(title), } - })).toEqual({ - footerFits: true, - scrolls: true, - titleInScrollport: true, }) + await expect.poll(readMetrics).not.toBeNull() + const metrics = await readMetrics() + expect(metrics?.footerFits).toBe(true) + expect(metrics?.titleInScrollport).toBe(true) + scrollStates.push(metrics?.scrolls ?? false) const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { const style = getComputedStyle(title) return title.getBoundingClientRect().height / parseFloat(style.lineHeight) }) expect(questionLines).toBeGreaterThan(2) } + expect(scrollStates).toContain(false) + expect(scrollStates).toContain(true) const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) @@ -632,6 +638,13 @@ describe('web e2e: resident question composer round trip', () => { expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) const preview = page.locator('[data-pinned="true"]') await preview.getByText(description, { exact: true }).waitFor() + expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).evaluate((item) => { + const style = getComputedStyle(item) + return { + ellipsized: item.scrollWidth > item.clientWidth, + lines: item.clientHeight / parseFloat(style.lineHeight), + } + })).toEqual({ ellipsized: true, lines: 1 }) const previewBox = await preview.boundingBox() const constrainedViewport = page.viewportSize() expect(constrainedViewport).not.toBeNull() @@ -642,6 +655,24 @@ describe('web e2e: resident question composer round trip', () => { .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) + expect(await preview.evaluate(card => ({ + overflowY: getComputedStyle(card).overflowY, + scrolls: card.scrollHeight > card.clientHeight, + }))).toEqual({ overflowY: 'auto', scrolls: true }) + expect(await preview.getByText(description, { exact: true }).evaluate((text) => { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(text) + selection?.removeAllRanges() + selection?.addRange(range) + const selectedLength = selection?.toString().length ?? 0 + selection?.removeAllRanges() + return selectedLength + })).toBe(description.length) + expect(await preview.evaluate((card) => { + card.scrollTop = card.scrollHeight + return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 + })).toBe(true) await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() From 5b96747630b7d82f4888ba71f377e999394a08e7 Mon Sep 17 00:00:00 2001 From: yudshj Date: Thu, 1 Oct 2026 23:09:03 +0800 Subject: [PATCH 40/73] fix(ui-user-questions): refine option detail callouts --- apps/web/tests/question-composer.e2e.ts | 32 +++++- .../ui-primitives/src/HoverCard.module.css | 50 ++++++++++ .../client/ui-primitives/src/HoverCard.tsx | 98 ++++++++++++++----- .../tests/hover-card.client.spec.tsx | 41 +++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 3 + .../src/client/QuestionComposer.tsx | 2 +- 9 files changed, 204 insertions(+), 30 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 73b77e9fc5..d0c71ab3ab 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -633,6 +633,15 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await info.getAttribute('aria-describedby') expect(descriptionId).not.toBeNull() expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).textContent()).toBe(description) + await info.hover() + const hoveredPreview = page.locator('[data-placement]') + await hoveredPreview.getByText(description, { exact: true }).waitFor() + const hoveredBox = await hoveredPreview.boundingBox() + expect(hoveredBox).not.toBeNull() + await page.mouse.move((hoveredBox?.x ?? 0) + 24, (hoveredBox?.y ?? 0) + 24) + await page.waitForTimeout(250) + expect(await hoveredPreview.isVisible()).toBe(true) + await info.click() expect(await composer.getByRole('checkbox', { name: 'Explicit install' }).getAttribute('aria-checked')).toBe('false') expect(await info.getAttribute('aria-describedby')).toBe(descriptionId) @@ -655,10 +664,29 @@ describe('web e2e: resident question composer round trip', () => { .toBeLessThanOrEqual((constrainedViewport?.width ?? 0) - 7.5) expect((previewBox?.y ?? 0) + (previewBox?.height ?? 0)) .toBeLessThanOrEqual((constrainedViewport?.height ?? 0) - 7.5) - expect(await preview.evaluate(card => ({ + expect(previewBox?.height).toBeLessThanOrEqual(240) + const scrollport = preview.locator('[data-hover-card-scroll]') + expect(await scrollport.evaluate(card => ({ overflowY: getComputedStyle(card).overflowY, scrolls: card.scrollHeight > card.clientHeight, }))).toEqual({ overflowY: 'auto', scrolls: true }) + const caretMetrics = await preview.evaluate((card, infoButton) => { + const caret = card.querySelector('[data-hover-card-caret]') + if (!(infoButton instanceof HTMLElement) || caret === null) return null + const anchorBox = infoButton.getBoundingClientRect() + const caretBox = caret.getBoundingClientRect() + const placement = card.dataset.placement + return { + horizontalDelta: Math.abs((anchorBox.left + anchorBox.width / 2) - (caretBox.left + caretBox.width / 2)), + verticalGap: placement === 'above' + ? anchorBox.top - caretBox.bottom + : caretBox.top - anchorBox.bottom, + } + }, await info.elementHandle()) + expect(caretMetrics).not.toBeNull() + expect(caretMetrics?.horizontalDelta).toBeLessThan(1) + expect(caretMetrics?.verticalGap).toBeGreaterThanOrEqual(0) + expect(caretMetrics?.verticalGap).toBeLessThanOrEqual(3) expect(await preview.getByText(description, { exact: true }).evaluate((text) => { const selection = window.getSelection() const range = document.createRange() @@ -669,7 +697,7 @@ describe('web e2e: resident question composer round trip', () => { selection?.removeAllRanges() return selectedLength })).toBe(description.length) - expect(await preview.evaluate((card) => { + expect(await scrollport.evaluate((card) => { card.scrollTop = card.scrollHeight return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 })).toBe(true) diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 50c62fa828..0542763a8a 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,3 +88,53 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } + +.callout { + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + isolation: isolate; + padding: 0; + border: 0; + background: transparent; + box-shadow: none; +} + +.calloutSurface { + position: relative; + z-index: 1; + padding: 4px; + overflow: hidden; + border: 0; + border-radius: var(--dsw-radius-xl); + background: var(--dsw-specific-bubble); + box-shadow: var(--dsw-elevation-panel); +} + +.calloutBody { + box-sizing: border-box; + max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); + overflow-y: auto; + overscroll-behavior: contain; + padding: 4px 8px; + border-radius: var(--dsw-radius-lg); + color: var(--dsw-alias-label-primary); +} + +.calloutCaret { + position: absolute; + z-index: 2; + left: var(--dsh-hover-card-anchor-x); + width: 10px; + height: 10px; + background: var(--dsw-specific-bubble); + transform: translateX(-50%) rotate(45deg); + pointer-events: none; +} + +.callout[data-placement='below'] .calloutCaret { + top: -4px; +} + +.callout[data-placement='above'] .calloutCaret { + bottom: -4px; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 218d86afc6..6f6d7288e7 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -14,6 +14,10 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 +const CALLOUT_MAX_HEIGHT = 240 +const CALLOUT_WIDTH = 300 +const CALLOUT_ARROW_EDGE_INSET = 16 +const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 @@ -28,7 +32,7 @@ type HoverCardProps = { openDelayMs?: number disabled?: boolean grouped?: boolean - variant?: 'compact' | 'preview' + variant?: 'compact' | 'preview' | 'callout' widthAnchorRef?: RefObject } & ({ anchor: ReactElement @@ -51,8 +55,8 @@ type HoverCardProps = { * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. + * @param props.variant - compact card beside the anchor; a preview with container-relative + * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. @@ -74,9 +78,12 @@ export function HoverCard(props: HoverCardProps) { copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, } = props const openOnClick = props.openOnClick === true + const callout = variant === 'callout' + const anchored = inline || callout const id = useId() const rootRef = useRef(null) const cardRef = useRef(null) + const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) @@ -86,7 +93,14 @@ export function HoverCard(props: HoverCardProps) { const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const [pos, setPos] = useState<{ + left: number + top: number + width?: number + maxHeight?: number + anchorX?: number + placement?: 'above' | 'below' + } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) @@ -164,7 +178,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !inline)) return + if (!open || openOnClick || (variant !== 'preview' && !anchored)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -174,7 +188,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, inline, cancelClose, close]) + }, [open, openOnClick, variant, anchored, cancelClose, close]) useEffect(() => { if (!openOnClick || !open) return @@ -222,19 +236,34 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (inline) { - const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) + if (anchored) { + const height = callout + ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) + : Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, + window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = onTop ? above : below + const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, + onTop ? above : below) + const anchorCenter = (r.left + r.right) / 2 + const idealLeft = callout ? anchorCenter - width / 2 : r.left + const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, + window.innerWidth - width - VIEWPORT_MARGIN)) + const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) setPos({ - left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), + left, top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, + ...(callout + ? { + anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), + placement: onTop ? 'above' as const : 'below' as const, + } + : {}), }) return } @@ -242,8 +271,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -253,19 +282,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, inline]) + }, [open, variant, widthAnchorRef, positioned, anchored, callout]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || inline) return + if (!open || pos === null || variant === 'preview' || anchored) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, inline]) + }, [open, pos, variant, anchored]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -301,16 +330,23 @@ export function HoverCard(props: HoverCardProps) { } close() } + const cardContent = copied ? : content const card = open && pos !== null && (
{ + cancelClose() + setPhase('open') + }} + onPointerLeave={() => { + if (!pinned + && rootRef.current?.contains(document.activeElement) !== true + && cardRef.current?.contains(document.activeElement) !== true) armClose() + }} > - {copied ? : content} + {callout + ? ( + <> +
) return ( { + className={clsx(css.root, anchored && css.inline)} + onFocus={anchored ? (event) => { if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={inline ? (event) => { + onBlur={anchored ? (event) => { if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 1059631263..fa51eef33a 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' + variant?: 'compact' | 'preview' | 'callout' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,6 +165,27 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) + it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { + const { wrapper } = mount({ variant: 'callout' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement + const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement + expect(card.style.width).toBe('300px') + expect(card.style.left).toBe('8px') + expect(card.style.top).toBe('82px') + expect(card.dataset.placement).toBe('below') + expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') + expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') + + Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('452px') + expect(card.dataset.placement).toBe('above') + expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') + }) + it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -254,6 +275,9 @@ describe('HoverCard', () => { fireEvent.blur(anchor, { relatedTarget: document.body }) expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerDown(card) expect(screen.getByText('card body')).toBeTruthy() fireEvent.pointerDown(document.body) @@ -425,6 +449,21 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('keeps the card open while its own pointer is inside and closes after leaving it', () => { + const { wrapper } = mount({ variant: 'callout' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) + fireEvent.pointerEnter(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('card body')).toBeNull() + }) + it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index b161fa2b89..5d9e49a032 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 05caa3aa1846dc07 - zh: 8055a578d71d3f58 + en: 579cf44ad03380ee + zh: 5fe535a78d8ed0b8 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 410455e5a8..f4b7b4abbc 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 9a20a9e5f4..73ab6e76a5 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会打开可选择文本的完整说明。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 5c6d8c013b..996272b543 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -363,8 +363,11 @@ } .descriptionPreview { + display: block; font-size: 13px; line-height: 20px; + overflow-wrap: anywhere; + white-space: pre-wrap; } /* Read-only note standing in for a question the user skipped: it takes the diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index d447f96840..c813d9b17b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -556,7 +556,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {hasDescription && ( Date: Thu, 1 Oct 2026 23:39:42 +0800 Subject: [PATCH 41/73] test(web): verify question callout in dark mode --- apps/web/tests/question-composer.e2e.ts | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index d0c71ab3ab..1bd0552f07 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -701,6 +701,44 @@ describe('web e2e: resident question composer round trip', () => { card.scrollTop = card.scrollHeight return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 })).toBe(true) + const palette: string[] = [] + const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') + try { + for (const dark of [false, true]) { + await page.evaluate((value) => { document.body.toggleAttribute('data-ds-dark-theme', value) }, dark) + const colors = await preview.evaluate((card) => { + const surface = card.querySelector('[class*="calloutSurface"]') + const body = card.querySelector('[data-hover-card-scroll]') + const caret = card.querySelector('[data-hover-card-caret]') + if (surface === null || body === null || caret === null) return null + return { + surface: getComputedStyle(surface).backgroundColor, + text: getComputedStyle(body).color, + caret: getComputedStyle(caret).backgroundColor, + } + }) + expect(colors).not.toBeNull() + expect(colors?.surface).toBe(colors?.caret) + if (colors === null) throw new Error('Callout colors are unavailable') + const channels = (color: string) => color.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [] + const luminance = (color: string) => channels(color).reduce((total, channel, index) => { + const normalized = channel / 255 + const linear = normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4 + return total + linear * [0.2126, 0.7152, 0.0722][index]! + }, 0) + const foreground = luminance(colors.text) + const background = luminance(colors.surface) + expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) + .toBeGreaterThanOrEqual(4.5) + palette.push(colors.surface) + } + expect(palette[0]).not.toBe(palette[1]) + } finally { + await page.evaluate((previous) => { + if (previous === null) document.body.removeAttribute('data-ds-dark-theme') + else document.body.setAttribute('data-ds-dark-theme', previous) + }, originalDarkTheme) + } await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() From 935a891a45ad1770ac239f84acc38d9fdc9302d6 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 2 Oct 2026 00:35:59 +0800 Subject: [PATCH 42/73] fix(ui-user-questions): preserve inline short option descriptions --- apps/web/tests/question-composer.e2e.ts | 77 ++++++++++++++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 12 ++- .../src/client/QuestionComposer.tsx | 76 ++++++++++++++++-- .../question-composer-styles.client.spec.ts | 8 ++ .../question-composer/composed.expected.md | 2 - .../late-answered.expected.md | 2 - .../web/question-composer/ui.expected.md | 2 - 10 files changed, 163 insertions(+), 24 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 1bd0552f07..3df60287d9 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -348,8 +348,8 @@ describe('web e2e: resident question composer round trip', () => { // composer at seat heights that force the cap, then restored for the answer // gesture below. Replay only: record mode must reach the recording write // below, not abort on layout. + const originalViewport = page.viewportSize() ?? { width: 1680, height: 1000 } if (MODE !== 'record') { - const original = page.viewportSize() ?? { width: 1680, height: 1000 } const scrollStates: boolean[] = [] for (const height of [520, 440, 380]) { await page.setViewportSize({ width: 640, height }) @@ -385,9 +385,10 @@ describe('web e2e: resident question composer round trip', () => { expect(squeeze.spill).toBeLessThan(0.6) } expect(scrollStates).toContain(true) - await page.setViewportSize(original) + await page.setViewportSize(originalViewport) } + await page.setViewportSize({ width: 640, height: 520 }) const blue = composer.getByRole('checkbox', { name: 'Blue' }) const blueDetail = 'A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.' const blueInfo = composer.getByRole('button', { name: 'Show details for “Blue”' }) @@ -400,6 +401,7 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(2) await composer.getByRole('heading', { name: 'Which color do you prefer?' }).click() await expect.poll(() => page.getByText(blueDetail, { exact: true }).count()).toBe(1) + await page.setViewportSize(originalViewport) // Multi-line custom answer: the field is a textarea whose hidden mirror // owns the box height, so a soft-wrapped or line-broken draft GROWS the @@ -551,6 +553,68 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) + it.skipIf(MODE === 'record')('keeps short option explanations beside their labels', async () => { + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'reasoning', + header: '确认思考等级', + question: '选择 HF provider 的思考等级?', + options: [ + { label: '高(high)', description: '倾向更充分的推理,通常更慢、消耗更多 token,需模型支持。' }, + { label: '中(medium)', description: '平衡推理深度与速度;需模型支持。' }, + { label: '低(low)', description: '优先速度与较低 token 消耗;需模型支持。' }, + { label: '先查看当前配置', description: '先确认 HF provider 的模型及其支持的思考参数,再决定。' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + const composer = page.locator('[data-question-key]') + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let completed = false + try { + await page.setViewportSize({ width: 1328, height: 720 }) + await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() + expect(await composer.getByRole('heading').evaluate((heading) => { + const style = getComputedStyle(heading) + return heading.getBoundingClientRect().height / parseFloat(style.lineHeight) + })).toBe(1) + const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { + const label = row.querySelector('[data-option-line] > span:first-child') + const description = row.querySelector('[data-option-description]') + if (label === null || description === null) return null + const style = getComputedStyle(description) + return { + sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1, + fontSize: style.fontSize, + lineHeight: style.lineHeight, + hasDetails: row.querySelector('[data-option-details]') !== null, + } + })) + expect(rows).toHaveLength(4) + expect(rows).toEqual(Array.from({ length: 4 }, () => ({ + sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, + }))) + await composer.getByRole('radio', { name: '中(medium)' }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'reasoning', selected: ['中(medium)'] }] } }) + completed = true + } finally { + controller.abort() + const cleanup = await Promise.allSettled([outcome, page.setViewportSize(original)]) + if (completed) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 60_000) + it.skipIf(MODE === 'record')('keeps long questions answerable in short and zoomed viewports', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-question-long-copy')) const sessionId = answeredSession @@ -573,8 +637,8 @@ describe('web e2e: resident question composer round trip', () => { multiSelect: true, options: [ { label: 'Explicit install', description }, - { label: 'Keep the default edge', description: 'Retain the current dependency and exempt it from the release graph invariant.' }, - { label: 'Move the package', description: 'Rename the package outside the experimental namespace without changing how profiles acquire it.' }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + { label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' }, ], }], }) @@ -614,6 +678,11 @@ describe('web e2e: resident question composer round trip', () => { expect(metrics?.footerFits).toBe(true) expect(metrics?.titleInScrollport).toBe(true) scrollStates.push(metrics?.scrolls ?? false) + expect(await composer.getByRole('button', { name: 'Show details for “Keep the default edge”' }).count()).toBe(0) + if (viewport.width === 1440 || viewport.width === 427) { + expect(await composer.getByRole('button', { name: 'Show details for “Move the package”' }).count()) + .toBe(viewport.width === 427 ? 1 : 0) + } const questionLines = await composer.getByRole('heading', { name: question }).evaluate((title) => { const style = getComputedStyle(title) return title.getBoundingClientRect().height / parseFloat(style.lineHeight) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 5d9e49a032..9ba61f789c 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 579cf44ad03380ee - zh: 5fe535a78d8ed0b8 + en: 1087967ef7402ed0 + zh: e7edbbb967b98af4 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index f4b7b4abbc..eb3bf89735 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. Each option description occupies one ellipsized line; its information action opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; only descriptions truncated there show an information action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 73ab6e76a5..d60ebfc784 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。每段选项说明只占一行并在溢出时显示省略号;悬停、键盘聚焦或点击信息按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时显示信息按钮,悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 996272b543..994b8d3102 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -188,6 +188,7 @@ } .optionRow { + --description-action-space: 30px; position: relative; width: 100%; min-height: 40px; @@ -215,7 +216,7 @@ } .optionWithDescription { - padding-right: 42px; + padding-right: calc(12px + var(--description-action-space)); } .optionSelected { @@ -334,6 +335,13 @@ white-space: nowrap; } +.descriptionInline { + color: var(--dsw-alias-label-tertiary); + font-size: 14px; + line-height: 24px; + font-weight: 400; +} + .descriptionAction { position: absolute; right: 12px; @@ -537,7 +545,7 @@ } .optionWithDescription { - padding-right: 36px; + padding-right: calc(6px + var(--description-action-space)); } .descriptionAction { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index c813d9b17b..baa439b40e 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,5 +1,5 @@ import { - useEffect, useId, useMemo, useRef, useState, + useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' @@ -242,6 +242,60 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + const [descriptionModes, setDescriptionModes] = useState | null>(null) + + useLayoutEffect(() => { + const surface = answerSurface.current + if (surface === null || minimized) return + const rows = [...surface.querySelectorAll('[data-option-row]')] + const measure = (): void => { + const next = new Map() + for (const row of rows) { + const description = row.querySelector('[data-option-description]') + if (description === null) continue + const optionIndex = Number(row.dataset.optionIndex) + const copy = row.querySelector('[data-option-copy]') + const line = row.querySelector('[data-option-line]') + if (copy === null || line === null || copy.clientWidth === 0) { + next.set(optionIndex, 'truncated') + continue + } + const actionSpace = parseFloat(getComputedStyle(row).getPropertyValue('--description-action-space')) + // The action's reserved width must not make a fitting description appear truncated. + const fullWidth = copy.clientWidth + (row.querySelector('[data-option-details]') === null ? 0 : actionSpace) + const probe = line.cloneNode(true) as HTMLElement + probe.querySelector('[data-option-description]')?.remove() + const probeDescription = description.cloneNode(true) as HTMLElement + probeDescription.removeAttribute('id') + probeDescription.className = clsx(css.descriptionInline) + probe.append(probeDescription) + probe.style.cssText = 'position:fixed;top:-10000px;left:0;width:max-content;flex-wrap:nowrap;visibility:hidden;pointer-events:none' + document.body.append(probe) + const inlineWidth = probe.getBoundingClientRect().width + probeDescription.className = clsx(css.description) + probeDescription.style.width = 'max-content' + const blockWidth = probeDescription.getBoundingClientRect().width + probe.remove() + next.set(optionIndex, inlineWidth <= fullWidth + 1 ? 'inline' + : blockWidth <= fullWidth + 1 ? 'block' : 'truncated') + } + setDescriptionModes(current => current !== null && current.size === next.size + && [...next].every(([optionIndex, mode]) => current.get(optionIndex) === mode) ? current : next) + } + measure() + const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + for (const row of rows) { + observer?.observe(row) + const copy = row.querySelector('[data-option-copy]') + if (copy !== null) observer?.observe(copy) + } + const fonts = 'fonts' in document ? document.fonts : null + fonts?.addEventListener('loadingdone', measure) + return () => { + observer?.disconnect() + fonts?.removeEventListener('loadingdone', measure) + } + }, [index, minimized, question.options]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -509,20 +563,23 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' + const descriptionMode = descriptionModes?.get(optionIndex) ?? 'truncated' + const showDetails = hasDescription && descriptionMode === 'truncated' const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- {hasDescription && ( - + {showDetails && ( + { + it('keeps fitting descriptions in the original option-line typography', () => { + expect(declarations('.descriptionInline')).toEqual(expect.arrayContaining([ + 'font-size: 14px', + 'line-height: 24px', + 'font-weight: 400', + ])) + }) + it('keeps the option description to one ellipsized line', () => { expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', diff --git a/snapshots/web/question-composer/composed.expected.md b/snapshots/web/question-composer/composed.expected.md index e5a7b73860..830627029d 100644 --- a/snapshots/web/question-composer/composed.expected.md +++ b/snapshots/web/question-composer/composed.expected.md @@ -5,9 +5,7 @@ - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [checked]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer": Include accessibility notes - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/late-answered.expected.md b/snapshots/web/question-composer/late-answered.expected.md index 49ac47eeb4..91f1ca73c2 100644 --- a/snapshots/web/question-composer/late-answered.expected.md +++ b/snapshots/web/question-composer/late-answered.expected.md @@ -35,9 +35,7 @@ - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue" [disabled]: Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - button "Show details for “Blue”" - checkbox "Green" [checked] [disabled]: Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer" [disabled]: Answered after the timeout - button "Previous question" [disabled] - text: 1 / 1 diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 3188d73c5b..99c6c0e4bb 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -5,9 +5,7 @@ - heading "Which color do you prefer?" [level=2] - group: - checkbox "Blue": Blue A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards. - - button "Show details for “Blue”" - checkbox "Green": Green A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions. - - button "Show details for “Green”" - textbox "Type your answer" - button "Previous question" [disabled] - text: 1 / 1 From 9d44ecefb74430651567e677a822eabac492cd01 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 2 Oct 2026 01:12:36 +0800 Subject: [PATCH 43/73] style(ui-user-questions): fade truncated option details --- apps/web/tests/question-composer.e2e.ts | 23 ++++++++++++------- .../client/ui-user-questions/README.i18n.yaml | 4 ++-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 15 +++++++++++- .../src/client/QuestionComposer.tsx | 10 +++++--- .../question-composer-styles.client.spec.ts | 7 ++++-- 7 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 3df60287d9..f16855916c 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -68,9 +68,8 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: }) } -// The options carry long descriptions on purpose: the squeeze assertion below -// verifies that each description remains a single ellipsized line while the -// complete text stays reachable through its information action. +// The options carry descriptions on purpose: the squeeze assertion below +// verifies that each description remains on one line in the card scrollport. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' function isRecord(value: unknown): value is Record { @@ -366,7 +365,6 @@ describe('web e2e: resident question composer round trip', () => { return { rows: rows.length, spill: Math.max(...spill), - ellipsized: descriptions.filter(item => item.scrollWidth > item.clientWidth).length, descriptionLines: Math.max(...descriptions.map((item) => { const style = getComputedStyle(item) return item.clientHeight / parseFloat(style.lineHeight) @@ -376,7 +374,6 @@ describe('web e2e: resident question composer round trip', () => { } }) expect(squeeze.rows).toBeGreaterThan(0) - expect(squeeze.ellipsized).toBeGreaterThan(0) expect(squeeze.descriptionLines).toBeLessThan(1.1) scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -595,11 +592,12 @@ describe('web e2e: resident question composer round trip', () => { fontSize: style.fontSize, lineHeight: style.lineHeight, hasDetails: row.querySelector('[data-option-details]') !== null, + maskImage: style.maskImage, } })) expect(rows).toHaveLength(4) expect(rows).toEqual(Array.from({ length: 4 }, () => ({ - sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, + sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none', }))) await composer.getByRole('radio', { name: '中(medium)' }).click() await composer.getByRole('button', { name: 'Submit', exact: true }).click() @@ -719,10 +717,19 @@ describe('web e2e: resident question composer round trip', () => { expect(await page.locator(`[id=${JSON.stringify(descriptionId)}]`).evaluate((item) => { const style = getComputedStyle(item) return { - ellipsized: item.scrollWidth > item.clientWidth, lines: item.clientHeight / parseFloat(style.lineHeight), + textOverflow: style.textOverflow, + fades: style.maskImage !== 'none', } - })).toEqual({ ellipsized: true, lines: 1 }) + })).toEqual({ lines: 1, textOverflow: 'clip', fades: true }) + expect(await info.evaluate((button) => { + const icon = button.querySelector('svg') + const ring = icon?.parentElement + return { + dots: icon?.querySelectorAll('path').length, + ring: ring instanceof HTMLElement ? getComputedStyle(ring).borderStyle : undefined, + } + })).toEqual({ dots: 3, ring: 'solid' }) const previewBox = await preview.boundingBox() const constrainedViewport = page.viewportSize() expect(constrainedViewport).not.toBeNull() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 9ba61f789c..89fe1a97f6 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 1087967ef7402ed0 - zh: e7edbbb967b98af4 + en: 9a8f58ca1fcd87db + zh: be2587dc4bd54aa6 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index eb3bf89735..8f8fa0e12c 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; only descriptions truncated there show an information action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index d60ebfc784..0c0ed83103 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时显示信息按钮,悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 994b8d3102..9d11ea12da 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -331,10 +331,14 @@ font-size: 13px; line-height: 20px; font-weight: 400; - text-overflow: ellipsis; + text-overflow: clip; white-space: nowrap; } +.descriptionTruncated { + mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); +} + .descriptionInline { color: var(--dsw-alias-label-tertiary); font-size: 14px; @@ -370,6 +374,15 @@ color: var(--dsw-alias-label-primary); } +.descriptionMoreIcon { + display: grid; + place-items: center; + width: 16px; + height: 16px; + border: 1px solid currentColor; + border-radius: 50%; +} + .descriptionPreview { display: block; font-size: 13px; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index baa439b40e..7fbe2e74d6 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - HoverCard, IconEditOutlineRegular, IconInfoOutlineRegular, MarkdownText, + HoverCard, IconEditOutlineRegular, IconEllipsisOutlineRegular, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -609,7 +609,11 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi )} {hasDescription && descriptionMode !== 'inline' && ( - {option.description} + {option.description} )} @@ -627,7 +631,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={t('option.details', { option: display.label })} aria-describedby={descriptionId} > - + )} content={} diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 13c942868b..d55618b7e5 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -23,16 +23,19 @@ describe('QuestionComposer compact long-copy layout', () => { ])) }) - it('keeps the option description to one ellipsized line', () => { + it('keeps the option description to one clipped line and fades only truncated text', () => { expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', 'min-width: 0', 'overflow: hidden', 'font-size: 13px', 'line-height: 20px', - 'text-overflow: ellipsis', + 'text-overflow: clip', 'white-space: nowrap', ])) + expect(declarations('.descriptionTruncated')).toContain( + 'mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent)', + ) }) it('keeps the question and choices in the card scrollport', () => { From fbc2550f52b5799bed9c4d39cb098fbca2efd20a Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 13:45:34 +0800 Subject: [PATCH 44/73] fix(ui-user-questions): preview long questions in three lines --- apps/web/tests/question-composer.e2e.ts | 35 ++++++++++++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 28 +++++++++++++ .../src/client/QuestionComposer.tsx | 39 +++++++++++++++++-- .../ui-user-questions/src/client/locales.ts | 2 + .../user-questions-composer.client.spec.tsx | 27 +++++++++++++ 8 files changed, 130 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f16855916c..96e88fb512 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -582,6 +582,8 @@ describe('web e2e: resident question composer round trip', () => { const style = getComputedStyle(heading) return heading.getBoundingClientRect().height / parseFloat(style.lineHeight) })).toBe(1) + expect(await composer.getByRole('button', { name: 'Show full question' }).count()).toBe(0) + expect(await composer.getByRole('heading').evaluate(heading => getComputedStyle(heading).maskImage)).toBe('none') const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -619,7 +621,7 @@ describe('web e2e: resident question composer round trip', () => { expect(sessionId).toBeDefined() const agent = scaffold.ctx.agents.get(sessionId as SessionId) expect(agent).toBeDefined() - const question = 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?' + const question = Array.from({ length: 8 }, () => 'The release gate reports that the installed application graph still contains an experimental package through a default dependency. Which approach should we use to preserve explicit installation while keeping the supported application graph valid?').join(' ') const detail = 'Review the shipped profile, package ownership, upgrade impact, and the path users follow before choosing. The answer must preserve the existing release invariant, avoid a hidden compatibility path, and remain understandable to people who did not participate in the implementation discussion.' const description = Array.from({ length: 48 }, (_, index) => `Migration step ${String(index + 1)} removes the experimental client from shipped templates, requires an explicit profile install, names the affected users, and records the cross-platform validation before rollout.`).join(' ') @@ -686,10 +688,41 @@ describe('web e2e: resident question composer round trip', () => { return title.getBoundingClientRect().height / parseFloat(style.lineHeight) }) expect(questionLines).toBeGreaterThan(2) + expect(questionLines).toBeLessThanOrEqual(3.05) + const truncated = await composer.getByRole('heading', { name: question }).evaluate(title => + title.scrollHeight > title.clientHeight + 1) + await expect.poll(() => composer.getByRole('button', { name: 'Show full question' }).count()) + .toBe(truncated ? 1 : 0) } expect(scrollStates).toContain(false) expect(scrollStates).toContain(true) + const questionInfo = composer.getByRole('button', { name: 'Show full question' }) + await questionInfo.scrollIntoViewIfNeeded() + const questionTitle = composer.getByRole('heading', { name: question }) + expect(await questionInfo.getAttribute('aria-describedby')).toBe(await questionTitle.getAttribute('id')) + const questionMask = await questionTitle.evaluate(title => getComputedStyle(title).maskImage) + expect(questionMask).toContain('linear-gradient') + await page.keyboard.press('Tab') + await questionInfo.focus() + const questionPreview = page.locator('[data-placement]') + await questionPreview.getByText(question, { exact: true }).waitFor() + expect(await questionPreview.evaluate(node => node.closest('[data-question-scroll]') === null)).toBe(true) + await page.keyboard.press('Escape') + await expect.poll(() => questionPreview.count()).toBe(0) + await questionInfo.click() + await questionPreview.getByText(question, { exact: true }).waitFor() + const previewGeometry = await questionPreview.evaluate((node) => { + const rect = node.getBoundingClientRect() + const scroll = node.querySelector('[data-hover-card-scroll]') + return { fits: rect.left >= 0 && rect.right <= innerWidth && rect.top >= 0 && rect.bottom <= innerHeight, + scrolls: scroll !== null && scroll.scrollHeight > scroll.clientHeight } + }) + expect(previewGeometry.fits).toBe(true) + expect(previewGeometry.scrolls).toBe(true) + await composer.getByRole('heading', { name: question }).click() + await expect.poll(() => questionPreview.count()).toBe(0) + const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) await detailText.scrollIntoViewIfNeeded() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 89fe1a97f6..8608006f15 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 9a8f58ca1fcd87db - zh: be2587dc4bd54aa6 + en: 02c47a01ae6fd4f4 + zh: 7adb34361d02bc9d /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 8f8fa0e12c..83e50f9533 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 0c0ed83103..f92fe5cf37 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 9d11ea12da..89852a34a6 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -106,6 +106,34 @@ font-weight: 500; } +/* Keep the full question accessible while only its first three lines are visible. */ +.questionTitle { + position: relative; + flex-shrink: 0; + margin: 5px 24px 0; +} + +.titlePreview { + margin: 0; + max-height: 3lh; + overflow: hidden; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.titleTruncated { + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 74px), transparent calc(100% - 28px)); + mask-size: 100% 2lh, 100% 1lh; + mask-position: top left, bottom left; + mask-repeat: no-repeat; +} + +.questionAction { + position: absolute; + right: 0; + bottom: -1px; +} + .cardMinimized .title { display: -webkit-box; margin: 0; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 7fbe2e74d6..0421a8f718 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -242,13 +242,16 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 + const [questionTruncated, setQuestionTruncated] = useState(false) const [descriptionModes, setDescriptionModes] = useState | null>(null) useLayoutEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return + const title = surface.querySelector('[data-question-title]') const rows = [...surface.querySelectorAll('[data-option-row]')] const measure = (): void => { + if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1) const next = new Map() for (const row of rows) { const description = row.querySelector('[data-option-description]') @@ -284,6 +287,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi } measure() const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) + if (title !== null) observer?.observe(title) for (const row of rows) { observer?.observe(row) const copy = row.querySelector('[data-option-copy]') @@ -295,7 +299,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi observer?.disconnect() fonts?.removeEventListener('loadingdone', measure) } - }, [index, minimized, question.options]) + }, [index, minimized, question.question, question.options]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -552,9 +556,36 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi onFocusCapture={focusAnswerSurface} onBlurCapture={blurAnswerSurface} > -

- {question.question} -

+
+

+ {question.question} +

+ {questionTruncated && ( + + + + + )} + content={} + /> + + )} +
{question.detail !== undefined && (
)} diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 26aa017cc9..e8e42ac851 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,6 +25,7 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', + 'question.details': '查看完整问题', 'option.details': '查看“{option}”的选项说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', @@ -64,6 +65,7 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', + 'question.details': 'Show full question', 'option.details': 'Show details for “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index c5ce1dc757..34fc7525b6 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -300,6 +300,33 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) + it('opens the full truncated question and removes its action on the next fitting question', () => { + const question = 'First question with four rendered lines' + const scrollHeight = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) { + return this.hasAttribute('data-question-title') && this.textContent === question ? 88 : 66 + }) + const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(66) + try { + const { carrier } = wait([ + { id: 'first', question, options: [{ label: 'Continue' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue' }] }, + ]) + render() + const details = screen.getByRole('button', { name: '查看完整问题' }) + expect(details.getAttribute('aria-describedby')).toBe(screen.getByRole('heading', { name: question }).id) + fireEvent.click(details) + expect(screen.getAllByText(question)).toHaveLength(2) + expect(screen.getByRole('radio', { name: 'Continue' }).getAttribute('aria-checked')).toBe('false') + fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) + expect(screen.queryByText(question)).toBeNull() + expect(screen.queryByRole('button', { name: '查看完整问题' })).toBeNull() + } finally { + cleanup() + scrollHeight.mockRestore() + clientHeight.mockRestore() + } + }) + it('opens a full option description without choosing the option', () => { const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' const { carrier } = wait([{ From 50a9e31c2c883c076798571a8201d4b1b218fa02 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 14:59:34 +0800 Subject: [PATCH 45/73] fix(client): preserve question focus and dismiss hover cards --- apps/web/tests/question-composer.e2e.ts | 42 ++++++++++++---- packages/client/ui-primitives/README.md | 2 +- .../client/ui-primitives/src/HoverCard.tsx | 40 ++++++++++----- .../tests/hover-card.client.spec.tsx | 49 ++++++++++++++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 4 +- .../src/client/QuestionComposer.tsx | 8 ++- .../question-composer-styles.client.spec.ts | 1 + .../user-questions-composer.client.spec.tsx | 24 ++++++++- .../web/question-composer/ui.expected.md | 1 + 12 files changed, 142 insertions(+), 37 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 96e88fb512..db6ae2e128 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -337,7 +337,13 @@ describe('web e2e: resident question composer round trip', () => { // This golden owns the stable question surface; the answered-state // golden below owns the resulting transcript. const snapshot = await captureStableAria(page, '[data-question-key]', scaffold.workspaceCwd) - await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + const descriptions = await page.locator('[data-question-key] [role="checkbox"]').evaluateAll(nodes => + nodes.map(node => ({ + label: node.getAttribute('aria-label'), + description: (node.getAttribute('aria-describedby') ?? '').split(/\s+/) + .filter(Boolean).map(id => document.getElementById(id)?.textContent ?? '').join(' '), + }))) + await compareOrRefreshGolden(UI_EXPECTED, `${snapshot}\nChoice descriptions: ${JSON.stringify(descriptions)}`, MODE) const sidebar = await captureStableAria(page, '[role="treeitem"][aria-selected="true"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } @@ -640,7 +646,7 @@ describe('web e2e: resident question composer round trip', () => { { label: 'Keep the default edge', description: 'Keep the current setup.' }, { label: 'Move the package', description: 'Move it to a separately installed package with an explicit profile entry.' }, ], - }], + }, { id: 'followup', question: 'Who should own the rollout?', options: [{ label: 'Release team' }] }], }) const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) @@ -664,7 +670,7 @@ describe('web e2e: resident question composer round trip', () => { const scroll = card.querySelector('[data-question-scroll]') const title = card.querySelector('h2') const submit = [...card.querySelectorAll('button')] - .find(button => button.textContent === 'Submit') + .find(button => button.textContent === 'Next') if (scroll === null || title === null || submit === undefined) return null const submitBox = submit.getBoundingClientRect() return { @@ -720,6 +726,15 @@ describe('web e2e: resident question composer round trip', () => { }) expect(previewGeometry.fits).toBe(true) expect(previewGeometry.scrolls).toBe(true) + const questionScroll = questionPreview.locator('[data-hover-card-scroll]') + await questionInfo.focus() + await page.keyboard.press('End') + expect(await questionScroll.evaluate(node => node.scrollTop + node.clientHeight >= node.scrollHeight - 1)).toBe(true) + await page.keyboard.press('Home') + expect(await questionScroll.evaluate(node => node.scrollTop)).toBe(0) + await page.keyboard.press('PageDown') + expect(await questionScroll.evaluate(node => node.scrollTop)).toBeGreaterThan(0) + await composer.getByRole('heading', { name: question }).click() await expect.poll(() => questionPreview.count()).toBe(0) @@ -727,7 +742,7 @@ describe('web e2e: resident question composer round trip', () => { expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) await detailText.scrollIntoViewIfNeeded() expect(await detailText.isVisible()).toBe(true) - expect(await composer.getByRole('button', { name: 'Submit', exact: true }).isVisible()).toBe(true) + expect(await composer.getByRole('button', { name: 'Next', exact: true }).isVisible()).toBe(true) const info = composer.getByRole('button', { name: 'Show details for “Explicit install”' }) const descriptionId = await info.getAttribute('aria-describedby') @@ -806,10 +821,11 @@ describe('web e2e: resident question composer round trip', () => { selection?.removeAllRanges() return selectedLength })).toBe(description.length) - expect(await scrollport.evaluate((card) => { - card.scrollTop = card.scrollHeight - return card.scrollTop + card.clientHeight >= card.scrollHeight - 1 - })).toBe(true) + await info.focus() + await page.keyboard.press('Home') + expect(await scrollport.evaluate(card => card.scrollTop)).toBe(0) + await page.keyboard.press('End') + expect(await scrollport.evaluate(card => card.scrollTop + card.clientHeight >= card.scrollHeight - 1)).toBe(true) const palette: string[] = [] const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') try { @@ -851,7 +867,13 @@ describe('web e2e: resident question composer round trip', () => { await page.keyboard.press('Escape') await composer.getByRole('checkbox', { name: 'Explicit install' }).click() - await composer.getByRole('textbox').fill('Keep the migration steps explicit.') + const answer = composer.getByRole('textbox') + await answer.fill('Keep the migration steps explicit.') + await answer.press('Enter') + await composer.getByRole('heading', { name: 'Who should own the rollout?' }).waitFor() + expect(await answer.evaluate(node => document.activeElement === node)).toBe(true) + await page.keyboard.type('Release owner') + expect(await answer.inputValue()).toBe('Release owner') await composer.getByRole('button', { name: 'Submit', exact: true }).click() expect(await outcome).toEqual({ value: { @@ -859,7 +881,7 @@ describe('web e2e: resident question composer round trip', () => { id: 'release', selected: ['Explicit install'], custom: 'Keep the migration steps explicit.', - }], + }, { id: 'followup', selected: [], custom: 'Release owner' }], }, }) await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index ab03603969..eea4ee840d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -92,7 +92,7 @@ The catalog above lists what each export is for; this section covers the behavio `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep it open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. +Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 6f6d7288e7..97305dfeea 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -64,6 +64,7 @@ type HoverCardProps = { * Group replacement removes the previous card immediately, including a fading preview. * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. + * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and @@ -88,6 +89,7 @@ export function HoverCard(props: HoverCardProps) { const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) + const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') @@ -118,6 +120,7 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { + keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() setPinned(false) @@ -206,10 +209,6 @@ export function HoverCard(props: HoverCardProps) { return () => { document.removeEventListener('keydown', dismiss, true) } }, [open, openOnClick, cancelClose, close]) - useEffect(() => { - if (pinned && !openOnClick) close() - }, [close, openOnClick, pinned]) - // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { @@ -321,6 +320,7 @@ export function HoverCard(props: HoverCardProps) { const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return + keyboardFocus.current = false clearTimer() cancelClose() if (openOnClick && !disabled && !pinned) { @@ -374,9 +374,7 @@ export function HoverCard(props: HoverCardProps) { setPhase('open') }} onPointerLeave={() => { - if (!pinned - && rootRef.current?.contains(document.activeElement) !== true - && cardRef.current?.contains(document.activeElement) !== true) armClose() + if (!pinned && !(anchored && keyboardFocus.current)) armClose() }} > {callout @@ -397,11 +395,33 @@ export function HoverCard(props: HoverCardProps) { ref={rootRef} className={clsx(css.root, anchored && css.inline)} onFocus={anchored ? (event) => { - if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } + if (!disabled && event.target.matches(':focus-visible')) { + keyboardFocus.current = true + cancelClose() + setPhase('open') + } } : undefined} onBlur={anchored ? (event) => { + keyboardFocus.current = false if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} + onKeyDown={callout && open ? (event) => { + const scroll = scrollRef.current + if (scroll === null || !rootRef.current?.contains(event.target as Node)) return + let top: number + switch (event.key) { + case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break + case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break + case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break + case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break + case 'Home': top = 0; break + case 'End': top = scroll.scrollHeight; break + default: return + } + event.preventDefault() + event.stopPropagation() + scroll.scrollTop = top + } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return if (event.pointerType === 'mouse' && !window.matchMedia('(any-hover: hover)').matches) return @@ -418,9 +438,7 @@ export function HoverCard(props: HoverCardProps) { // Leaving a closed card schedules a no-op close; only arm while // open, matching Menu's shape. Keyboard focus remains an independent // open trigger after the pointer leaves. - if (open && !pinned - && rootRef.current?.contains(document.activeElement) !== true - && cardRef.current?.contains(document.activeElement) !== true) armClose() + if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() }} onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} onClickCapture={dismissFromAnchor} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index fa51eef33a..e1730702db 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -331,11 +331,10 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('drops a click pin when click opening is removed or the card is disabled', () => { + it('drops a click pin when the card is disabled', () => { const pinnedCard = (disabled = false) => ( details} content={
card body
} /> ) - const hoverCard = details} content={
card body
} /> const view = render(pinnedCard()) const anchor = screen.getByRole('button', { name: 'details' }) fireEvent.click(anchor) @@ -343,11 +342,6 @@ describe('HoverCard', () => { view.rerender(pinnedCard(true)) expect(screen.queryByText('card body')).toBeNull() - view.rerender(pinnedCard()) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(hoverCard) - expect(screen.queryByText('card body')).toBeNull() }) it('removes click-pin dismissal listeners on unmount', () => { @@ -885,3 +879,44 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) + +it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { + const { wrapper } = mount({ copyText: 'value' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByRole('button', { name: 'Copy: value' }) + act(() => { card.focus() }) + expect(document.activeElement).toBe(card) + fireEvent.pointerLeave(card) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('card body')).toBeNull() +}) + +it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { + render(details} content="body" />) + const anchor = screen.getByRole('button', { name: 'details' }) + act(() => { anchor.focus() }) + fireEvent.click(anchor) + fireEvent.click(anchor) + fireEvent.pointerEnter(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('body')).toBeTruthy() + fireEvent.pointerLeave(anchor.parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(screen.queryByText('body')).toBeNull() +}) + +it('scrolls an open callout with the focused anchor keyboard controls', () => { + render(details} content="body" />) + const anchor = screen.getByRole('button', { name: 'details' }) + fireEvent.click(anchor) + const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement + Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) + Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) + for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { + expect(fireEvent.keyDown(anchor, { key })).toBe(false) + expect(scroll.scrollTop).toBe(top) + } + fireEvent.keyDown(anchor, { key: 'Tab' }) + expect(screen.queryByText('body')).toBeNull() +}) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 8608006f15..b5573fba8f 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -27,8 +27,8 @@ en: 6b12f1362c614a39 zh: 27d9cf54d4cbd5d2 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/failure-and-recovery: - en: 3509678f4d183834 - zh: 625542b79e434eb5 + en: c2307453aedca0d9 + zh: 683e57c6ea0b947e /deepseek-ai-dsh-client-ui-user-questions/understand-the-implementation: en: 77e1b2585acdf67b zh: d433ec185c6b8f57 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 83e50f9533..5e7738a74a 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -43,7 +43,7 @@ A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review ### Failure and recovery -An answer field autofocuses only when its answer channel is ready and no countdown exists. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. +An answer field autofocuses only when its answer channel is ready and no countdown exists. Custom-answer focus survives Enter navigation between option questions; navigation resets the question scroll position and closes its disclosures. Manual focus and blur during the claim handshake are retained: the countdown starts paused only if the answer surface still has focus when the remaining duration arrives. Returning a local answer does not release its foreground claim: the Host closes that stream after accepting the waterfall outcome. The plugin owns the claim through this delivery interval. Explicit delegation releases it before calling the next answerer, and plugin disposal cancels any remaining claims. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index f92fe5cf37..27d34e7059 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -43,7 +43,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 失败与恢复 -回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 +回答输入框只有在回答通道就绪且没有倒计时时才自动聚焦。在带选项的问题间按 Enter 切题时,自定义回答框保留焦点;切题会重置问题滚动位置并关闭其说明浮层。接手握手期间的手动聚焦与失焦会被保留:剩余时长到达时,只有回答区域仍持有焦点,倒计时才以暂停状态开始。 本地返回回答不会释放前台接手记录:Host 接受 waterfall 结果后才关闭该 stream。插件在这段传输期间继续拥有接手记录。显式委托会在调用下一个回答方前释放它,插件卸载则取消剩余接手记录。 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 89852a34a6..730be0fa6f 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -144,7 +144,7 @@ .detail { flex-shrink: 0; - margin: 0 14px 8px; + margin: 0 24px 8px; } .footerActions { @@ -445,7 +445,7 @@ it pushes the inline input past the footer. */ flex-shrink: 0; padding: 8px 12px 8px 8px; - border: 1px solid transparent; + border: 0.5px solid transparent; border-radius: var(--dsw-radius-md); transition: background-color 120ms ease, border-color 120ms ease; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 0421a8f718..0e3aa666fa 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -245,6 +245,10 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const [questionTruncated, setQuestionTruncated] = useState(false) const [descriptionModes, setDescriptionModes] = useState | null>(null) + useLayoutEffect(() => { + if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 + }, [index]) + useLayoutEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return @@ -549,7 +553,6 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {!minimized && ( <>
{ choose(option.label) }} onKeyDown={(event) => { @@ -651,6 +656,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi {showDetails && ( { const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement expect(screen.queryByRole('region', { name: 'First question?' })).toBeNull() expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() - expect(secondScroll).not.toBe(firstScroll) + expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) expect(screen.queryByText(firstDescription)).toBeNull() expect(screen.getAllByText(secondDescription)).toHaveLength(1) @@ -1043,3 +1043,25 @@ describe('review card', () => { expect(screen.getByText(en['review.skipped'])).toBeTruthy() }) }) + +it('retains custom-answer focus across Enter navigation between option questions', () => { + const { carrier } = wait([ + { id: 'one', question: 'First?', options: [{ label: 'One', description: 'First description' }] }, + { id: 'two', question: 'Second?', options: [{ label: 'Two' }] }, + ]) + render() + const choice = screen.getByRole('radio', { name: 'One' }) + expect(choice.getAttribute('aria-describedby')).toBeTruthy() + expect(document.getElementById(choice.getAttribute('aria-describedby') as string)?.textContent).toBe('First description') + const first = screen.getByRole('textbox') + act(() => { first.focus() }) + fireEvent.change(first, { target: { value: 'first answer' } }) + fireEvent.keyDown(first, { key: 'Enter' }) + expect(screen.getByRole('heading', { name: 'Second?' })).toBeTruthy() + const second = screen.getByRole('textbox') + expect(second).toBe(first) + expect(document.activeElement).toBe(second) + fireEvent.change(second, { target: { value: 'second answer' } }) + expect((second as HTMLTextAreaElement).value).toBe('second answer') + expect(screen.getByRole('radio', { name: 'Two' }).getAttribute('aria-describedby')).toBeNull() +}) diff --git a/snapshots/web/question-composer/ui.expected.md b/snapshots/web/question-composer/ui.expected.md index 99c6c0e4bb..4b6193da5b 100644 --- a/snapshots/web/question-composer/ui.expected.md +++ b/snapshots/web/question-composer/ui.expected.md @@ -13,3 +13,4 @@ - status - button "Skip" - button "Submit" [disabled] +Choice descriptions: [{"label":"Blue","description":"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards."},{"label":"Green","description":"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions."}] From f3bb36263def15e21e0092c6a108edd2ffbdd271 Mon Sep 17 00:00:00 2001 From: yudshj Date: Fri, 9 Oct 2026 15:34:01 +0800 Subject: [PATCH 46/73] fix(client): complete hover-card coverage and steering golden --- packages/client/ui-primitives/tests/hover-card.client.spec.tsx | 2 ++ .../ui-user-questions/src/client/QuestionComposer.module.css | 1 + snapshots/web/steering/mid-steer.expected.md | 2 +- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index e1730702db..a555826d23 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -913,6 +913,8 @@ it('scrolls an open callout with the focused anchor keyboard controls', () => { const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) + expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) + expect(scroll.scrollTop).toBe(0) for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { expect(fireEvent.keyDown(anchor, { key })).toBe(false) expect(scroll.scrollTop).toBe(top) diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 730be0fa6f..80841b8c3b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -409,6 +409,7 @@ height: 16px; border: 1px solid currentColor; border-radius: 50%; + corner-shape: round; } .descriptionPreview { diff --git a/snapshots/web/steering/mid-steer.expected.md b/snapshots/web/steering/mid-steer.expected.md index 1527155f0b..267dbd18d5 100644 --- a/snapshots/web/steering/mid-steer.expected.md +++ b/snapshots/web/steering/mid-steer.expected.md @@ -16,9 +16,9 @@ - button "Copy" - region "Ready to continue?": - text: Checkpoint - - heading "Ready to continue?" [level=2] - button "Collapse the question card" [expanded] - button "Dismiss all questions" + - heading "Ready to continue?" [level=2] - radiogroup: - radio "Yes": 1 Yes - radio "No": 2 No From 2b6360c1d9f4d71e6d610e9d5ee88560127e3947 Mon Sep 17 00:00:00 2001 From: yudshj Date: Tue, 20 Oct 2026 21:42:39 +0800 Subject: [PATCH 47/73] refactor(ui-user-questions): let CSS wrap option descriptions --- apps/web/tests/question-composer.e2e.ts | 4 +- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 27 +++---- .../src/client/QuestionComposer.tsx | 75 +++++++------------ .../question-composer-styles.client.spec.ts | 12 +-- .../user-questions-composer.client.spec.tsx | 18 ++++- 8 files changed, 58 insertions(+), 86 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index db6ae2e128..c8bd4a8a33 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -596,7 +596,7 @@ describe('web e2e: resident question composer round trip', () => { if (label === null || description === null) return null const style = getComputedStyle(description) return { - sameLine: Math.abs(label.getBoundingClientRect().top - description.getBoundingClientRect().top) < 1, + sameLine: description.getBoundingClientRect().top < label.getBoundingClientRect().bottom, fontSize: style.fontSize, lineHeight: style.lineHeight, hasDetails: row.querySelector('[data-option-details]') !== null, @@ -605,7 +605,7 @@ describe('web e2e: resident question composer round trip', () => { })) expect(rows).toHaveLength(4) expect(rows).toEqual(Array.from({ length: 4 }, () => ({ - sameLine: true, fontSize: '14px', lineHeight: '24px', hasDetails: false, maskImage: 'none', + sameLine: true, fontSize: '13px', lineHeight: '20px', hasDetails: false, maskImage: 'none', }))) await composer.getByRole('radio', { name: '中(medium)' }).click() await composer.getByRole('button', { name: 'Submit', exact: true }).click() diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index b5573fba8f..997ea58421 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 02c47a01ae6fd4f4 - zh: 7adb34361d02bc9d + en: e8d6e22798e572cd + zh: 26c9657013b014cd /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 5e7738a74a..c959c3f286 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A short option description stays beside its label on one line. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. Questions longer than three rendered lines fade at the end of the third line and show the same circled-ellipsis action for their complete text. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. CSS wraps option descriptions beside or below their labels, using 13px text with a 20px line height in both positions. Overflow detection adds disclosure actions without changing text widths. Longer descriptions move below the label; descriptions truncated there fade out at the line end and show a circled-ellipsis action, which opens the complete selectable text on hover, keyboard focus, or click in a caret-linked callout that scrolls beyond 240px. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 27d34e7059..d4f4e8d928 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。简短的选项说明与标签同占一行;较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。超过实际排版三行的问题在第三行末尾渐隐,并通过相同的圈内三点按钮显示完整文本。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。CSS 将选项说明排在标签旁或下方,两种位置均使用 13px 字号和 20px 行高。溢出检测会添加全文按钮,但不改变文本宽度。较长的说明移到标签下方,仅在下方仍被截断时让行尾文字渐隐,并显示圈内三点按钮。悬停、键盘聚焦或点击该按钮会在带指向角的气泡中打开可选择文本的完整说明,超过 240px 后在气泡内部滚动。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index 80841b8c3b..7bda79528f 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -216,7 +216,6 @@ } .optionRow { - --description-action-space: 30px; position: relative; width: 100%; min-height: 40px; @@ -243,10 +242,6 @@ cursor: pointer; } -.optionWithDescription { - padding-right: calc(12px + var(--description-action-space)); -} - .optionSelected { background: var(--dsw-alias-interactive-bg-hover); } @@ -324,12 +319,17 @@ } .optionCopy { - min-width: 0; + display: flex; flex: 1; + flex-wrap: wrap; + align-items: baseline; + gap: 0 6px; + min-width: 0; } .optionLine { display: flex; + max-width: 100%; align-items: baseline; flex-wrap: wrap; gap: 2px 6px; @@ -353,6 +353,8 @@ .description { display: block; + width: max-content; + max-width: 100%; min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary); @@ -364,14 +366,7 @@ } .descriptionTruncated { - mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent); -} - -.descriptionInline { - color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 24px; - font-weight: 400; + mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px)); } .descriptionAction { @@ -586,10 +581,6 @@ padding: 8px 6px; } - .optionWithDescription { - padding-right: calc(6px + var(--description-action-space)); - } - .descriptionAction { right: 6px; } diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 0e3aa666fa..20fb3bdfb7 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -1,5 +1,5 @@ import { - useEffect, useId, useLayoutEffect, useMemo, useRef, useState, + useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, } from 'react' import clsx from 'clsx' @@ -242,68 +242,46 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 - const [questionTruncated, setQuestionTruncated] = useState(false) - const [descriptionModes, setDescriptionModes] = useState | null>(null) + const [overflow, setOverflow] = useState<{ + question: typeof question + title: boolean + descriptions: Set + } | null>(null) + const questionTruncated = overflow?.question === question && overflow.title - useLayoutEffect(() => { + useEffect(() => { if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 }, [index]) - useLayoutEffect(() => { + useEffect(() => { const surface = answerSurface.current if (surface === null || minimized) return const title = surface.querySelector('[data-question-title]') - const rows = [...surface.querySelectorAll('[data-option-row]')] + const descriptions = [...surface.querySelectorAll('[data-option-description]')] const measure = (): void => { - if (title !== null) setQuestionTruncated(title.scrollHeight > title.clientHeight + 1) - const next = new Map() - for (const row of rows) { - const description = row.querySelector('[data-option-description]') - if (description === null) continue - const optionIndex = Number(row.dataset.optionIndex) - const copy = row.querySelector('[data-option-copy]') - const line = row.querySelector('[data-option-line]') - if (copy === null || line === null || copy.clientWidth === 0) { - next.set(optionIndex, 'truncated') - continue + const titleTruncated = title !== null && title.scrollHeight > title.clientHeight + 1 + const truncated = new Set() + for (const description of descriptions) { + if (description.scrollWidth > description.clientWidth + 1) { + truncated.add(Number(description.dataset.optionIndex)) } - const actionSpace = parseFloat(getComputedStyle(row).getPropertyValue('--description-action-space')) - // The action's reserved width must not make a fitting description appear truncated. - const fullWidth = copy.clientWidth + (row.querySelector('[data-option-details]') === null ? 0 : actionSpace) - const probe = line.cloneNode(true) as HTMLElement - probe.querySelector('[data-option-description]')?.remove() - const probeDescription = description.cloneNode(true) as HTMLElement - probeDescription.removeAttribute('id') - probeDescription.className = clsx(css.descriptionInline) - probe.append(probeDescription) - probe.style.cssText = 'position:fixed;top:-10000px;left:0;width:max-content;flex-wrap:nowrap;visibility:hidden;pointer-events:none' - document.body.append(probe) - const inlineWidth = probe.getBoundingClientRect().width - probeDescription.className = clsx(css.description) - probeDescription.style.width = 'max-content' - const blockWidth = probeDescription.getBoundingClientRect().width - probe.remove() - next.set(optionIndex, inlineWidth <= fullWidth + 1 ? 'inline' - : blockWidth <= fullWidth + 1 ? 'block' : 'truncated') } - setDescriptionModes(current => current !== null && current.size === next.size - && [...next].every(([optionIndex, mode]) => current.get(optionIndex) === mode) ? current : next) + setOverflow(current => current?.question === question && current.title === titleTruncated + && current.descriptions.size === truncated.size + && [...truncated].every(optionIndex => current.descriptions.has(optionIndex)) + ? current : { question, title: titleTruncated, descriptions: truncated }) } measure() const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure) if (title !== null) observer?.observe(title) - for (const row of rows) { - observer?.observe(row) - const copy = row.querySelector('[data-option-copy]') - if (copy !== null) observer?.observe(copy) - } + for (const description of descriptions) observer?.observe(description) const fonts = 'fonts' in document ? document.fonts : null fonts?.addEventListener('loadingdone', measure) return () => { observer?.disconnect() fonts?.removeEventListener('loadingdone', measure) } - }, [index, minimized, question.question, question.options]) + }, [minimized, question]) const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { actions.replace(pending.key, { @@ -598,8 +576,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' - const descriptionMode = descriptionModes?.get(optionIndex) ?? 'truncated' - const showDetails = hasDescription && descriptionMode === 'truncated' + const showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- )} - content={} - /> - - )} +
{question.detail !== undefined && (
@@ -576,18 +542,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const selected = draft.selected.includes(option.label) const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' - const showDetails = hasDescription && overflow?.question === question && overflow.descriptions.has(optionIndex) + const descriptionExpanded = expanded.has(optionIndex) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
- {showDetails && ( - - - - - )} - content={} - /> - + {hasDescription && ( + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index e8e42ac851..4995c365e4 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,8 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'question.details': '查看完整问题', - 'option.details': '查看“{option}”的选项说明', + 'text.expand': '全部', + 'text.collapse': '收起', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +65,8 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', - 'question.details': 'Show full question', - 'option.details': 'Show details for “{option}”', + 'text.expand': 'More', + 'text.collapse': 'Show less', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index e65c8eb063..4774ce8e67 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -8,29 +8,95 @@ const stylesheet = readFileSync( 'utf8', ).replace(/\/\*[\s\S]*?\*\//g, ' ') -function declarations(selector: string): string[] { - const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const rule = new RegExp(`(?:^|\\})\\s*${escaped}\\s*\\{([^{}]*)\\}`).exec(stylesheet) - if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) - return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean) +const escape = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +function split(body: string | undefined): string[] { + return (body ?? '').split(';').map(part => part.trim()).filter(Boolean) } -describe('QuestionComposer compact long-copy layout', () => { - it('keeps the option description to one clipped line and fades only truncated text', () => { +function declarations(selector: string): string[] { + const rule = new RegExp(`(?:^|\\})\\s*${escape(selector)}\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${selector}\` rule`) + return split(rule[1]) +} + +function keyframes(name: string): string[] { + const rule = new RegExp(`@keyframes ${escape(name)}\\s*\\{\\s*from, to\\s*\\{([^{}]*)\\}`).exec(stylesheet) + if (rule === null) throw new Error(`QuestionComposer.module.css has no \`${name}\` keyframes`) + return split(rule[1]) +} + +describe('QuestionComposer long-copy layout', () => { + it('reads question overflow against three lines and keeps two once it overflows', () => { + expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy') + expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([ + 'max-height: calc(3lh + 2px)', + 'margin: 0 0 -2px', + 'padding-bottom: 2px', + 'overflow: hidden', + 'scroll-timeline: --question-copy block', + 'animation-name: question-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --question-copy', + ])) + expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'mask-size: 100% 1lh, 100% 1lh', + 'mask-position: 0 0, 0 1lh', + ])) + expect(declarations('.questionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --question-copy', + ])) + }) + + it('reads description overflow against two lines and keeps one once it overflows', () => { + expect(declarations('.optionRow')).toContain('timeline-scope: --description-copy') expect(declarations('.description')).toEqual(expect.arrayContaining([ 'display: block', 'width: max-content', 'max-width: 100%', - 'min-width: 0', + 'max-height: calc(2lh + 2px)', + 'margin-bottom: -2px', + 'padding-bottom: 2px', 'overflow: hidden', 'font-size: 13px', 'line-height: 20px', - 'text-overflow: clip', - 'white-space: nowrap', + 'scroll-timeline: --description-copy block', + 'animation-name: description-clipped', + 'animation-fill-mode: both', + 'animation-timeline: --description-copy', ])) - expect(declarations('.descriptionTruncated')).toContain( - 'mask-image: linear-gradient(to right, #000 calc(100% - 52px), transparent calc(100% - 24px))', - ) + expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ + 'margin-bottom: calc(-1lh - 2px)', + 'mask-size: 100% 1lh', + ])) + expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([ + 'animation-name: copy-toggle-shown', + 'animation-timeline: --description-copy', + ])) + }) + + it('shows a toggle only through the overflow timeline or an expansion', () => { + expect(declarations('.copyToggle')).toContain('visibility: hidden') + expect(keyframes('copy-toggle-shown')).toEqual(['visibility: visible']) + expect(declarations('.questionTitle[data-expanded] .titlePreview,\n.optionRow[data-expanded] .description')) + .toEqual(['max-height: none', 'animation: none']) + expect(declarations('.questionTitle[data-expanded] .copyToggle,\n.optionRow[data-expanded] .copyToggle')) + .toEqual(['visibility: visible', 'animation: none']) + }) + + it('names timelines through longhands that the bundled CSS keeps apart', () => { + // lightningcss merges `animation` with `animation-timeline` into a shorthand browsers reject. + const timed = [...stylesheet.matchAll(/\{([^{}]*animation-timeline[^{}]*)\}/g)].map(match => match[1] ?? '') + expect(timed).toHaveLength(4) + for (const body of timed) expect(split(body).some(part => part.startsWith('animation:'))).toBe(false) + }) + + it('shows all copy where scroll timelines are unsupported', () => { + const fallback = /@supports not \(animation-timeline: scroll\(\)\)\s*\{([\s\S]*?)\n\}/.exec(stylesheet)?.[1] ?? '' + expect(fallback).toMatch(/\.titlePreview,\s*\.description\s*\{\s*max-height: none;\s*animation: none;\s*\}/) + expect(fallback).toMatch(/\.copyToggle\s*\{\s*display: none;\s*\}/) }) it('keeps the question and choices in the card scrollport', () => { diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 808d371502..c1ab2c6f7f 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -300,73 +300,7 @@ describe('QuestionComposer', () => { expect(screen.getByRole('button', { name: '正在提交…' }).disabled).toBe(true) }) - it('opens the full truncated question and removes its action on the next fitting question', () => { - const question = 'First question with four rendered lines' - const scrollHeight = vi.spyOn(HTMLElement.prototype, 'scrollHeight', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-question-title') && this.textContent === question ? 88 : 66 - }) - const clientHeight = vi.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(66) - try { - const { carrier } = wait([ - { id: 'first', question, options: [{ label: 'Continue' }] }, - { id: 'second', question: 'Second question?', options: [{ label: 'Continue' }] }, - ]) - render() - const details = screen.getByRole('button', { name: '查看完整问题' }) - expect(details.getAttribute('aria-describedby')).toBe(screen.getByRole('heading', { name: question }).id) - fireEvent.click(details) - expect(screen.getAllByText(question)).toHaveLength(2) - expect(screen.getByRole('radio', { name: 'Continue' }).getAttribute('aria-checked')).toBe('false') - fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) - expect(screen.queryByText(question)).toBeNull() - expect(screen.queryByRole('button', { name: '查看完整问题' })).toBeNull() - } finally { - cleanup() - scrollHeight.mockRestore() - clientHeight.mockRestore() - } - }) - - it('shows details only for overflow and removes them when the description fits after resizing', ({ onTestFinished }) => { - let availableWidth = 300 - const scrollWidth = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockReturnValue(200) - const clientWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => availableWidth) - const disconnect = vi.fn() - let resize = (): void => {} - vi.stubGlobal('ResizeObserver', class { - constructor(callback: () => void) { resize = callback } - observe(): void {} - disconnect = disconnect - }) - onTestFinished(() => { - cleanup() - scrollWidth.mockRestore() - clientWidth.mockRestore() - vi.unstubAllGlobals() - }) - const description = 'An explanation that fits in a wide row.' - const { carrier } = wait([{ - id: 'size', question: 'Which option?', options: [{ label: 'Option', description }], - }]) - const { unmount } = render() - const text = screen.getByText(description) - expect(screen.queryByRole('button', { name: '查看“Option”的选项说明' })).toBeNull() - availableWidth = 100 - act(() => { resize() }) - expect(screen.getByRole('button', { name: '查看“Option”的选项说明' })).toBeTruthy() - availableWidth = 300 - act(() => { resize() }) - expect(screen.queryByRole('button', { name: '查看“Option”的选项说明' })).toBeNull() - expect(screen.getByText(description)).toBe(text) - unmount() - expect(disconnect).toHaveBeenCalledOnce() - }) - - it('opens a full option description without choosing the option', ({ onTestFinished }) => { - const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-option-description') ? 500 : 0 - }) - onTestFinished(() => { cleanup(); width.mockRestore() }) + it('expands long copy in place without choosing an option', () => { const description = 'Remove the experimental client from every shipped template and require an explicit profile install.' const { carrier } = wait([{ id: 'release', question: 'How should the release graph change?', @@ -379,29 +313,39 @@ describe('QuestionComposer', () => { }]) render() + // CSS shows each toggle only while its copy overflows; every candidate is rendered. + const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' }) + expect(rest).toHaveLength(0) + expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) - const details = screen.getByRole('button', { name: '查看“Explicit install”的选项说明' }) - expect(screen.getAllByRole('button', { name: /选项说明/ })).toHaveLength(1) - expect(screen.getAllByText(description)).toHaveLength(1) - const describedBy = details.getAttribute('aria-describedby') - expect(describedBy).not.toBeNull() - expect(document.getElementById(describedBy as string)?.textContent).toBe(description) - fireEvent.click(details) + const text = screen.getByText(description) + expect(option.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false') + fireEvent.click(descriptionToggle as HTMLElement) + expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle?.textContent).toBe('收起') + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) + expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') - expect(details.getAttribute('aria-describedby')).toBe(describedBy) - expect(screen.getAllByText(description)).toHaveLength(2) - fireEvent.pointerDown(document.body) - expect(screen.getAllByText(description)).toHaveLength(1) + expect(questionToggle?.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(questionToggle as HTMLElement) + expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) + fireEvent.click(questionToggle as HTMLElement) + fireEvent.click(descriptionToggle as HTMLElement) + expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) + expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) + expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle]) }) - it('resets scrolling and an open option description when the question changes', ({ onTestFinished }) => { - const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-option-description') ? 500 : 0 - }) - onTestFinished(() => { cleanup(); width.mockRestore() }) - const firstDescription = 'First question detail that was pinned open.' - const secondDescription = 'Second question detail starts closed.' + it('resets scrolling and expanded copy when the question changes', () => { + const firstDescription = 'First question detail that was expanded.' + const secondDescription = 'Second question detail starts collapsed.' const { carrier } = wait([ { id: 'first', question: 'First question?', @@ -417,13 +361,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - const firstDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) - fireEvent.click(firstDetails) - expect(screen.getAllByText(firstDescription)).toHaveLength(2) - const firstDescribedIds = (firstDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) - expect(firstDescribedIds.length).toBeGreaterThan(0) - expect(firstDescribedIds.every(id => document.getElementById(id) !== null)).toBe(true) - expect(firstDescribedIds.map(id => document.getElementById(id)?.textContent)).toContain(firstDescription) + for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -431,14 +370,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - expect(screen.getAllByText(secondDescription)).toHaveLength(1) - const secondDetails = screen.getByRole('button', { name: '查看“Continue”的选项说明' }) - const describedIds = (secondDetails.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean) - expect(describedIds.length).toBeGreaterThan(0) - expect(describedIds.every(id => document.getElementById(id) !== null)).toBe(true) - expect(describedIds.map(id => document.getElementById(id)?.textContent)).toContain(secondDescription) - expect(describedIds.map(id => document.getElementById(id)?.textContent)).not.toContain(firstDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -1002,11 +938,7 @@ function reviewCard(review: readonly AskUserQuestionAnswerItem[] = RECORDED) { } describe('review card', () => { - it('walks a settled call record with every answer surface frozen', ({ onTestFinished }) => { - const width = vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { - return this.hasAttribute('data-option-description') ? 500 : 0 - }) - onTestFinished(() => { cleanup(); width.mockRestore() }) + it('walks a settled call record with every answer surface frozen', () => { const { carrier } = reviewCard() render() @@ -1015,7 +947,8 @@ describe('review card', () => { const chosen = screen.getByRole('radio', { name: '工程落地型' }) expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) - expect(screen.getByRole('button', { name: '查看“工程落地型”的选项说明' }).disabled).toBe(false) + // Long copy stays readable on a frozen card. + expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From 82b43da3dc63a501bd6163982208656bb9c0b244 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:14:46 +0800 Subject: [PATCH 51/73] revert(ui-primitives): drop HoverCard callout and click pinning The question composer expands long copy in place, so no caller uses the callout variant, openOnClick pinning, describeAnchor, or the preview-to-anchor pointer handling this pull request added. HoverCard, its stylesheet, tests, and README return to their merge-base state. --- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 4 +- packages/client/ui-primitives/README.zh.md | 4 +- .../ui-primitives/src/HoverCard.module.css | 50 ---- .../client/ui-primitives/src/HoverCard.tsx | 236 ++++-------------- .../tests/hover-card.client.spec.tsx | 234 +---------------- 6 files changed, 54 insertions(+), 478 deletions(-) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index fc9c17df52..ab799d699a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -21,8 +21,8 @@ en: cb874a0ca53a6747 zh: 31179193dd1d6e12 /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: - en: 3c1f0a4c28aa7240 - zh: 8dbc3c8615c58260 + en: 35403eb2a55cc77d + zh: 8f4466f7e15ae473 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 3a31ad8de6dd0af8 zh: 6b22881a929c33df diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index b1fafe0b64..9e054e13cc 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -88,12 +88,10 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Moving directly from the preview back to its anchor keeps it open. Its `callout` variant draws an anchor caret, keeps an 8px viewport margin, and scrolls content beyond the available space or 240px height cap. With `openOnClick`, clicking or touching the anchor pins the card; pointer leave and blur keep a pinned card open. Another activation, Escape, Tab, or an outside pointerdown dismisses it. Focused callout anchors scroll the full text with arrow keys, PageUp/PageDown, Home, and End. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. -Opt-in `HoverCard.openOnClick` requires one focusable React element that forwards ARIA props. Click and touch activation pin the card; pointer leave and blur keep a pinned card open so its text remains selectable; another anchor activation, Escape, Tab, or a pointerdown outside both anchor and card closes it. While open, the card joins the anchor's accessible description by default. Set `describeAnchor={false}` when the caller provides a stable `aria-describedby` relationship; the card then leaves that relationship unchanged. - `HoverCardGroup` owns private state for one mounted physical list. A `HoverCard` with `grouped` shares the first actually displayed card's dwell: subsequent cards replace the current card immediately during the same pointer interaction. The list and its active portaled card form one pointer interaction: the pointer may enter the card to read or copy, then return to another list item for immediate replacement. After the pointer leaves both, the shared dwell resets when the active card closes through its existing pointer-grace and close lifecycle; the group adds no separate timer. Leaving a list with no active card resets the dwell immediately. Opening an inline card by keyboard focus while the pointer is outside the list does not warm the next pointer visit. Exclusive display includes fading previews: ordinary preview dismissal retains its fade, but a group replacement removes the previous card immediately. Cards without `grouped` remain independent. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index bdd67dcce1..96b9db405a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -88,12 +88,10 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。指针从预览直接移回其锚点时,预览保持打开。其 `callout` 变体绘制指向锚点的小角,保留 8px 视口边距,并在内容超过可用空间或 240px 高度上限时内部滚动。启用 `openOnClick` 后,点击或触屏激活锚点会固定卡片;指针移出与失焦不会关闭已固定的卡片。再次激活、Escape、Tab 或外部 pointerdown 会关闭它。聚焦的 callout 锚点支持通过方向键、PageUp/PageDown、Home 和 End 滚动全文。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 -显式启用 `HoverCard.openOnClick` 时,锚点必须是单个可聚焦且会转发 ARIA props 的 React 元素。点击和触屏激活会固定卡片;指针离开和失焦不会关闭固定的卡片,因此文字仍可选择;再次激活锚点、按 Escape 或 Tab,或在锚点及卡片之外按下指针都会关闭卡片。卡片打开期间,默认会加入锚点的无障碍描述。调用方已通过 `aria-describedby` 提供稳定关联时设置 `describeAnchor={false}`;卡片将保持该关联不变。 - `HoverCardGroup` 为一个已挂载的物理列表持有私有状态。设置 `grouped` 的 `HoverCard` 共享首张实际显示卡片的等待:在同一次指针交互中,后续卡片立即替换当前卡片。列表与其当前活动的 portal 卡片构成一次指针交互:指针可进入卡片阅读或复制,再返回其他列表项并立即替换卡片。指针离开两者后,共享等待在活动卡片通过原有指针宽限及关闭生命周期关闭时重置;分组不另设计时器。列表没有活动卡片时,指针离开便立即重置等待。指针位于列表外时,通过键盘聚焦打开行内卡片不会让下一次指针进入跳过等待。互斥显示也包括正在淡出的预览:普通预览关闭保留淡出,但分组内替换会立即移除前一张卡片。未设置 `grouped` 的卡片保持独立。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 0542763a8a..50c62fa828 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -88,53 +88,3 @@ backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-panel); } - -.callout { - --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); - --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); - isolation: isolate; - padding: 0; - border: 0; - background: transparent; - box-shadow: none; -} - -.calloutSurface { - position: relative; - z-index: 1; - padding: 4px; - overflow: hidden; - border: 0; - border-radius: var(--dsw-radius-xl); - background: var(--dsw-specific-bubble); - box-shadow: var(--dsw-elevation-panel); -} - -.calloutBody { - box-sizing: border-box; - max-height: max(0px, calc(var(--dsh-hover-card-max-height) - 8px)); - overflow-y: auto; - overscroll-behavior: contain; - padding: 4px 8px; - border-radius: var(--dsw-radius-lg); - color: var(--dsw-alias-label-primary); -} - -.calloutCaret { - position: absolute; - z-index: 2; - left: var(--dsh-hover-card-anchor-x); - width: 10px; - height: 10px; - background: var(--dsw-specific-bubble); - transform: translateX(-50%) rotate(45deg); - pointer-events: none; -} - -.callout[data-placement='below'] .calloutCaret { - top: -4px; -} - -.callout[data-placement='above'] .calloutCaret { - bottom: -4px; -} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 601f5782ea..e30d671c36 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,12 +1,11 @@ -import { cloneElement, useCallback, useContext, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactElement, ReactNode, RefObject, SyntheticEvent } from 'react' +import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import { overlayTopMargin } from './overlay-top-margin.ts' import { HoverCardGroupContext } from './HoverCardGroup.tsx' -import { useDismissOnOutsidePointer } from './useDismissOnOutsidePointer.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ @@ -14,101 +13,64 @@ const PREVIEW_FADE_MS = 100 const PREVIEW_MAX_HEIGHT = 420 const PREVIEW_INSET = 24 const INLINE_PREVIEW_WIDTH = 300 -const CALLOUT_MAX_HEIGHT = 240 -const CALLOUT_WIDTH = 300 -const CALLOUT_ARROW_EDGE_INSET = 16 -const CALLOUT_SURFACE_INSET = 4 const ANCHOR_GAP = 8 const VIEWPORT_MARGIN = 8 -interface DescribedAnchorProps { - 'aria-describedby'?: string | undefined -} - -type HoverCardProps = { - /** Inline media preview using the shared menu material and keyboard focus. */ - inline?: boolean - content: ReactNode - openDelayMs?: number - disabled?: boolean - grouped?: boolean - variant?: 'compact' | 'preview' | 'callout' - widthAnchorRef?: RefObject -} & ({ - anchor: ReactElement - openOnClick: true - describeAnchor?: boolean -} | { - anchor: ReactNode - openOnClick?: false | undefined - describeAnchor?: never -}) & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { - copyText?: undefined - copyLabel?: string - copiedLabel?: string -}) - /** * Render an anchor with a hover-triggered preview card independently of nested tooltips. - * @param props.anchor - the hover target (rendered in place inside a wrapper span). With - * openOnClick, pass one focusable element that forwards ARIA props. + * Touch entry does not open a preview or intercept the anchor's action. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.variant - compact card beside the anchor; a preview with container-relative - * width and opacity transitions; or a 300px callout with an anchor caret and 240px height cap. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.grouped - share first-display dwell across the surrounding list and active portal. * Group replacement removes the previous card immediately, including a fading preview. - * @param props.openOnClick - clicking or touching the anchor pins the card open. Another activation, - * Escape, Tab, or an outside pointerdown dismisses it; pointer leave and blur do not dismiss a pinned card. - * For callouts, focused anchors scroll the card with arrow keys, PageUp/PageDown, Home, and End. - * @param props.describeAnchor - with openOnClick, append the open card to the anchor's accessible - * description (default true). Set false when the caller supplies a stable description relationship. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. * @param props.copiedLabel - localized visible success label. * @returns anchor wrapper with the conditional portaled card. */ -export function HoverCard(props: HoverCardProps) { - const { - content, openDelayMs = 500, disabled = false, grouped = false, describeAnchor = true, - copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, - } = props - const openOnClick = props.openOnClick === true - const callout = variant === 'callout' - const anchored = inline || callout - const id = useId() +export function HoverCard({ + anchor, content, openDelayMs = 500, disabled = false, grouped = false, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, inline = false, +}: { + anchor: ReactNode + /** Inline media preview using the shared menu material and keyboard focus. */ + inline?: boolean + content: ReactNode + openDelayMs?: number + disabled?: boolean + grouped?: boolean + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) - const scrollRef = useRef(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) const copyHeightRef = useRef(null) const copyEpochRef = useRef(0) - const keyboardFocus = useRef(false) const copyingRef = useRef(false) const mountedRef = useRef(true) const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') const open = phase !== 'closed' const closing = phase === 'closing' - const [pos, setPos] = useState<{ - left: number - top: number - width?: number - maxHeight?: number - anchorX?: number - placement?: 'above' | 'below' - } | null>(null) + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) const positioned = pos !== null const [copied, setCopied] = useState(false) const parentGroup = useContext(HoverCardGroupContext) const group = grouped ? parentGroup : null const [groupOwner] = useState(() => Symbol('hover-card')) - const [pinned, setPinned] = useState(false) const clearCopied = useCallback(() => { if (copyTimerRef.current !== null) { @@ -120,15 +82,12 @@ export function HoverCard(props: HoverCardProps) { }, []) const close = useCallback(() => { - keyboardFocus.current = false copyEpochRef.current += 1 clearCopied() - setPinned(false) setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) - useDismissOnOutsidePointer(rootRef, openOnClick && open, close, cardRef) const clearTimer = useCallback(() => { if (timerRef.current !== null) { @@ -181,7 +140,7 @@ export function HoverCard(props: HoverCardProps) { }, []) useEffect(() => { - if (!open || openOnClick || (variant !== 'preview' && !anchored)) return + if (!open || (variant !== 'preview' && !inline)) return const dismiss = (event: KeyboardEvent): void => { if (event.key !== 'Escape') return if (inline) event.stopPropagation() @@ -191,23 +150,7 @@ export function HoverCard(props: HoverCardProps) { } window.addEventListener('keydown', dismiss, inline) return () => { window.removeEventListener('keydown', dismiss, inline) } - }, [open, openOnClick, variant, anchored, cancelClose, close]) - - useEffect(() => { - if (!openOnClick || !open) return - const dismiss = (event: KeyboardEvent): void => { - if (event.key !== 'Escape' && event.key !== 'Tab') return - if (event.key === 'Escape') { - event.preventDefault() - event.stopPropagation() - } - clearTimer() - cancelClose() - close() - } - document.addEventListener('keydown', dismiss, true) - return () => { document.removeEventListener('keydown', dismiss, true) } - }, [open, openOnClick, cancelClose, close]) + }, [open, variant, inline, cancelClose, close]) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -235,34 +178,19 @@ export function HoverCard(props: HoverCardProps) { }) return } - if (anchored) { - const height = callout - ? Math.max(h, (scrollRef.current?.scrollHeight ?? 0) + CALLOUT_SURFACE_INSET * 2) - : Math.max(h, cardRef.current?.scrollHeight ?? 0) - const width = Math.max(0, Math.min(callout ? CALLOUT_WIDTH : INLINE_PREVIEW_WIDTH, - window.innerWidth - VIEWPORT_MARGIN * 2)) + if (inline) { + const height = Math.max(h, cardRef.current?.scrollHeight ?? 0) + const width = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2)) const topMargin = overlayTopMargin(VIEWPORT_MARGIN) const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) const onTop = height > below && above > below - const maxHeight = Math.min(callout ? CALLOUT_MAX_HEIGHT : Number.POSITIVE_INFINITY, - onTop ? above : below) - const anchorCenter = (r.left + r.right) / 2 - const idealLeft = callout ? anchorCenter - width / 2 : r.left - const left = Math.max(VIEWPORT_MARGIN, Math.min(idealLeft, - window.innerWidth - width - VIEWPORT_MARGIN)) - const arrowInset = Math.min(CALLOUT_ARROW_EDGE_INSET, width / 2) + const maxHeight = onTop ? above : below setPos({ - left, + left: Math.max(VIEWPORT_MARGIN, Math.min(r.left, window.innerWidth - width - VIEWPORT_MARGIN)), top: onTop ? r.top - ANCHOR_GAP - Math.min(height, maxHeight) : belowTop, width, maxHeight, - ...(callout - ? { - anchorX: Math.max(arrowInset, Math.min(anchorCenter - left, width - arrowInset)), - placement: onTop ? 'above' as const : 'below' as const, - } - : {}), }) return } @@ -270,8 +198,8 @@ export function HoverCard(props: HoverCardProps) { setPos({ left: r.right + ANCHOR_GAP, top }) } place() - const observer = (variant === 'preview' || anchored) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null - for (const element of [cardRef.current, scrollRef.current, rootRef.current, widthAnchorRef?.current]) { + const observer = (variant === 'preview' || inline) && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { if (element !== null && element !== undefined) observer?.observe(element) } window.addEventListener('scroll', place, true) @@ -281,19 +209,19 @@ export function HoverCard(props: HoverCardProps) { window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open, variant, widthAnchorRef, positioned, anchored, callout]) + }, [open, variant, widthAnchorRef, positioned, inline]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || variant === 'preview' || anchored) return + if (!open || pos === null || variant === 'preview' || inline) return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } - }, [open, pos, variant, anchored]) + }, [open, pos, variant, inline]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -310,43 +238,21 @@ export function HoverCard(props: HoverCardProps) { } const copyable = copyText !== undefined - const describedAnchor = props.openOnClick === true && describeAnchor - ? cloneElement(props.anchor, { - 'aria-describedby': open - ? [props.anchor.props['aria-describedby'], id].filter(Boolean).join(' ') - : props.anchor.props['aria-describedby'], - }) - : props.anchor const dismissFromAnchor = (event: SyntheticEvent): void => { // Portal content is a React child, but its clicks and text selection do not activate the anchor. if (cardRef.current?.contains(event.target as Node)) return - keyboardFocus.current = false clearTimer() cancelClose() - if (openOnClick && !disabled && !pinned) { - setPinned(true) - setPhase('open') - return - } close() } - const cardContent = copied ? : content const card = open && pos !== null && (
{ - cancelClose() - setPhase('open') - }} - onPointerLeave={(event) => { - // Portal-to-anchor movement stays within this card's interaction. - if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) { - cancelClose() - return - } - if (!pinned && !(anchored && keyboardFocus.current)) armClose() - }} > - {callout - ? ( - <> -
) return ( { - if (!disabled && event.target.matches(':focus-visible')) { - keyboardFocus.current = true - cancelClose() - setPhase('open') - } + className={clsx(css.root, inline && css.inline)} + onFocus={inline ? (event) => { + if (!disabled && event.target.matches(':focus-visible')) { cancelClose(); setPhase('open') } } : undefined} - onBlur={anchored ? (event) => { - keyboardFocus.current = false - if (!pinned && !event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } - } : undefined} - onKeyDown={callout && open ? (event) => { - const scroll = scrollRef.current - if (scroll === null || !rootRef.current?.contains(event.target as Node)) return - let top: number - switch (event.key) { - case 'ArrowDown': top = scroll.scrollTop + scroll.clientHeight / 8; break - case 'ArrowUp': top = scroll.scrollTop - scroll.clientHeight / 8; break - case 'PageDown': top = scroll.scrollTop + scroll.clientHeight; break - case 'PageUp': top = scroll.scrollTop - scroll.clientHeight; break - case 'Home': top = 0; break - case 'End': top = scroll.scrollHeight; break - default: return - } - event.preventDefault() - event.stopPropagation() - scroll.scrollTop = top + onBlur={inline ? (event) => { + if (!event.currentTarget.contains(event.relatedTarget)) { clearTimer(); cancelClose(); close() } } : undefined} onPointerEnter={(event) => { if (disabled || event.pointerType === 'touch') return @@ -441,14 +304,13 @@ export function HoverCard(props: HoverCardProps) { onPointerLeave={() => { clearTimer() // Leaving a closed card schedules a no-op close; only arm while - // open, matching Menu's shape. Keyboard focus remains an independent - // open trigger after the pointer leaves. - if (open && !pinned && !(anchored && keyboardFocus.current)) armClose() + // open, matching Menu's shape. + if (open) armClose() }} - onPointerDownCapture={openOnClick ? undefined : dismissFromAnchor} + onPointerDownCapture={dismissFromAnchor} onClickCapture={dismissFromAnchor} > - {describedAnchor} + {anchor} {open && copyable && {copied ? copiedLabel : ''}} {card !== false && createPortal(card, document.body)} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index cfdf11bc10..65fbad0a64 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -22,7 +22,7 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number function mount(props: { widthAnchorRef?: { current: HTMLElement | null } - variant?: 'compact' | 'preview' | 'callout' + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -165,27 +165,6 @@ describe('HoverCard', () => { expect(screen.queryByText('card body')).toBeNull() }) - it('caps a callout, keeps its caret on the anchor, and flips it above when needed', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - const body = card.querySelector('[data-hover-card-scroll]') as HTMLElement - expect(card.style.width).toBe('300px') - expect(card.style.left).toBe('8px') - expect(card.style.top).toBe('82px') - expect(card.dataset.placement).toBe('below') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - expect(card.style.getPropertyValue('--dsh-hover-card-anchor-x')).toBe('142px') - - Object.defineProperty(body, 'scrollHeight', { configurable: true, value: 600 }) - wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 700, width: 100, height: 34 }) - fireEvent(window, new Event('resize')) - expect(card.style.top).toBe('452px') - expect(card.dataset.placement).toBe('above') - expect(card.style.getPropertyValue('--dsh-hover-card-max-height')).toBe('240px') - }) - it('observes preview and container sizes until dismissal', () => { let resize = () => {} const observe = vi.fn() @@ -246,127 +225,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('pins on click for touch access and keeps the card through pointer leave and blur', () => { - render( - <> -

Existing description

- details} - content={
card body
} - /> - , - ) - const anchor = screen.getByRole('button', { name: 'details' }) - const wrapper = anchor.parentElement as HTMLElement - const touch = new Event('pointerover', { bubbles: true }) - Object.defineProperty(touch, 'pointerType', { value: 'touch' }) - fireEvent(wrapper, touch) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - const card = screen.getByText('card body').parentElement as HTMLElement - expect(card.getAttribute('data-pinned')).toBe('true') - expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${card.id}`) - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.getByText('card body')).toBeTruthy() - - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(card) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('existing-description') - }) - - it('preserves a caller-owned accessible description while click-pinned', () => { - render(<> -

Stable description

- details} - content={
card body
} - /> - ) - const anchor = screen.getByRole('button', { name: 'details' }) - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - fireEvent.pointerDown(document.body) - expect(screen.queryByText('card body')).toBeNull() - expect(anchor.getAttribute('aria-describedby')).toBe('stable-description') - }) - - it('dismisses a click-open card on another click, Escape, or Tab', () => { - const ownerKey = vi.fn() - render(
- details} content={
card body
} /> -
) - const anchor = screen.getByRole('button', { name: 'details' }) - - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.click(anchor) - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - fireEvent.keyDown(anchor, { key: 'a' }) - expect(screen.getByText('card body')).toBeTruthy() - ownerKey.mockClear() - expect(fireEvent.keyDown(anchor, { key: 'Escape' })).toBe(false) - expect(ownerKey).not.toHaveBeenCalled() - expect(screen.queryByText('card body')).toBeNull() - - fireEvent.click(anchor) - expect(fireEvent.keyDown(anchor, { key: 'Tab' })).toBe(true) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('drops a click pin when the card is disabled', () => { - const pinnedCard = (disabled = false) => ( - details} content={
card body
} /> - ) - const view = render(pinnedCard()) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - expect(screen.getByText('card body')).toBeTruthy() - view.rerender(pinnedCard(true)) - expect(screen.queryByText('card body')).toBeNull() - - }) - - it('removes click-pin dismissal listeners on unmount', () => { - const view = render( - details} content={
card body
} />, - ) - const registrations = vi.spyOn(document, 'addEventListener') - const removals = vi.spyOn(document, 'removeEventListener') - fireEvent.click(screen.getByRole('button', { name: 'details' })) - expect(screen.getByText('card body')).toBeTruthy() - - const listeners = registrations.mock.calls.filter(([type]) => type === 'keydown' || type === 'pointerdown') - expect(listeners.map(([type]) => type).sort()).toEqual(['keydown', 'pointerdown']) - view.unmount() - - for (const [type, listener, options] of listeners) { - const removed = removals.mock.calls.find(([removedType, removedListener]) => - removedType === type && removedListener === listener) - expect(removed).toBeDefined() - expect(removed?.[2]).toBe(options) - } - expect(fireEvent.keyDown(document.body, { key: 'Escape' })).toBe(true) - fireEvent.pointerDown(document.body) - }) - it('dismisses on keyboard clicks and cancels a pending hover', () => { const { anchor, wrapper } = mount({ variant: 'preview' }) fireEvent.pointerEnter(wrapper) @@ -443,37 +301,6 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) - it('keeps the card open while its own pointer is inside and closes after leaving it', () => { - const { wrapper } = mount({ variant: 'callout' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').closest('[data-placement]') as HTMLElement - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) }) - fireEvent.pointerEnter(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() - }) - - it('keeps a preview open when the pointer returns directly from its portal to the anchor', () => { - const { anchor, wrapper } = mount({ variant: 'preview' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByText('card body').parentElement as HTMLElement - fireEvent.pointerEnter(card) - fireEvent(card, new MouseEvent('pointerout', { bubbles: true, relatedTarget: anchor })) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS + 100) }) - expect(card.hasAttribute('data-closing')).toBe(false) - expect(screen.getByText('card body')).toBeTruthy() - fireEvent.pointerLeave(wrapper) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - act(() => { vi.advanceTimersByTime(100) }) - expect(screen.queryByText('card body')).toBeNull() - }) - it('re-entering while open does not queue a second dwell', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) @@ -829,22 +656,6 @@ it('opens inline previews only for keyboard focus and closes when focus leaves t matches.mockRestore() }) -it('keeps an inline preview open when the pointer leaves its focused anchor', () => { - render(details} content={thumbnail} />) - const anchor = screen.getByRole('button', { name: 'details' }) - const matches = vi.spyOn(anchor, 'matches').mockReturnValue(true) - fireEvent.focus(anchor) - expect(screen.getByText('thumbnail')).toBeTruthy() - const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(anchor) - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.getByText('thumbnail')).toBeTruthy() - activeElement.mockRestore() - fireEvent.blur(anchor, { relatedTarget: document.body }) - expect(screen.queryByText('thumbnail')).toBeNull() - matches.mockRestore() -}) - it('dismisses an inline hover without anchor focus and lets the next Escape reach the owner', () => { const ownerKey = vi.fn() render(
@@ -895,46 +706,3 @@ it('flips inline media above a bottom link and constrains it without covering th expect(card.style.top).toBe('42px') expect(Number.parseFloat(card.style.maxHeight)).toBe(window.innerHeight - 50) }) - -it('closes a compact copyable card after its mouse-focused card loses the pointer', () => { - const { wrapper } = mount({ copyText: 'value' }) - fireEvent.pointerEnter(wrapper) - act(() => { vi.advanceTimersByTime(500) }) - const card = screen.getByRole('button', { name: 'Copy: value' }) - act(() => { card.focus() }) - expect(document.activeElement).toBe(card) - fireEvent.pointerLeave(card) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('card body')).toBeNull() -}) - -it('closes a callout hover after click toggling leaves mouse focus on the anchor', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - act(() => { anchor.focus() }) - fireEvent.click(anchor) - fireEvent.click(anchor) - fireEvent.pointerEnter(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(500) }) - expect(screen.getByText('body')).toBeTruthy() - fireEvent.pointerLeave(anchor.parentElement as HTMLElement) - act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) - expect(screen.queryByText('body')).toBeNull() -}) - -it('scrolls an open callout with the focused anchor keyboard controls', () => { - render(details} content="body" />) - const anchor = screen.getByRole('button', { name: 'details' }) - fireEvent.click(anchor) - const scroll = document.querySelector('[data-hover-card-scroll]') as HTMLElement - Object.defineProperty(scroll, 'clientHeight', { configurable: true, value: 160 }) - Object.defineProperty(scroll, 'scrollHeight', { configurable: true, value: 640 }) - expect(fireEvent.keyDown(scroll, { key: 'PageDown' })).toBe(true) - expect(scroll.scrollTop).toBe(0) - for (const [key, top] of [['ArrowDown', 20], ['ArrowUp', 0], ['PageDown', 160], ['PageUp', 0], ['End', 640], ['Home', 0]] as const) { - expect(fireEvent.keyDown(anchor, { key })).toBe(false) - expect(scroll.scrollTop).toBe(top) - } - fireEvent.keyDown(anchor, { key: 'Tab' }) - expect(screen.queryByText('body')).toBeNull() -}) From 012bfe03e2b34fee592785ffae2c78ae14f1f71a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 10:47:02 +0800 Subject: [PATCH 52/73] style(ui-user-questions): expand long copy with chevron icon buttons The expand and collapse controls reuse the composer's 24px icon button with the existing downward and upward chevron icons instead of text. Each carries a localized accessible name, which a portaled Tooltip shows on hover. The fade at the end of clipped copy narrows to the 24px button. --- apps/web/tests/question-composer.e2e.ts | 39 ++++++++------- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 41 +++++----------- .../src/client/QuestionComposer.tsx | 49 +++++++++++-------- .../ui-user-questions/src/client/locales.ts | 12 +++-- .../user-questions-composer.client.spec.tsx | 46 +++++++++-------- 8 files changed, 99 insertions(+), 96 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index c96d102bdd..f6a0acfa63 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -70,12 +70,12 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: /** * Read how the composer draws one long-copy element: the lines it shows, - * whether CSS clipped it, whether its text overflows the line budget, and the - * label of its toggle while that toggle is visible. + * whether CSS clipped it, whether its text overflows the line budget, and what + * its toggle does while that toggle is visible. * @param element - a question heading or an option description. * @returns the drawn state of the copy. */ -function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: string | undefined } { +function readCopy(element: HTMLElement): { lines: number; clipped: boolean; overflows: boolean; toggle: 'expand' | 'collapse' | undefined } { const style = getComputedStyle(element) const toggle = element.tagName === 'H2' ? element.nextElementSibling @@ -86,7 +86,7 @@ function readCopy(element: HTMLElement): { lines: number; clipped: boolean; over clipped: style.maskImage !== 'none', overflows: element.scrollHeight > element.clientHeight + 0.5, toggle: toggle instanceof HTMLElement && getComputedStyle(toggle).visibility === 'visible' - ? toggle.textContent ?? undefined + ? toggle.getAttribute('aria-expanded') === 'true' ? 'collapse' : 'expand' : undefined, } } @@ -414,7 +414,7 @@ describe('web e2e: resident question composer round trip', () => { const copy = await description.evaluate(readCopy) expect(copy.lines).toBeLessThanOrEqual(2) expect(copy.clipped).toBe(copy.overflows) - expect(copy.toggle).toBe(copy.clipped ? 'More' : undefined) + expect(copy.toggle).toBe(copy.clipped ? 'expand' : undefined) } scrollStates.push(squeeze.scrolls) expect(squeeze.titleInScrollport).toBe(true) @@ -432,14 +432,14 @@ describe('web e2e: resident question composer round trip', () => { const blue = composer.getByRole('checkbox', { name: 'Blue' }) const blueText = composer.locator('[data-option-description]').first() const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) - await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await blueToggle.click() expect(await blue.getAttribute('aria-checked')).toBe('false') expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') - expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(await blueText.evaluate(readCopy)).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) await blueToggle.click() // A collapsed copy's scroll timeline reports the overflow from the next frame. - await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await page.setViewportSize(originalViewport) // Multi-line custom answer: the field is a textarea whose hidden mirror @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'More' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -726,15 +726,15 @@ describe('web e2e: resident question composer round trip', () => { // Past its three-line budget the question keeps two lines; past its // two-line budget a description keeps one. Copy within budget shows in full. await expect.poll(() => heading.evaluate(readCopy)) - .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Explicit install').evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) expect(await descriptionOf('Keep the default edge').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) for (const copy of await composer.locator('[data-option-description]').all()) { const drawn = await copy.evaluate(readCopy) expect(drawn.clipped).toBe(drawn.overflows) - expect(drawn.toggle).toBe(drawn.clipped ? 'More' : undefined) + expect(drawn.toggle).toBe(drawn.clipped ? 'expand' : undefined) expect(drawn.lines).toBeLessThanOrEqual(drawn.clipped ? 1 : 2) } if (viewport.width === 512) { @@ -758,15 +758,17 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') const expandedQuestion = await heading.evaluate(readCopy) - expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) expect(await questionToggle.evaluate(belowText, await heading.elementHandle())).toBe(true) expect(await readMetrics()).toEqual({ footerFits: true, scrolls: true, titleInScrollport: true }) await questionToggle.click() - await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'More' }) + await expect.poll(() => heading.evaluate(readCopy)).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) const detailText = composer.getByText(detail, { exact: true }) expect(await detailText.evaluate(node => node.closest('[data-question-scroll]') !== null)).toBe(true) @@ -782,10 +784,11 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) - expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'Show less' }) + expect(expandedDescription).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedDescription.lines).toBeGreaterThan(2) expect(await descriptionToggle.evaluate(belowText, await explicitText.elementHandle())).toBe(true) expect(await explicitText.evaluate((text) => { @@ -794,7 +797,7 @@ describe('web e2e: resident question composer round trip', () => { child.getBoundingClientRect().bottom <= row.getBoundingClientRect().bottom + 0.5) })).toBe(true) - // The toggle's ink stays readable on the card in both themes. + // The toggle's icon keeps non-text contrast on the card in both themes. const palette: string[] = [] const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme') try { @@ -814,7 +817,7 @@ describe('web e2e: resident question composer round trip', () => { const foreground = luminance(colors.text) const background = luminance(colors.surface) expect((Math.max(foreground, background) + 0.05) / (Math.min(foreground, background) + 0.05)) - .toBeGreaterThanOrEqual(4.5) + .toBeGreaterThanOrEqual(3) palette.push(colors.surface) } expect(palette[0]).not.toBe(palette[1]) @@ -826,7 +829,7 @@ describe('web e2e: resident question composer round trip', () => { } await descriptionToggle.click() await expect.poll(() => explicitText.evaluate(readCopy)) - .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'More' }) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) await composer.getByRole('checkbox', { name: 'Explicit install' }).click() const answer = composer.getByRole('textbox') diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 68f0c3442a..974ab9c64e 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: fda2aac4a93d6550 - zh: aa5ba8322b566f37 + en: 2173107a23e9f92a + zh: d3a6d17e50dd4b51 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index f31e20910f..9f2fe46110 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and offers **More** there, which expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and **More**. Expanded copy keeps **Show less** on a line below it. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index c487cdc47b..2ac78bf4c1 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并提供「全部」,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并提供「全部」。展开后的文字下方单独一行显示「收起」。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index a2de7c3ff6..fa75de76e4 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -109,7 +109,7 @@ /* Long copy keeps one line fewer than its budget once it overflows: a question shows in full up to three lines, an option description up to two; longer copy drops its last line, fades at the end of the line before, and - shows its toggle there. Each box keeps its full budget, plus a 2px + shows its expand button there. Each box keeps its full budget, plus a 2px allowance for glyphs taller than the line, so the scroll timeline that reports the overflow never depends on the collapsed layout. A negative bottom margin, not a smaller box, gives the dropped line back to the row. */ @@ -121,7 +121,7 @@ } .questionTitle[data-expanded] { - padding-bottom: 22px; + padding-bottom: 24px; } .titlePreview { @@ -142,7 +142,7 @@ @keyframes question-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh, 100% 1lh; mask-position: 0 0, 0 1lh; mask-repeat: no-repeat; @@ -151,8 +151,8 @@ .questionToggle { position: absolute; - right: -6px; - bottom: 0; + right: -4px; + bottom: -1px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --question-copy; @@ -400,7 +400,7 @@ @keyframes description-clipped { from, to { margin-bottom: calc(-1lh - 2px); - mask-image: linear-gradient(to right, #000 calc(100% - 72px), transparent calc(100% - 48px)); + mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh; mask-repeat: no-repeat; } @@ -408,16 +408,16 @@ .descriptionToggle { position: absolute; - right: 6px; - bottom: 7px; + right: 8px; + bottom: 6px; animation-name: copy-toggle-shown; animation-fill-mode: both; animation-timeline: --description-copy; } -/* Expanded copy keeps its toggle on a line of its own below the text. */ +/* Expanded copy keeps its collapse button on a line of its own below the text. */ .optionRow[data-expanded] .option { - padding-bottom: 30px; + padding-bottom: 32px; } .questionTitle[data-expanded] .titlePreview, @@ -426,18 +426,8 @@ animation: none; } +/* Shares .iconButton's 24px round control; hidden until its copy overflows or expands. */ .copyToggle { - padding: 1px 6px; - border: 0; - border-radius: var(--dsw-radius-sm); - background: transparent; - color: var(--dsw-alias-label-secondary); - font: inherit; - font-size: 13px; - line-height: 20px; - font-weight: 400; - white-space: nowrap; - cursor: pointer; visibility: hidden; } @@ -453,13 +443,6 @@ animation: none; } -@media (hover: hover) { - .copyToggle:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); - } -} - /* Without scroll timelines clipped copy cannot be told apart, so it shows in full. */ @supports not (animation-timeline: scroll()) { .titlePreview, @@ -640,7 +623,7 @@ } .descriptionToggle { - right: 0; + right: 2px; } .footer { diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index cfd8eeed0c..b8cdeae85b 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import { Button, IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, - IconEditOutlineRegular, MarkdownText, + IconEditOutlineRegular, MarkdownText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import { planReviewOf, type QuestionAnswer, type QuestionCardSnapshot, type QuestionComposerProps } from './contract/slots.ts' import type { PendingQuestion } from './contract/slots.ts' @@ -250,6 +250,8 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { const next = new Set(current) @@ -524,15 +526,18 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi > {question.question} - + + +
{question.detail !== undefined && (
@@ -543,6 +548,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) + const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{hasDescription && ( - + + + )}
) diff --git a/packages/client/ui-user-questions/src/client/locales.ts b/packages/client/ui-user-questions/src/client/locales.ts index 4995c365e4..0e8d3e6bdf 100644 --- a/packages/client/ui-user-questions/src/client/locales.ts +++ b/packages/client/ui-user-questions/src/client/locales.ts @@ -25,8 +25,10 @@ export const zh = { 'nav.maximize': '展开问题卡片', 'nav.cancel': '放弃整组问题', 'nav.close': '收起问题面板,可从工具调用重新打开', - 'text.expand': '全部', - 'text.collapse': '收起', + 'question.expand': '展开完整问题', + 'question.collapse': '收起问题', + 'option.expand': '展开“{option}”的完整说明', + 'option.collapse': '收起“{option}”的说明', 'option.recommended': '推荐', 'custom.placeholder': '输入你的答案', 'action.skip': '跳过', @@ -65,8 +67,10 @@ export const en = { 'nav.maximize': 'Expand the question card', 'nav.cancel': 'Dismiss all questions', 'nav.close': 'Close the panel — reopen it from the tool call', - 'text.expand': 'More', - 'text.collapse': 'Show less', + 'question.expand': 'Show the full question', + 'question.collapse': 'Show less of the question', + 'option.expand': 'Show the full description of “{option}”', + 'option.collapse': 'Show less of “{option}”', 'option.recommended': 'Recommended', 'custom.placeholder': 'Type your answer', 'action.skip': 'Skip', diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index c1ab2c6f7f..7562833067 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -315,32 +315,34 @@ describe('QuestionComposer', () => { // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '全部' }) - expect(rest).toHaveLength(0) - expect(questionToggle?.getAttribute('aria-controls')).toBe(heading.id) + const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) + const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) expect(option.getAttribute('aria-describedby')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-controls')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-describedby')).toBe(text.id) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('false') + expect(descriptionToggle.getAttribute('aria-controls')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-describedby')).toBe(text.id) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(descriptionToggle as HTMLElement) - expect(descriptionToggle?.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle?.textContent).toBe('收起') + fireEvent.click(descriptionToggle) + expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') - expect(questionToggle?.getAttribute('aria-expanded')).toBe('false') + expect(questionToggle.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(questionToggle as HTMLElement) - expect(questionToggle?.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(questionToggle) + expect(questionToggle.getAttribute('aria-expanded')).toBe('true') + expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) - fireEvent.click(questionToggle as HTMLElement) - fireEvent.click(descriptionToggle as HTMLElement) + fireEvent.click(questionToggle) + fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: '全部' })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -361,8 +363,9 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: '全部' })) fireEvent.click(toggle) - expect(screen.getAllByRole('button', { name: '收起' })).toHaveLength(2) + for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) + expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') + expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -370,11 +373,12 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getAllByRole('button', { name: '全部' })[1] - expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) + expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -948,7 +952,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: '全部' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From 5d5bd8eb412c1a520fa8ce95a5139afdb643fde3 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:03:13 +0800 Subject: [PATCH 53/73] test(web): re-check long question copy across chat and window widths A long question with long, width-sensitive, and short option descriptions is driven through the transcript width handle at its narrowest and widest positions, an opened and collapsed right sidebar, and a smaller and restored window. At each settled width every collapsed copy follows its own overflow, the expanded description stays expanded, and the actions stay reachable; collapsing the sidebar or restoring the window returns the wide state exactly. --- apps/web/tests/question-composer.e2e.ts | 153 ++++++++++++++++++++++++ 1 file changed, 153 insertions(+) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index f6a0acfa63..a120479c59 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -861,6 +861,159 @@ describe('web e2e: resident question composer round trip', () => { } }, 60_000) + it.skipIf(MODE === 'record')('re-evaluates long copy when the chat column or the window changes width', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-question-chat-width')) + const sessionId = answeredSession + expect(sessionId).toBeDefined() + const agent = scaffold.ctx.agents.get(sessionId as SessionId) + expect(agent).toBeDefined() + // Sized for the chat-width drag range: the question takes three lines or + // fewer in the widest chat column and more in the narrowest, and the staged + // description takes two lines or fewer and more. + const question = `${Array.from({ length: 2 }, () => 'The rollout plan must name the release owner, the verification lanes that gate it, and the rollback path before the candidate is cut.').join(' ')} Which plan should we adopt?` + const staged = 'Stage the rollout behind the release channel first, keep the experimental client out of every shipped template, ask plugin authors to declare the dependency explicitly, and publish the upgrade note together with the cross-platform validation results.' + const explicit = Array.from({ length: 24 }, (_, index) => + `Step ${String(index + 1)} removes the experimental client from shipped templates and records the validation before rollout.`).join(' ') + const controller = new AbortController() + const asked = scaffold.ctx.userQuestions.ask({ + agent: agent as NonNullable, + signal: controller.signal, + questions: [{ + id: 'rollout', + header: 'Rollout decision', + question, + options: [ + { label: 'Explicit install', description: explicit }, + { label: 'Stage the rollout', description: staged }, + { label: 'Keep the default edge', description: 'Keep the current setup.' }, + ], + }], + }) + const outcome = asked.then(value => ({ value }), (error: unknown) => ({ error })) + + const composer = page.locator('[data-question-key]') + const frame = page.locator('[style*="grid-template-columns"]').first() + const original = page.viewportSize() ?? { width: 1680, height: 1000 } + let bodyCompleted = false + try { + await page.setViewportSize({ width: 1680, height: 1000 }) + await composer.waitFor({ timeout: 30_000 }) + const heading = composer.getByRole('heading', { name: question }) + const descriptionOf = (label: string) => composer.locator('[data-option-row]', { + has: page.getByRole('radio', { name: label, exact: true }), + }).locator('[data-option-description]') + const toggleFor = async (copy: Locator) => + composer.locator(`button[aria-controls=${JSON.stringify(await copy.getAttribute('id'))}]`) + // Settled layout: no AppFrame column transition in flight, then every + // collapsed copy drawn as its own overflow decides, the expanded copy + // still expanded, and the actions reachable. + const settle = async () => { + await expect.poll(() => frame.evaluate(node => node.hasAttribute('data-animating'))).toBe(false) + const read = async () => { + const card = await composer.locator('section').evaluate((section) => { + const scroll = section.querySelector('[data-question-scroll]') + const submit = [...section.querySelectorAll('button')] + .find(button => button.textContent === 'Submit') + const submitBox = submit?.getBoundingClientRect() + return { + width: Math.round(section.getBoundingClientRect().width), + actionsReachable: submitBox !== undefined && submitBox.top >= -0.5 && submitBox.bottom <= window.innerHeight + 0.5, + titleInScrollport: scroll?.contains(section.querySelector('h2')) === true, + } + }) + return { + card, + question: await heading.evaluate(readCopy), + explicit: await descriptionOf('Explicit install').evaluate(readCopy), + staged: await descriptionOf('Stage the rollout').evaluate(readCopy), + short: await descriptionOf('Keep the default edge').evaluate(readCopy), + } + } + await expect.poll(async () => { + const state = await read() + const decided = (copy: Awaited>['question'], budget: number) => + copy.clipped === copy.overflows && copy.toggle === (copy.clipped ? 'expand' : undefined) + && copy.lines <= (copy.clipped ? budget - 1 : budget) + return state.card.actionsReachable && state.card.titleInScrollport + && decided(state.question, 3) && decided(state.staged, 2) && decided(state.short, 2) + && !state.explicit.clipped && !state.explicit.overflows && state.explicit.toggle === 'collapse' + }).toBe(true) + return await read() + } + // The right transcript handle widens the content column by twice its + // outward travel. The composer seat covers the handle's lower part, so + // the gesture grabs it beside the transcript. + const dragChatWidth = async (travel: number) => { + const box = await page.locator('[data-width-handle="right"]').boundingBox() + if (box === null) throw new Error('The chat width handle is not rendered') + const x = box.x + box.width / 2 + const y = box.y + 120 + await page.mouse.move(x, y) + await page.mouse.down() + try { + await page.mouse.move(x + travel, y, { steps: 8 }) + } finally { + await page.mouse.up() + } + } + + // Expanded copy is the user's choice and survives every width change below. + const explicitToggle = await toggleFor(descriptionOf('Explicit install')) + await explicitToggle.click() + expect(await explicitToggle.getAttribute('aria-expanded')).toBe('true') + expect(await composer.getByRole('radio', { name: 'Explicit install', exact: true }).getAttribute('aria-checked')).toBe('false') + + await dragChatWidth(-400) + const narrow = await settle() + expect(narrow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(narrow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + + await dragChatWidth(600) + const wide = await settle() + expect(wide.card.width).toBeGreaterThan(narrow.card.width) + expect(wide.question).toMatchObject({ clipped: false, overflows: false, toggle: undefined }) + expect(wide.staged).toEqual({ lines: 2, clipped: false, overflows: false, toggle: undefined }) + expect(wide.short).toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) + + // The right sidebar narrows the conversation column, and collapsing it restores the wide state. + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + const withSidebar = await settle() + expect(withSidebar.card.width).toBeLessThan(wide.card.width) + await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() + expect(await settle()).toEqual(wide) + + // A smaller window clamps the dragged width; restoring the window restores it. + await page.setViewportSize({ width: 900, height: 700 }) + const smallWindow = await settle() + expect(smallWindow.card.width).toBeLessThan(wide.card.width) + expect(smallWindow.question).toEqual({ lines: 2, clipped: true, overflows: true, toggle: 'expand' }) + expect(smallWindow.staged).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await page.setViewportSize({ width: 1680, height: 1000 }) + expect(await settle()).toEqual(wide) + + await explicitToggle.click() + await expect.poll(() => descriptionOf('Explicit install').evaluate(readCopy)) + .toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + await composer.getByRole('radio', { name: 'Stage the rollout', exact: true }).click() + await composer.getByRole('button', { name: 'Submit', exact: true }).click() + expect(await outcome).toEqual({ value: { answers: [{ id: 'rollout', selected: ['Stage the rollout'] }] } }) + await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) + bodyCompleted = true + } finally { + controller.abort() + // The dragged width is a persisted preference; dropping it and resizing republishes the default. + const cleanup = await Promise.allSettled([ + outcome, + page.evaluate(() => { localStorage.removeItem('dsh.conversation.contentWidth') }) + .then(() => page.setViewportSize(original)), + ]) + if (bodyCompleted) { + const failure = cleanup.find((result): result is PromiseRejectedResult => result.status === 'rejected') + if (failure !== undefined) throw failure.reason + } + } + }, 90_000) + }) describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { From f8a094e4656594d9ec99feb688e5559268e23df2 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 11:48:30 +0800 Subject: [PATCH 54/73] fix(ui-user-questions): label every copy toggle Expand or Collapse Option names made the toggle tooltips as long and unpredictable as the option labels themselves. Every question and description toggle now uses the same Expand and Collapse copy, and the question toggle, like the description toggles, is described by the text it controls. --- apps/web/tests/question-composer.e2e.ts | 9 ++++--- .../client/ui-user-questions/README.i18n.yaml | 4 +-- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.tsx | 5 ++-- .../ui-user-questions/src/client/locales.ts | 12 +++------ .../user-questions-composer.client.spec.tsx | 27 +++++++++---------- 7 files changed, 29 insertions(+), 32 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index a120479c59..0861e3a8ba 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -622,7 +622,7 @@ describe('web e2e: resident question composer round trip', () => { await composer.getByRole('heading', { name: '选择 HF provider 的思考等级?' }).waitFor() expect(await composer.getByRole('heading').evaluate(readCopy)) .toEqual({ lines: 1, clipped: false, overflows: false, toggle: undefined }) - expect(await composer.getByRole('button', { name: 'Show the full question' }).count()).toBe(0) + expect(await composer.getByRole('button', { name: 'Expand', exact: true }).count()).toBe(0) const rows = await composer.locator('[data-option-row]').evaluateAll(options => options.map((row) => { const label = row.querySelector('[data-option-line] > span:first-child') const description = row.querySelector('[data-option-description]') @@ -758,10 +758,11 @@ describe('web e2e: resident question composer round trip', () => { // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) expect(await questionToggle.getAttribute('aria-expanded')).toBe('false') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show the full question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Expand') + expect(await questionToggle.getAttribute('aria-describedby')).toBe(await heading.getAttribute('id')) await questionToggle.click() expect(await questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(await questionToggle.getAttribute('aria-label')).toBe('Show less of the question') + expect(await questionToggle.getAttribute('aria-label')).toBe('Collapse') const expandedQuestion = await heading.evaluate(readCopy) expect(expandedQuestion).toMatchObject({ clipped: false, overflows: false, toggle: 'collapse' }) expect(expandedQuestion.lines).toBeGreaterThan(3) @@ -784,7 +785,7 @@ describe('web e2e: resident question composer round trip', () => { const descriptionId = await explicitText.getAttribute('id') expect(await explicit.getAttribute('aria-describedby')).toBe(descriptionId) expect(await descriptionToggle.getAttribute('aria-describedby')).toBe(descriptionId) - expect(await descriptionToggle.getAttribute('aria-label')).toBe('Show the full description of “Explicit install”') + expect(await descriptionToggle.getAttribute('aria-label')).toBe('Expand') await descriptionToggle.click() expect(await explicit.getAttribute('aria-checked')).toBe('false') const expandedDescription = await explicitText.evaluate(readCopy) diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 974ab9c64e..09db781665 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -18,8 +18,8 @@ en: 8d3815c19858ac85 zh: 94fc905fa202d334 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/answering: - en: 2173107a23e9f92a - zh: d3a6d17e50dd4b51 + en: 244a344475118aa3 + zh: 8ca447340de5d341 /deepseek-ai-dsh-client-ui-user-questions/use-this-package/reading-a-settled-question-back: en: 10975f68ff9c6fcc zh: 92246aadc363f0eb diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index 9f2fe46110..d730c104df 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -29,7 +29,7 @@ When the agent asks a question, the attached card temporarily occupies the compo ### Answering -A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons carry a localized accessible name and a hover tooltip. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. +A multi-select draft keeps its selected labels while the user opens or edits the custom answer, so its submitted item may carry both `selected` and `custom`; a single-select custom answer remains exclusive. Question detail reuses the assistant-output `MarkdownText` primitive, including its GFM rendering and untrusted-content policy. The capped card keeps navigation and submission actions fixed while the question, long detail, and choices share an internal scroll region. A question appears in full up to three rendered lines; a longer one shows two, fades at the end of the second, and places a downward-chevron expand button there that expands it in place. Option descriptions use 13px text with a 20px line height and sit beside their label when they fit, otherwise below it; a description appears in full up to two lines, and a longer one shows one line with the same fade and expand button. Expanded copy keeps an upward-chevron collapse button on a line below it. Both buttons are named **Expand** or **Collapse**, show that name in a hover tooltip, and are described by the text they control. CSS scroll timelines decide which copy overflows; a browser without them shows all copy in full. "Skip" retains other drafts and emits the existing blank `{ selected: [] }` result for that item. Close is not an answer: a question the Host named by tool call only leaves the composer seat, and its `ask_user_question` tool call row brings the panel back; a request that carries no tool call has no row to return from, so closing it rejects the whole wait as `ASK_CANCELLED`. ### Reading a settled question back diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 2ac78bf4c1..dba1010bec 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -29,7 +29,7 @@ Web 客户端在聊天输入框旁显示 agent(智能体)的提问。用户 ### 作答 -用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮都有本地化的无障碍名称和悬停提示。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 +用户打开或编辑自定义答案时,多选题草稿会保留已选中的标签,因此提交项可以同时携带 `selected` 与 `custom`;单选题的自定义答案仍保持互斥。问题详情复用助手输出的 `MarkdownText` 原语,包括其 GFM 渲染与不受信任内容策略。限高卡片保持导航与提交动作固定,问题正文、超长详情和选项共享内部滚动区。问题在实际排版不超过三行时完整显示;更长的问题只显示两行,第二行末尾渐隐并放置向下箭头的展开按钮,点击后原地展开。选项说明使用 13px 字号和 20px 行高,放得下时排在标签旁,否则排在标签下方;说明不超过两行时完整显示,更长的说明只显示一行,同样渐隐并放置展开按钮。展开后的文字下方单独一行显示向上箭头的收起按钮。两种按钮名为「展开」或「收起」,悬停时显示同样的提示,并以其控制的文字作为无障碍描述。由 CSS 滚动时间线判断文字是否溢出;不支持该特性的浏览器完整显示全部文字。「跳过」会保留其他草稿,并为该项发出既有的空 `{ selected: [] }` 结果。关闭不是一种回答:Host 以工具调用命名的问题只是离开编辑器位置,其 `ask_user_question` 工具调用行会把面板带回来;不携带工具调用的请求没有可返回的行,关闭它仍以 `ASK_CANCELLED` 拒绝整个等待。 ### 回看已结束的问题 diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index b8cdeae85b..5c00ccbf53 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -250,7 +250,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // CSS decides which copy is clipped; only the user's expansions are state. const [expanded, setExpanded] = useState(NOTHING_EXPANDED) - const questionToggleLabel = t(expanded.has('question') ? 'question.collapse' : 'question.expand') + const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') const toggleCopy = (copy: 'question' | number): void => { setExpanded((current) => { @@ -533,6 +533,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi aria-label={questionToggleLabel} aria-expanded={expanded.has('question')} aria-controls={`question-${pending.key}-${String(index)}`} + aria-describedby={`question-${pending.key}-${String(index)}`} onClick={() => { toggleCopy('question') }} > {expanded.has('question') ? : } @@ -548,7 +549,7 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi const display = parseRecommendedLabel(option.label) const hasDescription = option.description !== undefined && option.description.trim() !== '' const descriptionExpanded = expanded.has(optionIndex) - const descriptionToggleLabel = t(descriptionExpanded ? 'option.collapse' : 'option.expand', { option: display.label }) + const descriptionToggleLabel = t(descriptionExpanded ? 'text.collapse' : 'text.expand') const descriptionId = `${descriptionIdPrefix}-question-${String(index)}-option-${String(optionIndex)}-description` return (
{ // CSS shows each toggle only while its copy overflows; every candidate is rendered. const heading = screen.getByRole('heading', { name: 'How should the release graph change?' }) - const questionToggle = screen.getByRole('button', { name: '展开完整问题' }) - const descriptionToggle = screen.getByRole('button', { name: '展开“Explicit install”的完整说明' }) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + const [questionToggle, descriptionToggle, ...rest] = screen.getAllByRole('button', { name: '展开' }) + expect(rest).toHaveLength(0) + if (questionToggle === undefined || descriptionToggle === undefined) throw new Error('Both copy toggles must render') + expect(questionToggle.getAttribute('aria-describedby')).toBe(heading.id) expect(questionToggle.getAttribute('aria-controls')).toBe(heading.id) const option = screen.getByRole('radio', { name: 'Explicit install' }) const text = screen.getByText(description) @@ -328,7 +329,7 @@ describe('QuestionComposer', () => { fireEvent.click(descriptionToggle) expect(descriptionToggle.getAttribute('aria-expanded')).toBe('true') - expect(descriptionToggle.getAttribute('aria-label')).toBe('收起“Explicit install”的说明') + expect(descriptionToggle.getAttribute('aria-label')).toBe('收起') expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(true) expect(screen.getByText(description)).toBe(text) expect(option.getAttribute('aria-checked')).toBe('false') @@ -336,13 +337,13 @@ describe('QuestionComposer', () => { fireEvent.click(questionToggle) expect(questionToggle.getAttribute('aria-expanded')).toBe('true') - expect(questionToggle.getAttribute('aria-label')).toBe('收起问题') + expect(questionToggle.getAttribute('aria-label')).toBe('收起') expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(true) fireEvent.click(questionToggle) fireEvent.click(descriptionToggle) expect(heading.parentElement?.hasAttribute('data-expanded')).toBe(false) expect(text.closest('[data-option-row]')?.hasAttribute('data-expanded')).toBe(false) - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })).toEqual([questionToggle, descriptionToggle]) + expect(screen.getAllByRole('button', { name: '展开' })).toEqual([questionToggle, descriptionToggle]) }) it('resets scrolling and expanded copy when the question changes', () => { @@ -363,9 +364,8 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'First question?' })).toBeTruthy() const firstScroll = screen.getByText('First question?').closest('[data-question-scroll]') as HTMLElement firstScroll.scrollTop = 80 - for (const toggle of screen.getAllByRole('button', { name: /^展开(完整问题|“)/ })) fireEvent.click(toggle) - expect(screen.getByRole('button', { name: '收起问题' }).getAttribute('aria-expanded')).toBe('true') - expect(screen.getByRole('button', { name: '收起“Continue”的说明' }).getAttribute('aria-expanded')).toBe('true') + for (const toggle of screen.getAllByRole('button', { name: '展开' })) fireEvent.click(toggle) + expect(screen.getAllByRole('button', { name: '收起' }).map(toggle => toggle.getAttribute('aria-expanded'))).toEqual(['true', 'true']) fireEvent.click(screen.getByRole('radio', { name: 'Continue' })) const secondScroll = screen.getByText('Second question?').closest('[data-question-scroll]') as HTMLElement @@ -373,12 +373,11 @@ describe('QuestionComposer', () => { expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() expect(secondScroll).toBe(firstScroll) expect(secondScroll.scrollTop).toBe(0) - expect(screen.queryByRole('button', { name: '收起问题' })).toBeNull() - expect(screen.queryByRole('button', { name: '收起“Continue”的说明' })).toBeNull() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() expect(secondScroll.querySelector('[data-expanded]')).toBeNull() expect(screen.queryByText(firstDescription)).toBeNull() - const descriptionToggle = screen.getByRole('button', { name: '展开“Continue”的完整说明' }) - expect(document.getElementById(descriptionToggle.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) + const descriptionToggle = screen.getAllByRole('button', { name: '展开' })[1] + expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) it('renders plan detail through the shared assistant Markdown primitive', () => { @@ -952,7 +951,7 @@ describe('review card', () => { expect(chosen.getAttribute('aria-checked')).toBe('true') expect(chosen.disabled).toBe(true) // Long copy stays readable on a frozen card. - expect(screen.getAllByRole('button', { name: /^展开(完整问题|“)/ }).map(toggle => toggle.disabled)).toEqual([false, false, false]) + expect(screen.getAllByRole('button', { name: '展开' }).map(toggle => toggle.disabled)).toEqual([false, false, false]) // Nothing is left to send, and the unused free-text field would read as // somewhere to type. expect(screen.queryByRole('button', { name: '跳过' })).toBeNull() From fb290b388d92a73cb9ee189c8c44242bf8ec10fa Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 12:02:02 +0800 Subject: [PATCH 55/73] fix(ui-user-questions): clip dropped lines from hit testing A collapsed question or description keeps its full line budget and only masked its dropped line, so that transparent line still took clicks: it covered the first line of the question detail and the top edge of the custom answer row. clip-path now removes the line from hit testing as well as from view, and Web e2e hit tests pin both targets. Expansions also record the question they belong to and apply only while that question is shown, so a position change from another surface of the same Session no longer carries them to a different question. `.title` returns to its base margin; `.questionTitle` owns the flex sizing. --- apps/web/tests/question-composer.e2e.ts | 16 +++++++++++ .../src/client/QuestionComposer.module.css | 9 ++++--- .../src/client/QuestionComposer.tsx | 27 +++++++++++-------- .../question-composer-styles.client.spec.ts | 6 +++-- .../user-questions-composer.client.spec.tsx | 18 +++++++++++++ 5 files changed, 59 insertions(+), 17 deletions(-) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 0861e3a8ba..1e06d0cbb6 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -433,6 +433,15 @@ describe('web e2e: resident question composer round trip', () => { const blueText = composer.locator('[data-option-description]').first() const blueToggle = composer.locator(`button[aria-controls=${JSON.stringify(await blueText.getAttribute('id'))}]`) await expect.poll(() => blueText.evaluate(readCopy)).toEqual({ lines: 1, clipped: true, overflows: true, toggle: 'expand' }) + // A clipped description's dropped line takes no clicks: the custom row's top edge belongs to it. + expect(await composer.locator('[data-option-description]').last().evaluate(readCopy)).toMatchObject({ clipped: true }) + expect(await composer.locator('textarea').evaluate((field) => { + const row = field.closest('[class*="customRow"]') + if (!(row instanceof HTMLElement)) return false + const box = row.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 2) + return hit !== null && row.contains(hit) + })).toBe(true) await blueToggle.click() expect(await blue.getAttribute('aria-checked')).toBe('false') expect(await blueToggle.getAttribute('aria-expanded')).toBe('true') @@ -754,6 +763,13 @@ describe('web e2e: resident question composer round trip', () => { expect(scrollStates).toContain(false) expect(scrollStates).toContain(true) + // A clipped question's dropped line takes no clicks: the detail's first line belongs to the detail. + expect(await composer.getByText(detail, { exact: true }).evaluate((text) => { + const box = text.getBoundingClientRect() + const hit = document.elementFromPoint(box.left + 8, box.top + 3) + return hit !== null && text.contains(hit) + })).toBe(true) + // The question expands in place; its choices move down inside the // scrollport while navigation and submission stay reachable. const questionToggle = await toggleFor(heading) diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css index fa75de76e4..5c530aef72 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.module.css +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.module.css @@ -99,8 +99,7 @@ } .title { - flex-shrink: 0; - margin: 5px 24px 0; + margin: 0; font-size: 16px; line-height: 22px; font-weight: 500; @@ -112,7 +111,8 @@ shows its expand button there. Each box keeps its full budget, plus a 2px allowance for glyphs taller than the line, so the scroll timeline that reports the overflow never depends on the collapsed layout. A negative - bottom margin, not a smaller box, gives the dropped line back to the row. */ + bottom margin, not a smaller box, gives the dropped line back to the row, + and clip-path removes that line from hit testing as well as from view. */ .questionTitle { position: relative; flex-shrink: 0; @@ -142,6 +142,7 @@ @keyframes question-clipped { from, to { margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); mask-image: linear-gradient(#000, #000), linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh, 100% 1lh; mask-position: 0 0, 0 1lh; @@ -160,7 +161,6 @@ .cardMinimized .title { display: -webkit-box; - margin: 0; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; @@ -400,6 +400,7 @@ @keyframes description-clipped { from, to { margin-bottom: calc(-1lh - 2px); + clip-path: inset(0 0 calc(1lh + 2px) 0); mask-image: linear-gradient(to right, #000 calc(100% - 56px), transparent calc(100% - 28px)); mask-size: 100% 1lh; mask-repeat: no-repeat; diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 5c00ccbf53..0baf6e54c5 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -27,10 +27,15 @@ const REMOVED_CARD: QuestionCardSnapshot = { state: 'open', waitState: 'counting', countdown: undefined, channel: 'none', closed: true, } -/** Long copy the user expanded: the question itself or an option description by index. */ -type ExpandedCopy = ReadonlySet<'question' | number> +/** Long copy the user expanded on one question: the question itself or an option description by index. */ +interface ExpandedCopy { + /** Index of the question the expansions belong to. */ + readonly index: number + /** Expanded copy of that question: `'question'` or an option index. */ + readonly copies: ReadonlySet<'question' | number> +} -const NOTHING_EXPANDED: ExpandedCopy = new Set() +const NOTHING_EXPANDED: ExpandedCopy['copies'] = new Set() /** * Split the conventional recommendation suffix without changing the answer value. @@ -247,24 +252,24 @@ function QuestionFlow({ pending, t, useStore, useQuestionCard, actions }: Questi // oxlint-disable-next-line typescript/no-non-null-assertion const draft = drafts[index]! const hasOptions = (question.options?.length ?? 0) > 0 - // CSS decides which copy is clipped; only the user's expansions are state. - const [expanded, setExpanded] = useState(NOTHING_EXPANDED) + // CSS decides which copy is clipped; only the user's expansions are state, + // and they apply only while their question is the one shown. + const [expandedCopy, setExpandedCopy] = useState({ index, copies: NOTHING_EXPANDED }) + const expanded = expandedCopy.index === index ? expandedCopy.copies : NOTHING_EXPANDED const questionToggleLabel = t(expanded.has('question') ? 'text.collapse' : 'text.expand') const toggleCopy = (copy: 'question' | number): void => { - setExpanded((current) => { - const next = new Set(current) - if (!next.delete(copy)) next.add(copy) - return next - }) + const copies = new Set(expanded) + if (!copies.delete(copy)) copies.add(copy) + setExpandedCopy({ index, copies }) } const replaceProgress = (nextIndex: number, nextDrafts: QuestionDraftAnswer[]): void => { if (nextIndex !== index) { // Another question starts at its top with its long copy collapsed. if (answerSurface.current !== null) answerSurface.current.scrollTop = 0 - setExpanded(NOTHING_EXPANDED) + setExpandedCopy({ index: nextIndex, copies: NOTHING_EXPANDED }) } actions.replace(pending.key, { index: nextIndex, diff --git a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts index 4774ce8e67..7778302296 100644 --- a/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts +++ b/packages/client/ui-user-questions/tests/question-composer-styles.client.spec.ts @@ -28,7 +28,7 @@ function keyframes(name: string): string[] { describe('QuestionComposer long-copy layout', () => { it('reads question overflow against three lines and keeps two once it overflows', () => { - expect(declarations('.questionTitle')).toContain('timeline-scope: --question-copy') + expect(declarations('.questionTitle')).toEqual(expect.arrayContaining(['flex-shrink: 0', 'timeline-scope: --question-copy'])) expect(declarations('.titlePreview')).toEqual(expect.arrayContaining([ 'max-height: calc(3lh + 2px)', 'margin: 0 0 -2px', @@ -41,6 +41,7 @@ describe('QuestionComposer long-copy layout', () => { ])) expect(keyframes('question-clipped')).toEqual(expect.arrayContaining([ 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', 'mask-size: 100% 1lh, 100% 1lh', 'mask-position: 0 0, 0 1lh', ])) @@ -69,6 +70,7 @@ describe('QuestionComposer long-copy layout', () => { ])) expect(keyframes('description-clipped')).toEqual(expect.arrayContaining([ 'margin-bottom: calc(-1lh - 2px)', + 'clip-path: inset(0 0 calc(1lh + 2px) 0)', 'mask-size: 100% 1lh', ])) expect(declarations('.descriptionToggle')).toEqual(expect.arrayContaining([ @@ -104,6 +106,6 @@ describe('QuestionComposer long-copy layout', () => { 'min-height: 0', 'overflow-y: auto', ])) - expect(declarations('.title')).toContain('flex-shrink: 0') + expect(declarations('.questionTitle')).toContain('flex-shrink: 0') }) }) diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 86650560fb..dfa9541320 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -380,6 +380,24 @@ describe('QuestionComposer', () => { expect(document.getElementById(descriptionToggle?.getAttribute('aria-controls') ?? '')?.textContent).toBe(secondDescription) }) + it('keeps expansions with their question when the shared position moves elsewhere', () => { + const { carrier } = wait([ + { id: 'first', question: 'First question?', options: [{ label: 'Continue', description: 'First detail.' }] }, + { id: 'second', question: 'Second question?', options: [{ label: 'Continue', description: 'Second detail.' }] }, + ]) + render() + fireEvent.click(screen.getAllByRole('button', { name: '展开' })[1] as HTMLElement) + expect(screen.getByRole('button', { name: '收起' })).toBeTruthy() + + // Another surface of the same Session moves the shared question position. + const blank = { selected: [], custom: '', skipped: false } + act(() => { kit.actions.replace(carrier.key, { index: 1, drafts: [blank, blank] }) }) + + expect(screen.getByRole('region', { name: 'Second question?' })).toBeTruthy() + expect(screen.queryByRole('button', { name: '收起' })).toBeNull() + expect(document.querySelector('[data-expanded]')).toBeNull() + }) + it('renders plan detail through the shared assistant Markdown primitive', () => { const { carrier } = wait([{ id: 'plan', From d12498798aaf223f0e6552be6324c870ea3afe44 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:26:54 +0800 Subject: [PATCH 56/73] fix(conversation): add missing Japanese quote removal label The Japanese dictionary landed without the quote removal copy its English and Chinese dictionaries already carry, so the conversation dictionaries do not typecheck against the current master. --- packages/client/ui-conversation/src/client/locales.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..36edb38e56 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', From cf620a98df920b747f858ff3e785f4f16a0cba94 Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:35:29 +0800 Subject: [PATCH 57/73] fix(message-quotes): register the Japanese dictionary The quote plugin's dictionaries predate the Japanese locale merge, so the locale dictionary parity gate rejects its object-form dictionary without Japanese copy. --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..fab1fbc782 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja = { + 'quote.add': 'テキストを引用', +} satisfies Record From c47d3285b5f7f2394438502dba4606e90ac42d6a Mon Sep 17 00:00:00 2001 From: yudshj Date: Wed, 21 Oct 2026 16:50:06 +0800 Subject: [PATCH 58/73] test(snapshots): record the current present tool description The background-wait-steer pinned tool schemas still carry the pre-reword present description, so the snapshot header no longer matches the shipped tool. --- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From 957379e8a908b7fd2e842406f16b2980ebafcdfc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:18:51 +0800 Subject: [PATCH 59/73] test(session-log-export): adapt merged input actions Add the master-introduced insertQuote input action to the isolated Header action fixture so the merged client aggregate typechecks. --- .../session-log-export/tests/header-action.client.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 86a35d5e04..dc085293d5 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -26,7 +26,7 @@ const standard: GlobalStandardProps & SessionStandardProps = { useSessionAccess: () => true, useRemoteAccess: () => true, useWorkspaceAccess: () => true, useConversation: unused, useInput: unused, useChat: unused, useTrajectory: unused, usePanelInfo: unused, useResource: unused, useWorkspaces: unused, - inputActions: { captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, + inputActions: { insertQuote: () => false, captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused, addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused }, } From 0ab1fcb6747af599f31d4de171364761633e00e2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:18:56 +0800 Subject: [PATCH 60/73] fix(session-log-export): clarify Session Markdown copy label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the Header menu item to Copy Session as Markdown (复制会话为 Markdown) so it states that the whole selected Session is copied. Update Header selectors, browser coverage, controller JSDoc, and the bilingual README; success toast wording stays unchanged. --- apps/web/tests/navigation-panes.e2e.ts | 4 ++-- .../session-log-export/README.i18n.yaml | 14 +++++++------- .../session-query/session-log-export/README.md | 8 ++++---- .../session-log-export/README.zh.md | 8 ++++---- .../src/client/copy-controller.ts | 2 +- .../session-log-export/src/client/locales.ts | 4 ++-- .../tests/header-action.client.spec.tsx | 17 +++++++++++------ 7 files changed, 31 insertions(+), 26 deletions(-) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index bce1dd19a4..ae8726c23f 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -448,7 +448,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { && new URL(response.url()).pathname === '/api/session.markdown', { timeout: 30_000 }) const copyButton = page.getByRole('button', { name: 'More actions' }) await copyButton.click() - await page.getByRole('menuitem', { name: 'Copy as Markdown' }).click() + await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click() const response = await markdownResponse expect(response.status()).toBe(200) expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8') @@ -481,7 +481,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(normalized.endsWith('\n\n')).toBe(false) await copyButton.click() - expect(await page.getByRole('menuitem', { name: 'Copy as Markdown' }).isDisabled()).toBe(false) + expect(await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).isDisabled()).toBe(false) await page.keyboard.press('Escape') }, 60_000) diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index 5fe8787069..b4cbc02951 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -9,17 +9,17 @@ en: eea87f5da57ff494 zh: db388203522b840f /deepseek-ai-dsh-session-log-export/summary: - en: 9268b3aaeaf82673 - zh: 99cc5b0de0b5a8bd + en: ee7d348dd7fe684f + zh: 6cd6d5d644176355 /deepseek-ai-dsh-session-log-export/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-session-log-export/use-this-package: - en: f37f81af3601784d - zh: 89b47be0660c8b8b -/deepseek-ai-dsh-session-log-export/use-this-package/copy-as-markdown: - en: 59ba15abb59b6bd0 - zh: 1ea2b05fc05b9e61 + en: bb12bdeafa8571b5 + zh: e7df75c03478f7ed +/deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown: + en: 55c163a4ae5b8d5d + zh: c1dac154ed1fa56d /deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it: en: 4011f686afd24f09 zh: 2710b9995c80ba1f diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 31416a2731..58cf4006df 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow. +`dsh-session-log-export` lets the Web interface export a session's complete history. The Session Header's more-actions menu offers `Copy Session as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,11 +25,11 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy Session as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-.zip`. -### Copy as Markdown +### Copy Session as Markdown -`Copy as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. +`Copy Session as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `
` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation. The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 67b94237e2..01df07877f 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。 +`dsh-session-log-export` 让 Web 界面导出会话的完整历史。Session Header 的更多操作菜单提供 `复制会话为 Markdown` 与 `下载 Session 日志`;`/export` 斜杠命令也会启动 ZIP 下载。ZIP 包含会话本身、其子会话与附件。本包拥有两条经过认证的 Host 路由、Markdown 记录格式化器以及浏览器控件和反馈。下载目标位置由浏览器选择,剪贴板由浏览器持有。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制会话为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-.zip`。 -### 复制为 Markdown +### 复制会话为 Markdown -`复制为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 +`复制会话为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `
` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。 格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。 diff --git a/packages/session-query/session-log-export/src/client/copy-controller.ts b/packages/session-query/session-log-export/src/client/copy-controller.ts index 50eeb0d1cb..7f4baa2a04 100644 --- a/packages/session-query/session-log-export/src/client/copy-controller.ts +++ b/packages/session-query/session-log-export/src/client/copy-controller.ts @@ -1,5 +1,5 @@ /** - * Browser controller for the Session Header's "Copy as Markdown" action. One + * Browser controller for the Session Header's "Copy Session as Markdown" action. One * plugin instance owns one pending copy: the clipboard is a browser-wide * destination, so a second request joins the pending operation instead of * racing it. diff --git a/packages/session-query/session-log-export/src/client/locales.ts b/packages/session-query/session-log-export/src/client/locales.ts index 36dbf042fe..b3bb8ba6e2 100644 --- a/packages/session-query/session-log-export/src/client/locales.ts +++ b/packages/session-query/session-log-export/src/client/locales.ts @@ -4,7 +4,7 @@ export const NS = 'session-log-download' /** Simplified-Chinese Session export strings. */ export const zh = { 'header.more': '更多操作', - 'menu.copyMarkdown': '复制为 Markdown', + 'menu.copyMarkdown': '复制会话为 Markdown', 'menu.download': '下载 Session 日志', 'menu.feedback': '反馈', 'toast.copied': 'Session 已复制为 Markdown', @@ -22,7 +22,7 @@ export const zh = { /** English Session export strings. */ export const en: Record = { 'header.more': 'More actions', - 'menu.copyMarkdown': 'Copy as Markdown', + 'menu.copyMarkdown': 'Copy Session as Markdown', 'menu.download': 'Download session log', 'menu.feedback': 'Feedback', 'toast.copied': 'Session copied as Markdown.', diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index dc085293d5..462064fbad 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -9,7 +9,7 @@ import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' -import { en } from '../src/client/locales.ts' +import { en, zh } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId const clipboard: SessionMarkdownClipboard = { @@ -69,6 +69,11 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo afterEach(cleanup) describe('Session export Header action', () => { + it('names whole-Session copying in English and Chinese', () => { + expect(en['menu.copyMarkdown']).toBe('Copy Session as Markdown') + expect(zh['menu.copyMarkdown']).toBe('复制会话为 Markdown') + }) + it('opens Session feedback and closes the menu without starting a download', () => { const b = bench(true) fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) @@ -82,7 +87,7 @@ describe('Session export Header action', () => { const b = bench() fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) expect(b.view.queryByRole('menuitem', { name: 'Feedback' })).toBeNull() - expect(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })).toBeTruthy() + expect(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })).toBeTruthy() expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy() }) @@ -108,10 +113,10 @@ describe('Session export Header action', () => { const b = bench() fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) expect(b.view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([ - 'Copy as Markdown', + 'Copy Session as Markdown', 'Download session log', ]) - fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy as Markdown' })) + fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' })) await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) }) expect(b.request).not.toHaveBeenCalled() expect(b.view.queryByRole('menu')).toBeNull() @@ -128,7 +133,7 @@ describe('Session export Header action', () => { const button = b.view.getByRole('button', { name: 'More actions' }) await waitFor(() => { expect(button.getAttribute('aria-expanded')).toBe('false') }) fireEvent.click(button) - const copyItem = b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) + const copyItem = b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) }) expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) @@ -188,7 +193,7 @@ describe('Session export Header action', () => { fireEvent.click(button) const item = b.view.getByRole('menuitem', { name: 'Download session log' }) expect((item as HTMLButtonElement).disabled).toBe(true) - expect((b.view.getByRole('menuitem', { name: 'Copy as Markdown' }) as HTMLButtonElement).disabled).toBe(false) + expect((b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false) expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false) release(new Response('zip')) await download From e4e1411a5cb77fcb59838ab94f7b01de6dfd5388 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:24:56 +0800 Subject: [PATCH 61/73] test(session-log-export): remove redundant label assertion Keep the rendered Header and browser scenarios as the copy-action coverage, and describe the backend converter as used by the Session Markdown copy Host route. --- packages/session-query/session-log-export/src/markdown.ts | 6 +++--- .../session-log-export/tests/header-action.client.spec.tsx | 7 +------ 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/session-query/session-log-export/src/markdown.ts b/packages/session-query/session-log-export/src/markdown.ts index 84261bee39..4ebd1d6be4 100644 --- a/packages/session-query/session-log-export/src/markdown.ts +++ b/packages/session-query/session-log-export/src/markdown.ts @@ -1,7 +1,7 @@ /** - * Pure backend converter for one complete committed Session log. The "Copy as - * Markdown" Host route uses it, and the package entry exports it for callers - * that already hold the logical events. The raw log is authoritative: + * Pure backend converter for one complete committed Session log. The Session + * Markdown copy Host route uses it, and the package entry exports it for + * callers that already hold the logical events. The raw log is authoritative: * compaction surface replacements never remove earlier messages, and * fork-inherited history appears once. Reasoning, system and developer * messages, injected non-human context, request headers, and runtime records diff --git a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx index 462064fbad..a8e6321b1a 100644 --- a/packages/session-query/session-log-export/tests/header-action.client.spec.tsx +++ b/packages/session-query/session-log-export/tests/header-action.client.spec.tsx @@ -9,7 +9,7 @@ import { SessionLogDownloadController } from '../src/client/controller.ts' import { SessionMarkdownCopyController, type SessionMarkdownClipboard } from '../src/client/copy-controller.ts' import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx' import type { SessionLogDownloadHeaderProps } from '../src/client/HeaderAction.tsx' -import { en, zh } from '../src/client/locales.ts' +import { en } from '../src/client/locales.ts' const SID = 'session-export-header' as SessionId const clipboard: SessionMarkdownClipboard = { @@ -69,11 +69,6 @@ function bench(feedbackAvailable = false, copyController = new SessionMarkdownCo afterEach(cleanup) describe('Session export Header action', () => { - it('names whole-Session copying in English and Chinese', () => { - expect(en['menu.copyMarkdown']).toBe('Copy Session as Markdown') - expect(zh['menu.copyMarkdown']).toBe('复制会话为 Markdown') - }) - it('opens Session feedback and closes the menu without starting a download', () => { const b = bench(true) fireEvent.click(b.view.getByRole('button', { name: 'More actions' })) From af4360620e03cd4f6116a31177e2b91d94ca6772 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:41:05 +0800 Subject: [PATCH 62/73] test(snapshots): align background wait present schema The SDK background-wait-steer fixture was authored on a branch that predates master's present description change (1ec651f2098). Refresh only its expected present tool description; committed Session generations and product behavior stay unchanged. --- snapshots/sdk/background-wait-steer/tool-schemas.expected.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json index 90228febab..c9fb03068a 100644 --- a/snapshots/sdk/background-wait-steer/tool-schemas.expected.json +++ b/snapshots/sdk/background-wait-steer/tool-schemas.expected.json @@ -325,7 +325,7 @@ }, { "name": "present", - "description": "Declare existing files as final deliverables for the user. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", + "description": "Declare existing files as final deliverables for the user. Hosts may show presented files only after your turn ends, so call it once the files are final. If the user needs to review files earlier, such as before a tool call that waits for their answer, link them in your message instead. Use it when the user needs a separate file, especially Office documents, spreadsheets, and slide decks; prefer your final response when that suffices. The user opens the current files; their contents are not copied.", "parameters": { "type": "object", "properties": { From c7278aa4d5483a4d41bcc6c606cd77436435b1a6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:46:00 +0800 Subject: [PATCH 63/73] fix(message-quotes): complete Japanese locale registration Add the Japanese quote action dictionary and register it with the existing Chinese and English dictionaries. The package entered master alongside the Japanese locale lane without a Japanese entry. --- packages/session/message-quotes/src/client/index.ts | 4 ++-- packages/session/message-quotes/src/client/locales.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/session/message-quotes/src/client/index.ts b/packages/session/message-quotes/src/client/index.ts index 09e352267e..b0748db925 100644 --- a/packages/session/message-quotes/src/client/index.ts +++ b/packages/session/message-quotes/src/client/index.ts @@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import { QuoteSelectionAction } from './QuoteSelection.tsx' import { QuotedUserText } from './QuotedUserText.tsx' import { quoteRule } from './quote-rule.ts' -import { en, NS, zh, type MessageQuotesKey } from './locales.ts' +import { en, ja, NS, zh, type MessageQuotesKey } from './locales.ts' export type {} from './quote-rule.ts' @@ -30,7 +30,7 @@ export const inject = ['slots', 'locale', 'sessions', 'uiConversation'] * @param ctx - Client root context owning every Quote contribution. */ export function apply(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-quotes: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja }), 'message-quotes: dictionaries') ctx.effect(() => ctx.uiConversation.userText.register(quoteRule), 'message-quotes: UserText rule') ctx.effect(() => ctx.uiConversation.interactions.register({ id: 'quote', diff --git a/packages/session/message-quotes/src/client/locales.ts b/packages/session/message-quotes/src/client/locales.ts index c36e0d3599..fab1fbc782 100644 --- a/packages/session/message-quotes/src/client/locales.ts +++ b/packages/session/message-quotes/src/client/locales.ts @@ -15,3 +15,8 @@ export type MessageQuotesKey = keyof typeof zh export const en: Record = { 'quote.add': 'Quote text', } + +/** Japanese dictionary. */ +export const ja = { + 'quote.add': 'テキストを引用', +} satisfies Record From 09899f3a0fd7de180cd05666b2d4c8b9d4dae173 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:39:05 +0800 Subject: [PATCH 64/73] =?UTF-8?q?feat(remote-access):=20=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E8=B7=9F=E9=9A=8F=E8=AE=BF=E9=97=AE=E7=8A=B6?= =?UTF-8?q?=E6=80=81=EF=BC=88whenAccess=EF=BC=89=EF=BC=8Cindex=20=E6=8C=89?= =?UTF-8?q?=20Peer=20=E6=B3=A8=E5=85=A5=E8=AE=BF=E9=97=AE=E5=BF=AB?= =?UTF-8?q?=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - remote-access 新增 whenAccess / whenResourceAccess,ui-session、ui-workspace 各自提供 whenAccess - schedule、plugin-manager、settings-general、settings-account、directory-picker-native 的入口包进 global whenAccess;消息反馈按钮按会话 useAccess 渲染 - webserver/index-inject 带 Peer,renderIndex / collectIndexInjections 需要 Peer,remote-access 注入 snapshotFor(peer.principal) - client-test-runtime 新增 StaticRemoteAccess、staticAccessPlugin 与 TestClientOptions.access - implemented Agent Note 2026-10-21-when-access 与升级指南 webserver-index-peer Refs #2940 --- ...peer-principal-and-remote-access.i18n.yaml | 8 +- ...-10-12-peer-principal-and-remote-access.md | 4 +- ...-12-peer-principal-and-remote-access.zh.md | 4 +- .../2026-10-21-when-access.i18n.yaml | 28 ++ .../architecture/2026-10-21-when-access.md | 88 +++++ .../architecture/2026-10-21-when-access.zh.md | 88 +++++ apps/desktop-host/src/index.ts | 2 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/typert.md | 7 +- docs/subsystems/typert.zh.md | 7 +- docs/subsystems/web-server.md | 29 +- docs/subsystems/web-server.zh.md | 29 +- .../webserver-index-peer/guide.i18n.yaml | 16 + .../v0.2.1-rc.2/webserver-index-peer/guide.md | 17 + .../webserver-index-peer/guide.zh.md | 17 + packages/api/remote-access/README.i18n.yaml | 8 +- packages/api/remote-access/README.md | 5 +- packages/api/remote-access/README.zh.md | 5 +- .../api/remote-access/src/client/index.ts | 9 +- .../api/remote-access/src/client/mount.ts | 39 +++ .../remote-access/src/client/when-allowed.ts | 85 +++++ packages/api/remote-access/src/index.ts | 13 +- .../tests/client-access.client.spec.ts | 225 +++++++++++++ .../tests/remote-access.host.spec.ts | 21 +- .../api/remote-access/tsconfig.client.json | 1 + .../web-app/tests/document-preview.spec.ts | 7 +- .../connection/tests/node-half.host.spec.ts | 7 +- .../shortcuts/tests/config.host.spec.ts | 7 +- .../ui-directory-picker-native/package.json | 1 + .../src/client/index.ts | 32 +- .../tests/client-flow.client.spec.tsx | 18 +- .../src/client/MessageFeedbackActions.tsx | 15 +- .../message-feedback-actions.client.spec.tsx | 20 +- .../client/ui-plugin-manager/package.json | 1 + .../ui-plugin-manager/src/client/index.ts | 144 ++++----- .../tests/browser-plugin.client.spec.tsx | 20 +- packages/client/ui-schedule/package.json | 1 + .../client/ui-schedule/src/client/index.ts | 305 +++++++++--------- .../tests/browser-plugin.client.spec.ts | 25 +- packages/client/ui-session/README.i18n.yaml | 4 +- packages/client/ui-session/README.md | 2 +- packages/client/ui-session/README.zh.md | 2 +- .../client/ui-session/src/client/index.ts | 20 +- .../tests/ui-session.client.spec.ts | 8 + .../client/ui-settings-account/package.json | 1 + .../ui-settings-account/src/client/index.ts | 74 +++-- .../tests/apply.client.spec.ts | 10 + .../tests/config.client.spec.ts | 9 +- .../tests/initialization.client.spec.ts | 8 + .../client/ui-settings-general/package.json | 1 + .../ui-settings-general/src/client/index.ts | 84 ++--- .../tests/apply.client.spec.ts | 2 +- .../tests/shell.client.spec.ts | 8 +- .../tests/apply.client.spec.ts | 9 +- .../tests/config.host.spec.ts | 9 +- .../client/ui-theme/tests/config.host.spec.ts | 7 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/navigation.ts | 20 ++ .../ui-workspace/tests/apply.client.spec.ts | 10 +- .../tests/bootstrap-route.host.spec.ts | 5 +- .../inspector/tests/plugin.host.spec.ts | 7 +- .../webworker-runtime/src/worker-host.ts | 11 +- .../extensions/tool-cordis/src/api-catalog.ts | 22 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 2 +- packages/host/frontend-static/README.zh.md | 2 +- packages/host/frontend-static/src/index.ts | 3 +- packages/host/webserver/README.i18n.yaml | 12 +- packages/host/webserver/README.md | 6 +- packages/host/webserver/README.zh.md | 6 +- packages/host/webserver/package.json | 1 + packages/host/webserver/src/index.ts | 38 ++- .../host/webserver/tests/webserver.spec.ts | 16 +- packages/host/webserver/tsconfig.json | 3 + .../client-runtime/README.i18n.yaml | 12 +- .../test-support/client-runtime/README.md | 6 +- .../test-support/client-runtime/README.zh.md | 6 +- .../test-support/client-runtime/src/access.ts | 82 +++-- .../src/assembly/test-client.ts | 10 +- .../test-support/client-runtime/src/index.ts | 2 +- .../tests/access.client.spec.ts | 82 +++++ pnpm-lock.yaml | 18 ++ 87 files changed, 1497 insertions(+), 521 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.md create mode 100644 .agents/notes/implemented/architecture/2026-10-21-when-access.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md create mode 100644 packages/api/remote-access/src/client/when-allowed.ts create mode 100644 packages/test-support/client-runtime/tests/access.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml index fbf95020c0..9378bc4f0b 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.i18n.yaml @@ -54,8 +54,8 @@ en: 7865e159528e2d30 zh: 1399d54632f4d926 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/access-snapshot-stream: - en: 41d7ac1faf2448d7 - zh: 6d523895a9abae68 + en: a928a63e8c36177c + zh: c943c1fdc8c4bb45 /agent-note-peer-principals-and-method-level-remote-access-control/decision/method-level-access-control/client-face: en: 7be28cd7e19e5377 zh: 30b5aee957271d6c @@ -81,8 +81,8 @@ en: 89452c3c3f042e75 zh: 9bcad00a60c65303 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/verification: - en: 048067eb02e6bf8a - zh: 12508b59f6b206b8 + en: 2279a8c153cc5b89 + zh: 63c0b6462722f0a3 /agent-note-peer-principals-and-method-level-remote-access-control/consequences/costs-and-limits: en: 1e254cec680b1a37 zh: 06a31e057ba965aa diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md index 324606f3ab..c9894ae409 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | Behavior | Detail | |---|---| -| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to the owner baseline `{ type: 'baseline', owner: true }`, to every index the webServer renders, through `webserver/index-inject`. A business package that renders a non-owner page (such as liveshare on the owner Host) places an assignment of that global to `snapshotFor(principal)` after every index injection row; the later assignment wins, and the Client reads the global when it mounts | +| First snapshot | `snapshotFor(principal)` computes a page's opening baseline. The webServer renders each index for the Peer the page speaks for, and `webserver/index-inject` passes that Peer to listeners; remote-access adds the global `REMOTE_ACCESS_SNAPSHOT_KEY`, set to `snapshotFor(peer.principal)`, to every index, so the owner's page receives the owner baseline `{ type: 'baseline', owner: true }` and a page rendered for a non-owner Peer (such as a liveshare viewer page on the owner Host) receives that principal's grants. The Client reads the global when it mounts | | Subscription | Each Peer subscribes to one access snapshot stream, whose baseline replaces the injected one | | Computation | All bindings of the policy for the principal's kind are merged by equal resource; each resource's `actions` are the endpoint actions of those bindings' roles that the same decision as a call allows (one resource, without args, conditions called without args); only a resource with at least one allowed endpoint action gets a grant. Decisions and snapshots share one source: for the same principal, action, and resource (without arguments), the snapshot answer equals `decide()` | | Endpoint actions only | `event:` actions do not enter the snapshot; the frontend decides entry visibility from endpoint actions only | @@ -638,7 +638,7 @@ The review rule's home is the "`@Remote` marks capability methods" section of [R | Resource resolution | A default field (top-level or first object argument) yields its resource; a lookup parameter that declares that kind as its `resource` yields its wire identity; a Context method gets its Session resource from the Agent Context identity in args without activating the Agent, and may declare other resources through `resources`; a `resources` reader overrides its kind, an array yields several resources, and `undefined` none; with nothing the call is `[global]`; a throw, an empty array, or a value that is not a non-empty string (including a Context identity) is invalid, refused by the Gateway with `resource-invalid` without asking `decide()`; a Context that declares no `resource` resolves by the ordinary rules; resources and `args` read the validated arguments, so an undeclared nested field changes no resource and no condition sees it, and a JSON argument failing validation returns `gateway/input-invalid` before the access check | Gateway host spec (gateway-access) | | Resource registry | `typert.resources` registers one default field per kind, rejects a second registration and `global`, and withdraws the registration with its fiber; a configured lookup resolver keeps the provider's `resource`; a Host Context adapter keeps its `resource` | typert-registry spec | | Forwarded events | A non-owner `$events` stream receives an event, whole, only when `event:` is granted on its resources; an event that declares no resources is decided on `global`; an event whose reader returns `undefined` does not reach non-owners, and `decide()` is not asked; the owner receives all | Gateway host spec (gateway-access) | -| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the owner baseline into every rendered index; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | +| First snapshot | `snapshotFor()` returns the owner baseline or the principal's grants; remote-access injects the snapshot of the Peer each index speaks for; the Client starts from the injected baseline and rejects a malformed one | remote-access host and client specs | | Type inference | The parameter types of `resources` readers are inferred from the method; lookup positions have wire types; a missing property, more parameters than the method has, and a `global` key fail compilation; a binding whose resource kind differs from its role's fails compilation | typert-protocol spec, remote-access spec (type tests) | | List filtering | `session/list`, `session/search`, `session/control`, and `workspace/follow` return non-owners only items for which `allows()` is true; `workspace/follow` sends a `replace` increment when a delivered Workspace loses visibility, and the Workspace reappears once access returns; owner results are unchanged | session-controller / workspace-controller host spec | | Access snapshots | The allowed set in a snapshot equals the Host decision for the same principal, action, and resource without arguments; it contains endpoint actions only; a resource with no allowed action gets no grant, and one whose allowed set becomes empty is removed; `invalidate()` and policy registration or removal push increments; a wait that a change ends leaves no abort listener behind; unloading the service ends every open snapshot stream; the owner receives only `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md index afbd49de7f..ed124e8159 100644 --- a/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md @@ -446,7 +446,7 @@ export const REMOTE_ACCESS_SNAPSHOT_KEY = '__DSH_REMOTE_ACCESS__' | 行为 | 说明 | |---|---| -| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。remote-access 经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如 owner Host 上的 liveshare)在所有 index 注入行之后放置一条把该全局变量赋值为 `snapshotFor(principal)` 的语句;后写的赋值生效,Client 在挂载时读取该全局变量 | +| 首份快照 | `snapshotFor(principal)` 计算页面打开时的 baseline。webServer 按页面所代表的 Peer 渲染 index,`webserver/index-inject` 把该 Peer 传给监听方;remote-access 为每个 index 加入全局变量 `REMOTE_ACCESS_SNAPSHOT_KEY`,值为 `snapshotFor(peer.principal)`,owner 页面因此得到 owner baseline `{ type: 'baseline', owner: true }`,为非 owner Peer 渲染的页面(例如 owner Host 上的 liveshare 读者页)得到该 principal 的 grant。Client 在挂载时读取该全局变量 | | 订阅 | 每个 Peer 订阅一条访问快照流,其 baseline 取代注入的快照 | | 计算 | 取该 principal kind 的 policy 的全部 binding,按资源相等归并;每个资源的 `actions` 为这些 binding 的 role 中、经与调用相同的判定(单个资源、不带 args,condition 以无 args 调用)放行的端点 action;只为至少放行一个端点 action 的资源给出 grant。判定与快照同源:同一 principal、action 与资源(不带参数)上,快照答案与 `decide()` 一致 | | 只含端点 action | `event:` action 不进入快照;前端只据端点 action 决定入口显隐 | @@ -638,7 +638,7 @@ GlobalStandardProps.useWorkspaceAccess: (workspaceId: WorkspaceId, action: Remot | 资源解析 | 默认字段(顶层或首个对象参数)得到对应资源;声明了该种类 `resource` 的 lookup 参数得到其 wire 身份;Context 方法从 args 中的 Agent Context 身份得到其 session 资源且不激活 Agent,另可经 `resources` 声明其他资源;`resources` 读取函数覆盖该种类,数组为多个资源,`undefined` 为无;都没有为 `[global]`;抛错、空数组或非非空字符串(含 Context 身份)为非法,Gateway 以 `resource-invalid` 拒绝且不询问 `decide()`;未声明 `resource` 的 Context 按普通规则解析;资源与 `args` 读取校验后的参数,未声明的嵌套字段不改变资源、condition 也读不到它,JSON 参数校验失败在访问检查前返回 `gateway/input-invalid` | Gateway host spec(gateway-access) | | 资源注册表 | `typert.resources` 每个种类登记一个默认字段,拒绝重复登记与 `global`,并随 fiber 撤回;配置的 lookup resolver 保留 provider 的 `resource`;Host Context adapter 保留其 `resource` | typert-registry spec | | 转发事件 | 非 owner 的 `$events` 流只在 `event:` 于其资源上被授权时整条收到事件;未声明资源的事件按 `global` 判定;读取函数返回 `undefined` 的事件不送达非 owner,且不询问 `decide()`;owner 全部收到 | Gateway host spec(gateway-access) | -| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为渲染的每个 index 注入 owner baseline;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | +| 首份快照 | `snapshotFor()` 返回 owner baseline 或该 principal 的 grant;remote-access 为每个 index 注入其所代表 Peer 的快照;Client 从注入的 baseline 起步并拒绝格式错误的快照 | remote-access host 与 client spec | | 类型推断 | `resources` 读取函数的参数类型从方法推断;lookup 位为 wire 类型;访问不存在的属性、参数多于方法参数与 `global` 键编译失败;binding 的资源种类与 role 不符编译失败 | typert-protocol spec、remote-access spec(类型测试) | | 列表过滤 | `session/list`、`session/search`、`session/control`、`workspace/follow` 对非 owner 只返回 `allows()` 为 true 的项;已下发工作区失去可见性时 `workspace/follow` 发送 `replace` 增量帧,访问恢复后该工作区重新出现;owner 结果不变 | session-controller / workspace-controller host spec | | 访问快照 | 快照中的允许集合与 Host 对同一 principal、action、资源(不带参数)的判定一致;只含端点 action;没有放行 action 的资源不出 grant,放行集合变空时推送 `remove`;`invalidate()` 与 policy 注册、撤回后推送增量;被变化结束的等待不残留 abort 监听器;卸载服务即结束所有打开的快照流;owner 只收 `{ owner: true }` | remote-access host spec | diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml new file mode 100644 index 0000000000..6a7ccaae0a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-10-21-when-access.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-when-access.md +/agent-note-whenaccess-registration-that-follows-access: + en: 2ef7cfef514c138a + zh: 064e3aecbfafcd9c +/agent-note-whenaccess-registration-that-follows-access/problem: + en: c12d401db33f7d77 + zh: 3bc2c8ad1a441bd7 +/agent-note-whenaccess-registration-that-follows-access/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-whenaccess-registration-that-follows-access/decision/whenaccess: + en: c168de20989cf9a4 + zh: bf3b0361beea083d +/agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration: + en: 0487c2e2f5e71bde + zh: 1750e83e7f9fa1cb +/agent-note-whenaccess-registration-that-follows-access/alternatives-considered: + en: 670e07d9da3ea599 + zh: 625e8e8bff57ff8c +/agent-note-whenaccess-registration-that-follows-access/consequences: + en: 5c7c7c49446fd140 + zh: f8fa106040fac4bc +/agent-note-whenaccess-registration-that-follows-access/related: + en: 89073ffbfcff0641 + zh: 736a180d13a08981 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md new file mode 100644 index 0000000000..66d8705c57 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -0,0 +1,88 @@ +# Agent Note: whenAccess registration that follows access + +Status: implemented + +English | [中文](2026-10-21-when-access.zh.md) + +## Problem + +Client plugins need to switch their own registrations on and off with the current Peer's access: sidebar entries, main panels, overlays, and data streams. The existing Client access surface covers only rendering: + +| Where | Existing capability | Gap | +|---|---|---| +| Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically | +| JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand | + +#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations do not follow an access change. + +## Decision + +### whenAccess + +The JS side has a `whenAccess` for each of the three access Hooks, from the package that publishes that Hook: + +| Interface | Provider | Matching Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` is `(() => void) | Iterable<() => void, void, void>`. + +| Rule | Content | +|---|---| +| Semantics | Same as `ctx.slots.inject`: the callback returns one disposer or an iterable of disposers; an iterable installs as one transaction and disposes in reverse order; the controller belongs to the caller's fiber and disposes when the plugin unloads | +| Synchronous run | When the answer is already true, the callback runs synchronously; the owner's answer is always true, so registration order matches direct registration | +| Stream failure | A non-owner's answer turns false and the installed effects dispose | +| Callback failure | A setup failure at the call throws synchronously; a setup failure on a later grant stops the controller and is rethrown in a microtask | +| Layering | remote-access knows only `global`; the Session and Workspace versions are built over remote-access's lower-level `whenResourceAccess(kind, id, action, callback)`, as their Hooks are built over `source(kind, id, action)`. Only the package that owns a resource kind calls `source` and `whenResourceAccess`; other plugins use only the access Hooks and `whenAccess` and pass no kind. All four entry points share [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### Plugin migration + +| Plugin | Change | Action | +|---|---|---| +| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the task tab type and the turn definition stay registered outside | `schedule/catalog` | +| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `pluginManager/listBundles` | +| ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | The account state stream (including default model initialization), quota notice, and settings launcher move into `remoteAccess.whenAccess` | `account/watch` | +| ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` | +| ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — | + +## Alternatives considered + +**Return after an owner check at the start of `apply`**: fixed on identity; does not follow an access change. + +**Trim the plugin list per connection**: a plugin cannot reload at once when access changes. + +**Declare access in the plugin package manifest**: defines an access-control concept in the package manifest. + +**One generic method with a resource parameter, or all three versions on remote-access**: business code would pass a kind, and remote-access would name the Session and Workspace resource kinds. + +## Consequences + +| Consequence | Note | +|---|---| +| Registrations follow access changes | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged | +| The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads | +| Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI | +| Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path | + +## Related + +- [Peer, principal, and remote access](2026-10-12-peer-principal-and-remote-access.md): this Note extends its Client access face. diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md new file mode 100644 index 0000000000..a4233dcdfe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -0,0 +1,88 @@ +# Agent Note: 按访问状态注册的 whenAccess + +Status: implemented + +[English](2026-10-21-when-access.md) | 中文 + +## 问题 + +Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏入口、主面板、浮层、数据流。现有 Client 访问面只覆盖渲染: + +| 位置 | 现有能力 | 缺口 | +|---|---|---| +| 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 | +| JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 | + +#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。 + +## 决策 + +### whenAccess + +JS 侧有与三个访问 Hook 一一对应的 `whenAccess`,由各自 Hook 的发布者提供: + +| 接口 | 提供者 | 对应 Hook | +|---|---|---| +| `ctx.remoteAccess.whenAccess(action, callback)` | remote-access | `useRemoteAccess(action)` | +| `ctx.uiSession.whenAccess(sessionId, action, callback)` | ui-session | `useSessionAccess(sessionId, action)` | +| `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` | ui-workspace | `useWorkspaceAccess(workspaceId, action)` | + +```ts ignore-check +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' + +/** The global-resource member; the Session and Workspace versions take the resource id first. */ +export interface WhenAccess { + /** + * Keep `callback`'s effects installed while the current Peer may call `action` + * on the resource: run it when the answer becomes true, dispose its effects in + * reverse order when it becomes false, and run it again on the next true. + * @returns disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void +} +``` + +`AccessEffect` 为 `(() => void) | Iterable<() => void, void, void>`。 + +| 规则 | 内容 | +|---|---| +| 语义 | 与 `ctx.slots.inject` 相同:回调返回一个 disposer 或一组 disposer;一组时按事务安装、逆序撤销;控制器属于调用方 fiber,插件卸载时撤销 | +| 同步执行 | 调用时答案为 true 则回调同步执行;owner 的答案恒为 true,注册顺序与直接注册相同 | +| 快照流失败 | 非 owner 的答案变为 false,已安装的效果撤销 | +| 回调失败 | 调用时安装失败同步抛出;之后重新授权时安装失败,控制器停止并在微任务中抛出 | +| 分层 | remote-access 只认识 global;会话版与工作区版经 remote-access 的底层方法 `whenResourceAccess(kind, id, action, callback)` 实现,与它们用 `source(kind, id, action)` 构造 Hook 相同。`source` 与 `whenResourceAccess` 只由拥有该资源种类的包调用,其他插件只用访问 Hook 与 `whenAccess`,不传 kind。四个入口共用 [`when-allowed.ts`](../../../../packages/api/remote-access/src/client/when-allowed.ts) | + +### 插件迁移 + +| 插件 | 改法 | action | +|---|---|---| +| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` | +| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` | +| ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` | +| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `account/watch` | +| ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` | +| ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` | +| ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — | + +## 考虑过的替代方案 + +**插件 `apply` 开头判断 owner 后 return**:按身份写死,授权变化后不跟随。 + +**按连接裁剪插件列表**:授权变化后插件无法立即重新加载。 + +**插件包清单声明访问性**:在包清单上定义访问控制概念。 + +**一个带资源参数的通用方法,或三个版本都挂在 remote-access 上**:业务代码要传 kind,且 remote-access 会出现会话、工作区的资源种类名。 + +## 后果 + +| 后果 | 说明 | +|---|---| +| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 | +| 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 | +| 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 | +| 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 | + +## 相关 + +- [Peer、principal 与 remote-access](2026-10-12-peer-principal-and-remote-access.zh.md):本 Note 扩展其 Client 访问面。 diff --git a/apps/desktop-host/src/index.ts b/apps/desktop-host/src/index.ts index 7ff0cb6473..6ce80e1aa5 100644 --- a/apps/desktop-host/src/index.ts +++ b/apps/desktop-host/src/index.ts @@ -104,7 +104,7 @@ async function main(): Promise { if (process.connected) process.send?.({ type: 'platform-session', session }) }) const url = ctx.connection.authenticatedUrl(`${ctx.webServer.protocol}//127.0.0.1:${String(ctx.webServer.port)}`) - if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections() }, (error) => { if (error !== null) console.error(error) }) + if (process.connected) process.send?.({ type: 'ready', url, injections: ctx.webServer.collectIndexInjections(ctx.connection.browserPeer) }, (error) => { if (error !== null) console.error(error) }) } /** Upper bound of the startup diagnostic carried over IPC; the head holds the message and stack. */ diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 313b63aa2b..c2b9457b1c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1706,7 +1706,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 56f50c6fe4..f141ef600d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1708,7 +1708,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-webserver` -- `source`: [`packages/host/webserver/src/index.ts:76`](../packages/host/webserver/src/index.ts) +- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts) ```ts config-catalog /** Web server listen, TLS, and response-compression config. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a405377a97..0e2c56c782 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -82,7 +82,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../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:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `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/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index e6010e3d32..1235fb8224 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -84,7 +84,7 @@ | `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:191`](../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:199`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:39`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` | | `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/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - | diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index 29b5d4e3b7..d68d4a1a36 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision allows(resource: RemoteAccessResource): boolean /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index ce33a70436..270e5509bd 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -561,10 +561,9 @@ decide(request: RemoteAccessRequest): RemoteAccessDecision allows(resource: RemoteAccessResource): boolean /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index c85f7a9b08..425c0faadc 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ -collectIndexInjections(): IndexInjection[] +collectIndexInjections(peer: PeerScope): IndexInjection[] /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ -renderIndex(html: string): string +renderIndex(html: string, peer: PeerScope): string ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec #### `webserver/index-inject` — emit -Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. +Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. ```ts cordis-catalog /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ -'webserver/index-inject'(table: IndexInjection[]): void +'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d108ad5f67..d1d469d9e0 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -235,20 +235,23 @@ tapIndex(transform: (html: string) => string): () => void applyIndexTaps(html: string): string /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ -collectIndexInjections(): IndexInjection[] +collectIndexInjections(peer: PeerScope): IndexInjection[] /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ -renderIndex(html: string): string +renderIndex(html: string, peer: PeerScope): string ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -285,17 +288,19 @@ Source: [`packages/client/connection/src/index.ts`](../../packages/client/connec #### `webserver/index-inject` — emit -Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. +Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row's data is read fresh at emit time. ```ts cordis-catalog /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ -'webserver/index-inject'(table: IndexInjection[]): void +'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void ``` Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml new file mode 100644 index 0000000000..1a41ab4629 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md +/: + en: 4182112137303167 + zh: 8451ce873cfca164 +/webserver-index-rendering-takes-a-peer: + en: 7986c2aaf1511f53 + zh: a6f78fc166e1ea19 +/webserver-index-rendering-takes-a-peer/change: + en: 07a11388305a0124 + zh: 662003b29c238d30 +/webserver-index-rendering-takes-a-peer/migration: + en: 7ef144b29ffb2f39 + zh: defb62ecc4ba6c2e diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md new file mode 100644 index 0000000000..42f8bdc773 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "WebServer index rendering takes the Peer the page speaks for, and the `webserver/index-inject` event passes that Peer to listeners." +--- +# WebServer index rendering takes a Peer + +English | [中文](guide.zh.md) + +## Change + +`renderIndex(html)` and `collectIndexInjections()` on `ctx.webServer` from `@deepseek-ai/dsh-host-webserver` took no Peer, and every index carried the owner's access snapshot. They are now `renderIndex(html, peer)` and `collectIndexInjections(peer)`, and the `webserver/index-inject` event passes the same Peer as its second argument, so the remote-access snapshot in the index belongs to that Peer. Code that calls either method breaks at compile time; listeners that read only the table keep working. + +## Migration + +1. Pass the Peer the page speaks for to every `ctx.webServer.renderIndex(html)` and `ctx.webServer.collectIndexInjections()` call. A page served to the owner's browser passes `ctx.connection.browserPeer`. +2. In a test that emits `webserver/index-inject` itself, pass a Peer after the table: `ctx.emit('webserver/index-inject', table, peer)`. +3. Run your TypeScript build and confirm no call to either method reports a missing argument. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md new file mode 100644 index 0000000000..de6d1c58da --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/webserver-index-peer/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "WebServer 渲染 index 时需要传入页面所代表的 Peer,`webserver/index-inject` 事件会把该 Peer 传给监听方。" +--- +# WebServer 渲染 index 需要传入 Peer + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-host-webserver` 中 `ctx.webServer` 的 `renderIndex(html)` 与 `collectIndexInjections()` 不接收 Peer,每个 index 携带的都是 owner 的访问快照。现在它们改为 `renderIndex(html, peer)` 与 `collectIndexInjections(peer)`,`webserver/index-inject` 事件以第二个参数传入同一个 Peer,index 中的 remote-access 快照因此属于该 Peer。调用这两个方法的代码会在编译时报错;只读取表格的监听方不受影响。 + +## 迁移 + +1. 每处 `ctx.webServer.renderIndex(html)` 与 `ctx.webServer.collectIndexInjections()` 调用都传入页面所代表的 Peer。提供给 owner 浏览器的页面传入 `ctx.connection.browserPeer`。 +2. 自行 emit `webserver/index-inject` 的测试在表格之后传入一个 Peer:`ctx.emit('webserver/index-inject', table, peer)`。 +3. 运行 TypeScript 构建,确认这两个方法的调用都不再报缺少参数。 diff --git a/packages/api/remote-access/README.i18n.yaml b/packages/api/remote-access/README.i18n.yaml index df8ad59235..461af0d1f9 100644 --- a/packages/api/remote-access/README.i18n.yaml +++ b/packages/api/remote-access/README.i18n.yaml @@ -15,11 +15,11 @@ en: a18ecc232e3c3933 zh: b8e43580403f6d77 /deepseek-ai-dsh-api-remote-access/host-service-remoteaccessservice-ctx-key-remoteaccess: - en: 9175835ea5c5bff1 - zh: 0e33b266fdac3d13 + en: 8a4be795c7aa2ef0 + zh: 02550b975e2e8d61 /deepseek-ai-dsh-api-remote-access/client-face: - en: 1db01235b6af84fa - zh: c0ced511cc1841a0 + en: d4cf8f646fdf2d39 + zh: 247f643e991aa806 /deepseek-ai-dsh-api-remote-access/model-experience: en: 8b07250cecacd3aa zh: d28ab93980bdcdad diff --git a/packages/api/remote-access/README.md b/packages/api/remote-access/README.md index 8d9df9e542..ab35a6b598 100644 --- a/packages/api/remote-access/README.md +++ b/packages/api/remote-access/README.md @@ -43,7 +43,7 @@ When `bindings()`, a condition, or `explain()` throws, the decision is refused w A forwarded event is decided as the action `event:` on the resources its forwarding entry's readers name, or on `global` when the entry declares none, and reaches a non-owner whole or not at all; its payload is never trimmed. An event carries every resource it declares, so a reader that returns `undefined` makes the event's resources invalid, and the Gateway delivers it to no non-owner without asking `decide()`. An event names at most one resource, so collection changes travel on follow streams (`session/follow`, `session/control`, `workspace/follow`), whose methods decide each item inline through `allows()`. -The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding the owner baseline `{ type: 'baseline', owner: true }` to every index the webServer renders through `webserver/index-inject`. A business package that renders a non-owner page, such as a liveshare viewer page rendered on the owner Host, places an assignment of that global to `ctx.remoteAccess.snapshotFor(principal)` after every index injection row, for example a `global` row appended to the end of `ctx.webServer.collectIndexInjections()`. The later assignment replaces the owner baseline, and the Client entry reads the global when it mounts, so the page opens with the viewer's grants instead of the owner's. +The service adds a `global` row named `REMOTE_ACCESS_SNAPSHOT_KEY` (`__DSH_REMOTE_ACCESS__`) holding `snapshotFor(peer.principal)` to every index the webServer renders through `webserver/index-inject`, for the Peer that render speaks for: the owner's browser page opens with `{ type: 'baseline', owner: true }`, and a page rendered for a non-owner Peer, such as a liveshare viewer page rendered on the owner Host, opens with that viewer's grants. The Client entry reads the global when it mounts. | `details.reason` of `remote-access/denied` | Cause | |---|---| @@ -60,9 +60,10 @@ Snapshot grants list, per resource the principal's bindings name, the sorted end The Client entry reads the baseline the index injected under `REMOTE_ACCESS_SNAPSHOT_KEY` synchronously, mounts the generated `remoteAccess` contribution itself through `mountAccess(ctx, contribution, initial)`, follows the snapshot stream, and provides: - the Client service `ctx.remoteAccess.source(kind, id, action)`, an identity-stable boolean source per `(kind, id, action)` for one resource of a declared kind; +- `ctx.remoteAccess.whenAccess(action, callback)`, which keeps the callback's effects installed while the `global` resource allows `action`: the callback runs when the answer becomes true, its effects dispose in reverse order when it becomes false, and it runs again on the next true. The callback returns one disposer or an iterable of disposers, as with `ctx.slots.inject`; it runs synchronously when the answer is already true, as it always is for the owner; the controller belongs to the caller's fiber. A setup failure while allowed throws at once; a later one retires the controller and is rethrown in a microtask. `whenResourceAccess(kind, id, action, callback)` is the same for one resource of a declared kind; - the standard Hook `useRemoteAccess(action)`, which answers on the `global` resource. -Domain UI packages publish their resource Hooks over `source()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)` and the Session-scope `useAccess(action)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` on Workspace resources. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with allow-all access in Client specs. +Domain UI packages publish their resource Hooks over `source()` and their `whenAccess` over `whenResourceAccess()`: [ui-session](../../client/ui-session/README.md) provides `useSessionAccess(sessionId, action)`, the Session-scope `useAccess(action)`, and `ctx.uiSession.whenAccess(sessionId, action, callback)` on Session resources, a different name from the `global` `useRemoteAccess`, and [ui-workspace](../../client/ui-workspace/README.md) provides `useWorkspaceAccess(workspaceId, action)` and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)` on Workspace resources. Only the package that owns a resource kind calls `source()` and `whenResourceAccess()`; other plugins read access through the access Hooks and the `whenAccess` methods. A Hook's `action` is a `RemoteEndpoint`, an endpoint some Client Remote namespace declares. A non-owner answer is whether that resource's grant lists the action and never falls back to the `global` answer, so a resource without a grant, or whose grant lists no action, answers false. Every answer is true for the owner; a non-owner page without an injected baseline, and a non-owner page whose stream fails terminally, answers false; the terminal failure is reported with its error, for a non-owner with the advice to reload the page and for the owner with the note that its answers are unaffected; a reconnect keeps the last snapshot. A malformed injected baseline fails the mount. Hooks never issue requests, and the Host still checks every call. [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md) replaces this row with fixed answers, allow-all by default, in Client specs. ## Model Experience diff --git a/packages/api/remote-access/README.zh.md b/packages/api/remote-access/README.zh.md index 8d593f7132..514f885b33 100644 --- a/packages/api/remote-access/README.zh.md +++ b/packages/api/remote-access/README.zh.md @@ -43,7 +43,7 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo 转发事件按其转发条目的读取函数指明的资源(条目未声明时为 `global`)判定 action `event:`,对非 owner 整条送达或整条不送,从不裁剪 payload。事件携带其声明的每个资源,因此读取函数返回 `undefined` 时该事件的资源非法,Gateway 不询问 `decide()`,不送达任何非 owner。一个事件至多指明一个资源,因此集合类变化走 follow 类流(`session/follow`、`session/control`、`workspace/follow`),由这些方法内联经 `allows()` 逐项判定。 -本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为 owner baseline `{ type: 'baseline', owner: true }`。渲染非 owner 页面的业务包(例如在 owner Host 上渲染 liveshare 读者页)在所有 index 注入行之后放置一条把该全局赋值为 `ctx.remoteAccess.snapshotFor(principal)` 的语句,例如追加到 `ctx.webServer.collectIndexInjections()` 末尾的一条 `global` 行。后写的赋值覆盖 owner baseline,Client 入口在挂载时读取该全局,因此页面以读者的 grant 而非 owner 的快照打开。 +本服务经 `webserver/index-inject` 为 webServer 渲染的每个 index 加入名为 `REMOTE_ACCESS_SNAPSHOT_KEY`(`__DSH_REMOTE_ACCESS__`)的 `global` 行,值为该次渲染所代表 Peer 的 `snapshotFor(peer.principal)`:owner 的浏览器页面以 `{ type: 'baseline', owner: true }` 打开,为非 owner Peer 渲染的页面(例如在 owner Host 上渲染的 liveshare 读者页)以该读者的 grant 打开。Client 入口在挂载时读取该全局。 | `remote-access/denied` 的 `details.reason` | 原因 | |---|---| @@ -60,9 +60,10 @@ Gateway 依次从 `@Remote` 与 `@RemoteScope` 的 `resources` 读取函数、lo Client 入口同步读取 index 注入在 `REMOTE_ACCESS_SNAPSHOT_KEY` 下的 baseline,经 `mountAccess(ctx, contribution, initial)` 自行挂载生成的 `remoteAccess` contribution,订阅快照流,并提供: - Client 服务 `ctx.remoteAccess.source(kind, id, action)`,为已声明种类的某个资源上的每个 `(kind, id, action)` 提供一个身份稳定的布尔 source; +- `ctx.remoteAccess.whenAccess(action, callback)`,在 `global` 资源允许 `action` 期间保持回调的效果:答案变为 true 时执行回调,变为 false 时逆序撤销其效果,下一次变为 true 时再次执行。回调返回一个 disposer 或一组 disposer,与 `ctx.slots.inject` 相同;答案已为 true 时(owner 恒为 true)回调同步执行;控制器属于调用方 fiber。允许期间的安装失败立即抛出;之后的安装失败使控制器停止,并在微任务中重新抛出。`whenResourceAccess(kind, id, action, callback)` 对已声明种类的某个资源做同样的事; - 标准 Hook `useRemoteAccess(action)`,回答 `global` 资源上的访问。 -领域 UI 包在 `source()` 之上发布各自资源的 Hook:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)` 与会话作用域的 `useAccess(action)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)`。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以全允许的访问替换本行。 +领域 UI 包在 `source()` 之上发布各自资源的 Hook,在 `whenResourceAccess()` 之上发布各自的 `whenAccess`:[ui-session](../../client/ui-session/README.zh.md) 提供 Session 资源上的 `useSessionAccess(sessionId, action)`、会话作用域的 `useAccess(action)` 与 `ctx.uiSession.whenAccess(sessionId, action, callback)`(与 `global` 资源上的 `useRemoteAccess` 不同名),[ui-workspace](../../client/ui-workspace/README.zh.md) 提供 Workspace 资源上的 `useWorkspaceAccess(workspaceId, action)` 与 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`。只有拥有某个资源种类的包才调用 `source()` 与 `whenResourceAccess()`;其他插件经访问 Hook 与各 `whenAccess` 读取访问。Hook 的 `action` 类型为 `RemoteEndpoint`,即某个 Client Remote namespace 声明的端点。非 owner 的答案是该资源的 grant 是否列出该 action,不回退 `global` 答案,因此没有 grant 或 grant 未列出任何 action 的资源回答 false。owner 的所有答案为 true;没有注入 baseline 的非 owner 页面、快照流终止失败的非 owner 页面,所有答案为 false;终止失败会连同错误一并报告,非 owner 附“刷新页面重连”的提示,owner 附“答案不受影响”的说明;重连期间保留上次快照。注入的 baseline 格式错误时挂载失败。Hook 从不发请求,Host 仍检查每次调用。[`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 在 Client spec 中以固定答案(默认全允许)的访问替换本行。 ## 模型体验 diff --git a/packages/api/remote-access/src/client/index.ts b/packages/api/remote-access/src/client/index.ts index 396f56a16a..f3e5123b5b 100644 --- a/packages/api/remote-access/src/client/index.ts +++ b/packages/api/remote-access/src/client/index.ts @@ -2,8 +2,11 @@ * Client face: starts from the access snapshot the index injected, follows * the calling Peer's snapshot stream, provides the `remoteAccess` Client * service whose `source(kind, id, action)` answers for any declared-kind - * resource, and publishes `useRemoteAccess(action)` on the global resource. - * Domain UI adapters publish their own resource Hooks over `source()`. Every + * resource and whose `whenAccess(action, callback)` keeps effects installed + * while the global resource allows an action, and publishes + * `useRemoteAccess(action)` on the global resource. Domain UI adapters + * publish their own resource Hooks over `source()` and their own + * `whenAccess` over `whenResourceAccess()`. Every * answer is a boolean: true for the owner, false without a snapshot or after * a non-owner stream fails; the Host enforces every call regardless. * @module @deepseek-ai/dsh-api-remote-access/client @@ -18,7 +21,7 @@ import { parseInjectedBaseline } from './state.ts' export type { RemoteAccessAction, RemoteAccessEndpoint, RemoteAccessFrame, RemoteAccessGrant, RemoteAccessResource, TypertResourceKind, } from '../types.ts' -export type { ClientRemoteAccess, RemoteEndpoint } from './mount.ts' +export type { AccessEffect, ClientRemoteAccess, RemoteEndpoint } from './mount.ts' /** Required Client services: the Remote mount face and the Slot registry. */ export const inject = ['remote', 'slots'] diff --git a/packages/api/remote-access/src/client/mount.ts b/packages/api/remote-access/src/client/mount.ts index 257aa396c2..68b868a8a7 100644 --- a/packages/api/remote-access/src/client/mount.ts +++ b/packages/api/remote-access/src/client/mount.ts @@ -19,6 +19,9 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type { TypertRemoteContribution, TypertRemoteMap, TypertResourceKind } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteAccessFrame } from '../types.ts' import { ClientAccessState, type Baseline } from './state.ts' +import { whenAllowed, type AccessEffect } from './when-allowed.ts' + +export type { AccessEffect } from './when-allowed.ts' /** Every endpoint the Client Remote namespaces declare. */ export type RemoteEndpoint = Extract @@ -29,12 +32,38 @@ export interface ClientRemoteAccess { * Whether the current Peer may call `action` on one resource. * True for the owner; false without a baseline and after a non-owner * stream fails; otherwise whether the resource's grant lists the action. + * Only the package that owns a resource kind calls it, to build that kind's + * access Hook; other plugins read access through the access Hooks or + * `whenAccess`. * @param kind - resource kind declared in `TypertResourceMap`. * @param id - resource identity. * @param action - endpoint action the caller would invoke. * @returns identity-stable boolean source for this `(kind, id, action)`. */ source(kind: TypertResourceKind, id: string, action: string): HostObservable + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on the global resource: run it when the answer becomes true, + * dispose its effects in reverse order when it becomes false, and run it + * again on the next true. The callback runs synchronously when the answer + * is already true, as it always is for the owner. Effect forms and fiber + * ownership match `ctx.slots.inject`. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the observation and any installed effects. + */ + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void + /** + * {@link whenAccess} for one resource of a declared kind. Only the package + * that owns a resource kind calls it, to build that kind's `whenAccess`, as + * it builds the kind's access Hook over {@link source}. + * @param kind - resource kind declared in `TypertResourceMap`. + * @param id - resource identity. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer for the observation and any installed effects. + */ + whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void } declare module '@deepseek-ai/cordis' { @@ -66,6 +95,16 @@ class ClientRemoteAccessService extends Service implements ClientRemoteAccess { source(kind: TypertResourceKind, id: string, action: string): HostObservable { return this.state.resource(kind, id, action) } + + // Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context. + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return whenAllowed(this.ctx, this.state.global(action), callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`) + } + + whenResourceAccess(kind: TypertResourceKind, id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return whenAllowed(this.ctx, this.state.resource(kind, id, action), callback, + `remoteAccess.whenResourceAccess(${JSON.stringify(kind)}, ${JSON.stringify(id)}, ${JSON.stringify(action)})`) + } } /** diff --git a/packages/api/remote-access/src/client/when-allowed.ts b/packages/api/remote-access/src/client/when-allowed.ts new file mode 100644 index 0000000000..e1c2ba890c --- /dev/null +++ b/packages/api/remote-access/src/client/when-allowed.ts @@ -0,0 +1,85 @@ +/** + * The effect controller behind `whenAccess`: install a callback's effects + * while one access answer is true and dispose them while it is false. + * @module @deepseek-ai/dsh-api-remote-access/src/client/when-allowed + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' + +/** One synchronous effect installed while access holds: a disposer, or disposers installed as one transaction. */ +export type AccessEffect = (() => void) | Iterable<() => void, void, void> + +/** + * Install `callback`'s effects while `answer` is true. The callback runs + * synchronously when the answer is already true; a later true runs it again + * after a false disposed the previous installation in reverse order. The + * controller belongs to `ctx`'s fiber. + * @param ctx - the caller's Context; it owns the controller and every installation. + * @param answer - the access answer to follow. + * @param callback - creates one disposer or an iterable of disposers. + * @param label - effect label for diagnostics. + * @returns idempotent disposer for the controller and any installed effects. + * @throws callback setup failures synchronously when the answer is already true. + */ +export function whenAllowed( + ctx: Context, + answer: HostObservable, + callback: () => AccessEffect, + label: string, +): () => void { + const disposeController = ctx.effect(() => { + let installed: (() => void) | undefined + let stopped = false + + const uninstall = (): void => { + const dispose = installed + installed = undefined + dispose?.() + } + + const reconcile = (): void => { + if (stopped) return + const allowed = answer.getSnapshot() + if (allowed === (installed !== undefined)) return + if (!allowed) { + uninstall() + return + } + // Each installation is a nested Cordis effect, so an iterable callback + // installs transactionally and disposes in reverse order. + const disposeEffect = ctx.effect(callback, `${label}: allowed`) + installed = () => { void disposeEffect() } + } + + const unsubscribe = answer.subscribe(() => { changed() }) + + const stop = (): void => { + if (stopped) return + // A failed installation retires the controller: a later grant never retries it. + stopped = true + unsubscribe() + uninstall() + } + + const changed = (): void => { + try { + reconcile() + } catch (error) { + stop() + if ((error as { code?: unknown } | null)?.code === 'INACTIVE_EFFECT') return + const failure = error instanceof Error ? error : new Error(String(error)) + queueMicrotask(() => { throw failure }) + } + } + + try { + reconcile() + } catch (error) { + stop() + throw error + } + return stop + }, label) + return () => { void disposeController() } +} diff --git a/packages/api/remote-access/src/index.ts b/packages/api/remote-access/src/index.ts index 5b3cf41e0f..98ee668ca4 100644 --- a/packages/api/remote-access/src/index.ts +++ b/packages/api/remote-access/src/index.ts @@ -134,10 +134,10 @@ export class RemoteAccessService extends TypertRemoteService { constructor(ctx: Context) { super(ctx, 'remoteAccess') ctx.effect(() => () => { this.lifetime.abort() }, 'remoteAccess: snapshot generations') - // Every index the webServer renders belongs to the owner; snapshotFor documents non-owner pages. + // Every index opens with the access snapshot of the Peer it speaks for. ctx.inject(['webServer'], (webCtx) => { - webCtx.on('webserver/index-inject', (table) => { - table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: OWNER_BASELINE }) + webCtx.on('webserver/index-inject', (table, peer) => { + table.push({ kind: 'global', name: REMOTE_ACCESS_SNAPSHOT_KEY, value: this.snapshotFor(peer.principal) }) }) }) } @@ -244,10 +244,9 @@ export class RemoteAccessService extends TypertRemoteService { } /** - * The baseline a page opens with before its snapshot stream connects. A - * business package rendering a non-owner page assigns this value to - * {@link REMOTE_ACCESS_SNAPSHOT_KEY} after every index injection row, - * replacing the owner baseline the webServer index carries. + * The baseline a page opens with before its snapshot stream connects; every + * index the webServer renders carries it under + * {@link REMOTE_ACCESS_SNAPSHOT_KEY} for the Peer the page speaks for. * @param principal - the page's caller identity. * @returns the owner baseline for the owner; otherwise the principal's current grants. */ diff --git a/packages/api/remote-access/tests/client-access.client.spec.ts b/packages/api/remote-access/tests/client-access.client.spec.ts index b73944ec58..5a70579b01 100644 --- a/packages/api/remote-access/tests/client-access.client.spec.ts +++ b/packages/api/remote-access/tests/client-access.client.spec.ts @@ -5,6 +5,7 @@ import type { TypertRemoteContribution, TypertResource } from '@deepseek-ai/dsh- import type { RemoteAccessFrame, RemoteAccessGrant } from '../src/types.ts' import { ClientAccessState, parseInjectedBaseline, type Baseline } from '../src/client/state.ts' import { mountAccess } from '../src/client/mount.ts' +import { whenAllowed } from '../src/client/when-allowed.ts' declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertResourceMap { @@ -12,6 +13,10 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'test-shelf': TypertResource 'test-desk': TypertResource } + interface TypertRemoteMap { + /** Test-only endpoint the `whenAccess` specs guard effects on. */ + 'test/guarded': () => Promise + } } const GRANTED: Extract = { @@ -289,3 +294,223 @@ describe('remote-access Client mount', () => { await access.dispose() }) }) + +/** A baseline whose grants allow `test/guarded` on the listed resources. */ +function followOn(...resources: RemoteAccessGrant['resource'][]): Extract { + return { type: 'baseline', owner: false, grants: resources.map(resource => ({ resource, actions: ['test/guarded'] })) } +} + +describe('remote-access whenAccess', () => { + it('installs the effects while the global resource allows the action, disposes them in reverse order on loss, and reinstalls on the next grant', async () => { + const access = await mounted() + const log: string[] = [] + const guarded = access.ctx.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + ctx.remoteAccess.whenAccess('test/guarded', function* () { + log.push('install') + yield () => { log.push('dispose first') } + yield () => { log.push('dispose second') } + }) + }, + }) + await guarded.await() + expect(log).toEqual([]) + + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(log).toEqual(['install']) }) + // A frame that leaves the answer unchanged installs nothing again. + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'test-shelf', id: 's1' }, actions: ['session/fork'] } }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/list'] } }) + await vi.waitFor(() => { expect(log).toEqual(['install', 'dispose second', 'dispose first']) }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } }) + await vi.waitFor(() => { expect(log).toHaveLength(4) }) + expect(log[3]).toBe('install') + + await guarded.dispose() + expect(log.slice(4)).toEqual(['dispose second', 'dispose first']) + await access.dispose() + }) + + it('runs the callback at once for the owner and disposes it with the returned disposer', async () => { + const access = await mounted({ type: 'baseline', owner: true }) + const dispose = vi.fn() + const callback = vi.fn(() => dispose) + const stop = access.ctx.remoteAccess.whenAccess('test/guarded', callback) + expect(callback).toHaveBeenCalledOnce() + stop() + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('follows one resource of a declared kind through whenResourceAccess', async () => { + const access = await mounted(followOn({ kind: 'test-shelf', id: 's1' })) + const dispose = vi.fn() + const shelf = vi.fn(() => dispose) + const desk = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenResourceAccess('test-shelf', 's1', 'test/guarded', shelf) + access.ctx.remoteAccess.whenResourceAccess('test-desk', 's1', 'test/guarded', desk) + expect(shelf).toHaveBeenCalledOnce() + expect(desk).not.toHaveBeenCalled() + access.stream().push({ type: 'remove', resource: { kind: 'test-shelf', id: 's1' } }) + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('disposes a non-owner\'s effects once its stream fails terminally', async () => { + const access = await mounted(followOn({ kind: 'global' })) + vi.spyOn(access.ctx.logger, 'error').mockImplementation(() => undefined) + const dispose = vi.fn() + access.ctx.remoteAccess.whenAccess('test/guarded', () => dispose) + access.stream().fail(new Error('carrier closed for good')) + await vi.waitFor(() => { expect(dispose).toHaveBeenCalledOnce() }) + await access.dispose() + }) + + it('throws a setup failure at once while allowed, and reports a later one without retrying it', async () => { + const owner = await mounted({ type: 'baseline', owner: true }) + expect(() => owner.ctx.remoteAccess.whenAccess('test/guarded', () => { throw new Error('setup failed') })) + .toThrow('setup failed') + await owner.dispose() + + const access = await mounted() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const failing = vi.fn((): () => void => { throw new Error('late failure') }) + const throwsValue = vi.fn(function* (): Generator<() => void> { + yield () => {} + throw new Error('late value') + }) + const later = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenAccess('test/guarded', failing) + access.ctx.remoteAccess.whenAccess('test/guarded', throwsValue) + access.ctx.remoteAccess.whenAccess('test/guarded', later) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(failures).toHaveLength(2) }) + expect(failures.map(String)).toEqual(['Error: late failure', 'Error: late value']) + expect(later).toHaveBeenCalledOnce() + access.stream().push({ type: 'remove', resource: { kind: 'global' } }) + access.stream().push({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['test/guarded'] } }) + await vi.waitFor(() => { expect(later).toHaveBeenCalledTimes(2) }) + expect(failing).toHaveBeenCalledOnce() + expect(throwsValue).toHaveBeenCalledOnce() + } finally { + process.off('uncaughtException', onLoud) + } + await access.dispose() + }) + + it('skips a controller another installation stopped during the same notification', async () => { + const access = await mounted() + let stopLater = (): void => {} + const first = vi.fn(() => { + stopLater() + return () => {} + }) + const later = vi.fn(() => () => {}) + access.ctx.remoteAccess.whenAccess('test/guarded', first) + stopLater = access.ctx.remoteAccess.whenAccess('test/guarded', later) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(first).toHaveBeenCalledOnce() }) + expect(later).not.toHaveBeenCalled() + await access.dispose() + }) + + it('installs nothing for a grant that arrives while its caller is unloading', async () => { + const access = await mounted() + const callback = vi.fn(() => () => {}) + let release = (): void => {} + const blocked = new Promise((resolve) => { release = resolve }) + const pauseUnload = vi.fn(async () => { await blocked }) + const caller = access.ctx.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + ctx.remoteAccess.whenAccess('test/guarded', callback) + ctx.effect(() => pauseUnload, 'pause caller unload') + }, + }) + await caller.await() + const disposing = caller.dispose() + await vi.waitFor(() => { expect(pauseUnload).toHaveBeenCalledOnce() }) + access.stream().push(followOn({ kind: 'global' })) + await vi.waitFor(() => { expect(value(access.global('test/guarded'))).toBe(true) }) + expect(callback).not.toHaveBeenCalled() + release() + await disposing + await access.dispose() + }) +}) + +/** A hand-driven access answer that notifies synchronously. */ +function drivenAnswer(): { readonly answer: HostObservable; set(allowed: boolean): void } { + let allowed = false + const listeners = new Set<() => void>() + return { + answer: { + getSnapshot: () => allowed, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + set(next) { + allowed = next + for (const listener of [...listeners]) listener() + }, + } +} + +describe('whenAllowed', () => { + it('installs nothing when the answer turns true while its caller is unloading', async () => { + const root = new Context() + const driven = drivenAnswer() + const callback = vi.fn(() => () => {}) + const caller = root.plugin({ + apply: (ctx: Context) => { + whenAllowed(ctx, driven.answer, callback, 'test') + // Disposed before the controller: it turns the answer true mid-unload. + ctx.effect(() => () => { driven.set(true) }, 'grant during unload') + }, + }) + await caller.await() + await caller.dispose() + expect(callback).not.toHaveBeenCalled() + await root.fiber.dispose() + }) + + it('ignores its disposer after a later failure retired it, and reports a non-Error failure as an Error', async () => { + const root = new Context() + const driven = drivenAnswer() + const failures: unknown[] = [] + const onLoud = (error: unknown): void => { failures.push(error) } + process.on('uncaughtException', onLoud) + try { + const stop = whenAllowed(root, driven.answer, () => { throw new Error('late failure') }, 'test') + driven.set(true) + await vi.waitFor(() => { expect(failures).toHaveLength(1) }) + expect(() => { stop() }).not.toThrow() + // A faulty answer that throws a non-Error value is reported as an Error naming it. + let reads = 0 + const listeners: (() => void)[] = [] + whenAllowed(root, { + getSnapshot: () => { + reads++ + if (reads > 1) throw 'unreadable answer' + return false + }, + subscribe: (listener) => { + listeners.push(listener) + return () => {} + }, + }, () => () => {}, 'test') + for (const listener of listeners) listener() + await vi.waitFor(() => { expect(failures).toHaveLength(2) }) + expect(String(failures[1])).toBe('Error: unreadable answer') + } finally { + process.off('uncaughtException', onLoud) + } + await root.fiber.dispose() + }) +}) diff --git a/packages/api/remote-access/tests/remote-access.host.spec.ts b/packages/api/remote-access/tests/remote-access.host.spec.ts index f29d4d1744..101991e0c4 100644 --- a/packages/api/remote-access/tests/remote-access.host.spec.ts +++ b/packages/api/remote-access/tests/remote-access.host.spec.ts @@ -400,18 +400,27 @@ describe('first snapshots', () => { expect(REMOTE_ACCESS_SNAPSHOT_KEY).toBe('__DSH_REMOTE_ACCESS__') }) - it('injects the owner baseline into every index the webServer renders', async () => { - const { ctx } = await setup() - const render = (): IndexInjection[] => { + it('injects the access snapshot of the Peer each index speaks for', async () => { + const { ctx, access } = await setup() + const policy = new GuestPolicy() + policy.hold(GUEST, { role: READER, resource: GLOBAL }) + access.registerPolicy('test-guest', policy) + const render = (principal: Principal): IndexInjection[] => { const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, new Peer(ctx, { principal })) return table } - expect(render()).toEqual([]) + expect(render(OWNER)).toEqual([]) ctx.provide('webServer', {} as Context['webServer']) await vi.waitFor(() => { - expect(render()).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) + expect(render(OWNER)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: true } }]) }) + expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { + type: 'baseline', owner: false, grants: [{ resource: GLOBAL, actions: ['notes/read', 'notes/rooms', 'notes/write'] }], + } }]) + // A grant change reaches the next render. + policy.hold(GUEST) + expect(render(GUEST)).toEqual([{ kind: 'global', name: '__DSH_REMOTE_ACCESS__', value: { type: 'baseline', owner: false, grants: [] } }]) }) it('lists per bound resource the endpoint actions decide() allows there without arguments, and no grant where none is allowed', async () => { diff --git a/packages/api/remote-access/tsconfig.client.json b/packages/api/remote-access/tsconfig.client.json index 0ddeaeac30..c365c4f780 100644 --- a/packages/api/remote-access/tsconfig.client.json +++ b/packages/api/remote-access/tsconfig.client.json @@ -9,6 +9,7 @@ "src/client/index.ts", "src/client/mount.ts", "src/client/state.ts", + "src/client/when-allowed.ts", "src/snapshot.ts", "src/types.ts" ], diff --git a/packages/bundle/web-app/tests/document-preview.spec.ts b/packages/bundle/web-app/tests/document-preview.spec.ts index dcf2bcff18..9af5ca6a4f 100644 --- a/packages/bundle/web-app/tests/document-preview.spec.ts +++ b/packages/bundle/web-app/tests/document-preview.spec.ts @@ -10,7 +10,7 @@ import { loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import WorkspaceFiles, { type WorkspaceFileScope } from '@deepseek-ai/dsh-api-workspace-files' import OfficeToPdf from '@deepseek-ai/dsh-office-to-pdf' import * as DocumentPreview from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' @@ -20,6 +20,9 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { Converter, ConverterOptions } from '@deepseek-ai/libreoffice-kit' import { expect, it, onTestFinished, vi } from 'vitest' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + const kit = vi.hoisted(() => ({ create: vi.fn<(options?: ConverterOptions) => Promise>() })) vi.mock('@deepseek-ai/libreoffice-kit', () => ({ createConverter: kit.create })) @@ -92,7 +95,7 @@ it('loads the shipped Office rows with separately patched settings and authorize const entries = new Map([...ctx.loader.entries()].map(entry => [entry.options.id, entry])) for (const row of configured) await entries.get(row.id)!.fiber!.await() const injections: IndexInjection[] = [] - ctx.emit('webserver/index-inject', injections) + ctx.emit('webserver/index-inject', injections, PAGE_PEER) expect(injections).toEqual([ { kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: clientConfig }, ]) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index b3793c9cfd..c3497a95c2 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -22,6 +22,9 @@ import { import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + /** Structural webServer fake recording both route registries. */ function fakeHttpServer( routes: WebRoute[], @@ -272,7 +275,7 @@ describe('connection node half', () => { const { ctx, dispose } = await mounted({ recovery: { generationReadyTimeoutMs: 25_000 } }) try { const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: '__DSH_CONNECTION_RECOVERY__', value: { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, @@ -281,7 +284,7 @@ describe('connection node half', () => { }]) await dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) } finally { await dispose() diff --git a/packages/client/shortcuts/tests/config.host.spec.ts b/packages/client/shortcuts/tests/config.host.spec.ts index 13ad7bc00a..ec507217c5 100644 --- a/packages/client/shortcuts/tests/config.host.spec.ts +++ b/packages/client/shortcuts/tests/config.host.spec.ts @@ -1,8 +1,11 @@ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it } from 'vitest' import { Config, apply } from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('validates positive sequence timing and publishes it through the product page injection', async () => { expect(Config({}).stopSequenceMs).toBe(500) expect(Config({ stopSequenceMs: 2_147_483_646 }).stopSequenceMs).toBe(2_147_483_646) @@ -11,7 +14,7 @@ it('validates positive sequence timing and publishes it through the product page try { apply(ctx, Config({ stopSequenceMs: 800 })) const table: IndexInjection[] = [] - await ctx.parallel('webserver/index-inject', table) + await ctx.parallel('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: '__DSH_SHORTCUTS_CONFIG__', value: { stopSequenceMs: 800 } }]) } finally { await ctx.fiber.dispose() } }) diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 6f54248246..ff25c9ae17 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -44,6 +44,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", diff --git a/packages/client/ui-directory-picker-native/src/client/index.ts b/packages/client/ui-directory-picker-native/src/client/index.ts index efec56fc49..1bdcafecdd 100644 --- a/packages/client/ui-directory-picker-native/src/client/index.ts +++ b/packages/client/ui-directory-picker-native/src/client/index.ts @@ -1,5 +1,7 @@ /** Native directory flow using the local desktop bridge or the Host's OS chooser. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the SlotMap merge declaring the directory-flow holes. import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' // Type-only: pulls the SlotRegistry service merge (ctx.slots). @@ -8,13 +10,14 @@ import type { NativeFlowInjected } from './flow.ts' import { NativeDirectoryFlow } from './flow.ts' -/** Required services (cordis fiber inject): the slot registry and workspace UI service. */ -export const inject = ['slots', 'uiWorkspace'] +/** Required services (cordis fiber inject): the slot registry, workspace UI service, and access. */ +export const inject = ['slots', 'uiWorkspace', 'remoteAccess'] /** - * Client plugin body: register the renderless native flow into both - * directory-flow holes through `slots.inject()` because the ui-workspace - * entries may activate later or replace their declarations. + * Client plugin body: while the current Peer may open the Host's directory + * chooser, register the renderless native flow into both directory-flow holes + * through `slots.inject()` because the ui-workspace entries may activate later + * or replace their declarations. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -26,13 +29,14 @@ export function apply(ctx: ClientContext): void { // Both declaration lifetimes must be live before the pair installs; the // generator makes the two registrations one transactional effect. The // outer/inner nesting order is arbitrary; neither hole has precedence. - ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => - ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { - yield ctx.slots.register({ - name: 'conversation.hero.workspace.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - yield ctx.slots.register({ - name: 'sidebar.workspaces.directoryFlow', inject: injected, - }, NativeDirectoryFlow) - })) + ctx.remoteAccess.whenAccess('directoryPicker/pick', () => + ctx.slots.inject('conversation.hero.workspace.directoryFlow', () => + ctx.slots.inject('sidebar.workspaces.directoryFlow', function* () { + yield ctx.slots.register({ + name: 'conversation.hero.workspace.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + yield ctx.slots.register({ + name: 'sidebar.workspaces.directoryFlow', inject: injected, + }, NativeDirectoryFlow) + }))) } diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index ed033999aa..fd3b22d315 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { DirectoryFlowOwnerProps } from '@deepseek-ai/dsh-client-ui-workspace/client' import { apply, inject } from '../src/client/index.ts' import { NativeDirectoryFlow } from '../src/client/flow.ts' @@ -29,9 +30,10 @@ afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); deskto const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const -async function bench() { +async function bench(allows: (action: string) => boolean = () => true) { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) const pickDirectory = vi.fn(async (): Promise => '/tmp/picked') ctx.provide('uiWorkspace', { pickDirectory } as never) const slots = ctx.get('slots') as SlotRegistry @@ -52,7 +54,19 @@ function owner(overrides: Partial = {}): DirectoryFlowO describe('directory-picker-native client half', () => { it('declares the services it drives', () => { - expect(inject).toEqual(['slots', 'uiWorkspace']) + expect(inject).toEqual(['slots', 'uiWorkspace', 'remoteAccess']) + }) + + it('fills no directory flow while the current Peer may not open the Host chooser', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + b.declare() + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('directoryPicker/pick') + for (const hole of HOLES) expect(b.slots.entries(hole)).toEqual([]) + await fiber.dispose() + await b.ctx.fiber.dispose() }) it('fills both directory-flow holes for declarations before or after apply, and leaves with its fiber', async () => { diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx index 6709f79984..c95a28d21b 100644 --- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx +++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.tsx @@ -17,12 +17,17 @@ import type { MessageFeedbackActionProps } from './slots.ts' import css from './MessageFeedbackActions.module.css' /** - * One message's feedback controls. - * @param props - the owner's message identity, the injected verbs, and the - * shared feedback hook. - * @returns the rating buttons with any failure notice beside them. + * One message's feedback controls, rendered only while the current Peer may + * record message feedback in this Session. + * @param props - the owner's message identity, the injected verbs, the + * shared feedback hook, and the Session access Hook. + * @returns the rating buttons with any failure notice beside them, or nothing without access. */ -export function MessageFeedbackActions({ +export function MessageFeedbackActions(props: MessageFeedbackActionProps) { + return props.useAccess('messageFeedback/put') ? : null +} + +function FeedbackRatingButtons({ messageId, ensure, current, retract, openDialog, useFeedback, t, }: MessageFeedbackActionProps) { const item = useFeedback(view => view.items.get(messageId)) diff --git a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx index 4121676697..2e80707a58 100644 --- a/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/message-feedback-actions.client.spec.tsx @@ -45,6 +45,8 @@ function mount(options: { ensureResult?: MessageFeedbackActionResult retractResult?: MessageFeedbackActionResult status?: MessageFeedbackView['status'] + /** The Session access answer for recording message feedback; allowed when omitted. */ + allowed?: boolean } = {}) { const view: MessageFeedbackView = { status: options.status ?? 'ready', @@ -58,12 +60,22 @@ function mount(options: { const current = vi.fn((_id: MessageId) => options.committed ?? options.current) const useFeedback = ((select: (v: MessageFeedbackView) => T): T => useSyncExternalStore(() => () => {}, () => select(view))) as never + const useAccess = vi.fn((_action: string) => options.allowed ?? true) const props = { messageId: MSG, ensure, current, retract, openDialog, useFeedback, t } as unknown as Parameters[0] - return { ...render(), ensure, retract, openDialog } + return { ...render(), ensure, retract, openDialog, useAccess } } describe('MessageFeedbackActions', () => { + it('renders nothing and reads nothing while the current Peer may not record message feedback in this Session', () => { + const ui = mount({ allowed: false }) + + expect(ui.useAccess).toHaveBeenCalledWith('messageFeedback/put') + expect(ui.queryByLabelText(zh['action.like'])).toBeNull() + expect(ui.queryByLabelText(zh['action.dislike'])).toBeNull() + expect(ui.ensure).not.toHaveBeenCalled() + }) + it('renders both rating buttons unpressed with no recorded feedback', () => { const ui = mount() @@ -222,7 +234,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -252,7 +264,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) @@ -286,7 +298,7 @@ describe('MessageFeedbackActions', () => { useFeedback, t, } as unknown as Parameters[0] - const ui = render() + const ui = render( true} />) const errors: unknown[] = [] const onError = (event: ErrorEvent): void => { errors.push(event.error) } window.addEventListener('error', onError) diff --git a/packages/client/ui-plugin-manager/package.json b/packages/client/ui-plugin-manager/package.json index 1cf5abf9b8..a9eceb424d 100644 --- a/packages/client/ui-plugin-manager/package.json +++ b/packages/client/ui-plugin-manager/package.json @@ -55,6 +55,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 52506f9fb7..6682477943 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -16,6 +16,8 @@ import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +// Type-only: the ctx.remoteAccess Context merge. +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: the ctx.remote Context merge and the forwarded-event key face. import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: the forwarded events' own declaration (`$on`'s key face resolves @@ -79,12 +81,13 @@ export const NS = 'pluginManager' /** The id shared by the sidebar entry and the main panel it opens. */ export const PANEL_ID = 'plugins' as MainPanelId -/** Services required by the sidebar registration and the Remote methods; the inventory says whether the Host manages a profile. */ -export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout'] +/** Services required by the sidebar registration, the Remote methods, and access; the inventory says whether the Host manages a profile. */ +export const inject = ['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess'] /** * Contribute the Plugins entry to the sidebar with the management page it - * opens, and keep it current on the Host's change events. + * opens, and keep it current on the Host's change events, while the current + * Peer may list the installed bundles. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { @@ -93,82 +96,77 @@ export function apply(ctx: ClientContext): void { const controller = new PluginManagerController(ctx) ctx.effect(() => () => { controller.dispose() }, 'ui-plugin-manager: controller') ctx.provide('pluginChanges', { bundle: packageName => controller.observeChanges(packageName) }) - // The Host says when what is installed, enabled, or composed changed — from - // this page, the CLI, or another browser — and streams install output. - ctx.effect(() => { - // A page never rendered holds no snapshot to refresh. - const refresh = (): void => { - // A profile change can replace a bundle's files, and a restarted Host is read afresh. - controller.details.reset() - if (controller.getSnapshot().status !== 'idle') void controller.load() - } - const disposers = [ - ctx.remote.$on('plugin-manager/changed', refresh), - ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }), - ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }), - ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }), - ctx.on('connection/reset', refresh), - ] - return () => { for (const dispose of disposers) dispose() } - }, 'ui-plugin-manager: host invalidations') - // The page is a global panel: it belongs to the profile, not to a Session, // and the sidebar's entry selects it. What is installed and switched on is // the page's own; a plugin's configuration arrives through the slots the // page declares here, so the page never names a configurable plugin. const configLedger = configLedgerSource(ctx) const face = controller.inject(configLedger, tabLedgerSource(ctx), text => ctx.locale.resolveText(text)) - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, - inject: (): PluginRefreshToastFace => ({ - hooks: { pluginManager: face.hooks.pluginManager }, - dismissNotice: face.dismissNotice, - }), - }, PluginRefreshToast)) - ctx.slots.inject('main', function* () { - const handle = createNavigationStore(), instance = handle.create() - const store: typeof handle = { ...handle, create: () => instance } - yield ctx.slots.register({ - name: 'main', - key: PANEL_ID, + ctx.remoteAccess.whenAccess('pluginManager/listBundles', function* () { + // The Host says when what is installed, enabled, or composed changed — from + // this page, the CLI, or another browser — and streams install output. + // A page never rendered holds no snapshot to refresh. + const refresh = (): void => { + // A profile change can replace a bundle's files, and a restarted Host is read afresh. + controller.details.reset() + if (controller.getSnapshot().status !== 'idle') void controller.load() + } + yield ctx.remote.$on('plugin-manager/changed', refresh) + yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) + yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) + yield ctx.remote.$on('plugin-manager/update-state', (progress) => { controller.updateProgress(progress) }) + yield ctx.on('connection/reset', refresh) + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'plugin-manager.refresh-toast', locale: NS, + inject: (): PluginRefreshToastFace => ({ + hooks: { pluginManager: face.hooks.pluginManager }, + dismissNotice: face.dismissNotice, + }), + }, PluginRefreshToast)) + yield ctx.slots.inject('main', function* () { + const handle = createNavigationStore(), instance = handle.create() + const store: typeof handle = { ...handle, create: () => instance } + yield ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: NS, + store, + inject: () => face, + children: { + 'plugins.add.actions': { kind: 'list', scope: 'root' }, + 'plugins.tab': { kind: 'list', scope: 'root' }, + 'plugins.item': { kind: 'list', scope: 'root' }, + 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, + 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, + 'plugins.row.config': { kind: 'keyed', scope: 'root' }, + 'plugins.detail.actions': { kind: 'list', scope: 'root' }, + 'plugins.detail.badge': { kind: 'list', scope: 'root' }, + 'plugins.detail.section': { kind: 'list', scope: 'root' }, + }, + }, PluginManagerPage) + yield ctx.layout.panelInfo.subscribe(() => { + if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) + }) + const disposeNavigation = ctx.reflect.provide('pluginNavigation', { + openList: () => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'list' }) + }, + openBundle: (packageName: string) => { + ctx.layout.selectPanel(PANEL_ID) + instance.actions.setTab('installed') + instance.actions.setView({ kind: 'package', name: packageName }) + }, + }) + yield () => { void disposeNavigation() } + }) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 0, + label: () => t('panel'), locale: NS, - store, - inject: () => face, - children: { - 'plugins.add.actions': { kind: 'list', scope: 'root' }, - 'plugins.tab': { kind: 'list', scope: 'root' }, - 'plugins.item': { kind: 'list', scope: 'root' }, - 'plugins.bundle.activation': { kind: 'keyed', scope: 'root' }, - 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, - 'plugins.row.config': { kind: 'keyed', scope: 'root' }, - 'plugins.detail.actions': { kind: 'list', scope: 'root' }, - 'plugins.detail.badge': { kind: 'list', scope: 'root' }, - 'plugins.detail.section': { kind: 'list', scope: 'root' }, - }, - }, PluginManagerPage) - yield ctx.layout.panelInfo.subscribe(() => { - if (ctx.layout.panelInfo.getSnapshot().activePanelId !== PANEL_ID) instance.actions.setView({ kind: 'list' }) - }) - const disposeNavigation = ctx.reflect.provide('pluginNavigation', { - openList: () => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'list' }) - }, - openBundle: (packageName: string) => { - ctx.layout.selectPanel(PANEL_ID) - instance.actions.setTab('installed') - instance.actions.setView({ kind: 'package', name: packageName }) - }, - }) - yield () => { void disposeNavigation() } + }, PluginsPanelIcon)) }) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 0, - label: () => t('panel'), - locale: NS, - }, PluginsPanelIcon)) - } diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 83f72145d7..3a62e8c5ce 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -8,7 +8,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ILayout, PanelInfo } from '@deepseek-ai/dsh-client-ui-layout/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { allowAllAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { allowAllAccess, StaticRemoteAccess, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' import * as settings from '@deepseek-ai/dsh-client-ui-settings/client' import { apply, inject, NS, PANEL_ID } from '../src/client/index.ts' import { PluginManagerPage } from '../src/client/PluginManagerPage.tsx' @@ -21,10 +21,11 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench() { +async function bench(allows: (action: string) => boolean = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -153,7 +154,20 @@ describe('ui-plugin-manager browser plugin', () => { }) it('declares only the services the page and its Remote methods use', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout']) + expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) + }) + + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { + const allows = vi.fn(() => false) + const b = await bench(allows) + declare(b.slots) + const fiber = b.ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('pluginManager/listBundles') + for (const name of ['main', 'shell.overlay', 'sidebar.panellist'] as const) expect(b.slots.entries(name)).toEqual([]) + expect(b.ctx.get('pluginNavigation')).toBeUndefined() + expect(b.ctx.get('pluginChanges')?.bundle('dsh-any').getSnapshot()).toBe(false) + await fiber.dispose() }) it('registers the sidebar entry and its page, which reads the Host only once rendered and follows Host changes', async () => { diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index dce1c30814..5d149a8d39 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -54,6 +54,7 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", diff --git a/packages/client/ui-schedule/src/client/index.ts b/packages/client/ui-schedule/src/client/index.ts index 4aa83ae1a8..8c6dcb3c5f 100644 --- a/packages/client/ui-schedule/src/client/index.ts +++ b/packages/client/ui-schedule/src/client/index.ts @@ -38,6 +38,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { MainPanelId } from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -73,16 +74,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, and original-Session navigation. */ +/** Required services for catalogs, ambient Session marks, the right Sidebar, Remote queries, original-Session navigation, and access. */ export const inject = [ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', - 'workspaces', 'sidebarRightTabs', 'sidebarRight', + 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess', ] /** * Register the Host task page, the Session-header reminder catalog, the * right-Sidebar page that shows one task's detail, and the transcript card of - * one created task. + * one created task while the current Peer may read the task catalog. * @param ctx - browser services used by these contributions. */ export function apply(ctx: ClientContext): void { @@ -90,157 +91,159 @@ export function apply(ctx: ClientContext): void { const managerDictionaries = { zh: managerZh, en: managerEn, ja: managerJa } ctx.effect(() => ctx.locale.register(MANAGER_NS, managerDictionaries), 'ui-schedule: manager dictionaries') const t = ctx.locale.bind(MANAGER_NS) - const manager: CatalogInjected = createCatalogSource({ - list: () => ctx.remote.schedule.catalog(), - remove: async (id) => { - const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id) - if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } } - return ctx.remote.schedule.delete({ sessionId: record.sessionId, id }) - }, - subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener), - subscribeReset: listener => ctx.on('connection/reset', listener), - }) - // One outcome store behind one `shell.overlay` entry announces every deletion - // this plugin's surfaces settle, so the notice outlives the panel, popover, or - // tab the deletion started in. - const deleteToast = createDeleteToastSource() - const reportedDelete = (onDelete: (id: ScheduleId) => Promise) => - async (id: ScheduleId): Promise => { - const outcome = await onDelete(id) - deleteToast.report(outcome) - return outcome - } - ctx.slots.inject('shell.overlay', () => ctx.slots.register({ - name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS, - inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }), - }, ScheduleDeleteToast)) - /** - * Forward one compare-and-update request, which may replace the task name, - * the instruction, and/or the timing, and refresh the authoritative catalog - * after an accepted or stale outcome. - * @param request - complete expected record with the optional content and timing change. - * @returns the original Remote mutation result. - */ - const updateTask = async (request: ScheduleUpdateRequest) => { - const result = await ctx.remote.schedule.update(request) - if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict' - || result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) { - // The write is in, so this readback must not join a read that was sent - // before it: naming every request seen so far makes the source issue a new - // one, the way the post-deletion readback supersedes. - await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest) - } - return result - } - const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request) - const openSession = (id: SessionId): void => { - if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') { - ctx.uiWorkspace.openSession(id) - } - } - const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { - hooks: manager.hooks, - onDelete: reportedDelete(manager.onDelete), - onRetry: manager.onRetry, - onUpdateTiming: updateTask, - loadHistory, - onOpenSession: openSession, - } - // The Sidebar persists a tab's layout record and not its navigation - // parameters, so this page type keeps the task it last showed per Session and - // tab id. That Session's committed tab ids bound every write, so a closed tab - // leaves no entry behind. - const taskBindings = new TaskTabBindings( - sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id), - ) ctx.effect(() => ctx.sidebarRightTabs.register(scheduleTaskDefinition(t)), 'ui-schedule: task tab type') - ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab', - key: SCHEDULE_TASK_ID, - locale: MANAGER_NS, - inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }), - }, ScheduleTaskTab)) - ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ - name: 'sidebar.right.pane.tab.title', - key: SCHEDULE_TASK_ID, - locale: MANAGER_NS, - inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }), - }, ScheduleTaskTabTitle)) - ctx.slots.inject('main', () => ctx.slots.register({ - name: 'main', - key: PANEL_ID, - locale: MANAGER_NS, - inject: (): TaskManagerInjected => ({ - ...detail, - // The page has no creation form: a new reminder starts in a Session. - onNewTask: () => { ctx.uiWorkspace.startSession() }, - }), - }, TaskManagerPage)) - ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ - name: 'sidebar.panellist', - id: PANEL_ID, - order: 10, - locale: MANAGER_NS, - label: () => t('panel'), - }, TaskManagerIcon)) // The created task is a Turn-level element, not a Tool-group row: the Turn - // Definition publishes the settled result and this tail list entry renders - // the card beneath the closing prose. + // Definition publishes the settled result and the tail list entry below + // renders the card beneath the closing prose. ctx.uiConversation.events.register(scheduleTurnDefinition) - ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ - name: 'conversation.chat.turnTail', - id: 'schedule-created', - order: 20, - locale: MANAGER_NS, - inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({ - hooks: { catalog: manager.hooks.catalog }, + ctx.remoteAccess.whenAccess('schedule/catalog', function* () { + const manager: CatalogInjected = createCatalogSource({ + list: () => ctx.remote.schedule.catalog(), + remove: async (id) => { + const record = manager.hooks.catalog.getSnapshot().records.find(item => item.id === id) + if (record === undefined) return { ok: true, value: { id, deleted: false, code: 'schedule_not_found' } } + return ctx.remote.schedule.delete({ sessionId: record.sessionId, id }) + }, + subscribeChanged: listener => ctx.remote.$on('schedule/changed', listener), + subscribeReset: listener => ctx.on('connection/reset', listener), + }) + // One outcome store behind one `shell.overlay` entry announces every deletion + // this plugin's surfaces settle, so the notice outlives the panel, popover, or + // tab the deletion started in. + const deleteToast = createDeleteToastSource() + const reportedDelete = (onDelete: (id: ScheduleId) => Promise) => + async (id: ScheduleId): Promise => { + const outcome = await onDelete(id) + deleteToast.report(outcome) + return outcome + } + yield ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'schedule.delete-toast', locale: MANAGER_NS, + inject: () => ({ hooks: deleteToast.hooks, dismiss: deleteToast.dismiss }), + }, ScheduleDeleteToast)) + /** + * Forward one compare-and-update request, which may replace the task name, + * the instruction, and/or the timing, and refresh the authoritative catalog + * after an accepted or stale outcome. + * @param request - complete expected record with the optional content and timing change. + * @returns the original Remote mutation result. + */ + const updateTask = async (request: ScheduleUpdateRequest) => { + const result = await ctx.remote.schedule.update(request) + if (result.ok && ('record' in result.value || result.value.code === 'schedule_conflict' + || result.value.code === 'schedule_ended' || result.value.code === 'schedule_not_found')) { + // The write is in, so this readback must not join a read that was sent + // before it: naming every request seen so far makes the source issue a new + // one, the way the post-deletion readback supersedes. + await manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest) + } + return result + } + const loadHistory = (request: ScheduleDeliveryHistoryRequest) => ctx.remote.schedule.history(request) + const openSession = (id: SessionId): void => { + if (sessionLinkState(id, ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()) === 'available') { + ctx.uiWorkspace.openSession(id) + } + } + const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = { + hooks: manager.hooks, + onDelete: reportedDelete(manager.onDelete), onRetry: manager.onRetry, - openTaskDetail: (id) => { - ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) - }, - }), - }, ScheduleTurnCard)) - // The Session header keeps one per-Session source: it lists the open - // Session's active tasks and starts only while that header is mounted. The - // Sidebar row seats read the shared `manager` catalog instead, so every - // visible row projects one Host query rather than issuing its own. - const createSource: SessionScheduleSourceFactory = sessionId => - createSessionScheduleSource(ctx, sessionId) - ctx.slots.inject( - 'conversation.session.header.utilities', - () => ctx.slots.register({ - name: 'conversation.session.header.utilities', - id: 'schedule-catalog', - // Sits left of the overflow menu (order 0) and right of the header's - // leading utilities (order -10), matching the shipped mock. - order: -5, + onUpdateTiming: updateTask, + loadHistory, + onOpenSession: openSession, + } + // The Sidebar persists a tab's layout record and not its navigation + // parameters, so this page type keeps the task it last showed per Session and + // tab id. That Session's committed tab ids bound every write, so a closed tab + // leaves no entry behind. + const taskBindings = new TaskTabBindings( + sessionId => ctx.sidebarRight.tabsIn(sessionId).map(tab => tab.id), + ) + yield ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: SCHEDULE_TASK_ID, + locale: MANAGER_NS, + inject: (): ScheduleTaskTabInjected => ({ ...detail, taskBindings }), + }, ScheduleTaskTab)) + yield ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab.title', + key: SCHEDULE_TASK_ID, + locale: MANAGER_NS, + inject: (): ScheduleTaskCatalogInjected & ScheduleTaskBindingInjected => ({ hooks: detail.hooks, taskBindings }), + }, ScheduleTaskTabTitle)) + yield ctx.slots.inject('main', () => ctx.slots.register({ + name: 'main', + key: PANEL_ID, + locale: MANAGER_NS, + inject: (): TaskManagerInjected => ({ + ...detail, + // The page has no creation form: a new reminder starts in a Session. + onNewTask: () => { ctx.uiWorkspace.startSession() }, + }), + }, TaskManagerPage)) + yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({ + name: 'sidebar.panellist', + id: PANEL_ID, + order: 10, + locale: MANAGER_NS, + label: () => t('panel'), + }, TaskManagerIcon)) + yield ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'schedule-created', + order: 20, + locale: MANAGER_NS, + inject: (sessionId: SessionId): ScheduleTurnCardInjected => ({ + hooks: { catalog: manager.hooks.catalog }, + onRetry: manager.onRetry, + openTaskDetail: (id) => { + ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) + }, + }), + }, ScheduleTurnCard)) + // The Session header keeps one per-Session source: it lists the open + // Session's active tasks and starts only while that header is mounted. The + // Sidebar row seats read the shared `manager` catalog instead, so every + // visible row projects one Host query rather than issuing its own. + const createSource: SessionScheduleSourceFactory = sessionId => + createSessionScheduleSource(ctx, sessionId) + yield ctx.slots.inject( + 'conversation.session.header.utilities', + () => ctx.slots.register({ + name: 'conversation.session.header.utilities', + id: 'schedule-catalog', + // Sits left of the overflow menu (order 0) and right of the header's + // leading utilities (order -10), matching the shipped mock. + order: -5, + locale: NS, + inject: (sessionId) => { + const source = createSource(sessionId) + return { + ...source, + // The header's deletions settle through the same app-wide toast as the + // Tasks page and the task tab. + onDelete: reportedDelete(source.onDelete), + openTaskDetail: (id: ScheduleId) => { + ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) + }, + } + }, + }, ScheduleCatalogAction), + ) + yield ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ + name: 'sidebar.session.row.leading', + id: 'schedule-mark', + order: 10, locale: NS, - inject: (sessionId) => { - const source = createSource(sessionId) - return { - ...source, - // The header's deletions settle through the same app-wide toast as the - // Tasks page and the task tab. - onDelete: reportedDelete(source.onDelete), - openTaskDetail: (id: ScheduleId) => { - ctx.sidebarRight.openTab(SCHEDULE_TASK_KIND, { params: { sessionId, id } }) - }, - } - }, - }, ScheduleCatalogAction), - ) - ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({ - name: 'sidebar.session.row.leading', - id: 'schedule-mark', - order: 10, - locale: NS, - inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), - }, SessionScheduleMark)) - ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({ - name: 'sidebar.session.row.hover', - id: 'schedule-tasks', - order: 10, - locale: NS, - inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), - }, SessionScheduleHover)) + inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), + }, SessionScheduleMark)) + yield ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({ + name: 'sidebar.session.row.hover', + id: 'schedule-tasks', + order: 10, + locale: NS, + inject: () => ({ hooks: { catalog: manager.hooks.catalog } }), + }, SessionScheduleHover)) + }) } diff --git a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts index 4fa73699b9..768c478bef 100644 --- a/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-schedule/tests/browser-plugin.client.spec.ts @@ -4,7 +4,7 @@ import { describe, expect, it, vi, type Mock } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client' import { resolveSlotLabel, type HostObservable, type StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' -import { stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' +import { StaticRemoteAccess, stubConfigForm } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' @@ -86,9 +86,11 @@ class RemoteStub extends Service { async function baseContext( remote: RemoteStubInput = { $on: () => () => {} }, sidebar: SidebarStubs = sidebarStubs(), + allows: (action: string) => boolean = () => true, ): Promise { const ctx = new Context() await ctx.plugin(SlotRegistry).await() + new StaticRemoteAccess(ctx, allows) new UiConversation(ctx, { binding: () => undefined } as never) ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, get: () => stubConfigForm().scope } as never) @@ -140,10 +142,29 @@ describe('ui-schedule browser half', () => { it('declares only the services used by registration', () => { expect(inject).toEqual([ 'slots', 'locale', 'remote', 'remote.schedule', 'conversation', 'uiConversation', 'uiWorkspace', 'sessions', - 'workspaces', 'sidebarRightTabs', 'sidebarRight', + 'workspaces', 'sidebarRightTabs', 'sidebarRight', 'remoteAccess', ]) }) + it('contributes its surfaces only while the current Peer may read the task catalog', async () => { + for (const allowed of [false, true]) { + const sidebar = sidebarStubs() + const allows = vi.fn(() => allowed) + const ctx = await baseContext(undefined, sidebar, allows) + const header = declareHeader(ctx) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(allows).toHaveBeenCalledWith('schedule/catalog') + // The Session-header reminder catalog is this plugin's entry point into the page. + expect(headerEntryIds(ctx)).toEqual(allowed ? ['schedule-catalog'] : []) + // The task tab type is a definition, registered either way. + expect(sidebar.register).toHaveBeenCalledOnce() + await fiber.dispose() + header() + await ctx.fiber.dispose() + } + }) + it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => { const ctx = await baseContext() const fiber = ctx.plugin({ inject: [...inject], apply }) diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index 447f8f489b..3a3d92d9d5 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -15,8 +15,8 @@ en: 12e1ffc25a2d6695 zh: 321156dea1356e55 /deepseek-ai-dsh-client-ui-session/access-hooks: - en: 619ba442c3e40fb0 - zh: af0206a774ac9bc1 + en: 7bf4bca5607248fa + zh: 665d5c4e9cd29f68 /deepseek-ai-dsh-client-ui-session/model-experience: en: cb2d4451e8bf8520 zh: 6d3adbb930d90c59 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index 79ac9f0e73..4c3928b35d 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -24,7 +24,7 @@ Running status comes from Host list baselines or status events. Subagent catalog ## Access Hooks -The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). +The package injects the Client `remoteAccess` service and publishes Session access Hooks over its `source()`: the root `useSessionAccess(sessionId, action)` answers on one Session resource, and the Session-scope `useAccess(action)` answers on the bound Session's resource; the `global` resource's answer is a different Hook, `useRemoteAccess(action)`, so neither shadows the other. `ctx.uiSession.whenAccess(sessionId, action, callback)` is the JS counterpart of `useSessionAccess`: it keeps the callback's effects installed while the current Peer may call `action` on that Session, with the semantics of `ctx.remoteAccess.whenAccess`. The SessionMaybe-scope `useAccess(action)` returns `undefined` while no Session is bound, which callers treat as not allowed; a decision on the `global` resource reads `useRemoteAccess` instead. Snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). ## Model Experience diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index 157cc79a7f..fb63e227cd 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -24,7 +24,7 @@ kind: "package-reference" ## 访问 Hook -本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 +本包注入 Client `remoteAccess` 服务,并在其 `source()` 之上发布会话访问 Hook:根作用域的 `useSessionAccess(sessionId, action)` 回答某一个 Session 资源上的访问,Session 作用域的 `useAccess(action)` 回答所绑定 Session 资源上的访问;`global` 资源上的答案是另一个 Hook `useRemoteAccess(action)`,两者互不遮蔽。`ctx.uiSession.whenAccess(sessionId, action, callback)` 是 `useSessionAccess` 在 JS 侧的对应:当前 Peer 能在该 Session 上调用 `action` 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`。SessionMaybe 作用域的 `useAccess(action)` 在未绑定 Session 时返回 `undefined`,调用方将其视为不允许;需要 `global` 资源上的判断时读 `useRemoteAccess`。快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 ## 模型体验 diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 29aeebce04..860a82287c 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -12,7 +12,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import { WeakMapWithValues } from '@deepseek-ai/dsh-util-values' import { standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots' @@ -360,6 +360,24 @@ export class UiSession extends Service { return this.sourceFor(owner) } + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on one Session resource, with the semantics of + * `ctx.remoteAccess.whenAccess`: `useSessionAccess` answers the same + * question during render. + * @param sessionId - the Session resource the guarded effects depend on. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer owned by the caller's Cordis fiber. + */ + whenAccess(sessionId: SessionId, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + // `ctx.get` traces the service to the caller's Context, which then owns the controller. + const access = this.ctx.get('remoteAccess') + /* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiSession is reachable. */ + if (access === undefined) throw new Error('ui-session: whenAccess requires the remoteAccess service') + return access.whenResourceAccess('session', sessionId, action, callback) + } + /** * Register one Session-scoped standard-source contribution. * @param descriptor - static member roster and per-binding resolver. diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index ae105e7b8e..776db63bed 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -728,7 +728,10 @@ describe('ui-session apply', () => { } return source }), + whenAccess: vi.fn(() => () => {}), + whenResourceAccess: vi.fn((): () => void => stopGuard), } + const stopGuard = vi.fn() ctx.provide('sessions', bench.sessions) ctx.provide('slots', slots as never) ctx.provide('remoteAccess', remoteAccess) @@ -758,6 +761,11 @@ describe('ui-session apply', () => { const binding = bench.binding(sessionId('s2')) const scoped = ctx.uiSession.bindingSource(bench.reference(binding)).getSnapshot() expect(scoped.keyedHooks.access?.('session/fork')).toBe(remoteAccess.source('session', sessionId('s2'), 'session/fork')) + + // `whenAccess` follows the same Session resource through the service's resource form. + const guarded = vi.fn(() => () => {}) + expect(ctx.uiSession.whenAccess(sessionId('s1'), 'session/fork', guarded)).toBe(stopGuard) + expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded) }) it('keeps the Host loader half inert', () => { diff --git a/packages/client/ui-settings-account/package.json b/packages/client/ui-settings-account/package.json index 681232a393..41b221656b 100644 --- a/packages/client/ui-settings-account/package.json +++ b/packages/client/ui-settings-account/package.json @@ -50,6 +50,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index 4847b9b6e9..db0901ee9b 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -8,6 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-theme/client' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-api-remote-access/client' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { AccountView, AccountDetails } from '@deepseek-ai/dsh-deepseek-account/types' import type { OnboardingChange } from './onboarding-contract.ts' @@ -41,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by account settings. */ -export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms'] +export const inject = ['slots', 'locale', 'remote', 'remote.account', 'remote.session', 'theme', 'configForms', 'remoteAccess'] /** Register account UI in Desktop and plain Web clients. @param ctx - client plugin context. */ export function apply(ctx: Context): void { const desktop = 'dshDesktop' in globalThis @@ -115,37 +116,40 @@ export function apply(ctx: Context): void { void request.finally(() => { if (refreshing === request) refreshing = undefined }) return request } - ctx.effect(() => () => { revision++ }, 'account: details request lifetime') - const stream = ctx.remote.$stream({ - name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), + // The account stream, and the reads it starts, follow access to this host's account state. + ctx.remoteAccess.whenAccess('account/watch', function* () { + yield () => { revision++ } + const stream = ctx.remote.$stream({ + name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), + }) + let disposed = false + yield () => { disposed = true; notices.end(); return stream.dispose() } + void (async () => { + for await (const frame of stream) { + revision++ + refreshing = undefined + avatar.reset() + const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' + && snapshot.view?.attempt?.phase !== 'succeeded' + const { notices: previousNotices, ...previous } = snapshot + publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false, + ...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) }) + browser.update(frame.value) + frame.accept() + if (frame.value.status === 'credential-stored') notices.begin() + else notices.end() + if (initialize) void (async () => { + try { + const initialized = await ctx.remote.session.initializeDefaultModel() + if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) + } catch (_error) { + console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) + } + })() + void refresh() + } + })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) }) - let disposed = false - ctx.effect(() => () => { disposed = true; return stream.dispose() }, 'account: state stream') - void (async () => { - for await (const frame of stream) { - revision++ - refreshing = undefined - avatar.reset() - const initialize = frame.value.status === 'credential-stored' && frame.value.attempt?.phase === 'succeeded' - && snapshot.view?.attempt?.phase !== 'succeeded' - const { notices: previousNotices, ...previous } = snapshot - publish({ ...previous, view: frame.value, details: undefined, avatar: undefined, failed: false, - ...(frame.value.status === 'credential-stored' && previousNotices ? { notices: previousNotices } : {}) }) - browser.update(frame.value) - frame.accept() - if (frame.value.status === 'credential-stored') notices.begin() - else notices.end() - if (initialize) void (async () => { - try { - const initialized = await ctx.remote.session.initializeDefaultModel() - if (!initialized.ok) console.error('[deepseek-account] default model initialization failed', { reason: 'refused' }) - } catch (_error) { - console.error('[deepseek-account] default model initialization failed', { reason: 'disconnected' }) - } - })() - void refresh() - } - })().catch(() => { if (!disposed) publish({ ...snapshot, failed: true }) }) const nativePlatform = (globalThis as typeof globalThis & { dshPlatform?: PlatformBridge }).dshPlatform // One request channel for the single native Platform view. It always exists // so every entry can observe whether a page is showing; only a native bridge @@ -290,14 +294,14 @@ export function apply(ctx: Context): void { ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations, }, AccountOnboarding)) - ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ name: 'shell.quota-notice', locale: 'settings.account', select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, inject: (): AccountQuotaNoticeInjected => ({ hooks: { account: operations.hooks.account, platformPage: platformPages }, ...nativePlatform === undefined ? {} : { openPlatformPage: platformPageOpener(refreshAccount) }, }), - }, AccountQuotaNotice)) + }, AccountQuotaNotice))) if (nativePlatform !== undefined) { ctx.slots.inject('shell.overlay', () => ctx.slots.register({ name: 'shell.overlay', id: 'account.platform-page', locale: 'settings.account', @@ -307,9 +311,9 @@ export function apply(ctx: Context): void { }), }, AccountPlatformHost)) } - ctx.slots.inject('settings.launcher', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({ name: 'settings.launcher', locale: 'settings.account', inject: () => operations, - }, AccountMenu)) + }, AccountMenu))) ctx.slots.inject('settings.section', () => { let unregister: (() => void) | undefined const update = () => { diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 0146bbfa4a..5eb29a1ade 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -23,6 +23,7 @@ import { AccountQuotaNotice } from '../src/client/AccountQuotaNotice.tsx' import type { AccountQuotaNoticeInjected } from '../src/client/AccountQuotaNotice.tsx' const it = createClientTest({ roster: webApp }) +const withoutAccountAccess = createClientTest({ roster: webApp }, { access: action => action !== 'account/watch' }) const SELF = '@deepseek-ai/dsh-client-ui-settings-account' const view: AccountView = { status: 'signed-out', attempt: null, @@ -77,6 +78,15 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac } }, 60_000) +withoutAccountAccess('opens no account stream and registers no launcher or quota notice while the current Peer may not watch the account', async ({ start }) => { + const c = await start() + expect(c.mock.streams.end('account/watch')).toBe(0) + expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) + expect(quotaNoticeEntry(c)).toBeUndefined() + // The models sign-in entry lives inside the settings shell, which follows its own access. + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1) +}, 60_000) + it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => { vi.stubGlobal('dshDesktop', {}) const c = await start() diff --git a/packages/client/ui-settings-account/tests/config.client.spec.ts b/packages/client/ui-settings-account/tests/config.client.spec.ts index a5a33dca61..850cf78660 100644 --- a/packages/client/ui-settings-account/tests/config.client.spec.ts +++ b/packages/client/ui-settings-account/tests/config.client.spec.ts @@ -1,24 +1,27 @@ /** Public questionnaire options are emitted in the page bootstrap. */ import { Context } from '@deepseek-ai/cordis' import { expect, it, onTestFinished, vi } from 'vitest' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config } from '../src/index.ts' import { CONTACT_CONFIG_GLOBAL } from '../src/contact-config.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('injects configured contact options and removes the listener on disposal', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) const config: Config = Config({ contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 }) await ctx.plugin({ Config, apply }, { contactSource: 'harness', bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100 }) const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([{ kind: 'global', name: CONTACT_CONFIG_GLOBAL, value: { contactFormUrl: config.contactFormUrl, contactSource: config.contactSource, bonusAckRetryDelayMs: 25, bonusAckRetryMaxDelayMs: 100, } }]) await ctx.fiber.dispose() table.length = 0 - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) expect(table).toEqual([]) }) diff --git a/packages/client/ui-settings-account/tests/initialization.client.spec.ts b/packages/client/ui-settings-account/tests/initialization.client.spec.ts index daa962d263..a17217f0d2 100644 --- a/packages/client/ui-settings-account/tests/initialization.client.spec.ts +++ b/packages/client/ui-settings-account/tests/initialization.client.spec.ts @@ -44,6 +44,14 @@ it.each(['accepted', 'refused', 'disconnected', 'pending'] as const)('initialize return () => {} }, }, + remoteAccess: { + whenAccess: (_action: string, install: () => (() => void) | Iterable<() => void> | undefined) => { + const effect = install() + if (typeof effect === 'function') disposers.push(effect) + else if (effect !== undefined) for (const dispose of effect) disposers.push(dispose) + return () => {} + }, + }, remote: { $on: () => () => {}, session: { initializeDefaultModel }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 29d5e63785..944ce57932 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index 7183dab668..180df12f10 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -9,6 +9,8 @@ * Export discipline: packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +// Type-only: pulls the remoteAccess service merge (ctx.remoteAccess). +import type {} from '@deepseek-ai/dsh-api-remote-access/client' // Type-only: pulls the ctx.remote merge and its fixed Host facts. import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' @@ -70,7 +72,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -185,46 +187,48 @@ export function apply(ctx: ClientContext): void { }, }, }) - ctx.slots.inject('sidebar.settings', () => { - const shellHandle = createSettingsShellStore() - const shellInstance = shellHandle.create() - const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance } - const disposeCommand = ctx.shortcuts.register({ - id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'], - defaults: { - 'desktop:macos': { code: 'Comma', modifiers: ['primary'] }, - 'desktop:windows': { code: 'Comma', modifiers: ['primary'] }, - 'desktop:linux': { code: 'Comma', modifiers: ['primary'] }, - 'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] }, - 'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] }, - }, - regions: ['page', 'editable', 'terminal'], modals: ['settings'], - resolve: ({ modal }) => { - if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' } - return { status: 'handled', run: () => { - if (modal === 'settings') closeTopModal(document) - else shellInstance.actions.open() - } } - }, - }) + // The settings shell reads this host's configuration, so it follows access to its description. + ctx.remoteAccess.whenAccess('settings/describe', () => + ctx.slots.inject('sidebar.settings', () => { + const shellHandle = createSettingsShellStore() + const shellInstance = shellHandle.create() + const shellStore: typeof shellHandle = { ...shellHandle, create: () => shellInstance } + const disposeCommand = ctx.shortcuts.register({ + id: 'settings.open' as ShortcutCommandId, label: () => t('shortcut.open'), aliases: ['settings', 'preferences'], + defaults: { + 'desktop:macos': { code: 'Comma', modifiers: ['primary'] }, + 'desktop:windows': { code: 'Comma', modifiers: ['primary'] }, + 'desktop:linux': { code: 'Comma', modifiers: ['primary'] }, + 'web:macos': { code: 'Comma', modifiers: ['primary', 'alt'] }, + 'web:windows': { code: 'Comma', modifiers: ['primary', 'alt'] }, + }, + regions: ['page', 'editable', 'terminal'], modals: ['settings'], + resolve: ({ modal }) => { + if (modal !== null && modal !== 'settings') return { status: 'blocked', reason: 'modal' } + return { status: 'handled', run: () => { + if (modal === 'settings') closeTopModal(document) + else shellInstance.actions.open() + } } + }, + }) - const disposeSlot = ctx.slots.register({ - name: 'sidebar.settings', - locale: NS, - store: shellStore, - children: { - 'settings.launcher': { kind: 'single', scope: 'root' }, - 'settings.trigger': { kind: 'single', scope: 'root' }, - 'settings.header': { kind: 'single', scope: 'root' }, - 'settings.action': { kind: 'list', scope: 'root' }, - 'settings.close': { kind: 'single', scope: 'root' }, - 'settings.section': { kind: 'list', scope: 'root' }, - 'settings.onboarding': { kind: 'list', scope: 'root' }, - }, - inject: shellInjected, - }, SettingsRoot) - return () => { disposeCommand(); disposeSlot() } - }) + const disposeSlot = ctx.slots.register({ + name: 'sidebar.settings', + locale: NS, + store: shellStore, + children: { + 'settings.launcher': { kind: 'single', scope: 'root' }, + 'settings.trigger': { kind: 'single', scope: 'root' }, + 'settings.header': { kind: 'single', scope: 'root' }, + 'settings.action': { kind: 'list', scope: 'root' }, + 'settings.close': { kind: 'single', scope: 'root' }, + 'settings.section': { kind: 'list', scope: 'root' }, + 'settings.onboarding': { kind: 'list', scope: 'root' }, + }, + inject: shellInjected, + }, SettingsRoot) + return () => { disposeCommand(); disposeSlot() } + })) ctx.slots.inject('settings.trigger', () => ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent)) diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index 691a9ca717..2b3a09a468 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -96,7 +96,7 @@ function setPageUrl(url: string): void { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']) }) it('fills the five seats of the shell it declares, with the locale-following General label', async ({ mock, start }) => { diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 47b7460e2c..a072a81df9 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -16,6 +16,7 @@ import type { DesktopUpdatePresentation } from '../src/types.ts' const SELF = '@deepseek-ai/dsh-client-ui-settings-general' const SIDEBAR = '@deepseek-ai/dsh-client-ui-sidebar' const it = createClientTest({ roster: webApp }) +const withoutSettingsAccess = createClientTest({ roster: webApp }, { access: action => action !== 'settings/describe' }) /** The whole roster's first boot pays the cold module transform of every plugin package. */ const COLD_BOOT_TIMEOUT_MS = 60_000 @@ -77,7 +78,7 @@ describe('ui-settings-general shell', () => { }, COLD_BOOT_TIMEOUT_MS) it('declares its services', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'configForms', 'shortcuts', 'modules', 'remoteAccess']) }) it('occupies sidebar.settings, declared by ui-sidebar, and declares every child slot', async ({ start }) => { @@ -86,6 +87,11 @@ describe('ui-settings-general shell', () => { for (const name of CHILD_NAMES) expect(c.ctx.slots.spec(name)).toEqual(CHILD_SPECS[name]) }, COLD_BOOT_TIMEOUT_MS) + withoutSettingsAccess('leaves sidebar.settings unoccupied while the current Peer may not read the settings description', async ({ start }) => { + const c = await start() + expect(c.ctx.slots.entries('sidebar.settings')).toEqual([]) + }, COLD_BOOT_TIMEOUT_MS) + it('projects the section ledger: product sections in order, defaults for bare rows, stable snapshots', async ({ start }) => { const c = await start() const { sections } = injectedOf(c).hooks diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index f9dac62ceb..66e3eae78c 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -17,10 +17,13 @@ import { import { ModelsSection } from '../src/client/ModelsSection.tsx' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import * as hostPlugin from '../src/index.ts' import { ONBOARDING_CONFIG_GLOBAL } from '../src/onboarding-config.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + afterEach(() => { vi.unstubAllGlobals() }) // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, @@ -74,7 +77,7 @@ describe('ui-settings-models apply', () => { const host = ctx.plugin(hostPlugin, { credentialOnboarding: false }) await host.await() const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: ONBOARDING_CONFIG_GLOBAL, value: { credentialOnboarding: false } }]) for (const row of rows) if (row.kind === 'global') vi.stubGlobal(row.name, row.value) const plugin = ctx.plugin({ inject: [...inject], apply }) @@ -87,7 +90,7 @@ describe('ui-settings-models apply', () => { expect(slots.entries('settings.onboarding')).toEqual([]) await host.dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) } finally { await ctx.fiber.dispose() diff --git a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts index 6646008850..8a09bdd497 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/config.host.spec.ts @@ -1,10 +1,13 @@ /** Host configuration supplies bounded Office reuse settings to browser pages. */ import { Context } from '@deepseek-ai/cordis' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import { expect, it, onTestFinished } from 'vitest' import { Config } from '../src/config.ts' import * as host from '../src/index.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + it('exposes Client cache and spreadsheet limits on the Host entry', () => { expect(host.Config).toBe(Config) expect(Config({})).toEqual({ office: { @@ -22,10 +25,10 @@ it('embeds YAML cache settings in browser pages and withdraws them on disposal', const fiber = ctx.plugin(host, config) await fiber.await() const rows: IndexInjection[] = [] - ctx.emit('webserver/index-inject', rows) + ctx.emit('webserver/index-inject', rows, PAGE_PEER) expect(rows).toEqual([{ kind: 'global', name: '__DSH_DOCUMENT_PREVIEW_CONFIG__', value: config }]) await fiber.dispose() const after: IndexInjection[] = [] - ctx.emit('webserver/index-inject', after) + ctx.emit('webserver/index-inject', after, PAGE_PEER) expect(after).toEqual([]) }) diff --git a/packages/client/ui-theme/tests/config.host.spec.ts b/packages/client/ui-theme/tests/config.host.spec.ts index 69a10570b8..0de2aac907 100644 --- a/packages/client/ui-theme/tests/config.host.spec.ts +++ b/packages/client/ui-theme/tests/config.host.spec.ts @@ -1,6 +1,6 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' -import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' import * as HostPlugin from '../src/index.ts' import { liveConfig, omitsGeneratedPage } from '../../../settings/settings/tests/live-config.ts' import { plainConfig } from '../../../settings/settings/src/schema.ts' @@ -8,11 +8,14 @@ import { DEFAULT_PREFERENCE, Config, apply, } from '@deepseek-ai/dsh-client-ui-theme' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + /** Collect the injection table the way an index render or boot payload does. */ function collect(ctx: Context): IndexInjection[] { const table: IndexInjection[] = [] - ctx.emit('webserver/index-inject', table) + ctx.emit('webserver/index-inject', table, PAGE_PEER) return table } diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index fb827521a7..34d19ae2bd 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -33,8 +33,8 @@ en: 9fb88260380bf093 zh: 3bace2b96ecd4a82 /deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers: - en: 0ae90db57a09c62c - zh: 453f350f1dea2ac4 + en: 57f47add55b9f5c2 + zh: af7fe7f6b32cd30a /deepseek-ai-dsh-client-ui-workspace/understand-the-implementation: en: 96a37e74555a36b7 zh: d03941523509a5cf diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 5849927e52..bc696e0520 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -83,7 +83,7 @@ Once both Workspace and Session startup baselines are ready, an empty installati The first-use directory name and its stored title are fixed, so neither follows the reader's language: `workspaces.initializeDefault` carries no names, and one installation keeps one on-disk path and one stored title across language switches. A Workspace still carrying that automatic title is labeled with the localized default name wherever this package shows it — sidebar rows and their hover card, search result meta, the hero picker menu, and the rename and delete dialogs — through the controller's `workspaceDisplayTitle`. The rename dialog is seeded with the label on screen while the stored title decides whether confirming saves, so confirming the untouched prefill on such a Workspace pins that name and the row stops following the language; the duplicate-title check excludes the target by Workspace identity rather than by title. -The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource; its snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). +The package injects the Client `remoteAccess` service and provides the root standard Hook `useWorkspaceAccess(workspaceId, action)`, which answers whether the current Peer may call `action` on one Workspace resource, and `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`, which keeps the callback's effects installed while that answer is true, with the semantics of `ctx.remoteAccess.whenAccess`; snapshot semantics belong to [`dsh-api-remote-access`](../../api/remote-access/README.md#client-face). ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index a067dd0788..1c714bb93d 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -83,7 +83,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works 首次使用的目录名与其存储标题都是固定的,都不跟随读者语言:`workspaces.initializeDefault` 不携带任何名称,同一安装环境在切换语言后仍只有一个磁盘路径和一个存储标题。仍保留该自动标题的工作区,在本包展示它的所有位置——侧栏行及其悬浮卡片、搜索结果元信息、hero 选择器菜单、重命名与删除对话框——都通过控制器的 `workspaceDisplayTitle` 显示为本地化的默认名称。重命名对话框以屏幕上的标签作为初值,而判断确认是否需要保存用的是存储标题,因此对这类工作区直接确认未改动的初值即可固定该名称,该行不再跟随语言;重名检查按工作区身份而非标题排除目标自身。 -本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`;其快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 +本包注入 Client `remoteAccess` 服务,并提供根作用域标准 Hook `useWorkspaceAccess(workspaceId, action)`,回答当前 Peer 能否在某个 Workspace 资源上调用 `action`,以及 `ctx.uiWorkspace.whenAccess(workspaceId, action, callback)`,在该答案为 true 期间保持回调的效果,语义同 `ctx.remoteAccess.whenAccess`;快照语义归 [`dsh-api-remote-access`](../../api/remote-access/README.zh.md#client-face)。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 55e03054ce..b4db2dd32e 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,6 +1,7 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { AccessEffect, RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions, @@ -130,6 +131,17 @@ export interface UiWorkspace { * @returns created absolute path. */ createDirectory(path: string, name: string): Promise + /** + * Keep `callback`'s effects installed while the current Peer may call + * `action` on one Workspace resource, with the semantics of + * `ctx.remoteAccess.whenAccess`: `useWorkspaceAccess` answers the same + * question during render. + * @param workspaceId - the Workspace resource the guarded effects depend on. + * @param action - endpoint action the guarded effects depend on. + * @param callback - creates one disposer or an iterable of disposers. + * @returns idempotent disposer owned by the caller's Cordis fiber. + */ + whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void } declare module '@deepseek-ai/cordis' { @@ -406,6 +418,14 @@ class UiWorkspaceService extends Service implements UiWorkspace { return result.value } + whenAccess(workspaceId: WorkspaceId, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + // `ctx.get` traces the service to the caller's Context, which then owns the controller. + const access = this.ctx.get('remoteAccess') + /* v8 ignore next -- this plugin injects remoteAccess, so the service is active while uiWorkspace is reachable. */ + if (access === undefined) throw new Error('ui-workspace: whenAccess requires the remoteAccess service') + return access.whenResourceAccess('workspace', workspaceId, action, callback) + } + private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' const reconcile = (): void => { diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index eade6885ff..1723a4decd 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -69,7 +69,9 @@ async function bench() { ctx.provide('uiConversation', {}) await ctx.plugin(SlotRegistry).await() const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) - ctx.provide('remoteAccess', { source: accessSource }) + const stopGuard = vi.fn() + const whenResourceAccess = vi.fn((): () => void => stopGuard) + ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -162,7 +164,7 @@ async function bench() { locale.setLocale('zh') ctx.provide('locale', locale) return { - ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, + ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, whenResourceAccess, stopGuard, retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession, workspacesSubscribe, initializeDefault, history, scope, sessionOf, setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot }, @@ -233,6 +235,10 @@ describe('ui-workspace apply', () => { expect(keyed?.('w1', 'workspace/follow')).toBe(ALLOWED) expect(b.accessSource).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow') expect(keyed?.('w1')).toBeUndefined() + // `whenAccess` follows the same Workspace resource through the service's resource form. + const guarded = vi.fn(() => () => {}) + expect(b.ctx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', guarded)).toBe(b.stopGuard) + expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded) }) it('reports a default Workspace creation failure through the shared notice overlay', async () => { diff --git a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts index f4843bfcb5..2ecf5fe41e 100644 --- a/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts +++ b/packages/experimental/inspector/tests/bootstrap-route.host.spec.ts @@ -13,6 +13,9 @@ import { apply, Config, inject, name } from '../src/index.ts' import { startInspector, type InspectorHandle, type InspectorOptions } from '../src/host/bridge/controller.ts' import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) vi.mock('../src/host/bridge/controller.ts', async (importOriginal) => { @@ -76,7 +79,7 @@ describe('experimental Inspector Host bootstrap route', () => { ) const injected = () => { const rows: IndexInjection[] = [] - context!.emit('webserver/index-inject', rows) + context!.emit('webserver/index-inject', rows, PAGE_PEER) return rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') } return { fiber, bootstrap, injected, register, registerUpgrade, messages, log } diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index be1d0f9e2d..2496877a86 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -12,6 +12,9 @@ import { apply, Config, inject, name, startInspector } from '../src/index.ts' import { isPlainObject } from '../src/shared/json.ts' import { INSPECTOR_BOOTSTRAP_PATH } from '../src/shared/web.ts' +/** The Peer an index render speaks for; this listener reads no field of it. */ +const PAGE_PEER = { principal: { kind: 'owner' } } as Parameters[1] + vi.mock('open', () => ({ default: vi.fn(async () => undefined), apps: { chrome: 'chrome' } })) interface CdpResponse { @@ -51,7 +54,7 @@ describe('experimental Inspector Host plugin', () => { await fiber.await() const rows: IndexInjection[] = [] - context.emit('webserver/index-inject', rows) + context.emit('webserver/index-inject', rows, PAGE_PEER) const bootstrap = rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' }) expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u)) @@ -116,7 +119,7 @@ describe('experimental Inspector Host plugin', () => { expect(releaseUpgrade).toHaveBeenCalledOnce() expect(rows).toHaveLength(1) const afterDispose: IndexInjection[] = [] - context.emit('webserver/index-inject', afterDispose) + context.emit('webserver/index-inject', afterDispose, PAGE_PEER) expect(afterDispose).toEqual([]) expect((await api.fetch(new Request(`http://localhost${INSPECTOR_BOOTSTRAP_PATH}`), connection.browserPeer)).status).toBe(404) }) diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 335edf56a5..5f19a032c3 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -26,7 +26,7 @@ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' import type { ProfileContext } from '@deepseek-ai/dsh-app-boot' import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' -import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' +import type { HostConnectionHandle, PeerScope } from '@deepseek-ai/dsh-client-connection' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' import { join } from './module-system/posix-path.ts' import { installProcessGlobal } from './node/globals/process.ts' @@ -273,7 +273,7 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { const peer = connection.browserPeer tunnel.serve({ directFetch: (request: Request) => handler.fetch(request, peer), - bootPayload: () => readBootPayload(ctx), + bootPayload: () => readBootPayload(ctx, peer), packageAssets: () => packPackageAssets(mounted, root), openStream: (endpoint, payload, uplink, signal) => typertGateway.wireStream.open(endpoint, payload, uplink, peer, signal), @@ -454,14 +454,15 @@ function prepareProfile( * from the in-process webserver service, never from the API surface, because * the page has no Cordis tree yet. * @param ctx - Booted host context. + * @param peer - the browser Peer the page speaks for. * @returns Boot payload for `GET /__boot__`. */ -function readBootPayload(ctx: HostContext): { injections: unknown } { - const webServer = ctx.get('webServer') as { collectIndexInjections(): unknown } | undefined +function readBootPayload(ctx: HostContext, peer: PeerScope): { injections: unknown } { + const webServer = ctx.get('webServer') as { collectIndexInjections(peer: PeerScope): unknown } | undefined if (webServer === undefined) { throw new Error('webworker host: no webServer service, so the page cannot receive its boot injections') } - return { injections: webServer.collectIndexInjections() } + return { injections: webServer.collectIndexInjections(peer) } } /** diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index c770d2aca9..95424d8267 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2091,7 +2091,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'snapshotFor(principal: Principal): RemoteAccessBaseline', - description: 'The baseline a page opens with before its snapshot stream connects. A business package rendering a non-owner page assigns this value to REMOTE_ACCESS_SNAPSHOT_KEY after every index injection row, replacing the owner baseline the webServer index carries.', + description: 'The baseline a page opens with before its snapshot stream connects; every index the webServer renders carries it under REMOTE_ACCESS_SNAPSHOT_KEY for the Peer the page speaks for.', parameters: [{ name: 'principal', description: 'the page\'s caller identity.' }], returns: 'the owner baseline for the owner; otherwise the principal\'s current grants.', }, @@ -3858,15 +3858,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the transformed body.', }, { - signature: 'collectIndexInjections(): IndexInjection[]', - description: 'Gather the structured injection table: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference) at emit time.', - parameters: [], + signature: 'collectIndexInjections(peer: PeerScope): IndexInjection[]', + description: 'Gather the structured injection table for one Peer: one `webserver/index-inject` emit, every subscriber pushes its current rows. Fresh per call, so subscribers read live state (module graph, theme preference, the Peer\'s access) at emit time.', + parameters: [{ name: 'peer', description: 'the Peer the page speaks for.' }], returns: 'rows in subscriber activation order.', }, { - signature: 'renderIndex(html: string): string', - description: 'Render one index.html body: the structured injection table first, then the raw `tapIndex` transforms over the result.', - parameters: [{ name: 'html', description: 'the raw index.html body.' }], + signature: 'renderIndex(html: string, peer: PeerScope): string', + description: 'Render one index.html body for one Peer: the structured injection table first, then the raw `tapIndex` transforms over the result.', + parameters: [{ name: 'html', description: 'the raw index.html body.' }, { name: 'peer', description: 'the Peer the page speaks for.' }], returns: 'the transformed body.', }, ], @@ -4732,10 +4732,10 @@ export const EVENT_API: readonly EventApiEntry[] = [ { name: 'webserver/index-inject', mode: 'emit', - signature: '\'webserver/index-inject\'(table: IndexInjection[]): void', - summary: 'Collect the structured index injection table.', - description: 'Collect the structured index injection table. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.', - parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }], + signature: '\'webserver/index-inject\'(table: IndexInjection[], peer: PeerScope): void', + summary: 'Collect the structured index injection table for the page `peer` speaks for.', + description: 'Collect the structured index injection table for the page `peer` speaks for. Emitted on every index render and every worker boot-payload request; listeners push their current rows, so a row\'s data is read fresh at emit time.', + parameters: [{ name: 'table', description: 'Mutable row table; listeners append in activation order.' }, { name: 'peer', description: 'The Peer the rendered page speaks for.' }], }, { name: 'workflow/agent-end', diff --git a/packages/host/frontend-static/README.i18n.yaml b/packages/host/frontend-static/README.i18n.yaml index 254fc82b3c..a53e4d9853 100644 --- a/packages/host/frontend-static/README.i18n.yaml +++ b/packages/host/frontend-static/README.i18n.yaml @@ -21,8 +21,8 @@ en: c58118ab4aba9bac zh: 334fcd54edcae152 /deepseek-ai-dsh-host-frontend-static/use-this-package/what-the-server-enforces: - en: 3bd43604d1195427 - zh: 4912adee677ac589 + en: 2bfa62a708bf6357 + zh: af117941f288207f /deepseek-ai-dsh-host-frontend-static/use-this-package/observable-failures: en: 7b6cca6ed823f515 zh: 30b5a5c594e8f1b8 diff --git a/packages/host/frontend-static/README.md b/packages/host/frontend-static/README.md index 43e3d23e2c..c328a57b4b 100644 --- a/packages/host/frontend-static/README.md +++ b/packages/host/frontend-static/README.md @@ -39,7 +39,7 @@ Compose this plugin in a browser-facing host that serves the built Web shell: it ### What the server enforces -Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex`, so the boot manifest reaches the page on `/` and on the configured index path. +Requests are served from the dist root (the directory containing `distIndex`). The dist root and the configured index path render `index.html` with HTTP 200; any other existing file is served directly with its MIME type, and unknown extensions ship as `application/octet-stream`. A path that resolves outside the root is rejected with 403, so a crafted path cannot read files above the dist. An absent or non-file target inside the dist root — a missing file, a directory, or a missing configured index — returns an empty 404. Non-GET/HEAD requests without a matching named route are answered 405. Every successful index response is rendered through the webserver's `renderIndex` for `connection.browserPeer`, the Peer Connection's browser authentication admits, so the boot manifest and that Peer's access snapshot reach the page on `/` and on the configured index path. The served HTML carries one document base, ``, ahead of every injected resource row, so it freezes the entry directory the page was loaded from: the shell's own app-directory-relative references and the Host's plugin-resource rows both resolve under the mount that served the page. The same index therefore serves the origin root and whatever mount a prefix-stripping proxy owns; this plugin renders it only for the dist root and the configured index path. diff --git a/packages/host/frontend-static/README.zh.md b/packages/host/frontend-static/README.zh.md index e3da3829cf..47230ca1ba 100644 --- a/packages/host/frontend-static/README.zh.md +++ b/packages/host/frontend-static/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" ### 服务器实施的约束 -请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 渲染,因此启动 manifest(元数据清单)会通过 `/` 与配置的 index 路径送达页面。 +请求从 dist 根目录(包含 `distIndex` 的目录)提供。dist 根目录与配置的 index 路径以 HTTP 200 渲染 `index.html`;任何其他已有文件按自身 MIME 类型直接提供,未知扩展名按 `application/octet-stream` 提供。解析到根目录之外的路径以 403 拒绝,因此精心构造的路径无法读取 dist 之上的文件。dist 根目录内不存在或不是文件的目标——文件缺失、目录或配置的 index 缺失——返回空 404。没有匹配具名路由的非 GET/HEAD 请求返回 405。每个成功的 index 响应都经 webserver 的 `renderIndex` 为 `connection.browserPeer`(Connection 浏览器认证所准入的 Peer)渲染,因此启动 manifest(元数据清单)与该 Peer 的访问快照会通过 `/` 与配置的 index 路径送达页面。 所服务的 HTML 携带唯一的文档 base ``,位于每一条注入资源行之前,因此它冻结页面加载时所处的入口目录:shell 自身的应用目录相对引用与宿主的插件资源行都在服务该页面的挂载下解析。同一份 index 因而既服务源站根目录,也服务剥离前缀的代理所拥有的任一挂载;本插件只为 dist 根目录与配置的 index 路径渲染它。 diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 71ccdd127c..bfd003ddc5 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -117,7 +117,8 @@ export function apply(ctx: Context, config: Config): void { const distRoot = dirname(distIndex) // Insert after all index transforms so the base precedes every resource reference. const renderIndex = async (): Promise => { - const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8')) + // Connection's browser authentication admits the owner's shared browser Peer. + const body = ctx.webServer.renderIndex(await readFile(distIndex, 'utf8'), ctx.connection.browserPeer) return body.replace(/]*)?>/i, open => `${open}`) } ctx.effect(() => ctx.webServer.registerFallback(async (req, res) => { diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 61f92b3798..2e84218ae0 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/README.i18n.yaml @@ -9,8 +9,8 @@ en: 9c6c5fcadd836722 zh: f5b79ea2c4be6dde /deepseek-ai-dsh-host-webserver/summary: - en: 0d1ffed238b25446 - zh: 9f66adce1c593c53 + en: e4daa778a9126818 + zh: f239eb0c8c7a624b /deepseek-ai-dsh-host-webserver/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb @@ -27,8 +27,8 @@ en: 296ffba200f0dc93 zh: 5d78d6b5f48634c9 /deepseek-ai-dsh-host-webserver/use-this-package/the-fallback-seat: - en: e21232b7aecbe7a8 - zh: 2c4a94cab2aa53ff + en: d7ecbb2b3ff9aa9b + zh: 08ebfd9798d96a62 /deepseek-ai-dsh-host-webserver/use-this-package/behavior-under-failure: en: dd2529dd54707e39 zh: 2615f469d66ac681 @@ -36,8 +36,8 @@ en: 42b15ac6321706aa zh: 4ed0861911abaf5d /deepseek-ai-dsh-host-webserver/understand-the-implementation/design-concept: - en: 728da97b91b797b6 - zh: 22541daf0223c663 + en: 88adbd5967b978d3 + zh: b4fabfd6f5df1b71 /deepseek-ai-dsh-host-webserver/understand-the-implementation/matching-and-lifecycle: en: 8589aba0cbaa1c76 zh: babf3b27b0cdb35e diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 8b03024639..d0eaef56d8 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It knows no harness concepts and serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. +Browsers reach the web GUI over HTTP through `dsh-host-webserver`: a `node:http` server — `node:https` once `tls` names a certificate and key — where other plugins register named routes, upgrade routes, index startup inputs, and one fallback handler. It serves no files — the `/api` bridge, plugin bundles, the HMR event stream, and the SPA dist belong to the plugins that register them. Route matching is fixed: exact over the whole table, then longest prefix, then the fallback handler. It serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. ## Table of Contents @@ -64,7 +64,7 @@ Set `compression: 'gzip'` to wrap eligible socket-backed responses without chang `registerFallback(handler)` claims the one handler for every request no named route matches. A second registration throws; while no fallback is registered the server answers 404. In the shipped Web composition the [SPA dist server](../frontend-static/README.md) owns the seat and calls `renderIndex` on every index response it renders. -Index startup inputs are two layers. `collectIndexInjections()` gathers a fresh injection table — one `webserver/index-inject` emit per call, each subscriber pushing its current rows — and `renderIndex(html)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses. +Index startup inputs are two layers, computed for the Peer the page speaks for. `collectIndexInjections(peer)` gathers a fresh injection table — one `webserver/index-inject` emit per call, passing `peer` to each subscriber, which pushes its current rows — and `renderIndex(html, peer)` renders those rows into the index.html body before applying the raw `tapIndex(transform)` transforms in registration order. A `script-preload` row renders an advisory classic-script preload link. Static deployments carry the same rows in their boot payload. `applyIndexTaps(html)` applies only the raw transforms; it is the escape hatch for markup no row expresses. ### Behavior under failure @@ -80,7 +80,7 @@ A listen failure (for example EADDRINUSE) rejects plugin initialization with the ### Design concept -The package is a plain route registry with no harness vocabulary: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters and `getOrigin()` expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source. +The package is a plain route registry whose only harness vocabulary is the Peer an index render speaks for: `WebServer` extends Cordis `Service` and holds three route tables plus the fallback slot, the raw index-tap list, and the `webserver/index-inject` event the index renderer gathers rows through. Index rendering composes two layers per response: `renderIndex` renders the fresh injection table, including advisory `script-preload` rows, into the body, then applies the raw taps in registration order; `applyIndexTaps` runs the taps alone. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host`, `port`, and `protocol` getters and `getOrigin()` expose composition-time facts other plugins adapt to (for example the directory-picker chooser), and an explicit `tls: null` normalizes to an unset listener, so `protocol` has a single source. ### Matching and lifecycle diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 29802a53a5..55f5e06a8f 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不了解任何 harness 概念,也不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。 +浏览器经由 `dsh-host-webserver` 通过 HTTP 访问 web GUI:一个 `node:http` 服务器——当 `tls` 指定证书与私钥时改用 `node:https`——其他插件在其中注册具名路由、upgrade 路由、index 启动输入与一个回退 handler。它不提供任何文件服务——`/api` 桥接、插件 bundle、HMR(热模块替换)事件流与 SPA dist 都属于注册它们的插件。路由匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退 handler。它只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。 ## 目录 @@ -64,7 +64,7 @@ kind: "package-reference" `registerFallback(handler)` 认领所有未被具名 route 命中的请求的唯一一个 handler。第二次注册会抛错;没有注册回退时服务器回答 404。在随附的 Web 组合中,[SPA dist 服务器](../frontend-static/README.zh.md)拥有该席位,并对其渲染的每个 index 响应调用 `renderIndex`。 -index 启动输入分两层。`collectIndexInjections()` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,每个订阅方推入其当前行——`renderIndex(html)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。 +index 启动输入分两层,按页面所代表的 Peer 计算。`collectIndexInjections(peer)` 收集一张全新的注入表——每次调用发一次 `webserver/index-inject` 事件,把 `peer` 传给每个订阅方,订阅方推入其当前行——`renderIndex(html, peer)` 先把这些行渲染进 index.html 正文,再按注册顺序应用原始 `tapIndex(transform)` 转换。`script-preload` 行会渲染为 classic script 的提示性 preload 链接。静态部署会在启动 payload 中携带同一批行。`applyIndexTaps(html)` 只应用原始转换;它是任何行都无法表达的标记的逃生口。 ### 失败时的行为 @@ -80,7 +80,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的 ### 设计理念 -本包是一个不带任何 harness 词汇的普通路由注册表:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port`、`protocol` getter 与 `getOrigin()` 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。 +本包是一个普通路由注册表,唯一的 harness 词汇是 index 渲染所代表的 Peer:`WebServer` 继承 Cordis `Service`,持有三张路由表、回退 slot、原始 index 转换列表,以及 `webserver/index-inject` 事件,index 渲染器经其收集行。index 渲染每次响应组合两层:`renderIndex` 先把包含提示性 `script-preload` 行的全新注入表渲染进正文,再按注册顺序应用原始转换;`applyIndexTaps` 只运行转换。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host`、`port`、`protocol` getter 与 `getOrigin()` 暴露其他插件据以自适应的组合期事实(例如 directory-picker 选择器),而显式的 `tls: null` 会归一为未设置监听,使 `protocol` 只有一个来源。 ### 匹配与生命周期 diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 0dde2bb6d2..0477e4f679 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -39,6 +39,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@types/compression": "^1.8.1", "@types/negotiator": "^0.6.5", "selfsigned": "^4.0.1" diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 73139550d3..5a70bdcb8c 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -1,8 +1,8 @@ /** * @deepseek-ai/dsh-host-webserver — node:http route registration — node:https - * when `tls` names a certificate and key — with optional gzip, index injection, - * and one fallback seat. It knows no harness concepts and serves no files; the - * composing application owns dist serving. Electron uses file:// plus IPC + * when `tls` names a certificate and key — with optional gzip, index injection + * for the Peer a page speaks for, and one fallback seat. It serves no files; + * the composing application owns dist serving. Electron uses file:// plus IPC * instead, and this package never prints the URL. * Route handlers retain direct response ownership. */ @@ -16,6 +16,7 @@ import { resolve } from 'node:path' import type { Duplex } from 'node:stream' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import compressionMiddleware from 'compression' import ipaddr from 'ipaddr.js' import Negotiator from 'negotiator' @@ -30,13 +31,15 @@ declare module '@deepseek-ai/cordis' { } interface Events { /** - * Collect the structured index injection table. Emitted on every index - * render and every worker boot-payload request; listeners push their - * current rows, so a row's data is read fresh at emit time. + * Collect the structured index injection table for the page `peer` speaks + * for. Emitted on every index render and every worker boot-payload + * request; listeners push their current rows, so a row's data is read + * fresh at emit time. * @param table - Mutable row table; listeners append in activation order. + * @param peer - The Peer the rendered page speaks for. * @mode emit */ - 'webserver/index-inject'(table: IndexInjection[]): void + 'webserver/index-inject'(table: IndexInjection[], peer: PeerScope): void } } @@ -564,25 +567,28 @@ export class WebServer extends Service { } /** - * Gather the structured injection table: one `webserver/index-inject` emit, - * every subscriber pushes its current rows. Fresh per call, so subscribers - * read live state (module graph, theme preference) at emit time. + * Gather the structured injection table for one Peer: one + * `webserver/index-inject` emit, every subscriber pushes its current rows. + * Fresh per call, so subscribers read live state (module graph, theme + * preference, the Peer's access) at emit time. + * @param peer - the Peer the page speaks for. * @returns rows in subscriber activation order. */ - collectIndexInjections(): IndexInjection[] { + collectIndexInjections(peer: PeerScope): IndexInjection[] { const table: IndexInjection[] = [] - this.ctx.emit('webserver/index-inject', table) + this.ctx.emit('webserver/index-inject', table, peer) return table } /** - * Render one index.html body: the structured injection table first, then - * the raw `tapIndex` transforms over the result. + * Render one index.html body for one Peer: the structured injection table + * first, then the raw `tapIndex` transforms over the result. * @param html - the raw index.html body. + * @param peer - the Peer the page speaks for. * @returns the transformed body. */ - renderIndex(html: string): string { - return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections())) + renderIndex(html: string, peer: PeerScope): string { + return this.applyIndexTaps(renderIndexInjections(html, this.collectIndexInjections(peer))) } } diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 3dad1dd472..20e0c395f7 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -21,6 +21,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, FiberState } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' +import type { PeerScope } from '@deepseek-ai/dsh-typert-protocol' import HttpServer, { bindUrlHost, isLoopbackHost, normalizeBindAddress, renderIndexInjections, type TlsConfig } from '../src/index.ts' import { materializeTlsFixture, type TlsFixture } from './tls-fixture.ts' @@ -516,11 +517,14 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) - it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + it('collects injection rows fresh per render for the rendered Peer and layers taps over the rendered rows', { timeout: 60_000 }, async () => { const loaded = await loadComposition() const server = loaded.webServer let flag = 'dark' - loaded.on('webserver/index-inject', (table) => { + const peer = { principal: { kind: 'owner' } } as PeerScope + const renderedFor: PeerScope[] = [] + loaded.on('webserver/index-inject', (table, renderingFor) => { + renderedFor.push(renderingFor) table.push( { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, @@ -532,7 +536,7 @@ describe('real Loader composition', () => { ) }) - const html = server.renderIndex('shell') + const html = server.renderIndex('shell', peer) // Head rows land right after the opening head tag in table order; the body // row lands right after the opening body tag. const order = [ @@ -552,12 +556,14 @@ describe('real Loader composition', () => { // Fresh collection per render: the listener reads live state at emit time. flag = 'light' - expect(server.renderIndex('')).toContain('window.__P__="light"') + expect(server.renderIndex('', peer)).toContain('window.__P__="light"') // Raw taps still run, over the already-rendered rows. const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) - expect(server.renderIndex('')).toContain('window.__Q__=2') + expect(server.renderIndex('', peer)).toContain('window.__Q__=2') untap() + // Every collection is dispatched with the Peer the render speaks for. + expect(renderedFor).toEqual([peer, peer, peer]) // Tag-less fragments: head rows prepend, body rows append, and the // boot-readiness tail lands after the last body row. diff --git a/packages/host/webserver/tsconfig.json b/packages/host/webserver/tsconfig.json index 339809d695..28f025d8fa 100644 --- a/packages/host/webserver/tsconfig.json +++ b/packages/host/webserver/tsconfig.json @@ -13,6 +13,9 @@ }, { "path": "../../../vendor/schemastery" + }, + { + "path": "../../typert/protocol" } ] } diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 0f544b7144..dd95dd79f1 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -18,8 +18,8 @@ en: 12afbb217bc06678 zh: c07f001ed8bb3300 /deepseek-ai-dsh-client-test-runtime/use-this-package/setting-up-a-feature-spec: - en: 620906bcbaba405a - zh: b7c34920ba5873d1 + en: 411248e12f21d834 + zh: cfb151ce0acbe4e7 /deepseek-ai-dsh-client-test-runtime/use-this-package/local-dom-snapshots: en: 1abbbed6cc4cf57b zh: ab3975178b589653 @@ -30,8 +30,8 @@ en: 1442fc215c8128e8 zh: 8439861b08e46ea2 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: - en: 2525f42ba8d139f7 - zh: 772661c7358042c8 + en: 707b824ae6391cc0 + zh: bb7621826e6b07db /deepseek-ai-dsh-client-test-runtime/use-this-package/when-to-use-it: en: eb2f9bc90acb7a62 zh: cb59a2134bc83106 @@ -45,8 +45,8 @@ en: 3264640018c1a0e8 zh: d777e0b8ad8b602f /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/source-map: - en: e3ef8397426d484b - zh: 23f735f4bda1534a + en: f9d1e576b34bd2d6 + zh: 73bd7c2d57852a9f /deepseek-ai-dsh-client-test-runtime/understand-the-implementation/lifecycle: en: b42779f364504719 zh: a10bad7ea1161388 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index 162e516a2f..2a02fcb6cf 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -44,7 +44,7 @@ await runtime.dispose() `mount` prechecks required services and fails loud when one is missing — `provide(name, value)` supplies an extra service first. The runtime provides a `fileUpload` stub that rejects every call until a suite replaces `runtime.fileUpload.upload`. `storeOf(key, scopeKey)` returns the live store instance the renderer hands a slot's component for identity and action-driven-write assertions. -The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service answers true for every resource, so every access Hook allows every action. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook. +The runtime installs `allowAllAccessPlugin` before ui-session: its `remoteAccess` service, a `StaticRemoteAccess`, answers true for every resource, so every access Hook allows every action and every `whenAccess` callback runs at once. `TestClientOptions.access` fixes the answer per action instead, through `staticAccessPlugin(allows)`. Hand-built component props spread `...allowAllAccess` to receive the same Hooks; a spec of a refused path overrides one Hook or provides `new StaticRemoteAccess(ctx, allows)`. The optional render options select a keyed entry with `entryKey` or a list item with `only`; `view.update(owner)` retains that selection. `runtime.panelInfo` supplies the default `usePanelInfo` source with no global panel selected. Release it with `releasePanelInfoSource()` before mounting the production Layout owner. `dispose()` releases both default Workspace and panel-info root sources; early release is idempotent and does not remove replacement owners. @@ -93,7 +93,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin`, or `staticAccessPlugin(options.access)`, in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it @@ -127,7 +127,7 @@ The bench copies no production logic: it mounts the production `SlotRegistry`, p | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` test doubles and `FixtureSession` behavior stubs | | [`src/fixtures.ts`](src/fixtures.ts) | Plain fixture builders: conversation snapshots, workspace list state | | [`src/snapshot.ts`](src/snapshot.ts) | DOM snapshot serializer (class-hash folding, `` fingerprint) | -| [`src/access.ts`](src/access.ts) | `allowAllAccess` props and `allowAllAccessPlugin`, the allow-all stand-in for the remote-access Client row | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props, `StaticRemoteAccess`, `staticAccessPlugin`, and `allowAllAccessPlugin`, the fixed-answer stand-ins for the remote-access Client row | | [`src/remote.ts`](src/remote.ts) | `TestRemote` double for host RPC, `RemoteError` value re-export | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | Translation and pinned-browser-language test helpers | | [`src/config-form.ts`](src/config-form.ts) | `stubConfigForm` with test-driven publications and write spies | diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 4c86eae6ec..c1721c8790 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -44,7 +44,7 @@ await runtime.dispose() `mount` 会预检必需服务,缺失时自明报错——先用 `provide(name, value)` 提供额外服务。运行时提供的 `fileUpload` 替身会拒绝每次调用,直到测试套件替换 `runtime.fileUpload.upload`。`storeOf(key, scopeKey)` 返回渲染器交给 slot 组件的实时存储实例,用于身份与动作驱动写入断言。 -运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务对每个资源都回答 true,因此每个访问 Hook 都允许每个 action。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook。 +运行时在 ui-session 之前安装 `allowAllAccessPlugin`:它的 `remoteAccess` 服务是一个 `StaticRemoteAccess`,对每个资源都回答 true,因此每个访问 Hook 都允许每个 action,每个 `whenAccess` 回调都立即执行。`TestClientOptions.access` 改为按 action 固定答案,经由 `staticAccessPlugin(allows)`。手工构造的组件 props 展开 `...allowAllAccess` 以获得同样的 Hook;测试被拒绝路径的 spec 覆盖其中一个 Hook,或提供 `new StaticRemoteAccess(ctx, allows)`。 可选渲染参数通过 `entryKey` 选择 keyed 条目,或通过 `only` 选择 list 条目;`view.update(owner)` 保留该选择。`runtime.panelInfo` 提供默认的 `usePanelInfo` 数据源,初始不选中全局面板。挂载生产 Layout 所有者之前,先调用 `releasePanelInfoSource()` 释放该数据源。`dispose()` 同时释放默认的工作区与面板信息根数据源;提前释放是幂等的,不会移除替代它们的所有者。 @@ -93,7 +93,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin` 或 `staticAccessPlugin(options.access)`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 @@ -127,7 +127,7 @@ test('registers into the sidebar', async ({ remote, start }) => { | [`src/sessions.ts`](src/sessions.ts) + [`src/workspaces.ts`](src/workspaces.ts) | `ISessions`/`IWorkspaces` 测试替身与 `FixtureSession` 行为桩 | | [`src/fixtures.ts`](src/fixtures.ts) | 普通 fixture 构造器:会话快照、workspace 列表状态 | | [`src/snapshot.ts`](src/snapshot.ts) | DOM 快照序列化器(类名哈希折叠、`` 指纹) | -| [`src/access.ts`](src/access.ts) | `allowAllAccess` props 与 `allowAllAccessPlugin`:remote-access Client 行的全允许替身 | +| [`src/access.ts`](src/access.ts) | `allowAllAccess` props、`StaticRemoteAccess`、`staticAccessPlugin` 与 `allowAllAccessPlugin`:remote-access Client 行的固定答案替身 | | [`src/remote.ts`](src/remote.ts) | 用于 host RPC 的 `TestRemote` 替身、`RemoteError` 值转出 | | [`src/translate.ts`](src/translate.ts) + [`src/locale-env.ts`](src/locale-env.ts) | 翻译与固定浏览器语言测试辅助 | | [`src/config-form.ts`](src/config-form.ts) | 带测试驱动发布与写入 spy 的 `stubConfigForm` | diff --git a/packages/test-support/client-runtime/src/access.ts b/packages/test-support/client-runtime/src/access.ts index 4c467ac61f..b2507556b3 100644 --- a/packages/test-support/client-runtime/src/access.ts +++ b/packages/test-support/client-runtime/src/access.ts @@ -1,17 +1,18 @@ /** - * Allow-all access for client specs. Production pages receive the + * Static access for client specs. Production pages receive the * `remoteAccess` Client service and the global-resource `useRemoteAccess` Hook * from the remote-access Client plugin, and the Session and Workspace access * Hooks (Session-scope `useAccess`, `useSessionAccess`, `useWorkspaceAccess`) * from ui-session and ui-workspace over that service. The test runtimes * install an allow-all service and `useRemoteAccess` Hook instead, and * hand-built component props spread {@link allowAllAccess}; a spec of a - * refused path overrides one Hook. + * refused path overrides one Hook or provides a {@link StaticRemoteAccess} + * that refuses the action. * @module @deepseek-ai/dsh-client-test-runtime/src/access */ -import type { Context } from '@deepseek-ai/cordis' -import type { ClientRemoteAccess } from '@deepseek-ai/dsh-api-remote-access/client' +import { Service, type Context } from '@deepseek-ai/cordis' +import type { AccessEffect, ClientRemoteAccess, RemoteEndpoint, TypertResourceKind } from '@deepseek-ai/dsh-api-remote-access/client' import type { GlobalStandardProps, HostObservable, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' @@ -31,23 +32,64 @@ export const allowAllAccess: AccessStandardProps = { } const ALLOWED: HostObservable = { getSnapshot: () => true, subscribe: () => () => {} } -const allowed = (): HostObservable => ALLOWED - -/** The `remoteAccess` Client service answering true for every resource. */ -const allowAllRemoteAccess: ClientRemoteAccess = { source: allowed } +const REFUSED: HostObservable = { getSnapshot: () => false, subscribe: () => () => {} } /** - * Plugin standing in for the remote-access Client row: it provides an - * allow-all `remoteAccess` service, so ui-session and ui-workspace publish - * their resource Hooks over it, and the allow-all `useRemoteAccess` Hook on - * the global resource. + * A `remoteAccess` Client service whose answers never change: `allows` + * decides each action on every resource once. `whenAccess` installs an + * allowed action's effects in the caller's fiber at once and never installs a + * refused action's effects. */ -export const allowAllAccessPlugin = { - name: 'client-test-runtime:allow-all-access', - inject: ['slots'], - /** @param ctx - plugin Context with the Slot registry injected. */ - apply(ctx: Context): void { - ctx.provide('remoteAccess', allowAllRemoteAccess) - ctx.slots.provideRoot({ keyedHooks: { remoteAccess: allowed } }) - }, +export class StaticRemoteAccess extends Service implements ClientRemoteAccess { + /** + * @param ctx - Context the service is provided on. + * @param allows - the fixed answer for each action; allows everything when omitted. + */ + constructor(ctx: Context, private readonly allows: (action: string) => boolean = () => true) { + super(ctx, 'remoteAccess') + } + + source(_kind: TypertResourceKind, _id: string, action: string): HostObservable { + return this.allows(action) ? ALLOWED : REFUSED + } + + // Prototype methods: the Cordis service proxy binds `this.ctx` to the caller's Context. + whenAccess(action: RemoteEndpoint, callback: () => AccessEffect): () => void { + if (!this.allows(action)) return () => {} + const dispose = this.ctx.effect(callback, `remoteAccess.whenAccess(${JSON.stringify(action)})`) + return () => { void dispose() } + } + + whenResourceAccess(_kind: TypertResourceKind, _id: string, action: RemoteEndpoint, callback: () => AccessEffect): () => void { + return this.whenAccess(action, callback) + } } + +/** The plugin shape {@link staticAccessPlugin} returns. */ +export interface StaticAccessPlugin { + readonly name: string + readonly inject: readonly string[] + apply(ctx: Context): void +} + +/** + * Plugin standing in for the remote-access Client row with fixed answers: it + * provides a {@link StaticRemoteAccess}, so ui-session and ui-workspace + * publish their resource Hooks over it, and the matching `useRemoteAccess` + * Hook on the global resource. + * @param allows - the fixed answer for each action on every resource. + * @returns the plugin. + */ +export function staticAccessPlugin(allows: (action: string) => boolean): StaticAccessPlugin { + return { + name: 'client-test-runtime:static-access', + inject: ['slots'], + apply(ctx: Context): void { + new StaticRemoteAccess(ctx, allows) + ctx.slots.provideRoot({ keyedHooks: { remoteAccess: (action: string) => (allows(action) ? ALLOWED : REFUSED) } }) + }, + } +} + +/** {@link staticAccessPlugin} allowing every action on every resource. */ +export const allowAllAccessPlugin: StaticAccessPlugin = staticAccessPlugin(() => true) diff --git a/packages/test-support/client-runtime/src/assembly/test-client.ts b/packages/test-support/client-runtime/src/assembly/test-client.ts index 27fcbfd27b..09a9fc97ce 100644 --- a/packages/test-support/client-runtime/src/assembly/test-client.ts +++ b/packages/test-support/client-runtime/src/assembly/test-client.ts @@ -18,7 +18,7 @@ import { act } from '@testing-library/react' import { createInProcessModules, loadPluginModules } from './modules.ts' import { assertPlan, graphFromRoster, type AssemblyPlan } from './roster.ts' import { REMOTES_PACKAGE, remoteNamespacesOf, remoteProxiesPlugin } from './remote-proxies.ts' -import { allowAllAccessPlugin } from '../access.ts' +import { allowAllAccessPlugin, staticAccessPlugin } from '../access.ts' /** The remote-access row: it mounts its own generated Remote contribution and publishes the access Hooks. */ const REMOTE_ACCESS_PACKAGE = '@deepseek-ai/dsh-api-remote-access' @@ -41,6 +41,8 @@ export interface TestClientOptions { readonly awaitConnected?: boolean /** Readiness budget in milliseconds before `start` rejects with the mock log summary. Default 5000. */ readonly connectTimeoutMs?: number + /** The fixed access answer for each action on every resource. Default: every action is allowed. */ + readonly access?: (action: string) => boolean } /** @@ -245,8 +247,10 @@ export class TestClient { release = sharedJsdomShims.acquire() const system = createInProcessModules(graphFromRoster(roster.rows), modules) ctx.plugin(remoteProxiesPlugin(remoteNamespacesOf(modules.values(), mock), mock) as Plugin) - // The dropped remote-access row's Hooks: every action is allowed on every resource. - if (generated.includes(REMOTE_ACCESS_PACKAGE)) ctx.plugin(allowAllAccessPlugin) + // The dropped remote-access row's Hooks: every action is allowed on every resource unless the spec fixes the answers. + if (generated.includes(REMOTE_ACCESS_PACKAGE)) { + ctx.plugin(options.access === undefined ? allowAllAccessPlugin : staticAccessPlugin(options.access)) + } await bootClient({ ctx, modules: system, manifest: system.manifest }) if (mountPoint.element !== undefined) { if (ctx.get('uiRenderer') === undefined) { diff --git a/packages/test-support/client-runtime/src/index.ts b/packages/test-support/client-runtime/src/index.ts index 40403354af..e3dd760f1a 100644 --- a/packages/test-support/client-runtime/src/index.ts +++ b/packages/test-support/client-runtime/src/index.ts @@ -58,7 +58,7 @@ export type { FixtureSnapshot, SessionBehaviorOverrides, SessionFixture, SessionFixtureSnapshot, Stabilizer, } from './fixtures.ts' export { makeTranslate } from './translate.ts' -export { allowAllAccess, allowAllAccessPlugin } from './access.ts' +export { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess } from './access.ts' export type { AccessStandardProps } from './access.ts' export { usePinnedBrowserLanguages } from './locale-env.ts' diff --git a/packages/test-support/client-runtime/tests/access.client.spec.ts b/packages/test-support/client-runtime/tests/access.client.spec.ts new file mode 100644 index 0000000000..4e4552edf9 --- /dev/null +++ b/packages/test-support/client-runtime/tests/access.client.spec.ts @@ -0,0 +1,82 @@ +/** Fixed-answer access stand-ins: the `remoteAccess` service and the plugin that installs it with the global Hook. */ +import { Context } from '@deepseek-ai/cordis' +import type { RemoteEndpoint } from '@deepseek-ai/dsh-api-remote-access/client' +import type { RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' +import { describe, expect, it, vi } from 'vitest' +import { allowAllAccess, allowAllAccessPlugin, StaticRemoteAccess, staticAccessPlugin } from '../src/access.ts' + +/** An endpoint the access stand-ins answer; the stand-ins read only its string. */ +const GUARDED = 'session/list' as RemoteEndpoint + +describe('StaticRemoteAccess', () => { + it('answers each action once for every resource and installs only an allowed action\'s effects in the caller', async () => { + const root = new Context() + const allows = vi.fn((action: string) => action === GUARDED) + new StaticRemoteAccess(root, allows) + expect(root.remoteAccess.source('session', 's1', GUARDED).getSnapshot()).toBe(true) + expect(root.remoteAccess.source('session', 's1', 'session/fork').getSnapshot()).toBe(false) + // Fixed answers never notify. + for (const action of [GUARDED, 'session/fork']) { + const listener = vi.fn() + root.remoteAccess.source('session', 's1', action).subscribe(listener)() + expect(listener).not.toHaveBeenCalled() + } + + const allowedDispose = vi.fn() + const refused = vi.fn(() => () => {}) + let stopAllowed = (): void => {} + let stopRefused = (): void => {} + const caller = root.plugin({ + inject: ['remoteAccess'], + apply: (ctx: Context) => { + stopAllowed = ctx.remoteAccess.whenResourceAccess('session', 's1', GUARDED, () => allowedDispose) + stopRefused = ctx.remoteAccess.whenAccess('session/fork', refused) + }, + }) + await caller.await() + expect(refused).not.toHaveBeenCalled() + expect(() => { stopRefused() }).not.toThrow() + stopAllowed() + await vi.waitFor(() => { expect(allowedDispose).toHaveBeenCalledOnce() }) + await caller.dispose() + await root.fiber.dispose() + }) +}) + +describe('allow-all access', () => { + it('allows every action through the props Hooks and a predicate-less service', async () => { + expect([ + allowAllAccess.useRemoteAccess(GUARDED), + allowAllAccess.useAccess(GUARDED), + allowAllAccess.useSessionAccess('s1' as Parameters[0], GUARDED), + allowAllAccess.useWorkspaceAccess('w1' as Parameters[0], GUARDED), + ]).toEqual([true, true, true, true]) + const root = new Context() + new StaticRemoteAccess(root) + expect(root.remoteAccess.source('workspace', 'w1', 'workspace/follow').getSnapshot()).toBe(true) + await root.fiber.dispose() + }) +}) + +describe('staticAccessPlugin', () => { + it('provides the fixed-answer service and the matching global-resource Hook', async () => { + const root = new Context() + let contribution: RootStandardSourceContribution | undefined + root.provide('slots', { provideRoot: (value: RootStandardSourceContribution) => { contribution = value; return () => {} } } as never) + await root.plugin(staticAccessPlugin(action => action === GUARDED)).await() + const hook = contribution?.keyedHooks?.['remoteAccess'] + expect(hook?.(GUARDED)?.getSnapshot()).toBe(true) + expect(hook?.('session/fork')?.getSnapshot()).toBe(false) + expect(root.get('remoteAccess')?.source('session', 's1', 'session/fork').getSnapshot()).toBe(false) + await root.fiber.dispose() + }) + + it('allows every action in its allow-all form', async () => { + const root = new Context() + let contribution: RootStandardSourceContribution | undefined + root.provide('slots', { provideRoot: (value: RootStandardSourceContribution) => { contribution = value; return () => {} } } as never) + await root.plugin(allowAllAccessPlugin).await() + expect(contribution?.keyedHooks?.['remoteAccess']?.('session/fork')?.getSnapshot()).toBe(true) + await root.fiber.dispose() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d3961a70d0..e81365a42a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4200,6 +4200,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:* version: link:../../test-support/client-runtime @@ -4819,6 +4822,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -5092,6 +5098,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -5265,6 +5274,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -5374,6 +5386,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-remote-access': + specifier: workspace:* + version: link:../../api/remote-access '@deepseek-ai/dsh-api-remotes': specifier: workspace:* version: link:../../api/remotes @@ -11081,6 +11096,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:* + version: link:../../typert/protocol '@types/compression': specifier: ^1.8.1 version: 1.8.1 From d5d43d931dd2980703c3cb120bafb8d9d5b5c4a0 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:34:40 +0800 Subject: [PATCH 65/73] =?UTF-8?q?fix(remote-access):=20=E6=8C=89=E8=AF=84?= =?UTF-8?q?=E5=AE=A1=E4=BF=AE=E6=AD=A3=E8=AE=BF=E9=97=AE=E9=97=A8=E6=8E=A7?= =?UTF-8?q?=E8=BE=B9=E7=95=8C=EF=BC=8C=E6=8F=92=E4=BB=B6=E5=AF=BC=E8=88=AA?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8F=AF=E9=80=89=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui-entry-customization、voice-input 不再把 pluginNavigation 当必需服务:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 - plugin-manager 每次授权时刷新已加载的列表 - settings-account 账号设置分区与 sign-in 一并按 account/watch 门控,revision 恢复 master 原样 - schedule 会话头部提醒另按会话 useAccess('schedule/list') 渲染 - 导出 staticAccessPlugin;补 whenAccess 归属、撤权与读者页启动测试 - 同步 README、web-server 子系统文档、webserver 包描述与 Agent Note --- .../2026-10-21-when-access.i18n.yaml | 16 +-- .../architecture/2026-10-21-when-access.md | 12 +-- .../architecture/2026-10-21-when-access.zh.md | 12 +-- docs/subsystems/web-server.i18n.yaml | 8 +- docs/subsystems/web-server.md | 4 +- docs/subsystems/web-server.zh.md | 4 +- .../README.i18n.yaml | 4 +- .../ui-directory-picker-native/README.md | 2 + .../ui-directory-picker-native/README.zh.md | 2 + .../ui-entry-customization/README.i18n.yaml | 4 +- .../client/ui-entry-customization/README.md | 2 + .../ui-entry-customization/README.zh.md | 2 + .../ui-entry-customization/package.json | 1 + .../src/client/EntryMenuContent.tsx | 18 ++-- .../src/client/index.ts | 18 +++- .../tests/menu.client.spec.tsx | 31 +++++- .../tests/reader-boot.client.spec.ts | 12 +++ .../ui-entry-customization/tsconfig.json | 3 +- .../ui-message-feedback/README.i18n.yaml | 4 +- packages/client/ui-message-feedback/README.md | 2 + .../client/ui-message-feedback/README.zh.md | 2 + .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 2 + .../client/ui-plugin-manager/README.zh.md | 2 + .../ui-plugin-manager/src/client/index.ts | 2 + .../tests/browser-plugin.client.spec.tsx | 69 +++++++++++- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 + packages/client/ui-schedule/README.zh.md | 2 + .../src/client/ScheduleCatalogAction.tsx | 13 ++- .../schedule-catalog-action.client.spec.tsx | 15 +++ .../tests/ui-session.client.spec.ts | 20 ++++ .../ui-settings-account/README.i18n.yaml | 4 +- packages/client/ui-settings-account/README.md | 2 + .../client/ui-settings-account/README.zh.md | 2 + .../ui-settings-account/src/client/index.ts | 13 ++- .../tests/access-revocation.client.spec.ts | 101 ++++++++++++++++++ .../tests/apply.client.spec.ts | 6 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 + .../client/ui-settings-general/README.zh.md | 2 + .../ui-workspace/tests/apply.client.spec.ts | 22 +++- .../client-ui-voice-input/README.i18n.yaml | 4 +- .../client-ui-voice-input/README.md | 2 + .../client-ui-voice-input/README.zh.md | 2 + .../src/client/VoiceInput.tsx | 13 ++- .../src/client/VoiceSetupDialog.tsx | 13 ++- .../client-ui-voice-input/src/client/mount.ts | 17 ++- .../tests/mount.client.spec.ts | 26 ++++- .../tests/voice-input.client.spec.tsx | 15 ++- packages/host/webserver/package.json | 2 +- .../references/packages.md | 2 +- .../test-support/client-runtime/src/index.ts | 4 +- pnpm-lock.yaml | 3 + 54 files changed, 469 insertions(+), 90 deletions(-) create mode 100644 packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts create mode 100644 packages/client/ui-settings-account/tests/access-revocation.client.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml index 6a7ccaae0a..070034fe6f 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.i18n.yaml @@ -6,8 +6,8 @@ en: 2ef7cfef514c138a zh: 064e3aecbfafcd9c /agent-note-whenaccess-registration-that-follows-access/problem: - en: c12d401db33f7d77 - zh: 3bc2c8ad1a441bd7 + en: 4f88d3ee7868eaa5 + zh: 72a4ce2dde13bef7 /agent-note-whenaccess-registration-that-follows-access/decision: en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 @@ -15,14 +15,14 @@ en: c168de20989cf9a4 zh: bf3b0361beea083d /agent-note-whenaccess-registration-that-follows-access/decision/plugin-migration: - en: 0487c2e2f5e71bde - zh: 1750e83e7f9fa1cb + en: 6f30d13790e8792f + zh: fffeaac334b10e6a /agent-note-whenaccess-registration-that-follows-access/alternatives-considered: - en: 670e07d9da3ea599 - zh: 625e8e8bff57ff8c + en: ddc432b693608163 + zh: 620294d8812b0a31 /agent-note-whenaccess-registration-that-follows-access/consequences: - en: 5c7c7c49446fd140 - zh: f8fa106040fac4bc + en: de44fa92c49bc335 + zh: 34436fe971ab013f /agent-note-whenaccess-registration-that-follows-access/related: en: 89073ffbfcff0641 zh: 736a180d13a08981 diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.md index 66d8705c57..9a9bd3bd21 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-when-access.md +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.md @@ -13,7 +13,7 @@ Client plugins need to switch their own registrations on and off with the curren | Components | `useRemoteAccess(action)` (global), `useSessionAccess(sessionId, action)`, `useWorkspaceAccess(workspaceId, action)`, and the Session-scope `useAccess(action)` | Readable only during render; a changed answer re-renders automatically | | JS (`apply`, services) | `ctx.remoteAccess.source(kind, id, action)` returns a `HostObservable` | A plugin must read `getSnapshot` and subscribe to changes itself, then register and dispose by hand | -#6486 therefore writes `if (!ctx.remoteAccess.owner) return` at the start of `apply` in 8 plugins: the check is fixed on identity, and registrations do not follow an access change. +Without a JS entry point, a plugin can only decide its registrations once by identity at `apply`, and they do not follow an access change. ## Decision @@ -56,17 +56,17 @@ export interface WhenAccess { | Plugin | Change | Action | |---|---|---| -| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the task tab type and the turn definition stay registered outside | `schedule/catalog` | -| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`; the controller and `pluginChanges` stay outside | `pluginManager/listBundles` | +| ui-schedule | The task catalog source, delete toast overlay, right-sidebar task tab and title, main panel, sidebar entry, turn-tail card, Session-header reminder catalog, and Session row mark and hover list move into `remoteAccess.whenAccess`; the Session-header catalog reads the per-Session `schedule.list`, so its component also decides through the Session-scope `useAccess` whether to render; the task tab type and the turn definition stay registered outside | `schedule/catalog`, `schedule/list` | +| ui-plugin-manager | Host change subscriptions, refresh toast overlay, main panel (including `pluginNavigation`), and sidebar entry move into `remoteAccess.whenAccess`, and each grant refreshes a list already loaded; the controller and `pluginChanges` stay outside, and `pluginChanges` receives no updates while access is withdrawn. ui-entry-customization and voice-input use `pluginNavigation` optionally: without the service they hide the browse-plugins and open/details buttons and still start | `pluginManager/listBundles` | | ui-settings-general | The settings shell `sidebar.settings` and the `settings.open` shortcut move into `remoteAccess.whenAccess` | `settings/describe` | -| ui-settings-account | The account state stream (including default model initialization), quota notice, and settings launcher move into `remoteAccess.whenAccess` | `account/watch` | +| ui-settings-account | The account state stream (including default model initialization), quota notice, settings launcher, account settings section, and `settings.models.sign-in` move into `remoteAccess.whenAccess`; the stream's disposer also ends this generation's bonus notices, which a frame of that stream begins, and the notice controller stays outside and begins again under the next grant | `account/watch` | | ui-directory-picker-native | Both directory flows move into `remoteAccess.whenAccess` | `directoryPicker/pick` | | ui-message-feedback | The message feedback buttons stay registered and the component decides whether to render through the Session-scope `useAccess`; `messageFeedback/*` requests carry `sessionId`, which the Gateway resolves to that Session through the `session` resource's default field, so they declare nothing. The `/feedback` decoration has no Client check: the decoration registry is global while `sessionFeedback/record` is decided per Session, so without access the dialog still opens and the Host refuses the submission, which the dialog reports with its failure code | `messageFeedback/put` | | ui-deliverables, ui-open-in-app | Unchanged: an owner-only HTTP route that cannot be read falls back as before | — | ## Alternatives considered -**Return after an owner check at the start of `apply`**: fixed on identity; does not follow an access change. +**Return after an owner check at the start of `apply` (the #6486 approach)**: fixed on identity; does not follow an access change. **Trim the plugin list per connection**: a plugin cannot reload at once when access changes. @@ -78,7 +78,7 @@ export interface WhenAccess { | Consequence | Note | |---|---| -| Registrations follow access changes | The 6 plugins no longer return at the start of `apply`; without a grant, a non-owner sees none of the corresponding entries, which appear after a grant and disappear after a revocation; the owner's behavior is unchanged | +| Registrations follow access changes | Without a grant, a migrated plugin registers none of the corresponding entries for a non-owner; they appear after a grant and disappear after a revocation; the owner's answer is always true, so its registrations match direct registration | | The callback must hand back disposers | An effect registered through the plugin's `ctx` inside the callback also hangs on the plugin fiber; a registration whose disposer is not yielded does not dispose on access loss, only when the plugin unloads | | Readers receive all plugin code | Plugins load as before; the Gateway decides each call, and `whenAccess` and the Hooks control the UI | | Test stand-ins | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.md) replaces remote-access with fixed answers through `StaticRemoteAccess`, `staticAccessPlugin`, and `TestClientOptions.access`, so plugin specs cover the refused path | diff --git a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md index a4233dcdfe..736433ea95 100644 --- a/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-21-when-access.zh.md @@ -13,7 +13,7 @@ Client 插件需要按当前 Peer 的访问状态开关自己的注册:侧栏 | 组件 | `useRemoteAccess(action)`(global)、`useSessionAccess(sessionId, action)`、`useWorkspaceAccess(workspaceId, action)`、会话作用域的 `useAccess(action)` | 只能在渲染时读,答案变化时自动重渲染 | | JS(`apply`、服务) | `ctx.remoteAccess.source(kind, id, action)` 返回 `HostObservable` | 插件要自己读 `getSnapshot`、订阅变化,再手动注册与撤销 | -#6486 因此在 8 个插件的 `apply` 开头写 `if (!ctx.remoteAccess.owner) return`:按身份写死,授权变化后注册不跟随。 +没有 JS 侧入口时,插件只能在 `apply` 时按身份一次性决定注册与否,授权变化后注册不跟随。 ## 决策 @@ -56,17 +56,17 @@ export interface WhenAccess { | 插件 | 改法 | action | |---|---|---| -| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog` | -| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`;controller 与 `pluginChanges` 留在外面 | `pluginManager/listBundles` | +| ui-schedule | 任务列表数据源、删除提示浮层、右栏任务 tab 与标题、主面板、侧栏入口、turn 尾部卡片、会话头部提醒、会话行标记与悬浮列表包进 `remoteAccess.whenAccess`;会话头部提醒读取按会话判定的 `schedule.list`,组件另在会话作用域里用 `useAccess` 决定是否渲染;任务 tab 类型与 turn 定义的注册留在外面 | `schedule/catalog`、`schedule/list` | +| ui-plugin-manager | Host 变更订阅、刷新提示浮层、主面板(含 `pluginNavigation`)、侧栏入口包进 `remoteAccess.whenAccess`,每次授权时刷新已加载过的列表;controller 与 `pluginChanges` 留在外面,撤权期间 `pluginChanges` 不更新。依赖 `pluginNavigation` 的 ui-entry-customization 与 voice-input 改为可选使用:服务不在时隐藏「浏览插件」与「打开 / 查看详情」按钮,插件照常启动 | `pluginManager/listBundles` | | ui-settings-general | 设置外壳 `sidebar.settings` 与 `settings.open` 快捷键包进 `remoteAccess.whenAccess` | `settings/describe` | -| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器包进 `remoteAccess.whenAccess` | `account/watch` | +| ui-settings-account | 账号状态流(含默认模型初始化)、额度提醒、设置启动器、账号设置分区与 `settings.models.sign-in` 包进 `remoteAccess.whenAccess`;账号流的 disposer 同时结束本轮奖励提示(提示由该流的帧开始),提示控制器留在外面,下次授权时重新开始 | `account/watch` | | ui-directory-picker-native | 两个目录选择流程包进 `remoteAccess.whenAccess` | `directoryPicker/pick` | | ui-message-feedback | 消息反馈按钮照常注册,组件在会话作用域里用 `useAccess` 判断是否渲染;`messageFeedback/*` 的请求带 `sessionId`,Gateway 按 `session` 资源的默认字段自动解析为该 Session,无需声明。`/feedback` 装饰不做前端判断:装饰注册表是全局的,而 `sessionFeedback/record` 按会话判定;没有权限时对话框照常打开,提交被 Host 拒绝并显示失败码 | `messageFeedback/put` | | ui-deliverables、ui-open-in-app | 不动:owner 专用 HTTP 路由读不到时走已有回退 | — | ## 考虑过的替代方案 -**插件 `apply` 开头判断 owner 后 return**:按身份写死,授权变化后不跟随。 +**插件 `apply` 开头判断 owner 后 return(#6486 的做法)**:按身份写死,授权变化后不跟随。 **按连接裁剪插件列表**:授权变化后插件无法立即重新加载。 @@ -78,7 +78,7 @@ export interface WhenAccess { | 后果 | 说明 | |---|---| -| 注册跟随授权变化 | 6 个插件不再在 `apply` 开头 return;非 owner 无授权时相应入口不出现,授予后出现,撤销后消失;owner 行为不变 | +| 注册跟随授权变化 | 迁移的插件在非 owner 无授权时不注册相应入口,授予后出现,撤销后消失;owner 的答案恒为 true,注册与直接注册相同 | | 回调必须交还 disposer | 回调里经插件 `ctx` 注册的效果也挂在插件 fiber 上;不 yield 其 disposer 的注册在失权时不会撤销,只在插件卸载时撤销 | | 读者拿到全部插件代码 | 插件照常加载;访问由 Gateway 逐次判定,界面由 `whenAccess` 与 Hook 控制 | | 测试替身 | [`dsh-client-test-runtime`](../../../../packages/test-support/client-runtime/README.zh.md) 的 `StaticRemoteAccess`、`staticAccessPlugin` 与 `TestClientOptions.access` 以固定答案替换 remote-access,供插件测试无权限路径 | diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 9a9d78794a..bd4c01c442 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-server.md /http-server: - en: c9b2f67d3248c496 - zh: 5fa36d2837ff4f63 + en: 56b268a69964f651 + zh: c8ac8f16bf58a98a /http-server/routes: en: e949c3738a35b58f zh: 138e3aaeb319ddc4 @@ -12,5 +12,5 @@ en: 4b162bd9f280eb06 zh: 3bea98dfee88c582 /http-server/the-service: - en: cc36972b8458279f - zh: c129044935d44769 + en: bbf84a6cd0460dc2 + zh: cc63ce45b0675c6d diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index 425c0faadc..c6c731343a 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -81,7 +81,7 @@ No composition can bind every interface at once: the unspecified address is reje ## The service -`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections()` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit, and `renderIndex(html)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). +`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`). A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md). diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index d1d469d9e0..21a97be800 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) @@ -81,7 +81,7 @@ interface TlsConfig { ## 服务 -`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections()` 经一次 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 +`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。 处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。 diff --git a/packages/client/ui-directory-picker-native/README.i18n.yaml b/packages/client/ui-directory-picker-native/README.i18n.yaml index 80a96f4d74..d7fe01a1f6 100644 --- a/packages/client/ui-directory-picker-native/README.i18n.yaml +++ b/packages/client/ui-directory-picker-native/README.i18n.yaml @@ -18,8 +18,8 @@ en: 1e2172c756246031 zh: f33dc7b40b14cceb /deepseek-ai-dsh-client-ui-directory-picker-native/use-this-package/when-to-choose-it: - en: bcba761168d2162c - zh: d484e2305e074c0b + en: f30bb7c6aae98fec + zh: 1d28613c86531119 /deepseek-ai-dsh-client-ui-directory-picker-native/understand-the-implementation: en: f7a287740b4b08f9 zh: 3347375c4e486b22 diff --git a/packages/client/ui-directory-picker-native/README.md b/packages/client/ui-directory-picker-native/README.md index b23d48bbb5..7a77767050 100644 --- a/packages/client/ui-directory-picker-native/README.md +++ b/packages/client/ui-directory-picker-native/README.md @@ -33,6 +33,8 @@ In the local Electron application, this flow uses the narrow preload directory-p Choose this surface when the browser runs on the same machine as the Host, so an OS dialog can open there. Choose the [`-browse`](../ui-directory-picker-browse/README.md) surface when the browser is remote or in-process and no local chooser exists. The two surfaces fill the same slots, so switching is a composition change, not a code change. +Access: both directory flows are registered only while the current Peer may call `directoryPicker/pick`. The owner always has access. + ----- diff --git a/packages/client/ui-directory-picker-native/README.zh.md b/packages/client/ui-directory-picker-native/README.zh.md index 57cbad2d5d..0bb78f28d1 100644 --- a/packages/client/ui-directory-picker-native/README.zh.md +++ b/packages/client/ui-directory-picker-native/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" 当浏览器与 Host 运行在同一台机器上、操作系统对话框可以在那里打开时,选择此表面。当浏览器为远程或进程内、没有本地选择器时,选择 [`-browse`](../ui-directory-picker-browse/README.zh.md) 表面。两个表面填充相同的 slot,因此切换只是组合改动,而非代码改动。 +访问控制:两个目录选择流程只在当前 Peer 可以调用 `directoryPicker/pick` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-entry-customization/README.i18n.yaml b/packages/client/ui-entry-customization/README.i18n.yaml index 3dc39a2b7d..66ea423657 100644 --- a/packages/client/ui-entry-customization/README.i18n.yaml +++ b/packages/client/ui-entry-customization/README.i18n.yaml @@ -15,8 +15,8 @@ en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-client-ui-entry-customization/use-this-package: - en: 7cc90968634e3f7b - zh: 54f58e17b14f7404 + en: e642513bcfd42d68 + zh: 38b9109ec017900d /deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation: en: 3dcb1f29d03d5afe zh: 5ee39637636cd062 diff --git a/packages/client/ui-entry-customization/README.md b/packages/client/ui-entry-customization/README.md index e8ec30ae03..45ca947c2e 100644 --- a/packages/client/ui-entry-customization/README.md +++ b/packages/client/ui-entry-customization/README.md @@ -34,6 +34,8 @@ Open **More** beneath the left navigation entries, **More conversation views** a On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing. +The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it. + ----- diff --git a/packages/client/ui-entry-customization/README.zh.md b/packages/client/ui-entry-customization/README.zh.md index 4b0169538c..3132236ded 100644 --- a/packages/client/ui-entry-customization/README.zh.md +++ b/packages/client/ui-entry-customization/README.zh.md @@ -34,6 +34,8 @@ kind: "package-reference" 在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。 +「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。 + ----- diff --git a/packages/client/ui-entry-customization/package.json b/packages/client/ui-entry-customization/package.json index 51ef4894fb..f98c8ed620 100644 --- a/packages/client/ui-entry-customization/package.json +++ b/packages/client/ui-entry-customization/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", diff --git a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx index 0370d547ee..22a73babc1 100644 --- a/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx +++ b/packages/client/ui-entry-customization/src/client/EntryMenuContent.tsx @@ -1,8 +1,8 @@ -/** Rows of a navigation region's More menu: customizable entries, then plugin discovery and creation. */ +/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */ import { IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import { EntryPickerRow } from './EntryPickerRow.tsx' @@ -13,6 +13,10 @@ export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar' export interface EntryMenuInjected { /** Region of this registration; selects pin copy and the Creator prompt. */ readonly region: EntryRegion + readonly hooks: { + /** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */ + readonly pluginNavigation: HostObservable + } /** Open the Plugins main list. */ readonly browsePlugins: () => void /** Start a Creator draft with this region's prompt without sending it. */ @@ -29,8 +33,9 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'> * @returns Menu rows for the region's More menu. */ export function EntryMenuContent({ - entries, onOpen, onPinnedChange, onDismiss, region, browsePlugins, createPlugin, t, + entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t, }: EntryMenuContentProps) { + const browsable = usePluginNavigation(present => present) return <> {entries.map((entry) => { const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`) @@ -39,11 +44,12 @@ export function EntryMenuContent({ onOpen={() => { onDismiss(); onOpen(entry.id) }} onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} /> })} - } separatorBefore={entries.length > 0} + {browsable && } separatorBefore={entries.length > 0} onSelect={() => { onDismiss(); browsePlugins() }}> {t('install')} - - } onSelect={() => { onDismiss(); createPlugin() }}> + } + } separatorBefore={!browsable && entries.length > 0} + onSelect={() => { onDismiss(); createPlugin() }}> {t('create')} diff --git a/packages/client/ui-entry-customization/src/client/index.ts b/packages/client/ui-entry-customization/src/client/index.ts index d838a25749..ba41d131a9 100644 --- a/packages/client/ui-entry-customization/src/client/index.ts +++ b/packages/client/ui-entry-customization/src/client/index.ts @@ -1,5 +1,6 @@ /** Rows for the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. */ import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -18,21 +19,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -/** Services used by the menu rows' footer actions. */ -export const inject = ['slots', 'locale', 'pluginNavigation', 'uiWorkspace'] +/** Services used by the menu rows' footer actions; the optional `pluginNavigation` shows the discovery row while present. */ +export const inject = ['slots', 'locale', 'uiWorkspace'] /** * Register the same menu rows into each region's More menu. - * @param ctx - Browser context with slot, locale, plugin navigation, and Workspace services. + * @param ctx - Browser context with slot, locale, and Workspace services, and the optional plugin navigation. */ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register('entryCustomization', { en, zh, ja })) const t = ctx.locale.bind('entryCustomization') + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'entry-customization: plugin navigation presence') + }) const injected = (region: EntryRegion): EntryMenuInjected => ({ region, + hooks: { pluginNavigation }, browsePlugins: () => { ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'browse_plugins' }) - ctx.pluginNavigation.openList() + ctx.get('pluginNavigation')?.openList() }, createPlugin: () => { ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action: 'create_plugin' }) diff --git a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx index 25560a9f65..1a38e84430 100644 --- a/packages/client/ui-entry-customization/tests/menu.client.spec.tsx +++ b/packages/client/ui-entry-customization/tests/menu.client.spec.tsx @@ -34,6 +34,7 @@ function content(overrides: Partial = {}) { const props = { entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }], region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused, + usePluginNavigation: (select: (present: boolean) => T): T => select(true), usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused, useResource: unused, useWorkspaces: unused, ...callbacks, @@ -112,7 +113,10 @@ function Frame({ renderSlot }: PropsRenderSlots) { } -async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analytics?: boolean } = {}) { +async function bench( + language: 'en' | 'zh' = 'en', + { analytics = true, navigation = true }: { analytics?: boolean; navigation?: boolean } = {}, +) { const runtime = await SlotTestRuntime.create() runtimes.add(runtime) const openList = vi.fn(), startSession = vi.fn(), track = vi.fn() @@ -123,7 +127,7 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy locale.setLocale(language) ctx.provide('locale', locale) ctx.slots.installLocale(locale) - ctx.provide('pluginNavigation', { openList } as never) + if (navigation) ctx.provide('pluginNavigation', { openList } as never) ctx.provide('uiWorkspace', { startSession } as never) if (analytics) ctx.provide('productAnalytics', { track } as never) }, @@ -139,6 +143,29 @@ async function bench(language: 'en' | 'zh' = 'en', { analytics = true }: { analy } describe('More menu registrations', () => { + it('shows plugin discovery only while the Plugins page navigation is present', async () => { + const b = await bench('en', { navigation: false }) + const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' })) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy() + // The Creator row takes the separator the absent discovery row would carry. + expect(sidebar().getAllByRole('separator')).toHaveLength(1) + const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void } + expect(() => { injected.browsePlugins() }).not.toThrow() + + const navigation = await b.runtime.mount({ + inject: [], + apply(ctx: Context) { ctx.provide('pluginNavigation', { openList: b.openList } as never) }, + }) + await act(async () => { await Promise.resolve() }) + fireEvent.click(sidebar().getByRole('menuitem', { name: 'Browse other plugins' })) + expect(b.openList).toHaveBeenCalledExactlyOnceWith() + + await navigation.dispose() + await act(async () => { await Promise.resolve() }) + expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull() + }) + it('registers one row set per region and removes all of them when the plugin unloads', async () => { const b = await bench() for (const seat of SEATS) { diff --git a/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts new file mode 100644 index 0000000000..539dd64964 --- /dev/null +++ b/packages/client/ui-entry-customization/tests/reader-boot.client.spec.ts @@ -0,0 +1,12 @@ +// @vitest-environment jsdom +/** A page whose Peer may not list plugin bundles still boots, and the More menus keep their other rows. */ +import { expect } from 'vitest' +import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' + +const withoutPluginManager = createClientTest({ roster: webApp }, { access: action => action !== 'pluginManager/listBundles' }) + +withoutPluginManager('boots the shipped Web client without the Plugins page navigation', async ({ start }) => { + const c = await start() + expect(c.ctx.get('pluginNavigation')).toBeUndefined() + expect(c.ctx.slots.entries('sidebar.panellist.actions').map(entry => entry.options.id)).toContain('entry-customization') +}, 60_000) diff --git a/packages/client/ui-entry-customization/tsconfig.json b/packages/client/ui-entry-customization/tsconfig.json index 4933d135c5..e65933f5ea 100644 --- a/packages/client/ui-entry-customization/tsconfig.json +++ b/packages/client/ui-entry-customization/tsconfig.json @@ -13,6 +13,7 @@ { "path": "../../client/ui-workspace" }, { "path": "../../client/ui-primitives" }, { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-slots" } + { "path": "../../client/ui-slots" }, + { "path": "../store" } ] } diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index e171909bad..91205d3552 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -18,8 +18,8 @@ en: e0127a604ff4aebb zh: 38f1b3db30f81b22 /deepseek-ai-dsh-client-ui-message-feedback/use-this-package/failures: - en: f060de7d9f44b058 - zh: 60770de36be6e90f + en: e4807e47bca06517 + zh: 649a3f9f62771865 /deepseek-ai-dsh-client-ui-message-feedback/understand-the-implementation: en: 61260495346ceb3e zh: 32e05d3eb82fcaf2 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index ef293870b6..feffb193d6 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -31,6 +31,8 @@ Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislik A rating or list-load failure shows inline in the row; a submission failure shows in a warning toast while the dialog stays open so the draft can be corrected. Only finalized messages reach the message entry — an interruption-frozen partial carries no `messageId` and therefore no feedback controls. +Access: the rating buttons render only while the current Peer may call `messageFeedback/put` on the Session; the `/feedback` decoration has no Client check, and the Host refuses a submission without access. + ----- diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index fce7d27049..27a879c2c8 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 评分或列表加载失败在行内展示;提交失败通过警告 toast 展示,弹窗保持打开以便修正草稿。只有已定稿的消息能到达消息条目——被中断冻结的部分输出不带 `messageId`,因此没有反馈控件。 +访问控制:评分按钮只在当前 Peer 可以在该会话上调用 `messageFeedback/put` 时渲染;`/feedback` 装饰不做前端判断,没有权限时提交由 Host 拒绝。 + ----- diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 922ddc8b0f..fef7504d0f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -36,8 +36,8 @@ en: 54d7666c630da471 zh: d7ded257d0fd5559 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/page-tabs: - en: 1de4fe390871d0da - zh: 1723ae12ccbe3697 + en: fc469bab4fbd6028 + zh: 8f5a04e1b9a46471 /deepseek-ai-dsh-client-ui-plugin-manager/understand-the-implementation: en: 203168fc87e939bd zh: 72d0ccd2c54f3f44 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 5a56e1e745..62c0aa1568 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -112,6 +112,8 @@ A row's page exists only while a `plugins.row.config` entry names the row, so a Another plugin adds a tab after the page's own **Installed** tab by registering into the `plugins.tab` list slot; the entry's `label` names the tab and `order` places it. The tabs appear under the page header once one entry exists and are hidden on a bundle's, row's, or official plugin's page; the selected tab survives leaving **Plugins**, and a tab whose entry leaves falls back to **Installed**. The page renders the selected entry in place of its groups and hides its own refresh and add buttons there. The owner props carry `install({ spec, registry?, title, version })` and `openPackage(name)`. `install` is for a version the person already confirmed in the tab: the install dialog opens at its check screen, without the spec form, names the plugin with `title` and `version` above the spec, which it shows in smaller monospace text, keeps that name on the progress and result screens, with the installed version once done, asks `registry` first for that install only and never remembers it, and offers **Retry** after a refusal; a hidden installation reopens instead. `openPackage` switches to **Installed** and opens the bundle's page when that tab lists it, and that page's back button returns to the tab that opened it; a bundle the page leaves out, such as a built-in profile bundle, leaves the Installed list open. A confirmed install offers no GitHub mirror recovery, since that recovery reopens the spec form, and its back button cancels a running install. [`ui-plugin-market`](../ui-plugin-market/README.md) contributes the **Discover** tab here. +Access: the Plugins entry, page, refresh notice, Host change subscriptions, and `pluginNavigation` exist only while the current Peer may call `pluginManager/listBundles`; each later grant re-reads a list already loaded, and `pluginChanges` receives no updates while access is withdrawn. The owner always has access. + ----- diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 6b9f54f19e..84d3833b0a 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -112,6 +112,8 @@ ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({ 其他插件向 `plugins.tab` 列表 slot 注册条目,即可在页面自带的**已安装**标签页之后添加一个标签页;条目的 `label` 为标签页命名,`order` 决定其位置。至少存在一个条目时,标签页显示在页头下方;在组合包、行或官方插件的页面上隐藏。离开**插件**后,所选标签页保持不变;所选标签页的条目被移除时,回到**已安装**。页面渲染所选条目以代替自身的分组,并在此时隐藏自身的刷新和添加按钮。owner props 提供 `install({ spec, registry?, title, version })` 和 `openPackage(name)`。`install` 用于用户已在标签页中确认的版本:安装对话框在检查界面打开,不显示 spec 表单,在以较小等宽字显示的 spec 上方用 `title` 和 `version` 标明插件,并在进度和结果界面保持该名称,完成后显示已安装的版本;只在这一次安装中首先使用 `registry`,且从不记住它;被拒绝后提供**重试**;如果有隐藏中的安装任务,则改为重新打开该任务。`openPackage` 切换到**已安装**,并在该标签页列出该组合包时打开其页面,该页面的返回按钮回到打开它的标签页;页面不列出的组合包(例如内置 profile 组合包)只打开「已安装」列表。已确认的安装不提供 GitHub 镜像恢复,因为该恢复会重新打开 spec 表单;其返回按钮用于取消正在进行的安装。[`ui-plugin-market`](../ui-plugin-market/README.zh.md) 在这里贡献**发现**标签页。 +访问控制:插件入口、页面、刷新提示、Host 变更订阅与 `pluginNavigation` 只在当前 Peer 可以调用 `pluginManager/listBundles` 时存在;之后每次授权都会重读已加载过的列表,撤权期间 `pluginChanges` 不更新。owner 始终有权限。 + ----- diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 6682477943..2a6a3a48da 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -111,6 +111,8 @@ export function apply(ctx: ClientContext): void { controller.details.reset() if (controller.getSnapshot().status !== 'idle') void controller.load() } + // Host changes made while access was withdrawn reached no subscription below. + refresh() yield ctx.remote.$on('plugin-manager/changed', refresh) yield ctx.remote.$on('plugin-manager/install-log', (chunk) => { controller.appendLog(chunk) }) yield ctx.remote.$on('plugin-manager/install-state', (progress) => { controller.installProgress(progress) }) diff --git a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx index 3a62e8c5ce..2a6f019ad4 100644 --- a/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/browser-plugin.client.spec.tsx @@ -21,11 +21,53 @@ usePinnedBrowserLanguages('zh-CN') beforeEach(() => { localStorage.clear() }) afterEach(cleanup) -async function bench(allows: (action: string) => boolean = () => true) { +type AccessEffect = (() => void) | Iterable<() => void> + +/** One guarded callback, the Context that owns it, and the disposer of its current installation. */ +interface Guard { + readonly owner: Context + readonly callback: () => AccessEffect + dispose: (() => void) | undefined +} + +/** A `remoteAccess` stand-in whose one answer a spec switches, installing or withdrawing every guarded effect. */ +class SwitchableAccess extends Service { + private allowed = true + private readonly guards: Guard[] = [] + + constructor(serviceCtx: Context) { + super(serviceCtx, 'remoteAccess') + } + + whenAccess(_action: string, callback: () => AccessEffect): () => void { + const guard: Guard = { owner: this.ctx, callback, dispose: undefined } + this.guards.push(guard) + this.install(guard) + return () => {} + } + + /** @param allowed - the answer every guarded effect follows from now on. */ + set(allowed: boolean): void { + this.allowed = allowed + for (const guard of this.guards) { + if (allowed) this.install(guard) + else { guard.dispose?.(); guard.dispose = undefined } + } + } + + private install(guard: Guard): void { + if (!this.allowed || guard.dispose !== undefined) return + const dispose = guard.owner.effect(guard.callback) + guard.dispose = () => { void dispose() } + } +} + +async function bench(allows: ((action: string) => boolean) | 'switchable' = () => true) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(SlotRegistry).await() - new StaticRemoteAccess(ctx, allows) + const access = allows === 'switchable' ? new SwitchableAccess(ctx) : undefined + if (allows !== 'switchable') new StaticRemoteAccess(ctx, allows) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) class LocaleHolder extends Service { @@ -51,7 +93,7 @@ async function bench(allows: (action: string) => boolean = () => true) { ctx.provide('layout', { panelInfo, selectPanel, beginNavigation: () => new AbortController().signal, toggleSidebar: vi.fn(), openRightbar: vi.fn(), closeRightbar: vi.fn() }) await ctx.plugin(settings).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo } + return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, list, remote, selectPanel, panelInfo, access } } function declare(slots: SlotRegistry): () => void { @@ -157,6 +199,27 @@ describe('ui-plugin-manager browser plugin', () => { expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.pluginManager', 'remote.pluginInventory', 'remote.pluginRegistryProbe', 'configForms', 'layout', 'remoteAccess']) }) + it('reads the Host again on a later grant after showing the page, and not while access is withdrawn', async () => { + const b = await bench('switchable') + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const injected: object = b.slots.entries('main')[0]!.inject!() + const face = injected as PluginManagerFace + face.ensure() + await vi.waitFor(() => { expect(face.hooks.pluginManager.getSnapshot().status).toBe('ready') }) + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(false) + expect(b.slots.entries('main')).toEqual([]) + b.remote.emit('plugin-manager/changed', [{ reason: 'install' }]) + await Promise.resolve() + expect(b.list).toHaveBeenCalledTimes(1) + + b.access!.set(true) + expect(b.slots.entries('main')).toHaveLength(1) + await vi.waitFor(() => { expect(b.list).toHaveBeenCalledTimes(2) }) + }) + it('contributes the entry, page, and navigation only while the current Peer may list bundles, and observes changes either way', async () => { const allows = vi.fn(() => false) const b = await bench(allows) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index d3f7274631..f21ddb5997 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -18,8 +18,8 @@ en: 4c0dca55e4e9866d zh: 9ee6b565c7a81353 /deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: - en: 6bd6882dbbbefcb0 - zh: 03aa15f1a65d13dc + en: 47cd16c142e82385 + zh: 181849a3c7a167fd /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: en: 959145f9a08c3913 zh: caae642a82d2acd3 diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index bfcb80179c..da8d202d0d 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -57,6 +57,8 @@ The Sidebar's Session rows mark scheduled Sessions: while a row is idle, unarchi Time-zone inventory and localization are offline. `Intl` reads ICU/CLDR tables embedded in the browser runtime and never fetches or downloads zone data while DSH runs. There is no crawler to refresh; a third-party locale supplies `time.locale` and translates only the selector's own UI copy. +Access: the plugin registers these surfaces only while the current Peer may call `schedule/catalog`, and the Session-header reminder catalog renders only while that Session also allows `schedule/list`; the task tab type and the turn definition stay registered. The owner always has access. + ## Understand the implementation diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b201a5ca5d..057e42a2ac 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -57,6 +57,8 @@ kind: "package-reference" 时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。 +访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index abeba05542..9aa03b8f81 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -31,8 +31,17 @@ export type ScheduleCatalogActionProps = const SECOND_MS = 1_000 const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } -/** Current-Session reminder catalog with durable deletion. */ -export function ScheduleCatalogAction({ +/** + * Current-Session reminder catalog with durable deletion, rendered only while + * the current Peer may list this Session's reminders. + * @param props - the Session seats, the injected catalog and verbs, and localized copy. + * @returns the reminder catalog action, or nothing without access. + */ +export function ScheduleCatalogAction(props: ScheduleCatalogActionProps) { + return props.useAccess('schedule/list') ? : null +} + +function SessionScheduleCatalog({ useSession, useCatalog, onDelete, onRetry, openTaskDetail, t, }: ScheduleCatalogActionProps) { const openState = useSession(snapshot => snapshot.openState) diff --git a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx index 9aadcc8bfb..5e0a6e1670 100644 --- a/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx +++ b/packages/client/ui-schedule/tests/schedule-catalog-action.client.spec.tsx @@ -108,6 +108,7 @@ function props( records: readonly ScheduleRecord[] | undefined, openState: SessionSnapshot['openState'] = 'open', dictionary: typeof zh | typeof en = en, + allowed = true, ): ScheduleCatalogActionProps { const snapshot = sessionSnapshot(openState) const useSession = (select: (value: SessionSnapshot) => T): T => select(snapshot) @@ -120,6 +121,7 @@ function props( return { sessionId: SESSION, useSession, + useAccess: vi.fn((_action: string) => allowed), useCatalog, onDelete: vi.fn(async () => {}), onRetry: vi.fn(async () => {}), @@ -140,6 +142,19 @@ function metadataLines(): Element[] { } describe('ScheduleCatalogAction visibility', () => { + it('renders nothing and reads no catalog while the current Peer may not list this Session\'s reminders', () => { + const refused = props([record('active', 'after', START + 60_000)], 'open', en, false) + let catalogReads = 0 + const useCatalog: ScheduleCatalogActionProps['useCatalog'] = (select) => { + catalogReads++ + return refused.useCatalog(select) + } + const view = render() + expect(view.container.innerHTML).toBe('') + expect(refused.useAccess).toHaveBeenCalledWith('schedule/list') + expect(catalogReads).toBe(0) + }) + it('hides the entry once a successful read proves there are no active reminders', () => { const active = [record('active', 'after', START + 60_000)] const view = render() diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 776db63bed..c12901a0d8 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -10,6 +10,7 @@ import type { } from '@deepseek-ai/dsh-api-session-controller/client' import { MutableSessionEventSource } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import { StaticRemoteAccess } from '@deepseek-ai/dsh-client-test-runtime' import type { HostObservable, RootStandardSourceContribution } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -768,6 +769,25 @@ describe('ui-session apply', () => { expect(remoteAccess.whenResourceAccess).toHaveBeenCalledWith('session', sessionId('s1'), 'session/fork', guarded) }) + it('hangs Session-guarded effects on the calling plugin, so they leave when it unloads', async () => { + const ctx = new Context() + const bench = createSessionsBench(ctx) + ctx.provide('sessions', bench.sessions) + ctx.provide('slots', { provideRoot: vi.fn(), installScope: vi.fn(), bindStoreScope: vi.fn() } as never) + new StaticRemoteAccess(ctx) + apply(ctx) + const dispose = vi.fn() + const caller = ctx.plugin({ + inject: ['uiSession'], + apply: (callerCtx: Context) => { callerCtx.uiSession.whenAccess(sessionId('s1'), 'session/fork', () => dispose) }, + }) + await caller.await() + expect(dispose).not.toHaveBeenCalled() + await caller.dispose() + expect(dispose).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + it('keeps the Host loader half inert', () => { expect(() => { nodeApply() }).not.toThrow() }) diff --git a/packages/client/ui-settings-account/README.i18n.yaml b/packages/client/ui-settings-account/README.i18n.yaml index 1612f7825a..4a3303fc7e 100644 --- a/packages/client/ui-settings-account/README.i18n.yaml +++ b/packages/client/ui-settings-account/README.i18n.yaml @@ -18,8 +18,8 @@ en: 45aa7b44d5424d9b zh: 20c7d344c4e9bce0 /deepseek-ai-dsh-client-ui-settings-account/use-this-package/desktop-onboarding: - en: c1c4db71fd09ca13 - zh: a40c0a01e9951945 + en: 3e5371e2bf7bd577 + zh: 91b1586963d9df58 /deepseek-ai-dsh-client-ui-settings-account/understand-the-implementation: en: 186b2046cfb5ef76 zh: 57b042a618fb612e diff --git a/packages/client/ui-settings-account/README.md b/packages/client/ui-settings-account/README.md index 2b02a9d990..47e540a394 100644 --- a/packages/client/ui-settings-account/README.md +++ b/packages/client/ui-settings-account/README.md @@ -63,6 +63,8 @@ The Desktop preload marker enables the introduction after account credentials ar The welcome page requires Get started. The credit page always appears. A positive balance confirmed at first entry selects Continue as the primary action and Add credits as the secondary action. This choice stays fixed until the onboarding controller is recreated and is never persisted; late balance results cannot change the buttons. Only a confirmed absence of positive credit adds a confirmation before continuing without recharge; pending or failed queries do not block continuing. Purpose descriptions remain unchanged when selected. Returning from the native top-up view keeps the credit page and re-reads profile and balance in the background after any read already running, whether loading or payment succeeded. That read does not recompute the funded-mode choice, so a completed recharge leaves the buttons as first entered while continuing still evaluates the refreshed balance. Next confirms only a known zero balance; Skip uses the credit warning on the credit page and the setup warning on the question pages. Office-only use completes with compact presentation; development or both purposes also ask for process detail. Back preserves selections, and explicit skip from any supported step applies standard process, compact performance/usage, and disabled Coding Tools. This flow creates no workspace or demonstration task. +Access: the account stream, quota notice, settings launcher, account settings section, and models sign-in entry exist only while the current Peer may call `account/watch`; Desktop onboarding and the platform page are not gated. The owner always has access. + ## Understand the implementation diff --git a/packages/client/ui-settings-account/README.zh.md b/packages/client/ui-settings-account/README.zh.md index 7485d31445..b03a324e34 100644 --- a/packages/client/ui-settings-account/README.zh.md +++ b/packages/client/ui-settings-account/README.zh.md @@ -63,6 +63,8 @@ Desktop preload 标记在账号凭证已存储后启用引导。进度属于本 欢迎页必须点击“开始设置”。额度页始终展示。首次进入时若已确认有正余额,则主操作为“继续”,次操作为“去充值”。该选择在引导控制器重建前保持固定且不持久化,后到的余额结果不会改变按钮。仅在确认不存在正余额时,继续而不充值才需确认;查询中或失败时不阻止继续。用途卡片选中后描述不变。从原生充值视图返回后保留额度页,并在等待任何已在进行的读取结束后于后台重读资料与余额,不受页面加载或支付结果影响。该读取不会重新计算该选择,因此充值成功后按钮仍保持首次进入时的状态,而继续前行仍按刷新后的余额判断。“下一步”仅在确认零余额时提示;额度页的“跳过”使用充值提示,答题页使用设置提示。仅选办公用途时以简洁展示完成;选择开发或两种用途时还会询问过程详情。上一步保留已有选择,任意可跳过步骤均采用标准工作过程、简洁性能与用量以及关闭代码工作工具的默认值。此流程不创建工作区或示例任务。 +访问控制:账号状态流、额度提醒、设置启动器、账号设置分区与模型登录入口只在当前 Peer 可以调用 `account/watch` 时存在;桌面引导与平台页面不受此控制。owner 始终有权限。 + ## 理解实现 diff --git a/packages/client/ui-settings-account/src/client/index.ts b/packages/client/ui-settings-account/src/client/index.ts index db0901ee9b..65a989b700 100644 --- a/packages/client/ui-settings-account/src/client/index.ts +++ b/packages/client/ui-settings-account/src/client/index.ts @@ -116,13 +116,16 @@ export function apply(ctx: Context): void { void request.finally(() => { if (refreshing === request) refreshing = undefined }) return request } + ctx.effect(() => () => { revision++ }, 'account: details request lifetime') // The account stream, and the reads it starts, follow access to this host's account state. ctx.remoteAccess.whenAccess('account/watch', function* () { - yield () => { revision++ } const stream = ctx.remote.$stream({ name: 'account', open: signal => ctx.remote.account.watch(signal), ended: () => new Error('account stream ended'), }) let disposed = false + // `notices.end()` disposes this generation's notice lifetime, which a frame + // of this stream begins; the notice controller stays outside and begins + // again under the next grant. yield () => { disposed = true; notices.end(); return stream.dispose() } void (async () => { for await (const frame of stream) { @@ -291,9 +294,9 @@ export function apply(ctx: Context): void { }), }, DesktopOnboardingEntry)) } - ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.models.sign-in', () => ctx.slots.register({ name: 'settings.models.sign-in', locale: 'settings.account', inject: () => operations, - }, AccountOnboarding)) + }, AccountOnboarding))) ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('shell.quota-notice', () => ctx.slots.register({ name: 'shell.quota-notice', locale: 'settings.account', select: owner => owner.code === 'ACCOUNT_QUOTA' ? owner : null, @@ -314,7 +317,7 @@ export function apply(ctx: Context): void { ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.launcher', () => ctx.slots.register({ name: 'settings.launcher', locale: 'settings.account', inject: () => operations, }, AccountMenu))) - ctx.slots.inject('settings.section', () => { + ctx.remoteAccess.whenAccess('account/watch', () => ctx.slots.inject('settings.section', () => { let unregister: (() => void) | undefined const update = () => { if (snapshot.view?.status === 'credential-stored') { @@ -330,5 +333,5 @@ export function apply(ctx: Context): void { listeners.add(update) update() return () => { listeners.delete(update); unregister?.() } - }) + })) } diff --git a/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts new file mode 100644 index 0000000000..90735f78fd --- /dev/null +++ b/packages/client/ui-settings-account/tests/access-revocation.client.spec.ts @@ -0,0 +1,101 @@ +// @vitest-environment jsdom +/** Account surfaces follow `account/watch` access: a revocation withdraws them and a later grant restores them. */ +import { afterEach, expect, it, vi } from 'vitest' +import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types' +import { apply } from '../src/client/index.ts' + +afterEach(() => { vi.unstubAllEnvs() }) + +type AccessEffect = (() => void) | Iterable<() => void> + +/** One `whenAccess` controller: its callback and the disposers of its current installation. */ +interface Guard { + readonly action: string + readonly install: () => AccessEffect + installed: (() => void)[] | undefined +} + +it('withdraws every account surface and the account stream on revocation, and restores them on the next grant', async () => { + vi.stubEnv('DSH_CLIENT_VERSION', '0.0.0-test') + const links = { usageUrl: 'https://example.test/usage', topUpUrl: 'https://example.test/top_up' } + const signedIn: AccountView = { status: 'credential-stored', links, attempt: null } + const guards: Guard[] = [] + const grant = (): void => { + for (const guard of guards) { + const effect = guard.install() + guard.installed = typeof effect === 'function' ? [effect] : [...effect] + } + } + const revoke = (): void => { + for (const guard of [...guards].reverse()) { + for (const dispose of (guard.installed ?? []).reverse()) dispose() + guard.installed = undefined + } + } + const registered = new Map() + const streams: { readonly signal: AbortSignal; readonly accepted: Promise }[] = [] + const lifetime: (() => void)[] = [] + const ctx = { + effect: (install: () => (() => void) | undefined) => { const dispose = install(); if (dispose) lifetime.push(dispose) }, + locale: { register: () => () => {}, bind: () => (key: string) => key, getSnapshot: () => ({ active: 'en' }) }, + configForms: { + get: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ status: 'loading', value: undefined }) }), + describe: () => ({ subscribe: () => () => {}, getSnapshot: () => ({ error: null }) }), + }, + slots: { + inject: (_name: string, install: () => (() => void) | undefined) => { + const dispose = install() + return () => { dispose?.() } + }, + register: (entry: { readonly name: string }) => { + registered.set(entry.name, (registered.get(entry.name) ?? 0) + 1) + return () => { registered.set(entry.name, registered.get(entry.name)! - 1) } + }, + }, + remoteAccess: { + whenAccess: (action: string, install: () => AccessEffect) => { + guards.push({ action, install, installed: undefined }) + return () => {} + }, + }, + remote: { + $on: () => () => {}, + session: { initializeDefaultModel: async () => ({ ok: true, value: undefined }) }, + account: { getProfile: async () => ({ ok: true, value: null }), getBalance: async () => ({ ok: true, value: null }) }, + $stream: () => { + const abort = new AbortController() + const accepted = Promise.withResolvers() + streams.push({ signal: abort.signal, accepted: accepted.promise }) + return { + signal: abort.signal, + dispose: () => { abort.abort() }, + async *[Symbol.asyncIterator]() { + yield { value: signedIn, accept: () => { accepted.resolve(undefined) } } + await new Promise((resolve) => { abort.signal.addEventListener('abort', resolve) }) + }, + } + }, + }, + } + const surfaces = (): Record => Object.fromEntries( + ['settings.launcher', 'shell.quota-notice', 'settings.models.sign-in', 'settings.section'].map(name => [name, registered.get(name)]), + ) + try { + apply(ctx as never) + expect(new Set(guards.map(guard => guard.action))).toEqual(new Set(['account/watch'])) + grant() + await streams[0]!.accepted + expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 }) + + revoke() + expect(streams[0]!.signal.aborted).toBe(true) + expect(surfaces()).toEqual({ 'settings.launcher': 0, 'shell.quota-notice': 0, 'settings.models.sign-in': 0, 'settings.section': 0 }) + + grant() + await streams[1]!.accepted + expect(surfaces()).toEqual({ 'settings.launcher': 1, 'shell.quota-notice': 1, 'settings.models.sign-in': 1, 'settings.section': 1 }) + } finally { + revoke() + for (const dispose of lifetime.reverse()) dispose() + } +}) diff --git a/packages/client/ui-settings-account/tests/apply.client.spec.ts b/packages/client/ui-settings-account/tests/apply.client.spec.ts index 5eb29a1ade..c944e6ce3f 100644 --- a/packages/client/ui-settings-account/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-account/tests/apply.client.spec.ts @@ -78,13 +78,13 @@ it('registers account sign-in in a plain browser without the Desktop-only surfac } }, 60_000) -withoutAccountAccess('opens no account stream and registers no launcher or quota notice while the current Peer may not watch the account', async ({ start }) => { +withoutAccountAccess('opens no account stream and registers no account surface while the current Peer may not watch the account', async ({ start }) => { const c = await start() expect(c.mock.streams.end('account/watch')).toBe(0) expect(c.ctx.slots.entries('settings.launcher')).toHaveLength(0) expect(quotaNoticeEntry(c)).toBeUndefined() - // The models sign-in entry lives inside the settings shell, which follows its own access. - expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(1) + expect(c.ctx.slots.entries('settings.models.sign-in')).toHaveLength(0) + expect(c.ctx.slots.entries('settings.section').some(entry => entry.options.id === 'account')).toBe(false) }, 60_000) it('claims account balance notices from the frame-wide quota chain and declines generic quota', async ({ start }) => { diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 85030a3d13..377d9797cd 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -24,8 +24,8 @@ en: 597df68b5913e8d7 zh: bc9114f3e877679a /deepseek-ai-dsh-client-ui-settings-general/use-this-package/onboarding-steps: - en: 27f0f6ec55f00228 - zh: 69b47ff1efd81c17 + en: ba55fe92bf49dc5a + zh: 9a0e1345496a0ae6 /deepseek-ai-dsh-client-ui-settings-general/understand-the-implementation: en: 1531b45780d0689f zh: 844548208c08a9c9 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 1bfd4e9aab..0b14da3bcb 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -59,6 +59,8 @@ On a loopback browser, the shell renders **Open configuration file** only when t The onboarding ledger projects in ascending order and mounts exactly one step at a time. Registrants own durable completion, capability readiness, copy, mutations, and their visible wrapper, so independently registered flows cannot stack and the shell does not become a second configuration fact source. Visible steps own their dialog chrome and app-root `inert` lifecycle. +Access: the settings shell and the `settings.open` shortcut are registered only while the current Peer may call `settings/describe`. The owner always has access. + ----- diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 932dc56e46..d1e276b141 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -59,6 +59,8 @@ Web 与桌面端的通用设置底部显示当前发布版本,使用构建注 引导账本按升序投影,每次只挂载一个步骤。注册方持有持久化完成状态、能力就绪状态、文案、变更操作与可见包装,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。可见步骤自行持有弹窗框架与应用根节点 `inert` 生命周期。 +访问控制:设置外壳与 `settings.open` 快捷键只在当前 Peer 可以调用 `settings/describe` 时注册。owner 始终有权限。 + ----- diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 1723a4decd..bf5acfea6b 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -10,7 +10,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import type { HostObservable, PropsRenderSlots, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots' import type { ShortcutCommand } from '@deepseek-ai/dsh-client-shortcuts/client' -import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' +import { RemoteError, StaticRemoteAccess, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' @@ -56,7 +56,7 @@ const workspaceState = ( const ALLOWED: HostObservable = createSnapshotStore(true) -async function bench() { +async function bench(access: 'fake' | 'static' = 'fake') { const ctx = new Context() const commands = new Map() ctx.provide('shortcuts', { @@ -71,7 +71,8 @@ async function bench() { const accessSource = vi.fn((_kind: string, _id: string, _action: string): HostObservable => ALLOWED) const stopGuard = vi.fn() const whenResourceAccess = vi.fn((): () => void => stopGuard) - ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) + if (access === 'static') new StaticRemoteAccess(ctx) + else ctx.provide('remoteAccess', { source: accessSource, whenAccess: vi.fn(() => () => {}), whenResourceAccess }) const create = vi.fn(async (input: { name: string } | { path: string }) => ({ workspaceId: 'ws-new' as never, path: 'name' in input ? `/projects/${input.name}` : input.path, @@ -241,6 +242,21 @@ describe('ui-workspace apply', () => { expect(b.whenResourceAccess).toHaveBeenCalledWith('workspace', 'w1', 'workspace/follow', guarded) }) + it('hangs Workspace-guarded effects on the calling plugin, so they leave when it unloads', async () => { + const b = await bench('static') + onTestFinished(() => b.ctx.fiber.dispose()) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const dispose = vi.fn() + const caller = b.ctx.plugin({ + inject: ['uiWorkspace'], + apply: (callerCtx: Context) => { callerCtx.uiWorkspace.whenAccess('w1' as WorkspaceId, 'workspace/follow', () => dispose) }, + }) + await caller.await() + expect(dispose).not.toHaveBeenCalled() + await caller.dispose() + expect(dispose).toHaveBeenCalledOnce() + }) + it('reports a default Workspace creation failure through the shared notice overlay', async () => { const b = await bench() onTestFinished(() => b.ctx.fiber.dispose()) diff --git a/packages/experimental/client-ui-voice-input/README.i18n.yaml b/packages/experimental/client-ui-voice-input/README.i18n.yaml index c906ffc9c7..6aa37a6ef0 100644 --- a/packages/experimental/client-ui-voice-input/README.i18n.yaml +++ b/packages/experimental/client-ui-voice-input/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 364c80ee00a374ff /deepseek-ai-dsh-experimental-client-ui-voice-input/use-this-package: - en: 4db1c67430cfa691 - zh: 484808eeff0d7692 + en: b7a99b656839e8a0 + zh: 5c6e2635eb20fc11 /deepseek-ai-dsh-experimental-client-ui-voice-input/understand-the-implementation: en: 8fe96eeef9afe7d7 zh: 11936f647ded39eb diff --git a/packages/experimental/client-ui-voice-input/README.md b/packages/experimental/client-ui-voice-input/README.md index 3eba1c3825..2aa4fdd707 100644 --- a/packages/experimental/client-ui-voice-input/README.md +++ b/packages/experimental/client-ui-voice-input/README.md @@ -31,6 +31,8 @@ Use the native **Input device** selector in bundle details to follow the system Before downloading or retrying, **Model download source** offers Automatic and the origins advertised by the Host. SenseVoice normally offers Hugging Face and HF-Mirror; a fixed private deployment exposes only its configured origin. Manual selection uses only that source, while Automatic preserves the provider’s comparison and fallback policy. The choice is retained for retries in the current card, without changing deployment configuration or recognition preferences. Source controls are unavailable during preparation and hidden when resources are ready. +`pluginNavigation` is optional: the plugin starts without it, and the setup dialog offers the details button only while the service is present. + ----- diff --git a/packages/experimental/client-ui-voice-input/README.zh.md b/packages/experimental/client-ui-voice-input/README.zh.md index b7e9164a02..3dadc0d92d 100644 --- a/packages/experimental/client-ui-voice-input/README.zh.md +++ b/packages/experimental/client-ui-voice-input/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" 下载或重试前,**模型下载源**提供自动选择及 Host 公布的源。SenseVoice 默认提供 Hugging Face 和 HF-Mirror;固定私有部署仅显示配置的源。手动选择只使用指定源,自动选择沿用提供方的比较与回退策略。当前卡片中的重试会保留选择,不修改部署配置或识别偏好。准备期间不能改源,资源就绪后隐藏源选择器。 +`pluginNavigation` 为可选服务:没有它时插件照常启动,设置对话框只在该服务存在时提供详情按钮。 + ----- diff --git a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx index 7b60ca9b7e..a396f1d7ab 100644 --- a/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx +++ b/packages/experimental/client-ui-voice-input/src/client/VoiceInput.tsx @@ -31,7 +31,12 @@ export interface VoiceInputActions { /** Entry-injected Host readiness and microphone operations. */ export interface VoiceInputInjected extends VoiceInputActions { - hooks: { speechReadiness: HostObservable; microphoneDevice: HostObservable } + hooks: { + speechReadiness: HostObservable + microphoneDevice: HostObservable + /** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */ + pluginNavigation: HostObservable + } } /** Composer-owned expansion and draft actions; preferences stay in plugin settings. */ @@ -58,8 +63,9 @@ async function disposeRecording(capture: Recording): Promise { /** Render a compact microphone or an expanded capture, transcription, or retry row. */ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, - createRecording, transcribe, openSettings, useSpeechReadiness, t }: VoiceInputProps) { + createRecording, transcribe, openSettings, useSpeechReadiness, usePluginNavigation, t }: VoiceInputProps) { const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog + const navigable = usePluginNavigation(present => present) const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId) const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby' || provider?.preparation.phase === 'waking') @@ -153,7 +159,8 @@ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange, { setSetupOpen(false) }} onOpenDetails={() => { setSetupOpen(false); openSettings() }} t={t} /> + onDismiss={() => { setSetupOpen(false) }} + onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} /> return
- + {onOpenDetails !== undefined && } }>

{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}

diff --git a/packages/experimental/client-ui-voice-input/src/client/mount.ts b/packages/experimental/client-ui-voice-input/src/client/mount.ts index d79d281def..bfe499f91b 100644 --- a/packages/experimental/client-ui-voice-input/src/client/mount.ts +++ b/packages/experimental/client-ui-voice-input/src/client/mount.ts @@ -6,6 +6,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { VoiceInput, type VoiceInputInjected } from './VoiceInput.tsx' import { Recording } from './audio.ts' import { createMicrophoneDeviceStore } from './microphone-device.ts' @@ -15,7 +16,7 @@ import { observeReadiness } from './readiness.ts' import { VoicePreparation } from './PreparationCard.tsx' import { VoiceSetupPrompt } from './VoiceSetupPrompt.tsx' -export const inject = ['remote', 'slots', 'locale', 'pluginNavigation'] +export const inject = ['remote', 'slots', 'locale'] function registerUi(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en, ja })) @@ -24,9 +25,17 @@ function registerUi(ctx: Context): void { const readiness = observeReadiness(ctx) ctx.effect(() => readiness.dispose) ctx.effect(() => async () => { await Promise.all([...recordings].map(recording => recording.dispose())) }) + // The Plugins page provides its navigation only while the current Peer may use it. + const pluginNavigation = createSnapshotStore(false) + ctx.inject(['pluginNavigation'], (scope: Context) => { + scope.effect(() => { + pluginNavigation.set(true) + return () => { pluginNavigation.set(false) } + }, 'voice-input: plugin navigation presence') + }) const actions: VoiceInputInjected = { - openSettings: () => { ctx.pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, - hooks: { speechReadiness: readiness.state, microphoneDevice: microphone }, + openSettings: () => { ctx.get('pluginNavigation')?.openBundle('@deepseek-ai/dsh-experimental-voice-input-bundle') }, + hooks: { speechReadiness: readiness.state, microphoneDevice: microphone, pluginNavigation }, selectMicrophone: (device) => { microphone.set(device) }, createRecording: () => { const recording = new Recording(() => { recordings.delete(recording) }, microphone.getSnapshot().id) @@ -62,7 +71,7 @@ function registerUi(ctx: Context): void { */ export async function mountVoiceInput(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise> { const disposeRemote = await ctx.remote.$mount(contribution) - const ui = ctx.inject(['remote.speech', 'slots', 'locale', 'pluginNavigation'], registerUi) + const ui = ctx.inject(['remote.speech', 'slots', 'locale'], registerUi) try { await ui } catch (error) { await ui.dispose(); await disposeRemote(); throw error } return async () => { await ui.dispose(); await disposeRemote() } } diff --git a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts index 3287f92a1f..a328473da6 100644 --- a/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-voice-input/tests/mount.client.spec.ts @@ -34,7 +34,7 @@ function assertVoiceActions(value: Record): asserts value is Re assert(typeof value.hooks === 'object' && value.hooks !== null) } -async function fixture(fail = false) { +async function fixture(fail = false, navigation = true) { const ctx = new Context(), unmount = vi.fn(async () => {}) class Remote extends Service { constructor() { super(ctx, 'remote') } @@ -45,7 +45,7 @@ async function fixture(fail = false) { } new Remote() const openBundle = vi.fn() - ctx.provide('pluginNavigation', { openBundle }) + if (navigation) ctx.provide('pluginNavigation', { openBundle }) const configure = vi.fn(async () => ({ ok: true, value: {} })) const prepare = vi.fn(async () => ({ ok: true, value: {} })) const cancelPreparation = vi.fn(async () => ({ ok: true, value: {} })) @@ -63,6 +63,28 @@ async function fixture(fail = false) { return { ctx, unmount, openBundle, configure, prepare, cancelPreparation, transcribe } } +it('starts without the Plugins page navigation and follows its presence', async () => { + hostApply() + const b = await fixture(false, false) + try { + await b.ctx.plugin({ inject: [...inject], apply: ctx => mountVoiceInput(ctx, REMOTE) }) + const entry = b.ctx.slots.entries('conversation.input.activity').find(item => item.component === VoiceInput) + const actions = entry!.inject!() + assertVoiceActions(actions) + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + expect(() => { actions.openSettings() }).not.toThrow() + const navigation = b.ctx.plugin({ apply: (ctx: Context) => { ctx.provide('pluginNavigation', { openBundle: b.openBundle } as never) } }) + await navigation + await vi.waitFor(() => { expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(true) }) + actions.openSettings() + expect(b.openBundle).toHaveBeenCalledWith('@deepseek-ai/dsh-experimental-voice-input-bundle') + await navigation.dispose() + expect(actions.hooks.pluginNavigation.getSnapshot()).toBe(false) + } finally { + await b.ctx.fiber.dispose() + } +}) + it('withdraws its Remote, localized slot and microphone captures on disposal', async () => { hostApply() const b = await fixture() diff --git a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx index 2dd0f150cf..b0e23ff464 100644 --- a/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx +++ b/packages/experimental/client-ui-voice-input/tests/voice-input.client.spec.tsx @@ -18,7 +18,7 @@ beforeEach(() => { vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); vi.st afterEach(() => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals() }) const id = 'sensevoice-local' as SpeechProviderId const transcript: Transcript = { text: '检查 TypeScript 类型', audioSeconds: 2, inferenceSeconds: 0.4 } -function fixture(recording?: Recording) { +function fixture(recording?: Recording, navigable = true) { const capture = Object.assign(new Recording(() => {}), { start: vi.fn(async () => {}), stop: vi.fn(async () => new Uint8Array(48)), amplitude: () => 0, dispose: vi.fn(async () => {}) }) const inputActions = { notify: vi.fn(), insertQuote: () => false, @@ -35,6 +35,7 @@ function fixture(recording?: Recording) { openSettings: vi.fn(), prepare: vi.fn(async () => {}), cancelPreparation: vi.fn(async () => {}), configure: vi.fn(async () => {}), selectMicrophone: vi.fn(), useMicrophoneDevice: bindSnapshotSelector(createSnapshotStore({ id: '', label: '' })), useSpeechReadiness: bindSnapshotSelector(readiness), createRecording: () => recording ?? capture, + usePluginNavigation: bindSnapshotSelector(createSnapshotStore(navigable)), t: makeTranslate(zh, commonZh) } const view = render() return { props, capture, inputActions, readiness, transcribe, view } @@ -335,6 +336,18 @@ it.each([ expect(b.props.openSettings).toHaveBeenCalledOnce() }) +it('offers no plugin details from setup guidance while the Plugins page navigation is absent', () => { + const b = fixture(undefined, false), state = b.readiness.getSnapshot() + act(() => { b.readiness.set({ ...state, catalog: { ...state.catalog!, providers: [ + { ...state.catalog!.providers[0]!, preparation: { phase: 'unprepared' } }, + ] } }) }) + fireEvent.click(screen.getByRole('button', { name: zh['setupPrompt.trigger'] })) + const dialog = within(screen.getByRole('dialog')) + expect(dialog.queryByRole('button', { name: zh['setupPrompt.open'] })).toBeNull() + expect(dialog.getByRole('button', { name: zh['setupPrompt.later'] })).toBeTruthy() + expect(b.props.openSettings).not.toHaveBeenCalled() +}) + it('moves focus into setup guidance, contains Tab traversal, and restores the draft focus on dismissal', () => { const b = fixture() render(