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 001/102] 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 002/102] 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 003/102] 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 004/102] 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 005/102] 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 006/102] 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 007/102] 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 008/102] 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 5d3dd1bbb012e17fca5a9836a0d89607e7ff29d5 Mon Sep 17 00:00:00 2001
From: fz
Date: Mon, 19 Oct 2026 13:02:20 +0800
Subject: [PATCH 009/102] feat(sdk): bridge internal approvals to stdio clients
---
.../tests/profiles/sdk/fixtures/approval.mjs | 14 ++
docs/event-producer-consumer.md | 2 +-
docs/event-producer-consumer.zh.md | 2 +-
packages/sdk/client/README.i18n.yaml | 4 +-
packages/sdk/client/README.md | 2 +
packages/sdk/client/README.zh.md | 2 +
packages/sdk/client/src/api.ts | 3 +
packages/sdk/client/src/client.ts | 12 ++
packages/sdk/client/src/types.ts | 2 +
packages/sdk/client/tests/fake-runtime.ts | 13 +-
packages/sdk/client/tests/sdk-client.spec.ts | 33 ++++
packages/sdk/protocol/README.i18n.yaml | 8 +-
packages/sdk/protocol/README.md | 4 +-
packages/sdk/protocol/README.zh.md | 4 +-
packages/sdk/protocol/src/index.ts | 3 +
packages/sdk/protocol/src/transport.ts | 6 +-
packages/sdk/protocol/src/types.ts | 25 +++
packages/sdk/protocol/tests/transport.spec.ts | 3 +
packages/sdk/server/README.i18n.yaml | 4 +-
packages/sdk/server/README.md | 2 +
packages/sdk/server/README.zh.md | 2 +
packages/sdk/server/package.json | 2 +
packages/sdk/server/src/server.ts | 37 +++++
packages/sdk/server/tests/approval.spec.ts | 150 ++++++++++++++++++
packages/sdk/server/tsconfig.json | 3 +
pnpm-lock.yaml | 3 +
python/sdk/README.i18n.yaml | 7 +-
python/sdk/README.md | 21 +++
python/sdk/README.zh.md | 21 +++
python/sdk/src/deepseek_harness/__init__.py | 4 +-
python/sdk/src/deepseek_harness/api.py | 2 +
python/sdk/src/deepseek_harness/client.py | 9 ++
python/sdk/src/deepseek_harness/models.py | 17 +-
python/sdk/tests/test_approval.py | 49 ++++++
python/sdk/tests/test_approval_runtime.py | 52 ++++++
python/sdk/tests/test_client.py | 2 +
snapshots/sdk/approval/runtime.cordis.yml | 5 +
snapshots/sdk/approval/session.v4.jsonl | 29 ++++
snapshots/sdk/approval/snapshot.yml | 9 ++
.../approval/workspace.expected/approved.txt | 1 +
snapshots/sdk/sdk.snapshot.ts | 19 +++
41 files changed, 574 insertions(+), 18 deletions(-)
create mode 100644 apps/cli/tests/profiles/sdk/fixtures/approval.mjs
create mode 100644 packages/sdk/server/tests/approval.spec.ts
create mode 100644 python/sdk/tests/test_approval.py
create mode 100644 python/sdk/tests/test_approval_runtime.py
create mode 100644 snapshots/sdk/approval/runtime.cordis.yml
create mode 100644 snapshots/sdk/approval/session.v4.jsonl
create mode 100644 snapshots/sdk/approval/snapshot.yml
create mode 100644 snapshots/sdk/approval/workspace.expected/approved.txt
diff --git a/apps/cli/tests/profiles/sdk/fixtures/approval.mjs b/apps/cli/tests/profiles/sdk/fixtures/approval.mjs
new file mode 100644
index 0000000000..1f71f23adf
--- /dev/null
+++ b/apps/cli/tests/profiles/sdk/fixtures/approval.mjs
@@ -0,0 +1,14 @@
+/** Exercises the composed internal approval service. */
+import { appendFile } from 'node:fs/promises'
+
+export const name = 'sdk-approval-fixture'
+export const inject = ['approval']
+
+/** Ask once per turn and expose whether the guarded operation ran. */
+export function apply(ctx, config) {
+ ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
+ const outcome = await ctx.approval.request({ agent, signal, toolName: 'fixture-write', reason: 'write approval marker' })
+ if (outcome === 'allowed-once') await appendFile(config.path, 'approved\n')
+ return config.stopAfterApproval ? { kind: 'reject' } : next()
+ })
+}
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 9661237f21..c41f93cbbf 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -28,7 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
-| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
+| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 77f6a926d4..e1f0c76409 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -30,7 +30,7 @@
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:620`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:627`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) |
-| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
+| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes`, `server` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
| `compaction/summary-error` | `waterfall` | [`packages/compaction/compaction/src/index.ts:106`](../packages/compaction/compaction/src/index.ts) | [`compaction-basic`](../packages/compaction/compaction-basic) (`waterfall`) | [`compaction-image-offload`](../packages/compaction/compaction-image-offload) |
diff --git a/packages/sdk/client/README.i18n.yaml b/packages/sdk/client/README.i18n.yaml
index db75bc1878..309ae3aeb0 100644
--- a/packages/sdk/client/README.i18n.yaml
+++ b/packages/sdk/client/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-client/use-this-package:
- en: ff421f12a5db28a4
- zh: 2ca9b71a3458b5e0
+ en: 38bb14f9c588dea2
+ zh: 3f8218a0eb4f7ec3
/deepseek-ai-dsh-sdk-client/use-this-package/running-agent-turns-with-deepseekharness:
en: 01c58dddd7e62de2
zh: 6dbd46e244f3f135
diff --git a/packages/sdk/client/README.md b/packages/sdk/client/README.md
index 2b64ba7417..3a6edd9853 100644
--- a/packages/sdk/client/README.md
+++ b/packages/sdk/client/README.md
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
## Use this package
+For client-owned approvals, call `harness.client.start()`, install `harness.client.onRequest(handler)`, then call `harness.start()` with `approvalRequests: true` in the Harness options. The handler receives `approval/request` parameters and returns `{ outcome: "allowed-once" | "rejected" | "cancelled" }`; a rejected handler returns a JSON-RPC error and the runtime fails that approval closed. A replacement client needs its own handler; a closed client cannot restart. Without the opt-in, existing approval answerers remain in control. See the [wire protocol](../protocol/README.md) for request fields and cancellation.
+
`await harness.resumeSession(savedId)` restores a persisted session from the configured `dshHome` and returns a `HarnessSession` ready for `run()`. Save the original session id and reuse its home and workspace. Restoration preserves conversation history and its directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. `session(id)` remains a lazy handle for an in-process session; it does not request persisted restoration. Shell, browser, and other external processes are not restored by this API.
`HarnessSession.getWorkingDirectory()` reads the effective directory; `setWorkingDirectory(path)` changes it and returns the validated absolute path. Either method creates an unknown Session without starting a model turn. Relative paths use the Session's current directory. Origin metadata, permissions, and existing processes remain unchanged. `HarnessClient` exposes the same methods with a leading `sessionId` argument.
diff --git a/packages/sdk/client/README.zh.md b/packages/sdk/client/README.zh.md
index 2dd38f0251..375ee1ab37 100644
--- a/packages/sdk/client/README.zh.md
+++ b/packages/sdk/client/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-library"
## 使用本包
+若由客户端处理审批,先调用 `harness.client.start()`,安装 `harness.client.onRequest(handler)`,再调用 `harness.start()`,并在 Harness 选项中设置 `approvalRequests: true`。处理器接收 `approval/request` 参数,返回 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`;处理器拒绝时返回 JSON-RPC 错误,运行时拒绝放行该审批。替换客户端时需要为新实例安装处理器;已关闭的客户端不能重启。未启用时保留现有审批应答链。请求字段与取消规则见[协议](../protocol/README.zh.md)。
+
`await harness.resumeSession(savedId)` 从配置的 `dshHome` 恢复持久化会话,并返回可调用 `run()` 的 `HarnessSession`。请保存原会话 id,并沿用其 home 和工作区。恢复会保留对话历史及其目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。`session(id)` 仍是当前进程内会话的惰性句柄,不会请求持久化恢复。此 API 不恢复 shell、浏览器或其他外部进程。
`HarnessSession.getWorkingDirectory()` 读取有效目录;`setWorkingDirectory(path)` 修改目录并返回校验后的绝对路径。两个方法都可创建未知 Session,而不启动模型轮次。相对路径以 Session 当前目录解析。起始目录元数据、权限与已有进程保持不变。`HarnessClient` 提供同名方法,并以 `sessionId` 作为首个参数。
diff --git a/packages/sdk/client/src/api.ts b/packages/sdk/client/src/api.ts
index 33436b0074..85e61a77ac 100644
--- a/packages/sdk/client/src/api.ts
+++ b/packages/sdk/client/src/api.ts
@@ -27,6 +27,7 @@ export class DeepSeekHarness implements AsyncDisposable {
private readonly model: string
private readonly reasoningEffort: DeepSeekHarnessOptions['reasoningEffort']
private readonly maxTokens: number | undefined
+ private readonly approvalRequests: boolean | undefined
private initialized: Promise | undefined
private closed = false
@@ -43,6 +44,7 @@ export class DeepSeekHarness implements AsyncDisposable {
this.model = options.model ?? 'deepseek-v4-flash'
this.reasoningEffort = options.reasoningEffort
this.maxTokens = options.maxTokens
+ this.approvalRequests = options.approvalRequests
}
/**
@@ -76,6 +78,7 @@ export class DeepSeekHarness implements AsyncDisposable {
model: this.model,
...this.reasoningEffort === undefined ? {} : { reasoningEffort: this.reasoningEffort },
...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens },
+ ...this.approvalRequests === undefined ? {} : { approvalRequests: this.approvalRequests },
})
} catch (error) {
this.initialized = undefined
diff --git a/packages/sdk/client/src/client.ts b/packages/sdk/client/src/client.ts
index 632e06d872..57b431d6bb 100644
--- a/packages/sdk/client/src/client.ts
+++ b/packages/sdk/client/src/client.ts
@@ -268,6 +268,18 @@ export class HarnessClient {
this.transport = transport
}
+ /**
+ * Install the server-request handler on a started client, replacing the prior handler.
+ * @param handler - receives wire parameters and returns the JSON-RPC result; rejection sends an error.
+ * @throws when the client has not started or has already closed.
+ */
+ onRequest(handler: (method: string, params: Record) => Promise): void {
+ if (this.transport === undefined || this.closeTask !== undefined) {
+ throw new TransportClosedError('start the SDK client before installing a request handler')
+ }
+ this.transport.onRequest(handler)
+ }
+
/**
* Perform the process-wide handshake.
* @param params - workspace cwd plus the provider/model route.
diff --git a/packages/sdk/client/src/types.ts b/packages/sdk/client/src/types.ts
index 9e91eb1036..3f8f85175b 100644
--- a/packages/sdk/client/src/types.ts
+++ b/packages/sdk/client/src/types.ts
@@ -64,6 +64,8 @@ export interface DeepSeekHarnessOptions extends HarnessClientOptions {
reasoningEffort?: ReasoningEffortId
/** Maximum output tokens for each conversation-model request. */
maxTokens?: number
+ /** Enable server approval requests; install client.onRequest() after client.start() and before harness.start(). */
+ approvalRequests?: boolean
}
/** One owned session activity interval, from enqueue receipt through idle. */
diff --git a/packages/sdk/client/tests/fake-runtime.ts b/packages/sdk/client/tests/fake-runtime.ts
index bbdec4a4e6..84ef3529aa 100644
--- a/packages/sdk/client/tests/fake-runtime.ts
+++ b/packages/sdk/client/tests/fake-runtime.ts
@@ -245,7 +245,17 @@ const reader = createInterface({ input: process.stdin })
const settledSessions = new Set()
reader.on('line', (line) => {
if (line.trim().length === 0) return
- const frame = JSON.parse(line) as { id?: string | number; method?: string; params?: Record }
+ const frame = JSON.parse(line) as {
+ id?: string | number
+ method?: string
+ params?: Record
+ result?: unknown
+ error?: unknown
+ }
+ if (frame.id === 'approval-client-test') {
+ notify('approval/response', { result: frame.result, error: frame.error })
+ return
+ }
if (frame.method === undefined || frame.id === undefined) return
const respond = (result: object): void => { write({ jsonrpc: '2.0', id: frame.id, result }) }
switch (frame.method) {
@@ -282,6 +292,7 @@ reader.on('line', (line) => {
return
}
respond({ serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } })
+ if (env.FAKE_APPROVAL !== undefined) write({ jsonrpc: '2.0', id: 'approval-client-test', method: 'approval/request', params: { sessionId: 'main', toolName: 'bash' } })
return
case 'session/resume':
if (env.FAKE_RESUME_ERROR !== undefined) {
diff --git a/packages/sdk/client/tests/sdk-client.spec.ts b/packages/sdk/client/tests/sdk-client.spec.ts
index 7618df7218..72f82b81db 100644
--- a/packages/sdk/client/tests/sdk-client.spec.ts
+++ b/packages/sdk/client/tests/sdk-client.spec.ts
@@ -299,6 +299,7 @@ describe('DeepSeekHarness', () => {
model: 'custom-model',
reasoningEffort: ReasoningEffortId('max'),
maxTokens: 4096,
+ approvalRequests: true,
})
cleanups.push(() => harness.close())
await harness.run('one')
@@ -311,6 +312,7 @@ describe('DeepSeekHarness', () => {
model: 'custom-model',
reasoningEffort: 'max',
maxTokens: 4096,
+ approvalRequests: true,
}])
})
@@ -765,3 +767,34 @@ describe('pure helpers', () => {
])).toBe('ab')
})
})
+
+
+describe('server-to-client SDK requests', () => {
+ it('requires a started client and returns client decisions over the wire', async () => {
+ const client = processClient(fakeLaunch({ FAKE_APPROVAL: '1' }))
+ cleanups.push(() => client.close())
+ expect(() => { client.onRequest(async () => ({})) }).toThrow('start the SDK client')
+ client.start()
+ client.onRequest(async (method, params) => {
+ expect(method).toBe('approval/request')
+ expect(params).toEqual({ sessionId: 'main', toolName: 'bash' })
+ return { outcome: 'rejected' }
+ })
+ const notifications = client.subscribe()
+ await client.initialize({ cwd: process.cwd(), provider: 'mock', model: 'mock', approvalRequests: true })
+ expect(await notifications.next()).toMatchObject({ method: 'approval/response', params: { result: { outcome: 'rejected' } } })
+ notifications.close()
+ await client.close()
+ expect(() => { client.onRequest(async () => ({})) }).toThrow('start the SDK client')
+ })
+})
+
+
+describe('SDK cancellation reason projection', () => {
+ it.each(['user', 'parent', 'disposed', 'legacy'])('retains the %s cancellation cause', async (kind) => {
+ const harness = harnessWith({ FAKE_REASON_KIND: 'aborted', FAKE_ABORT_REASON_KIND: kind })
+ const result = await harness.run('cancelled')
+ expect(result.events.findLast(event => event.type === 'turn/end')?.data.reason)
+ .toEqual({ kind: 'aborted', reason: { kind } })
+ })
+})
diff --git a/packages/sdk/protocol/README.i18n.yaml b/packages/sdk/protocol/README.i18n.yaml
index aa6e00701a..54275c2b7b 100644
--- a/packages/sdk/protocol/README.i18n.yaml
+++ b/packages/sdk/protocol/README.i18n.yaml
@@ -15,14 +15,14 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-protocol/use-this-package:
- en: c4ca91b51aab452a
- zh: 1c77b1deaeb34931
+ en: 3b1752b7f9f6c317
+ zh: 066b03b904b6448b
/deepseek-ai-dsh-sdk-protocol/use-this-package/framing-and-transport:
en: be53c71505c0d1b4
zh: 6210dee577715539
/deepseek-ai-dsh-sdk-protocol/use-this-package/the-sdk-methods:
- en: 8057053746fc2755
- zh: 36d5989488c1a351
+ en: 5bd04dbafb8b0fab
+ zh: de0225a5c9794c34
/deepseek-ai-dsh-sdk-protocol/use-this-package/payload-semantics:
en: 3cf5d4ffbaf37f69
zh: 23c4450e31c91d67
diff --git a/packages/sdk/protocol/README.md b/packages/sdk/protocol/README.md
index 2b9ff7eacf..567d1bb245 100644
--- a/packages/sdk/protocol/README.md
+++ b/packages/sdk/protocol/README.md
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
## Use this package
+`InitializeParams.approvalRequests: true` enables `approval/request` server requests, indexed by `HarnessSdkClientRequestMap`. `ApprovalRequestParams` contains `sessionId`, `toolName`, optional `callId`, and optional `reason`; `ApprovalRequestResult` is `{ outcome: "allowed-once" | "rejected" | "cancelled" }`. Omission or `false` leaves existing answerers in control. Only `allowed-once` grants one operation. Cancellation discards the pending request and late responses; shutdown and transport loss fail pending decisions closed. The client must keep reading and answering requests while waiting for an Agent to finish.
+
Use this package when you build or debug an SDK wire end — the serving plugin, a client library, or custom tooling that speaks the SDK protocol. It gives you one transport for JSON-RPC 2.0 over caller-owned byte streams and the typed shapes for every SDK method and notification.
### Framing and transport
@@ -33,7 +35,7 @@ Wire one JSON-RPC 2.0 message per `\n`-terminated line over byte streams you own
### The SDK methods
-Both wire ends share one method set: seven client-to-server requests and four server-to-client notifications.
+Both wire ends share one method set: client-to-server requests, opt-in server-to-client requests, and server notifications.
| Direction | Method | Payload types |
|---|---|---|
diff --git a/packages/sdk/protocol/README.zh.md b/packages/sdk/protocol/README.zh.md
index ebafcdccb0..6a9e7a65be 100644
--- a/packages/sdk/protocol/README.zh.md
+++ b/packages/sdk/protocol/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-library"
## 使用本包
+`InitializeParams.approvalRequests: true` 启用由 `HarnessSdkClientRequestMap` 索引的服务端请求 `approval/request`。`ApprovalRequestParams` 包含 `sessionId`、`toolName`,以及可选的 `callId` 和 `reason`;`ApprovalRequestResult` 为 `{ outcome: "allowed-once" | "rejected" | "cancelled" }`。省略或设为 `false` 时保留现有应答链。只有 `allowed-once` 批准单次操作。取消会丢弃待处理请求及迟到响应;shutdown 和传输断开使待处理决定拒绝放行。客户端等待 Agent 完成期间,必须继续读取并回答请求。
+
当你构建或调试 SDK 协议端——服务插件、客户端库或使用该协议的自定义工具——时使用本包。它为你提供一个在调用方持有的字节流上承载 JSON-RPC 2.0 的传输,以及每个 SDK 方法与通知的类型化结构。
### 分帧与传输
@@ -33,7 +35,7 @@ kind: "package-library"
### SDK 方法
-两个协议端共享同一套方法:七个客户端到服务端请求与四个服务端到客户端通知。
+两个协议端共享同一套方法:客户端到服务端请求、显式启用的服务端到客户端请求,以及服务端通知。
| 方向 | 方法 | 载荷类型 |
|---|---|---|
diff --git a/packages/sdk/protocol/src/index.ts b/packages/sdk/protocol/src/index.ts
index 698ecb6360..f98d84f88a 100644
--- a/packages/sdk/protocol/src/index.ts
+++ b/packages/sdk/protocol/src/index.ts
@@ -11,6 +11,9 @@
export { JsonRpcLineTransport, JsonRpcResponseError } from './transport.ts'
export type { JsonRpcTransportPeer } from './transport.ts'
export type {
+ ApprovalRequestParams,
+ ApprovalRequestResult,
+ HarnessSdkClientRequestMap,
HarnessSdkNotificationMap,
HarnessSdkRequestMap,
InitializeParams,
diff --git a/packages/sdk/protocol/src/transport.ts b/packages/sdk/protocol/src/transport.ts
index 36574f46bf..12842e3ef6 100644
--- a/packages/sdk/protocol/src/transport.ts
+++ b/packages/sdk/protocol/src/transport.ts
@@ -36,10 +36,11 @@ export interface JsonRpcTransportPeer {
* Send a request and await its response.
* @param method - the JSON-RPC method name.
* @param params - the request parameters object.
+ * @param signal - optional cancellation; removes the pending waiter and rejects on abort.
* @returns the result; rejects with {@link JsonRpcResponseError} on an error
* response, and with a plain `Error` on a write failure or closure.
*/
- request(method: string, params: object): Promise
+ request(method: string, params: object, signal?: AbortSignal): Promise
/**
* Send a notification; omitted params produce no `params` member.
* @param method - the JSON-RPC method name.
@@ -61,6 +62,7 @@ interface PendingRequest {
*/
export class JsonRpcLineTransport implements JsonRpcTransportPeer {
private buffer = ''
+ private failure: Error | undefined
private readonly decoder = new StringDecoder('utf8')
private started = false
private requestHandler: RequestHandler | undefined
@@ -119,6 +121,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
* @returns the result; rejects per {@link JsonRpcTransportPeer.request}.
*/
request(method: string, params: object, signal?: AbortSignal): Promise {
+ if (this.failure !== undefined) return Promise.reject(this.failure)
const id = `req_${randomUUID().replaceAll('-', '')}`
const message = { jsonrpc: '2.0', id, method, params }
return new Promise((resolve, reject) => {
@@ -262,6 +265,7 @@ export class JsonRpcLineTransport implements JsonRpcTransportPeer {
}
private failPending(error: Error): void {
+ this.failure ??= error
const pending = [...this.pending.values()]
this.pending.clear()
for (const waiter of pending) waiter.reject(error)
diff --git a/packages/sdk/protocol/src/types.ts b/packages/sdk/protocol/src/types.ts
index 25891197e2..7b2fe33df5 100644
--- a/packages/sdk/protocol/src/types.ts
+++ b/packages/sdk/protocol/src/types.ts
@@ -16,6 +16,8 @@ import type { SubagentStopReason } from '@deepseek-ai/dsh-subagent'
export interface InitializeParams {
/** Working directory recorded on every SDK-created session's header. */
cwd: string
+ /** Enable server-to-client approval/request calls; omission leaves existing answerers in control. */
+ approvalRequests?: boolean
/** Provider route every SDK-created agent runs on. */
provider: string
/** Model name every SDK-created agent runs on (the server may mount a fallback adapter; see `HarnessSdkJsonRpcServer.initialize`). */
@@ -151,3 +153,26 @@ export interface HarnessSdkRequestMap {
'session/working-directory/set': { params: SessionWorkingDirectorySetParams; result: SessionWorkingDirectoryResult }
'shutdown': { params: undefined; result: Record }
}
+
+/** Internal approval projected to the SDK client without live runtime objects. */
+export interface ApprovalRequestParams {
+ /** Session owning the operation, including an in-process SDK descendant. */
+ sessionId: string
+ /** Tool whose operation needs approval. */
+ toolName: string
+ /** Exact tool call, when the asker supplied one. */
+ callId?: string
+ /** Asker-supplied explanation, when available. */
+ reason?: string
+}
+
+/** One-shot client decision; grants never persist beyond this operation. */
+export interface ApprovalRequestResult {
+ /** Only allowed-once permits the operation. */
+ outcome: 'allowed-once' | 'rejected' | 'cancelled'
+}
+
+/** Requests sent by the server after the client explicitly enables them. */
+export interface HarnessSdkClientRequestMap {
+ 'approval/request': { params: ApprovalRequestParams; result: ApprovalRequestResult }
+}
diff --git a/packages/sdk/protocol/tests/transport.spec.ts b/packages/sdk/protocol/tests/transport.spec.ts
index c14111be22..3c24389c70 100644
--- a/packages/sdk/protocol/tests/transport.spec.ts
+++ b/packages/sdk/protocol/tests/transport.spec.ts
@@ -235,6 +235,7 @@ describe('JsonRpcLineTransport', () => {
aToB.end()
await expect(pending).rejects.toThrow('JSON-RPC input closed')
+ await expect(b.request('after-eof', {})).rejects.toThrow('JSON-RPC input closed')
b.close()
})
@@ -246,6 +247,7 @@ describe('JsonRpcLineTransport', () => {
aToB.emit('error', new Error('input broke'))
await expect(pending).rejects.toThrow('input broke')
+ await expect(b.request('after-error', {})).rejects.toThrow('input broke')
b.close()
})
@@ -256,6 +258,7 @@ describe('JsonRpcLineTransport', () => {
b.close()
await expect(pending).rejects.toThrow('JSON-RPC transport closed')
+ await expect(b.request('after-close', {})).rejects.toThrow('JSON-RPC transport closed')
})
it('rejects a request when writing the frame throws', async () => {
diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml
index 2f62e76afe..2d695b268d 100644
--- a/packages/sdk/server/README.i18n.yaml
+++ b/packages/sdk/server/README.i18n.yaml
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package:
- en: e136ff3a0b42cc22
- zh: f6c4df5bbafa335f
+ en: c7db38347b9da536
+ zh: 05767011d6b389bf
/deepseek-ai-dsh-sdk-jsonrpc-server/use-this-package/wiring:
en: 47a33e9397e36e15
zh: e6d173820f9ea6e5
diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md
index bc78c02cdb..69c023ab96 100644
--- a/packages/sdk/server/README.md
+++ b/packages/sdk/server/README.md
@@ -25,6 +25,8 @@ English | [中文](README.zh.md)
## Use this package
+Set `initialize.approvalRequests` to `true` to send internal approvals as server-to-client `approval/request` calls. The bridge handles SDK-owned Agents and their live in-process descendants; unrelated Agents and clients that omit the flag continue through the composed answerer chain. The approval service still applies `never` policy before dispatch and records the ask/decision audit pair. The bridge accepts only `allowed-once`, `rejected`, or `cancelled`; an invalid response, client error, or transport loss yields the service's fail-closed `unavailable` outcome. Request cancellation abandons the pending wire waiter; shutdown abandons all such waiters before draining Agents. Late replies cannot grant an abandoned operation. See the [protocol](../protocol/README.md) for the payload and the [Python example](../../../python/sdk/README.md#client-approvals) for client handling.
+
`session/resume` accepts a persisted `{ sessionId }` after initialization and returns `{}` once its Agent is ready. It loads history through `agents.resume()`, repairs an interrupted final turn, and preserves the stored origin and effective directory while applying the initialized model route. It sends no new input. Missing, unreadable, incompatible, or occupied sessions reject without creating a replacement. Prompts for an identity currently being restored wait for that restoration; shutdown drains pending restoration and releases its Agent. Historical events are retained in the log rather than streamed again as new notifications.
`session/working-directory/get` accepts `{ sessionId }`; `session/working-directory/set` accepts `{ sessionId, path }`. Both return `{ cwd }` and create an unknown Session without starting a model turn. Reads recover missing directories through the working-directory service; changes preserve origin metadata and permissions, and reach the model through logged user context.
diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md
index 772ae98e6f..60fe9d4429 100644
--- a/packages/sdk/server/README.zh.md
+++ b/packages/sdk/server/README.zh.md
@@ -25,6 +25,8 @@ kind: "package-reference"
## 使用本包
+将 `initialize.approvalRequests` 设为 `true`,即可把内部审批作为服务端到客户端的 `approval/request` 请求发送。桥接处理 SDK 拥有的 Agent 及其仍在运行的进程内后代;其他 Agent,以及未提供该标志的客户端,继续使用已组合的应答链。审批服务仍在分发前执行 `never` 策略,并记录询问与决定的审计事件。桥接仅接受 `allowed-once`、`rejected` 或 `cancelled`;无效响应、客户端错误或传输断开会产生审批服务的拒绝放行结果 `unavailable`。请求取消时释放待处理的协议等待项;shutdown 在等待 Agent 结束前释放所有此类等待项。迟到响应不能授权已放弃的操作。载荷见[协议](../protocol/README.zh.md),客户端处理见 [Python 示例](../../../python/sdk/README.zh.md#client-approvals)。
+
`session/resume` 在初始化后接受持久化的 `{ sessionId }`,待其 Agent 就绪后返回 `{}`。它通过 `agents.resume()` 加载历史、修复最后一个被中断的轮次,并保留存储的起始目录和当前有效目录,同时应用初始化时选定的模型路由。它不发送新输入。会话缺失、不可读、不兼容或被占用时,请求失败且不创建替代会话。对正在恢复的标识发送提示词时,会等待恢复完成;shutdown 会等待进行中的恢复并释放其 Agent。历史事件保留在日志中,不会作为新通知重新发送。
`session/working-directory/get` 接收 `{ sessionId }`;`session/working-directory/set` 接收 `{ sessionId, path }`。两者均返回 `{ cwd }`,并可创建未知 Session,而不启动模型轮次。读取通过工作目录服务恢复缺失目录;修改保留起始目录元数据与权限,并通过已记录的用户上下文告知模型。
diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json
index 4628de64fb..aaa09e4708 100644
--- a/packages/sdk/server/package.json
+++ b/packages/sdk/server/package.json
@@ -40,6 +40,7 @@
"@deepseek-ai/dsh-sdk-protocol": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
},
"devDependencies": {
@@ -57,6 +58,7 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
+ "@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/dsh-working-directory": "workspace:*"
}
}
diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts
index 726f430f15..850a24ac7c 100644
--- a/packages/sdk/server/src/server.ts
+++ b/packages/sdk/server/src/server.ts
@@ -14,10 +14,12 @@ import { createUserMessage, ReasoningEffortId, type ContentBlock, type LlmRuntim
import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-working-directory'
+import type {} from '@deepseek-ai/dsh-user-approval'
import type SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek-api-key'
import type {
+ ApprovalRequestParams,
InitializeParams,
InitializeResult,
JsonRpcTransportPeer,
@@ -93,12 +95,32 @@ export class HarnessSdkJsonRpcServer {
private shutdownTask: Promise> | undefined
private shuttingDown = false
private initialized = false
+ private approvalRequests = false
+ private readonly lifetime = new AbortController()
constructor(
private readonly ctx: Context,
private readonly transport: JsonRpcTransportPeer,
private readonly options: HarnessSdkJsonRpcServerOptions = {},
) {
+ this.disposers.push(ctx.on('approval/request', async (request, next) => {
+ if (!this.approvalRequests || !this.ownsAgent(request.agent)) return next()
+ const params: ApprovalRequestParams = {
+ sessionId: request.agent.session.id,
+ toolName: request.toolName,
+ ...(request.callId === undefined ? {} : { callId: request.callId }),
+ ...(request.reason === undefined ? {} : { reason: request.reason }),
+ }
+ const signal = request.signal === undefined
+ ? this.lifetime.signal
+ : AbortSignal.any([request.signal, this.lifetime.signal])
+ const result = await this.transport.request('approval/request', params, signal)
+ if (result === null || typeof result !== 'object' || !('outcome' in result)
+ || (result.outcome !== 'allowed-once' && result.outcome !== 'rejected' && result.outcome !== 'cancelled')) {
+ throw new TypeError('approval/request response requires an allowed-once, rejected, or cancelled outcome')
+ }
+ return result.outcome
+ }))
const serverOptions = this.options
this.disposers.push(ctx.on('session/event', (session, event) => {
const record = this.sessions.get(session.id)
@@ -153,6 +175,9 @@ export class HarnessSdkJsonRpcServer {
* @returns server identity for the handshake.
*/
async initialize(params: InitializeParams): Promise {
+ if (params.approvalRequests !== undefined && typeof params.approvalRequests !== 'boolean') {
+ throw new TypeError('initialize approvalRequests must be a boolean')
+ }
if (params.reasoningEffort !== undefined
&& (typeof params.reasoningEffort !== 'string' || params.reasoningEffort.length === 0)) {
throw new TypeError('initialize reasoningEffort must be a non-empty string')
@@ -184,6 +209,7 @@ export class HarnessSdkJsonRpcServer {
this.model = model
this.reasoningEffort = reasoningEffort
this.maxTokens = params.maxTokens
+ this.approvalRequests = params.approvalRequests === true
this.initialized = true
return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }
}
@@ -256,6 +282,16 @@ export class HarnessSdkJsonRpcServer {
}
}
+ private ownsAgent(agent: Agent): boolean {
+ let current: Agent | undefined = agent
+ while (current !== undefined) {
+ if (this.sessions.get(current.session.id)?.handle.agent === current) return true
+ const parent: SessionId | undefined = current.session.header.parentSession
+ current = parent === undefined ? undefined : this.ctx.agents.get(parent)
+ }
+ return false
+ }
+
private assertLiveAgent(rec: SessionRecord, sessionId: string): void {
if (this.ctx.agents.get(rec.handle.agent.id) !== rec.handle.agent) {
throw new Error(`session agent was disposed outside the server: ${sessionId}`)
@@ -302,6 +338,7 @@ export class HarnessSdkJsonRpcServer {
private async performShutdown(): Promise> {
this.shuttingDown = true
+ this.lifetime.abort(new Error('SDK server is shutting down'))
const pendingCreations = [...this.sessionCreations.values()]
await Promise.allSettled(pendingCreations)
this.sessionCreations.clear()
diff --git a/packages/sdk/server/tests/approval.spec.ts b/packages/sdk/server/tests/approval.spec.ts
new file mode 100644
index 0000000000..d9d07b53a2
--- /dev/null
+++ b/packages/sdk/server/tests/approval.spec.ts
@@ -0,0 +1,150 @@
+/** SDK approvals use the real bidirectional wire and the internal audit service. */
+import { PassThrough } from 'node:stream'
+import { Context } from '@deepseek-ai/cordis'
+import { describe, expect, it, onTestFinished } from 'vitest'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { SessionId } from '@deepseek-ai/dsh-session'
+import { ToolCallId } from '@deepseek-ai/dsh-llm'
+import ApprovalService from '@deepseek-ai/dsh-user-approval'
+import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol'
+import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { mountWorkingDirectoryFixture } from '../../../subagent/subagent/tests/working-directory-fixture.ts'
+import { HarnessSdkJsonRpcServer } from '../src/server.ts'
+
+async function setup(enabled?: boolean) {
+ const ctx = new Context()
+ onTestFinished(() => ctx.fiber.dispose())
+ await mountAgentLoopTestDependencies(ctx)
+ await mountWorkingDirectoryFixture(ctx)
+ await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(ApprovalService)
+ ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('done')]))
+ const input = new PassThrough()
+ const output = new PassThrough()
+ const serverWire = new JsonRpcLineTransport(input, output)
+ const client = new JsonRpcLineTransport(output, input)
+ const server = new HarnessSdkJsonRpcServer(ctx, serverWire)
+ onTestFinished(async () => {
+ await server.shutdown()
+ serverWire.close()
+ client.close()
+ input.destroy()
+ output.destroy()
+ })
+ serverWire.onRequest((method, params) => server.handleRequest(method, params))
+ serverWire.start()
+ client.start()
+ await client.request('initialize', {
+ cwd: process.cwd(), provider: 'mock', model: 'mock',
+ ...(enabled === undefined ? {} : { approvalRequests: enabled }),
+ })
+ await client.request('session/working-directory/get', { sessionId: 'main' })
+ const agent = ctx.agents.get(SessionId('main'))!
+ agent.session.append('turn/start', { turn: 1 })
+ return { ctx, server, serverWire, client, agent }
+}
+
+describe('SDK approval bridge', () => {
+ it.each(['allowed-once', 'rejected', 'cancelled'] as const)('applies and audits %s', async (outcome) => {
+ const h = await setup(true)
+ h.client.onRequest(async (method, params) => {
+ expect(method).toBe('approval/request')
+ expect(params).toEqual({ sessionId: 'main', toolName: 'bash', callId: 'call-1', reason: 'write outside workspace' })
+ return { outcome }
+ })
+ await expect(h.ctx.approval.request({
+ agent: h.agent, toolName: 'bash', callId: ToolCallId('call-1'), reason: 'write outside workspace',
+ })).resolves.toBe(outcome)
+ expect(h.agent.session.snapshotEvents().slice(-2).map(e => [e.type, e.data])).toEqual([
+ ['approval/asked', expect.objectContaining({ toolName: 'bash', reason: 'write outside workspace' })],
+ ['approval/decided', expect.objectContaining({ outcome })],
+ ])
+ })
+
+ it.each([undefined, false])('delegates when approvalRequests is %s', async (enabled) => {
+ const h = await setup(enabled)
+ h.ctx.on('approval/request', () => Promise.resolve('rejected'))
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('rejected')
+ })
+
+ it.each([null, {}, { outcome: 'allow-always' }, { outcome: true }])('fails closed for invalid response %j', async (result) => {
+ const h = await setup(true)
+ h.client.onRequest(() => Promise.resolve(result))
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
+ })
+
+ it('fails closed on client errors and a disconnected transport', async () => {
+ const h = await setup(true)
+ h.client.onRequest(() => Promise.reject(new Error('no decision')))
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
+ h.serverWire.close()
+ await expect(h.ctx.approval.request({ agent: h.agent, toolName: 'bash' })).resolves.toBe('unavailable')
+ })
+
+ it('correlates concurrent requests while another client request is in flight', async () => {
+ const h = await setup(true)
+ const first = Promise.withResolvers