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