Merge pull request #6472 from deepseek-harness/worktree/session-copy-markdown
feat(session): copy sessions as Markdown with source paths
This commit is contained in:
commit
2a48671b83
42 files changed
+2609
-129
No files matched your search
@@ -0,0 +1,81 @@
|
||||
# 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.
|
||||
|
||||
<details><summary>3 previous messages</summary>
|
||||
|
||||
**Tool: `bash`**
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "echo NAVIGATION_OK",
|
||||
"description": "Print NAVIGATION_OK"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
NAVIGATION_OK
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-a.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-a.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # alpha nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
**Tool: `read`**
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "nav-b.md"
|
||||
}
|
||||
```
|
||||
|
||||
**Result**
|
||||
|
||||
```
|
||||
<path>{{cwd}}/workspace/nav-b.md</path>
|
||||
<type>file</type>
|
||||
<content>
|
||||
1: # beta nav
|
||||
|
||||
(End of file - total 1 lines)
|
||||
</content>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
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
|
||||
```
|
||||
@@ -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,
|
||||
@@ -32,6 +32,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`
|
||||
@@ -424,6 +425,66 @@ 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'])
|
||||
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<typeof originalWrite>) => {
|
||||
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 })
|
||||
const copyButton = page.getByRole('button', { name: 'More actions' })
|
||||
await copyButton.click()
|
||||
await page.getByRole('menuitem', { name: 'Copy Session as Markdown' }).click()
|
||||
const response = await markdownResponse
|
||||
expect(response.status()).toBe(200)
|
||||
expect(response.headers()['content-type']).toBe('text/markdown; charset=utf-8')
|
||||
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())
|
||||
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('<details><summary>3 previous messages</summary>')
|
||||
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 Session 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)
|
||||
@@ -561,7 +622,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-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md',
|
||||
'terminal-card.expected.md', 'timing.expected.md',
|
||||
|
||||
@@ -2892,7 +2892,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:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
|
||||
@@ -2894,7 +2894,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:48`](../packages/session-query/session-log-export/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Session-log archive policy. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -3509,6 +3509,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'',
|
||||
'client-ui-workspace RowActionToast id \'workspace.row-toast\'',
|
||||
'cua PermissionToast id \'cua-permission-outcome\'',
|
||||
'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}',
|
||||
|
||||
@@ -7471,6 +7471,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\'>;',
|
||||
@@ -7549,7 +7553,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',
|
||||
|
||||
+1
-1
@@ -387,7 +387,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
|
||||
|
||||
@@ -3,26 +3,32 @@
|
||||
# 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: ee7d348dd7fe684f
|
||||
zh: 6cd6d5d644176355
|
||||
/deepseek-ai-dsh-session-log-export/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-session-log-export/use-this-package:
|
||||
en: 1135b03eb3423c74
|
||||
zh: 2fd2a18d503df70c
|
||||
en: bb12bdeafa8571b5
|
||||
zh: e7df75c03478f7ed
|
||||
/deepseek-ai-dsh-session-log-export/use-this-package/copy-session-as-markdown:
|
||||
en: 55c163a4ae5b8d5d
|
||||
zh: c1dac154ed1fa56d
|
||||
/deepseek-ai-dsh-session-log-export/use-this-package/when-to-choose-it:
|
||||
en: 2b9dbec4877d332e
|
||||
zh: dcf6638938eedb01
|
||||
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: 2fef1fe4411cc5da
|
||||
zh: cff5dbcc4901402b
|
||||
en: 4168a2d3e653ab73
|
||||
zh: 8bf955cfab7b08e2
|
||||
/deepseek-ai-dsh-session-log-export/use-this-package/configuration:
|
||||
en: 377a0d8860c46f63
|
||||
zh: 9eb7698f813b2fa3
|
||||
@@ -33,38 +39,41 @@
|
||||
en: 44be1f1d1903d12a
|
||||
zh: c8f1eb5b92b71a30
|
||||
/deepseek-ai-dsh-session-log-export/use-this-package/failures:
|
||||
en: 3fc8985946d67024
|
||||
zh: 5bb40a4b531d68f4
|
||||
en: e92a382bebfe99e3
|
||||
zh: 671178b06808bfb9
|
||||
/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: d4e02b56ffb8ee8a
|
||||
zh: 0a329039788a13ee
|
||||
/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: 961c206403204080
|
||||
zh: c0cea2c965cc0b1b
|
||||
/deepseek-ai-dsh-session-log-export/known-limitations-and-deferred-work/dev-note:
|
||||
en: 4ee4d24c26c127bf
|
||||
zh: 1facdfbfa3ccefbc
|
||||
|
||||
@@ -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 Session as Markdown` and `Download session log`; an `/export` slash command also starts the ZIP download. The ZIP contains the session, its sub-sessions, and attachments. The package owns both authenticated Host routes, the Markdown transcript formatter, and the browser controls and feedback. The browser chooses the download destination and owns the clipboard. Setup and usage come first; implementation details follow.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,13 +25,34 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## 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-<id>.zip`.
|
||||
Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Copy Session as Markdown` or `Download session log` from the Session Header's more-actions menu, or type `/export`; the browser copies the transcript or downloads `dsh-session-<id>.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 Session as Markdown
|
||||
|
||||
`Copy Session as Markdown` copies the complete selected Session, including history that the client has not loaded, as portable Markdown. The transcript starts with the latest log-backed title. Human user messages are blockquotes in chronological order, final assistant replies remain ordinary Markdown, and earlier assistant commentary and tool activity in each turn are folded into a collapsible `<details>` section before that turn's final reply. A turn whose last committed assistant message carries tool calls has no final reply, so all of its activity stays folded. An incomplete turn renders the committed messages it has. Only append message events are exported. An unfinished assistant code fence is closed at the message boundary, and four-space or tab-indented code keeps its indentation.
|
||||
|
||||
The formatter reads the committed raw log, so a compaction surface replacement never removes earlier messages, and fork-inherited history appears once. The title and body come from one `readSession` snapshot. When persistence metadata reports a selected materialized artifact, the transcript adds a `Session file:` fenced text block with that exact path; without one, the block is absent. Only the selected Session and its inherited prefix are copied; descendant logs remain ZIP-only. Reasoning blocks, system and developer messages, injected user-role context whose source kind is not `user`, request headers and context, and runtime records are omitted. Images and files become readable labels; attachment bytes and authentication links never enter the transcript. Structural labels such as `Assistant`, `Tool`, and attachment placeholders are intentionally English; original message text stays unchanged. The text ends with exactly one newline.
|
||||
|
||||
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.
|
||||
|
||||
### 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, 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
|
||||
|
||||
@@ -40,7 +61,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
|
||||
|
||||
@@ -63,7 +84,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 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.
|
||||
|
||||
-----
|
||||
|
||||
@@ -77,7 +98,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 +108,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. During the click, the controller requests the deferred `ClipboardItem` write while it loads the document-relative `api/session.markdown?sessionId=<id>` 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.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -106,19 +133,19 @@ Read these pages when the package-level contract is not enough. They move from t
|
||||
<a id="model-experience"></a>
|
||||
## 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 +156,11 @@ 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.
|
||||
- **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.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -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,13 +25,34 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `下载 Session 日志` 或输入 `/export`;浏览器会下载 `dsh-session-<id>.zip`。
|
||||
当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `复制会话为 Markdown` 或 `下载 Session 日志`,或输入 `/export`;浏览器会复制记录或下载 `dsh-session-<id>.zip`。
|
||||
|
||||
挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;导出功能保持独立可用。
|
||||
### 复制会话为 Markdown
|
||||
|
||||
`复制会话为 Markdown` 会把选中的完整 Session 复制为可移植 Markdown,包括客户端尚未加载的历史。记录以最新的日志标题开头。人类用户消息按时间顺序作为引用块,最终的 assistant 回复保持普通 Markdown,每轮中较早的 assistant 说明与工具活动折叠到该轮最终回复之前的 `<details>` 区段。若一轮中最后一条已提交 assistant 消息带有工具调用,该轮没有最终回复,全部活动保持折叠。未完成的一轮渲染其已提交的消息。仅导出 append 消息事件。未完成的 assistant 代码围栏会在消息边界闭合,四空格或制表符缩进的代码保留缩进。
|
||||
|
||||
格式化器读取已提交的原始日志,因此压缩产生的表层替换不会移除较早消息,fork 继承的历史只出现一次。标题与正文来自同一个 `readSession` 快照。持久化元数据报告选中的已物化产物时,记录会在标题后添加一个 `Session file:` 围栏文本块,包含该精确路径;没有时省略该块。只复制选中的 Session 及其继承前缀;子会话日志仍只由 ZIP 导出。推理块、system 与 developer 消息、source kind 不是 `user` 的注入用户角色上下文、请求头与上下文以及运行时记录都会被省略。图片与文件变成可读标签;附件字节与认证链接不会进入记录。`Assistant`、`Tool` 和附件占位符等结构性标签有意使用英文;原始消息文本保持不变。文本以恰好一个换行结尾。
|
||||
|
||||
剪贴板是浏览器范围的唯一目标,因此本插件实例一次只运行一项复制。在支持 `ClipboardItem` 与 `navigator.clipboard.write` 的宿主上,控制器会在点击时请求延迟的 `ClipboardItem` 写入,同时加载文档相对的 `api/session.markdown?sessionId=<id>` 路由;其他宿主在准备完成后回退到共享剪贴板助手。复制进行中到达的请求会加入该操作,并保留第一次点击的 Session。所有 Session Header 通过 `aria-busy` 反映复制状态,并在操作结束前禁用各自的复制行。成功、准备失败或剪贴板失败通过根 overlay 浮层反馈。释放会中止读取,并阻止后续文本进入由 Promise 提供的 Blob;操作系统已经接受的剪贴板写入无法回滚。
|
||||
|
||||
挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;两个导出操作保持独立可用。
|
||||
|
||||
### 何时选择
|
||||
|
||||
为需要带可见下载弹窗的面向用户的会话导出的 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)
|
||||
}
|
||||
```
|
||||
|
||||
### 组合
|
||||
|
||||
@@ -40,7 +61,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 与命令界面。
|
||||
|
||||
### 配置
|
||||
|
||||
@@ -63,7 +84,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands`
|
||||
|
||||
### 失败
|
||||
|
||||
当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
||||
当 ZIP 流式传输开始前的预检失败时——例如 Host 端点不可达或配置错误——弹窗显示准备阶段错误。浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。Markdown 准备失败显示重试 Session 读取的浮层;剪贴板写入失败显示单独的检查剪贴板权限浮层。二者都不会为不完整或缺失的记录报告成功,也不会显示进行中的浮层。
|
||||
|
||||
-----
|
||||
|
||||
@@ -77,7 +98,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 +108,12 @@ Header 的更多操作入口使用公共紧凑 Button,点击区域为 28px 正
|
||||
|
||||
Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。
|
||||
|
||||
### Markdown 复制流程
|
||||
|
||||
Header 向 Markdown 复制控制器请求被点击的 Session。点击期间,控制器在加载文档相对的 `api/session.markdown?sessionId=<id>` 路由的同时请求延迟的 `ClipboardItem` 写入;其他宿主在准备完成后回退到共享剪贴板助手。Host 路由通过 `ctx.sessionQuery.readSession` 读取完整的 live-preferred 逻辑日志,并把事件交给纯格式化器。它另外通过 `ctx.get('sessionPersistence')?.stat` 获取选中已物化产物的路径,并把可选路径传给格式化器;元数据读取失败属于经过清理的准备失败。该读取是分离的导出观察:不创建 Agent、不保留 Session,也不写入 Session 数据。Session 不存在时返回 404,其他读取失败返回经过清理的 500。
|
||||
|
||||
控制器持有一个进行中的操作和一个 `shell.overlay` 浮层条目。第二个 Header 请求加入该进行中的操作,因此剪贴板及其提示始终描述第一次点击的 Session。准备 promise 与已接受的剪贴板写入都会被观察,所有拒绝都被包含。释放会中止读取、阻止后续文本进入由 Promise 提供的 Blob、抑制提示,并等待已接受的剪贴板工作完成。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
@@ -106,19 +133,19 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
### 用户 `/export` 控制
|
||||
### 用户导出与复制控制
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。
|
||||
无。`/export` 留在用户命令平面,ZIP 下载不会进入模型历史。Markdown 复制操作同样只属于人类:它读取已提交日志、写入浏览器剪贴板,且不追加 Session 事件。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
为零。该命令不创建模型轮次。
|
||||
为零。该命令不创建模型轮次,两个导出控件也不会改变请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无。仅日志命令生命周期与浏览器下载不会改变派生请求前缀。
|
||||
无。仅日志命令生命周期、浏览器下载与剪贴板写入不会改变派生请求前缀。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
@@ -129,6 +156,11 @@ Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用
|
||||
|
||||
- **浏览器下载,而非 Host 路径写入**——目标位置由浏览器选择;不会返回 Host 路径或原生文件夹操作。
|
||||
- **预检只报告流式传输前的失败**——浏览器接受 GET 后发生的子会话或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。
|
||||
- **Markdown 只包含已提交历史**——未稳定的实时 token 分块没有已提交文本,因此不会出现在复制的记录中。压缩与 fork 继承遵循原始日志,而非当前模型上下文。
|
||||
- **Markdown 复制仅限选中的 Session**——子会话日志由 ZIP 下载导出,而不是 Markdown 复制。
|
||||
- **记录文本缓冲在内存中**——完整的 Markdown 字符串及其 Blob 在剪贴板接受前都会保留。浏览器剪贴板限制可能拒绝大型导出;本包不增加字节上限,也不截断请求的完整记录。
|
||||
- **已接受的剪贴板写入无法回滚**——释放会停止待处理的准备与反馈,但操作系统已经成功的写入会留在剪贴板中。
|
||||
- **来源路径是观察到的元数据,不是原子日志声明**——`stat` 标识导出时选中的已物化产物。实时已提交事件可能领先于该文件,未物化的 Session 没有来源路径块。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -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:*",
|
||||
@@ -67,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": {
|
||||
@@ -75,6 +77,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"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
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, 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'
|
||||
|
||||
/** Copy state and dismissal bound into the root overlay entry. */
|
||||
export interface SessionMarkdownCopyToastInjected {
|
||||
hooks: { sessionMarkdownCopy: ObservableSnapshot<SessionMarkdownCopyState> }
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
/** Props of the root overlay copy notice. */
|
||||
export type SessionMarkdownCopyToastProps = PropsRuntime<'shell.overlay'>
|
||||
& PropsLocale<typeof NS>
|
||||
& InjectFace<SessionMarkdownCopyToastInjected>
|
||||
|
||||
/**
|
||||
* 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'
|
||||
? <Toast key={notice.seq} text={t('toast.copied')} tone="success" onDone={dismiss} />
|
||||
: (
|
||||
<Toast
|
||||
key={notice.seq}
|
||||
text={t(notice.kind === 'preparationFailed' ? 'toast.preparationFailed' : 'toast.clipboardFailed')}
|
||||
icon={<IconWarningOutlineRegular />}
|
||||
onDone={dismiss}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<boolean> }
|
||||
/** Markdown copy state and action bound into the Session Header contribution. */
|
||||
export interface SessionMarkdownCopyHeaderInjected {
|
||||
hooks: { sessionMarkdownCopy: ObservableSnapshot<SessionMarkdownCopyState> }
|
||||
/**
|
||||
* Copy one complete Session transcript to the clipboard.
|
||||
* @param sessionId - Session captured by the menu click.
|
||||
*/
|
||||
copyMarkdown: (sessionId: SessionId) => Promise<void>
|
||||
}
|
||||
|
||||
/** Session download controls plus Markdown copy and observable feedback availability. */
|
||||
export interface SessionLogDownloadHeaderInjected
|
||||
extends SessionLogDownloadDialogInjected, SessionMarkdownCopyHeaderInjected {
|
||||
hooks: SessionLogDownloadDialogInjected['hooks']
|
||||
& SessionMarkdownCopyHeaderInjected['hooks']
|
||||
& { feedbackAvailable: ObservableSnapshot<boolean> }
|
||||
/**
|
||||
* 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<SessionLogDownloadHeaderInjected>
|
||||
|
||||
/**
|
||||
* 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: <IconCopyOutlineRegular />, disabled: copying },
|
||||
{ id: 'download', label: t('menu.download'), icon: <IconDownloadOutlineRegular />, disabled: busy },
|
||||
...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: <IconPaperPlaneOutlineRegular /> }] : [],
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
if (id === 'feedback') openFeedback(sessionId)
|
||||
else if (id === 'copy') void copyMarkdown(sessionId)
|
||||
else void request(sessionId)
|
||||
}}
|
||||
anchor={(
|
||||
@@ -56,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) }}
|
||||
>
|
||||
<IconEllipsisOutlineRegular />
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Browser controller for the Session Header's "Copy Session as Markdown" action. One
|
||||
* plugin instance owns one pending copy: the clipboard is a browser-wide
|
||||
* destination, so a second request joins the pending operation instead of
|
||||
* racing it.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
|
||||
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, 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 {
|
||||
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
|
||||
}
|
||||
|
||||
/** 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<Blob>): Promise<void>
|
||||
/** Write prepared text on hosts without the deferred API; false means the browser rejected it. */
|
||||
writeText(text: string): Promise<boolean>
|
||||
}
|
||||
|
||||
/** HTTP carrier for the Markdown route. */
|
||||
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
|
||||
|
||||
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<void>): Promise<void> {
|
||||
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. */
|
||||
readonly store: SnapshotStore<SessionMarkdownCopyState> = createSnapshotStore(INITIAL)
|
||||
|
||||
private pending: { readonly abort: AbortController; readonly done: Promise<void> } | undefined
|
||||
private disposed = false
|
||||
private noticeSeq = 0
|
||||
|
||||
constructor(
|
||||
private readonly fetcher: Fetch = (input, init) => fetch(input, init),
|
||||
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 work settles, a failure notice is published, or a late post-disposal request is ignored.
|
||||
*/
|
||||
copy(sessionId: SessionId): Promise<void> {
|
||||
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 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<void> {
|
||||
this.disposed = true
|
||||
const pending = this.pending
|
||||
pending?.abort.abort()
|
||||
await pending?.done
|
||||
this.store.set(INITIAL)
|
||||
}
|
||||
|
||||
private run(sessionId: SessionId, signal: AbortSignal): Promise<void> {
|
||||
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<string> {
|
||||
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<string>,
|
||||
data: Promise<Blob>,
|
||||
write: Promise<void>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
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')
|
||||
} finally {
|
||||
this.store.update((state) => { state.copying = false })
|
||||
}
|
||||
}
|
||||
|
||||
private async settleFallback(preparation: Promise<string>): Promise<void> {
|
||||
try {
|
||||
let markdown: string
|
||||
try {
|
||||
markdown = await preparation
|
||||
} catch {
|
||||
if (!this.isCancelled()) this.publishNotice('preparationFailed')
|
||||
return
|
||||
}
|
||||
if (this.isCancelled()) return
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
private isCancelled(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
private publishNotice(kind: SessionMarkdownCopyNoticeKind): void {
|
||||
this.store.update((state) => {
|
||||
state.notice = { kind, seq: ++this.noticeSeq }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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, ja, 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, ja }), '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) },
|
||||
|
||||
@@ -4,8 +4,12 @@ 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.preparationFailed': '无法读取 Session 进行复制,请重试',
|
||||
'toast.clipboardFailed': '无法写入剪贴板,请检查剪贴板权限后重试',
|
||||
'dialog.preparingTitle': '正在导出 Session',
|
||||
'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。',
|
||||
'dialog.successTitle': 'Session 导出已开始下载',
|
||||
@@ -18,8 +22,12 @@ export const zh = {
|
||||
/** English Session export strings. */
|
||||
export const en: Record<keyof typeof zh, string> = {
|
||||
'header.more': 'More actions',
|
||||
'menu.copyMarkdown': 'Copy Session as Markdown',
|
||||
'menu.download': 'Download session log',
|
||||
'menu.feedback': 'Feedback',
|
||||
'toast.copied': 'Session copied as Markdown.',
|
||||
'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',
|
||||
@@ -35,8 +43,12 @@ export type SessionLogDownloadKey = keyof typeof zh
|
||||
/** Japanese copy, checked against the English keys. */
|
||||
export const ja = {
|
||||
'header.more': 'その他の操作',
|
||||
'menu.copyMarkdown': 'セッションを Markdown としてコピー',
|
||||
'menu.download': 'セッションログをダウンロード',
|
||||
'menu.feedback': 'フィードバック',
|
||||
'toast.copied': 'セッションを Markdown としてコピーしました',
|
||||
'toast.preparationFailed': 'コピーするセッションを読み取れませんでした。再試行してください。',
|
||||
'toast.clipboardFailed': 'クリップボードに書き込めませんでした。クリップボードの権限を確認して再試行してください。',
|
||||
'dialog.preparingTitle': 'セッションをエクスポート中',
|
||||
'dialog.preparingDescription': 'このセッション、そのサブセッション、添付ファイルを含むZIPを準備しています。',
|
||||
'dialog.successTitle': 'セッションのダウンロードを開始しました',
|
||||
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
@@ -36,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']
|
||||
@@ -71,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.
|
||||
*/
|
||||
@@ -99,12 +101,75 @@ 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 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 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.
|
||||
*/
|
||||
async function sessionMarkdownResponse(ctx: Context, request: Request): Promise<Response> {
|
||||
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<SessionId>(sessionIdValue)
|
||||
try {
|
||||
request.signal.throwIfAborted()
|
||||
const snapshot = await sessionQuery.readSession(sessionId)
|
||||
request.signal.throwIfAborted()
|
||||
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) {
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* Pure backend converter for one complete committed Session log. The Session
|
||||
* Markdown copy Host route uses it, and the package entry exports it for
|
||||
* callers that already hold the logical events. The raw log is authoritative:
|
||||
* compaction surface replacements never remove earlier messages, and
|
||||
* fork-inherited history appears once. Reasoning, system and developer
|
||||
* messages, injected non-human context, request headers, and runtime records
|
||||
* are omitted.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
AssistantMessage,
|
||||
SessionEvent,
|
||||
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. */
|
||||
const UNTITLED_SESSION_MARKDOWN_TITLE = 'Untitled Session'
|
||||
|
||||
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<string, ToolEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(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[] = []
|
||||
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'
|
||||
? closeUnfinishedFence(trimBlankEdgeLines(final.text))
|
||||
: ''
|
||||
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 (trimBlankEdgeLines(text) !== '') 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 `<details><summary>${summary}</summary>\n\n${body}\n\n</details>`
|
||||
}
|
||||
|
||||
function renderEntry(entry: TurnEntry): string {
|
||||
if (entry.kind === 'assistant') {
|
||||
const text = escapeWrapperTagsOutsideFences(
|
||||
closeUnfinishedFence(trimBlankEdgeLines(entry.text)),
|
||||
)
|
||||
return `**Assistant**\n\n${text}`
|
||||
}
|
||||
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 = '', 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))
|
||||
return `${fence}${language}\n${content}\n${fence}`
|
||||
}
|
||||
|
||||
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')
|
||||
const scanned: ScannedLine[] = []
|
||||
let fence: Fence | undefined
|
||||
let indented = false
|
||||
for (const line of lines) {
|
||||
if (fence !== undefined) {
|
||||
scanned.push({ text: line, code: true })
|
||||
if (closesFence(line, fence)) fence = undefined
|
||||
continue
|
||||
}
|
||||
const opening = openingFence(line)
|
||||
if (opening !== undefined) {
|
||||
fence = opening
|
||||
indented = false
|
||||
scanned.push({ text: line, code: true })
|
||||
continue
|
||||
}
|
||||
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 { 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 {
|
||||
return text.replace(/<(?=\/?(?:details|summary)(?=[\s/>]))/giu, '<')
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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,10 +23,16 @@ 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)
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -37,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)
|
||||
|
||||
@@ -54,7 +65,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 +74,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 = injectedOf(entry) 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 = injectedOf(overlay) as SessionMarkdownCopyToastInjected
|
||||
await injected.copyMarkdown(SID)
|
||||
expect(injected.hooks.sessionMarkdownCopy.getSnapshot().notice?.kind).toBe('preparationFailed')
|
||||
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 +119,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()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
SessionMarkdownCopyController,
|
||||
type SessionMarkdownClipboard,
|
||||
} 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()
|
||||
})
|
||||
|
||||
function fallbackClipboard(writeText: (text: string) => Promise<boolean>): SessionMarkdownClipboard {
|
||||
return {
|
||||
supportsDeferredWrite: () => false,
|
||||
writeDeferred: () => Promise.reject(new Error('deferred write must not start')),
|
||||
writeText,
|
||||
}
|
||||
}
|
||||
|
||||
describe('SessionMarkdownCopyController', () => {
|
||||
it('starts the deferred write synchronously and resolves its Blob promise later', async () => {
|
||||
const responses = Promise.withResolvers<Response>()
|
||||
let deferred: Promise<Blob> | undefined
|
||||
const writeDeferred = vi.fn((data: Promise<Blob>) => {
|
||||
deferred = data
|
||||
return Promise.resolve()
|
||||
})
|
||||
const controller = new SessionMarkdownCopyController(
|
||||
(_input: string | URL, _init?: RequestInit) => responses.promise,
|
||||
{
|
||||
supportsDeferredWrite: () => true,
|
||||
writeDeferred,
|
||||
writeText: vi.fn(async () => false),
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
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' },
|
||||
})
|
||||
})
|
||||
|
||||
it('joins a second-Session request to the pending copy and keeps its captured target', async () => {
|
||||
const responses = Promise.withResolvers<Response>()
|
||||
const fetcher = vi.fn((_input: string | URL, _init?: RequestInit) => responses.promise)
|
||||
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(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.markdown?sessionId=${SID}`)
|
||||
|
||||
responses.resolve(new Response('markdown for first'))
|
||||
await 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('falls back to writing prepared text only when deferred APIs are missing', async () => {
|
||||
const responses = Promise.withResolvers<Response>()
|
||||
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 }),
|
||||
{
|
||||
supportsDeferredWrite: () => true,
|
||||
writeDeferred: async () => {},
|
||||
writeText: vi.fn(async () => false),
|
||||
},
|
||||
)
|
||||
await http.copy(SID)
|
||||
expect(http.store.getSnapshot().notice?.kind).toBe('preparationFailed')
|
||||
|
||||
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('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'),
|
||||
fallbackClipboard(async () => false),
|
||||
)
|
||||
await rejected.copy(SID)
|
||||
expect(rejected.store.getSnapshot().notice?.kind).toBe('clipboardFailed')
|
||||
})
|
||||
|
||||
it('contains an immediate native denial followed by a later preparation rejection', async () => {
|
||||
const responses = Promise.withResolvers<Response>()
|
||||
const controller = new SessionMarkdownCopyController(
|
||||
(_input: string | URL, _init?: RequestInit) => responses.promise,
|
||||
{
|
||||
supportsDeferredWrite: () => true,
|
||||
writeDeferred: async () => { throw new Error('denied immediately') },
|
||||
writeText: vi.fn(async () => false),
|
||||
},
|
||||
)
|
||||
|
||||
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 the read and prevents text after disposal before readiness', async () => {
|
||||
const responses = Promise.withResolvers<Response>()
|
||||
let deferred: Promise<Blob> | 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()
|
||||
responses.resolve(new Response('late markdown'))
|
||||
await disposing
|
||||
await pending
|
||||
|
||||
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 })
|
||||
})
|
||||
|
||||
it('awaits admitted clipboard work and suppresses its notice on disposal', async () => {
|
||||
const writeGate = Promise.withResolvers<boolean>()
|
||||
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
|
||||
await pending
|
||||
|
||||
expect(controller.store.getSnapshot()).toEqual({ copying: false, notice: null })
|
||||
})
|
||||
|
||||
it('falls back to writing on a host without deferred clipboard APIs', async () => {
|
||||
const responses = Promise.withResolvers<Response>()
|
||||
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 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<Response>()
|
||||
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<boolean>()
|
||||
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<Response>()
|
||||
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('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<Response>()
|
||||
let deferred: Promise<Blob> | undefined
|
||||
class TestClipboardItem {
|
||||
readonly text: Promise<Blob>
|
||||
constructor(items: Record<string, Promise<Blob>>) {
|
||||
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)
|
||||
vi.stubGlobal('navigator', { clipboard: { 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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// @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 { 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<SessionMarkdownCopyState>) {
|
||||
return function useSessionMarkdownCopy<T>(selector: (state: SessionMarkdownCopyState) => T): T {
|
||||
return useSyncExternalStore(
|
||||
listener => store.subscribe(listener),
|
||||
() => selector(store.getSnapshot()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function bench(notice: SessionMarkdownCopyState['notice']) {
|
||||
const store = createSnapshotStore<SessionMarkdownCopyState>({ copying: false, notice })
|
||||
const dismiss = vi.fn()
|
||||
const dictionary: Record<string, string> = en
|
||||
const props: SessionMarkdownCopyToastProps = {
|
||||
...standard,
|
||||
useSessionMarkdownCopy: bindSnapshot(store),
|
||||
dismiss,
|
||||
t: key => dictionary[key] ?? key,
|
||||
}
|
||||
return { ...render(<SessionMarkdownCopyToast {...props} />), 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 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.',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders nothing while no notice shows', () => {
|
||||
const b = bench(null)
|
||||
expect(b.container.childElementCount).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,32 @@ 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, 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<string, string> = 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: { insertQuote: () => false, captureInsertion: unused, insertText: unused, setDraft: unused, persistDraft: unused,
|
||||
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused },
|
||||
}
|
||||
|
||||
function bindSnapshot<State>(store: ObservableSnapshot<State>) {
|
||||
return function useSnapshot<T>(selector: (state: State) => T): T {
|
||||
@@ -20,24 +39,31 @@ function bindSnapshot<State>(store: ObservableSnapshot<State>) {
|
||||
}
|
||||
}
|
||||
|
||||
function bench(feedbackAvailable = false) {
|
||||
function bench(feedbackAvailable = false, copyController = new SessionMarkdownCopyController(
|
||||
async () => new Response('markdown'),
|
||||
clipboard,
|
||||
)) {
|
||||
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 props = {
|
||||
sessionId: SID,
|
||||
const useSessionMarkdownCopy = bindSnapshot(copyController.store)
|
||||
const props: SessionLogDownloadHeaderProps = {
|
||||
...standard,
|
||||
useSessionLogDownload,
|
||||
useSessionMarkdownCopy,
|
||||
useFeedbackAvailable: bindSnapshot(feedback),
|
||||
openFeedback,
|
||||
request,
|
||||
copyMarkdown,
|
||||
dismiss,
|
||||
t: (key: keyof typeof en): string => en[key],
|
||||
} as unknown as SessionLogDownloadHeaderProps
|
||||
t: translate,
|
||||
}
|
||||
const view = render(<SessionLogDownloadHeaderAction {...props} />)
|
||||
return { controller, request, openFeedback, feedback, view }
|
||||
return { controller, copyController, request, copyMarkdown, openFeedback, feedback, view }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
@@ -56,6 +82,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 Session as Markdown' })).toBeTruthy()
|
||||
expect(b.view.getByRole('menuitem', { name: 'Download session log' })).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -77,6 +104,43 @@ 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 Session as Markdown',
|
||||
'Download session log',
|
||||
])
|
||||
fireEvent.click(b.view.getByRole('menuitem', { name: 'Copy Session as Markdown' }))
|
||||
await waitFor(() => { expect(b.copyMarkdown).toHaveBeenCalledWith(SID) })
|
||||
expect(b.request).not.toHaveBeenCalled()
|
||||
expect(b.view.queryByRole('menu')).toBeNull()
|
||||
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<Response>()
|
||||
const copyController = new SessionMarkdownCopyController(() => gate.promise, clipboard)
|
||||
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 Session as Markdown' })
|
||||
await waitFor(() => { expect((copyItem as HTMLButtonElement).disabled).toBe(true) })
|
||||
expect((b.view.getByRole('menuitem', { name: 'Download session log' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
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 () => {
|
||||
const b = bench()
|
||||
const button = b.view.getByRole('button', { name: 'More actions' })
|
||||
@@ -105,15 +169,18 @@ describe('Session export Header action', () => {
|
||||
const pending = new Promise<Response>((resolve) => { release = resolve })
|
||||
const controller = new SessionLogDownloadController(() => pending, vi.fn())
|
||||
const useSessionLogDownload = bindSnapshot(controller.store)
|
||||
b.view.rerender(<SessionLogDownloadHeaderAction {...({
|
||||
sessionId: SID,
|
||||
const rerendered: SessionLogDownloadHeaderProps = {
|
||||
...standard,
|
||||
useSessionLogDownload,
|
||||
useSessionMarkdownCopy: bindSnapshot(b.copyController.store),
|
||||
useFeedbackAvailable: bindSnapshot(b.feedback),
|
||||
openFeedback: b.openFeedback,
|
||||
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(<SessionLogDownloadHeaderAction {...rerendered} />)
|
||||
|
||||
const download = controller.download(SID)
|
||||
const button = b.view.getByRole('button', { name: 'More actions' })
|
||||
@@ -121,6 +188,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 Session as Markdown' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
expect((b.view.getByRole('menuitem', { name: 'Feedback' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
release(new Response('zip'))
|
||||
await download
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
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 { 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, onTestFinished, vi } from 'vitest'
|
||||
import { apply, inject } from '../src/index.ts'
|
||||
import { SESSION_MARKDOWN_EXPORT_PATH } from '../src/routes.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<SessionSeq>(1), time: 1, data: { turn: 1 } },
|
||||
{
|
||||
type: 'user/message',
|
||||
seq: brandNumber<SessionSeq>(2),
|
||||
time: 2,
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
role: 'user',
|
||||
id: brandString<MessageId>('message-1'),
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: 'Route question' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'session/title',
|
||||
seq: brandNumber<SessionSeq>(3),
|
||||
time: 3,
|
||||
data: { title: 'Route title from events', messageSeqs: [brandNumber<SessionSeq>(2)], source: { kind: 'fallback' } },
|
||||
},
|
||||
{
|
||||
type: 'assistant/message',
|
||||
seq: brandNumber<SessionSeq>(4),
|
||||
time: 4,
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: brandString<MessageId>('message-2'),
|
||||
source: { kind: 'model', provider: 'test', model: 'test' },
|
||||
content: [{ type: 'text', text: 'Route answer' }],
|
||||
},
|
||||
stream: [],
|
||||
},
|
||||
},
|
||||
{ type: 'turn/end', seq: brandNumber<SessionSeq>(5), time: 5, 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<unknown>
|
||||
} = {}): unknown {
|
||||
return {
|
||||
readSession: overrides.readSession
|
||||
?? (async () => ({ session: HEADER, inheritedEventCount: 0, events: EVENTS })),
|
||||
}
|
||||
}
|
||||
|
||||
async function mounted(
|
||||
sessionQuery: unknown,
|
||||
persistence?: Pick<SessionPersistence, 'stat'>,
|
||||
): Promise<{
|
||||
readonly shared: ReturnType<HostConnectionService['createSharedFetchHandler']>
|
||||
readonly connection: HostConnectionService
|
||||
readonly dispose: () => Promise<void>
|
||||
}> {
|
||||
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
|
||||
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 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(
|
||||
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('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)
|
||||
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('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(200)
|
||||
expect(await response.text()).toContain('# Route title from events')
|
||||
expect(readSession).toHaveBeenCalledOnce()
|
||||
expect(readTitle).not.toHaveBeenCalled()
|
||||
await dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,692 @@
|
||||
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'
|
||||
|
||||
const messageId = (value: string): MessageId => brandString<MessageId>(value)
|
||||
const callId = (value: string): ToolCallId => brandString<ToolCallId>(value)
|
||||
const attachmentId = (value: string): AttachmentId => brandString<AttachmentId>(value)
|
||||
const sequence = (value: number): SessionSeq => brandNumber<SessionSeq>(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 } }
|
||||
}
|
||||
|
||||
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('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 = renderWithTitle({
|
||||
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 = renderWithTitle({
|
||||
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',
|
||||
'',
|
||||
'<details><summary>2 previous messages</summary>',
|
||||
'',
|
||||
'**Assistant**',
|
||||
'',
|
||||
'I will inspect.',
|
||||
'',
|
||||
'**Tool: `bash`**',
|
||||
'',
|
||||
'```json',
|
||||
'{',
|
||||
' "command": "echo hi"',
|
||||
'}',
|
||||
'```',
|
||||
'',
|
||||
'**Result**',
|
||||
'',
|
||||
'```',
|
||||
'hi',
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'Done.',
|
||||
'',
|
||||
].join('\n'))
|
||||
})
|
||||
|
||||
it('keeps a mid-turn steering message after the activity that preceded it', () => {
|
||||
const markdown = renderWithTitle({
|
||||
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(/<details>/gu)).toHaveLength(2)
|
||||
expect(markdown.match(/<details><summary>2 previous messages<\/summary>/gu)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves authored details and code fences in the final reply', () => {
|
||||
const authored = [
|
||||
'Before',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>Authored</summary>',
|
||||
'',
|
||||
'```html',
|
||||
'<summary>code</summary>',
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'After',
|
||||
].join('\n')
|
||||
const markdown = renderWithTitle({
|
||||
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 = renderWithTitle({
|
||||
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(/<details>/gu)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits no empty assistant section and exactly one trailing newline', () => {
|
||||
const markdown = renderWithTitle({
|
||||
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('preserves four-space and tab indentation in final and commentary prose', () => {
|
||||
const markdown = renderWithTitle({
|
||||
title: 'Indented code',
|
||||
events: [
|
||||
start(1, 1),
|
||||
user(2, 'Show code'),
|
||||
assistant(3, [text(' <details>\n <summary>literal code</summary>')]),
|
||||
assistant(4, [text('\tif (ready) {\n\t run()\n\t}')]),
|
||||
end(5, 1),
|
||||
],
|
||||
})
|
||||
|
||||
expect(markdown).toContain('**Assistant**\n\n <details>\n <summary>literal code</summary>')
|
||||
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 = renderWithTitle({
|
||||
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</details>\n\nAfter backtick')
|
||||
|
||||
const tilde = renderWithTitle({
|
||||
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</details>\n\nAfter tilde')
|
||||
|
||||
const longer = renderWithTitle({
|
||||
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</details>\n\nAfter longer')
|
||||
})
|
||||
|
||||
it('does not treat an invalid backtick info string as an open fence', () => {
|
||||
const markdown = renderWithTitle({
|
||||
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</details>\n\nAfter invalid')
|
||||
})
|
||||
|
||||
it('closes an unfinished final fence before the next turn', () => {
|
||||
const markdown = renderWithTitle({
|
||||
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 = renderWithTitle({
|
||||
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 = renderWithTitle({
|
||||
title: 'Escaping',
|
||||
events: [
|
||||
start(1, 1),
|
||||
user(2, 'Run it'),
|
||||
assistant(3, [text('Before\n\n</details>\n\n```md\n</details>\n```\n\n```bad`info')]),
|
||||
toolCall(4, 'call-1', '</summary>', '{"value":"````"}'),
|
||||
toolResult(5, 'call-1', [text('`````\n</details>')]),
|
||||
assistant(6, [text('Finished')]),
|
||||
end(7, 1),
|
||||
],
|
||||
})
|
||||
|
||||
expect(markdown).toContain('</details>')
|
||||
expect(markdown).toContain('```md\n</details>\n```')
|
||||
expect(markdown).toContain('`````json')
|
||||
expect(markdown).toContain('``````')
|
||||
expect(markdown).toContain('**Tool: `</summary>`**')
|
||||
expect(markdown.endsWith('</details>\n\nFinished\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('labels attachments and omits reasoning, system prompts, and injected context', () => {
|
||||
const markdown = renderWithTitle({
|
||||
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(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 = renderWithTitle({
|
||||
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('<details><summary>1 previous message</summary>')
|
||||
expect(markdown).toContain('**Result (error)**')
|
||||
expect(markdown).toContain('Error: exit 1')
|
||||
expect(markdown).toContain('[Image: attachment (1×2, 10 bytes)]')
|
||||
expect(markdown).not.toContain('<details><summary>0 previous messages</summary>')
|
||||
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 = renderWithTitle({
|
||||
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 = renderWithTitle({
|
||||
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('<details><summary>1 previous message</summary>')
|
||||
})
|
||||
})
|
||||
@@ -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" },
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"files": [
|
||||
"src/archive.ts",
|
||||
"src/index.ts",
|
||||
"src/markdown.ts",
|
||||
"src/routes.ts"
|
||||
],
|
||||
"references": [
|
||||
@@ -19,6 +20,7 @@
|
||||
{ "path": "../../interaction/commands" },
|
||||
{ "path": "../../session/session-format" },
|
||||
{ "path": "../../session/session-persistence" },
|
||||
{ "path": "../../session/session-title" },
|
||||
{ "path": "../session-query" }
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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` 拒绝。
|
||||
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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}. */
|
||||
|
||||
Generated
+6
@@ -12947,6 +12947,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
|
||||
@@ -12974,6 +12977,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
|
||||
|
||||
@@ -1498,10 +1498,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
|
||||
@@ -1509,10 +1505,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
|
||||
|
||||
Reference in new issue
Block a user