fix(session-query): rename session log export package

This commit is contained in:
imccyu
2026-08-13 05:02:00 +08:00
parent 57abe62a83
commit 34dd480ae5
45 changed files with 66 additions and 66 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md
README.md: f3625338e85a38ab279c5fa4700a7a9e1124cfb3
README.zh.md: 5a2fa86d126f6bda808d3036bddc0e26fd310690

View File

@@ -0,0 +1,49 @@
# @deepseek-ai/dsh-session-log-export
English | [中文](README.zh.md)
Web Session-log download control over the host-streamed ZIP endpoint owned by `dsh-host-apiproxy`. The Host half registers `/export`; the browser half owns a 111×32 `Session log` action in the Session Header, one download controller, and one modal shared by that button and the slash command. ZIP generation, raw JSONL/zstd reads, descendants, attachments, backpressure, and HTTP error semantics remain owned by the [ApiProxy download implementation](../../host/apiproxy/README.md).
## Command contract
| Input | Result |
|---|---|
| `/export` | Record a human-command lifecycle; the submitting browser receives the local execution acknowledgment and downloads `GET /api/session.export?sessionId=<id>&includeDescendants=true`. |
| `/export <path>` | Return an error. Browser downloads choose their destination through the browser's ordinary download behavior. |
The command is mounted only by the Web bundle. The local `command/executed` acknowledgment triggers the slash download only after a successful `/export` result in the browser that submitted it; other tabs still render the durable command row without repeating the browser side effect. The Header button calls the same controller directly. Both entry paths issue a `HEAD` preflight, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript; they share in-flight collapsing, cancellation of the preflight on plugin disposal, preparation-error handling, browser save behavior, and the same Modal.
The Host download endpoint flushes a live root Session before `readRaw`, so a slash-triggered ZIP includes the `command/run` and `command/done` pair whose acknowledgment started the download. Cold persisted Sessions require no flush.
The modal reports preparation, download start, or failure. Closing it does not cancel an in-flight download and does not reopen it when that operation later settles. One Session admits one active download at a time; repeated gestures share that operation.
## Composition
```yaml
- id: session-log-download
name: '@deepseek-ai/dsh-session-log-export'
```
The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. The package contributes its button and modal to the right-aligned `conversation.session.header.utilities` list, independently of the title-adjacent mode, Subagent, and Task entries in `conversation.session.header.actions`; Trajectory carries no export control.
## Model Experience
### Human `/export` control
#### What the model sees
Nothing. `/export` stays on the human-command plane, and the ZIP download does not enter model history.
#### Token effect
Zero. The command creates no model turn.
#### KV Cache effect
None. The log-only command lifecycle and browser download do not change the derived request prefix.
## Known Limitations and Deferred Work
- The download endpoint requires a persistence backend with a per-Session raw artifact. The shipped JSONL backend supports plaintext and zstd artifacts; SQLite export is not included in this change.
- This is a browser download, not a Host-path writer. The browser chooses the local destination; no Host path or native folder action is returned.
- The preflight reports failures found before ZIP streaming starts. A descendant or attachment failure after the browser accepts the GET is reported by the browser download manager, not by the modal.

View File

@@ -0,0 +1,49 @@
# @deepseek-ai/dsh-session-log-export
[English](README.md) | 中文
Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流式 ZIP 端点。Host 半包注册 `/export`;浏览器半包在 Session Header 中提供 111×32 的 `Session log` 操作以及一个供该按钮与斜杠命令共用的下载控制器和弹窗。ZIP 生成、原始 JSONL/zstd 读取、子 Session、附件、背压和 HTTP 错误语义仍由 [ApiProxy 下载实现](../../host/apiproxy/README.md)负责。
## 命令约定
| 输入 | 结果 |
|---|---|
| `/export` | 记录一组用户命令生命周期;提交命令的浏览器收到本地执行确认后,下载 `GET /api/session.export?sessionId=<id>&includeDescendants=true`。 |
| `/export <path>` | 返回错误。浏览器下载通过浏览器的普通下载行为选择目标位置。 |
该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载其他标签页仍会渲染持久命令行但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器。两种入口都会先发出 `HEAD` 预检,再把 GET URL 交给浏览器下载管理器JavaScript 不会缓冲 ZIP它们共用并发折叠、插件释放时取消预检、准备阶段错误处理、浏览器保存行为和同一个 Modal。
Host 下载端点会在 `readRaw` 前 flush 活动的根 Session因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run``command/done` 事件对。冷持久化 Session 不需要 flush。
弹窗报告准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载;该操作随后完成时也不会重新打开弹窗。每个 Session 同时只允许一项下载,重复操作会共用该任务。
## 组合
```yaml
- id: session-log-download
name: '@deepseek-ai/dsh-session-log-export'
```
Web bundle 将本包与 `dsh-host-apiproxy``dsh-commands``dsh-client-ui-commands``dsh-client-ui-conversation` 一起挂载。本包把按钮和弹窗贡献到最右侧的 `conversation.session.header.utilities` 列表,与标题旁 `conversation.session.header.actions` 中的模式、Subagent 和 Task 配置项相互独立Trajectory 不包含导出入口。
## 模型体验
### 用户 `/export` 控制
#### 模型看到什么
无。`/export` 留在用户命令平面ZIP 下载不会进入模型历史。
#### Token 影响
为零。该命令不创建模型轮次。
#### KV Cache 影响
无。仅日志命令生命周期和浏览器下载不会改变派生请求前缀。
## 已知限制与暂缓事项
- 下载端点要求持久化后端具有逐 Session 原始工件。随附 JSONL 后端支持明文和 zstd 工件;本次改动不包含 SQLite 导出。
- 这是浏览器下载,不是 Host 路径写入。目标位置由浏览器选择,不会返回 Host 路径或原生文件夹操作。
- 预检只报告 ZIP 开始流式传输前发现的失败。浏览器接受 GET 后发生的子 Session 或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。

View File

@@ -0,0 +1,63 @@
{
"name": "@deepseek-ai/dsh-session-log-export",
"description": "Web Session-log export command and shared download dialog",
"version": "0.0.1-rc.3",
"publishConfig": { "access": "restricted" },
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/session-query/session-log-export"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" },
"./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" },
"./client": { "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" },
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"],
"scripts": { "bundle": "tsdown", "watch": "tsdown --watch" },
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
},
"dsh": {
"client": {
"inject": [
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-commands",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
}
}

View File

@@ -0,0 +1,49 @@
import type { ObservableSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionLogDownloadState } from './controller.ts'
import { NS } from './locales.ts'
/** Browser operations and state injected into the Session Header contribution. */
export interface SessionLogDownloadDialogInjected {
hooks: { sessionLogDownload: ObservableSnapshot<SessionLogDownloadState> }
request: (sessionId: SessionId) => Promise<void>
dismiss: (sessionId: SessionId) => void
}
export type SessionLogDownloadDialogProps =
PropsRuntime<'conversation.session.header.utilities'>
& PropsLocale<typeof NS>
& InjectFace<SessionLogDownloadDialogInjected>
/**
* Modal shared by the Session Header button and this browser's `/export` command.
* @param props - Session runtime, bound controller state, actions, and localized copy.
* @returns the modal portal contribution.
*/
export function SessionLogDownloadDialog({
sessionId, useSessionLogDownload, dismiss, t,
}: SessionLogDownloadDialogProps) {
const entry = useSessionLogDownload(state => state.bySession[String(sessionId)])
const status = entry?.status
const open = entry?.open === true
const error = status === 'error' ? entry?.error || t('dialog.commandFailed') : null
const title = status === 'downloading'
? t('dialog.preparingTitle')
: status === 'success' ? t('dialog.successTitle') : t('dialog.errorTitle')
const description = status === 'downloading'
? t('dialog.preparingDescription')
: status === 'success' ? t('dialog.successDescription') : error ?? t('dialog.commandFailed')
return (
<Modal
open={open}
onClose={() => { dismiss(sessionId) }}
title={title}
description={description}
closeLabel={t('dialog.close')}
footer={<Button variant="primary" onClick={() => { dismiss(sessionId) }}>{t('dialog.close')}</Button>}
/>
)
}

View File

@@ -0,0 +1,36 @@
.sessionLogButton {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 111px;
height: 32px;
padding: 6px 12px;
gap: 4px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 18px;
color: var(--dsw-alias-label-primary);
background: transparent;
font-family: var(--dsw-font-family);
font-size: 13px;
font-weight: 400;
line-height: 20px;
cursor: pointer;
}
.sessionLogButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.sessionLogButton:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: wait;
}
.sessionLogButton span,
.sessionLogButton svg {
flex: none;
}
.sessionLogButton span {
white-space: nowrap;
}

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from 'react'
import { IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx'
import css from './HeaderAction.module.css'
/**
* Render the Session Header export capsule and its shared result dialog.
* @param props - Session runtime, download controller, and localized dialog copy.
* @returns the persistent Header action and Session-scoped dialog.
*/
export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogProps): ReactNode {
const { sessionId, useSessionLogDownload, request } = props
const entry = useSessionLogDownload(state => state.bySession[String(sessionId)])
const busy = entry?.status === 'downloading'
return (
<>
<button
type="button"
className={css.sessionLogButton}
disabled={busy}
aria-busy={busy}
onClick={() => { void request(sessionId) }}
>
<span>Session log</span>
<IconDownloadOutline16 size={12} />
</button>
<SessionLogDownloadDialog {...props} />
</>
)
}

View File

@@ -0,0 +1,137 @@
/** Browser download state shared by the Session Header button and `/export`. */
import { createSnapshotStore, type SessionId, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
/** Download phases presented by the shared modal. */
export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error'
/** One Session's current download-dialog state. */
export interface SessionLogDownloadEntry {
readonly open: boolean
readonly status: SessionLogDownloadStatus
readonly error: string | null
}
/** Download states keyed by the Session whose Header owns the dialog. */
export interface SessionLogDownloadState {
bySession: Record<string, SessionLogDownloadEntry | undefined>
}
type Fetch = (input: string | URL, init?: RequestInit) => Promise<Response>
type Save = (url: string, filename: string) => void
const INITIAL: SessionLogDownloadState = { bySession: {} }
/**
* Collapse an untrusted Session id into the filename convention owned by the host endpoint.
* @param sessionId - Session whose archive is downloaded.
* @returns one safe browser download filename.
*/
export function sessionLogZipFilename(sessionId: SessionId): string {
return `dsh-session-${String(sessionId).replace(/[^A-Za-z0-9_-]/g, '_')}.zip`
}
/**
* Hand a Host download URL to the browser download manager.
* @param url - same-origin Host download URL.
* @param filename - browser download filename.
*/
export function downloadUrl(url: string, filename: string): void {
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.click()
}
/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */
function hostBase(): string {
const origin = (globalThis as { location?: { origin?: string } }).location?.origin
return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal'
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** Owns one in-flight browser download per Session and publishes modal state. */
export class SessionLogDownloadController {
/** uSES-safe state source shared by every Session-scoped modal contribution. */
readonly store: SnapshotStore<SessionLogDownloadState> = createSnapshotStore(INITIAL)
private readonly active = new Map<SessionId, { readonly abort: AbortController; readonly done: Promise<void> }>()
private disposed = false
/**
* @param fetcher - HTTP carrier used to read the host-streamed ZIP.
* @param save - browser save operation.
*/
constructor(
private readonly fetcher: Fetch = (input, init) => fetch(input, init),
private readonly save: Save = downloadUrl,
) {}
/**
* Download one Session tree; concurrent gestures for the same Session share one operation.
* @param sessionId - root Session whose ZIP includes descendants and attachments.
* @returns after the browser save starts, an error state is published, or a late post-disposal request is ignored.
*/
download(sessionId: SessionId): Promise<void> {
const existing = this.active.get(sessionId)
if (existing !== undefined) return existing.done
if (this.disposed) return Promise.resolve()
const abort = new AbortController()
const done = this.run(sessionId, abort.signal).finally(() => {
this.active.delete(sessionId)
})
this.active.set(sessionId, { abort, done })
return done
}
/**
* Close one Session's dialog without cancelling an in-flight browser download.
* @param sessionId - Session whose modal closes.
*/
dismiss(sessionId: SessionId): void {
const current = this.store.getSnapshot().bySession[String(sessionId)]
if (current === undefined || !current.open) return
this.publish(sessionId, { ...current, open: false })
}
/**
* Abort active fetches and reach quiescence.
* @returns after every active operation settles.
*/
async dispose(): Promise<void> {
this.disposed = true
const active = [...this.active.values()]
for (const operation of active) operation.abort.abort()
await Promise.allSettled(active.map(operation => operation.done))
}
private async run(sessionId: SessionId, signal: AbortSignal): Promise<void> {
this.publish(sessionId, { open: true, status: 'downloading', error: null })
try {
const url = new URL('/api/session.export', hostBase())
url.searchParams.set('sessionId', sessionId)
url.searchParams.set('includeDescendants', 'true')
const response = await this.fetcher(url, { method: 'HEAD', signal })
if (!response.ok) {
const detail = await response.text().catch(() => '')
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
}
this.save(url.toString(), sessionLogZipFilename(sessionId))
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
this.publish(sessionId, { open, status: 'success', error: null })
} catch (error: unknown) {
if (signal.aborted) return
const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true
this.publish(sessionId, { open, status: 'error', error: messageOf(error) })
}
}
private publish(sessionId: SessionId, entry: SessionLogDownloadEntry): void {
this.store.update((state) => {
state.bySession = { ...state.bySession, [String(sessionId)]: entry }
})
}
}

View File

@@ -0,0 +1,52 @@
/** Browser plugin owning Session export download state and its shared modal. */
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-commands/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SessionLogDownloadController } from './controller.ts'
import type { SessionLogDownloadDialogInjected } from './Dialog.tsx'
import { SessionLogDownloadHeaderAction } from './HeaderAction.tsx'
import { en, NS, zh, type SessionLogDownloadKey } from './locales.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
sessionLogDownload: SessionLogDownloadController
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
'session-log-download': SessionLogDownloadKey
}
}
export type { SessionLogDownloadEntry, SessionLogDownloadState } from './controller.ts'
export const inject = ['slots', 'locale']
/**
* Provide the download controller and mount its modal into the Session Header.
* @param ctx - browser context carrying slots and locale services.
*/
export function apply(ctx: ClientContext): void {
const controller = new SessionLogDownloadController()
ctx.provide('sessionLogDownload', controller)
ctx.effect(() => async () => { await controller.dispose() }, 'session-log-download: browser download lifecycle')
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-log-download: browser dictionaries')
ctx.on('command/executed', (sessionId, commandName, result) => {
if (commandName === 'export' && result.kind === 'success') void controller.download(sessionId)
})
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
name: 'conversation.session.header.utilities',
id: 'session-log-download',
locale: NS,
inject: (): SessionLogDownloadDialogInjected => ({
hooks: { sessionLogDownload: controller.store },
request: (sessionId: SessionId) => controller.download(sessionId),
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
}),
}, SessionLogDownloadHeaderAction))
}
export type { SessionLogDownloadDialogInjected, SessionLogDownloadDialogProps } from './Dialog.tsx'

View File

@@ -0,0 +1,27 @@
/** Locale namespace owned by Session export browser feedback. */
export const NS = 'session-log-download'
/** Simplified-Chinese Session export strings. */
export const zh = {
'dialog.preparingTitle': '正在导出 Session',
'dialog.preparingDescription': '正在准备包含当前 Session、子 Session 和附件的 ZIP 文件。',
'dialog.successTitle': 'Session 导出已开始下载',
'dialog.successDescription': '浏览器正在下载 Session ZIP 文件。',
'dialog.errorTitle': 'Session 导出失败',
'dialog.close': '关闭',
'dialog.commandFailed': '无法启动 Session 导出。',
} as const
/** English Session export strings. */
export const en: Record<keyof typeof zh, string> = {
'dialog.preparingTitle': 'Exporting Session',
'dialog.preparingDescription': 'Preparing a ZIP containing this Session, its sub-Sessions, and attachments.',
'dialog.successTitle': 'Session download started',
'dialog.successDescription': 'The browser is downloading the Session ZIP.',
'dialog.errorTitle': 'Session export failed',
'dialog.close': 'Close',
'dialog.commandFailed': 'Could not start the Session export.',
}
/** Stable locale keys consumed by the shared modal. */
export type SessionLogDownloadKey = keyof typeof zh

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,26 @@
/** Web Session-log download command over the host endpoint owned by ApiProxy. */
import type { Context } from '@deepseek-ai/cordis'
import type { CommandResult } from '@deepseek-ai/dsh-commands'
export const name = 'session-log-download'
export const inject = ['commands']
const REQUESTED: CommandResult = {
kind: 'success',
text: 'Session log download requested.',
}
/**
* Register the Web-only `/export` command that the browser download plugin observes.
* @param ctx - Host context carrying the human-command registry.
*/
export function apply(ctx: Context): void {
ctx.effect(() => ctx.commands.register({
name: 'export',
description: 'Download this Session log as a ZIP archive',
handler: invocation => Promise.resolve(invocation.rawInput.trim() === ''
? REQUESTED
: { kind: 'error', text: 'The Web /export command does not accept a path.' }),
}), 'session-log-download: command')
}

View File

@@ -0,0 +1,22 @@
/** Package invariant companion for `@deepseek-ai/dsh-session-log-export`. */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-log-export'
export const name = 'session-export-invariant'
export const inject = ['invariants']
/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Host context carrying the invariant registry.
* @returns the registration disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,86 @@
import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import { apply, inject } from '../src/client/index.ts'
const SID = 'session-export-apply' as SessionId
afterEach(() => { vi.unstubAllGlobals() })
function declare(slots: SlotRegistry): () => void {
return slots.register({
name: 'root',
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
},
} as never, () => null)
}
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
const declaration = declare(slots)
ctx.provide('locale', new LocaleRuntime(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
return { ctx, slots, declaration, fiber }
}
describe('session-log-download browser plugin', () => {
it('provides one controller and removes its Header contribution on disposal', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 500 })))
const b = await bench()
expect(inject).toEqual(['slots', 'locale'])
expect(b.ctx.sessionLogDownload).toBeDefined()
expect(b.slots.entries('conversation.session.header.actions')).toHaveLength(0)
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)()
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)
await b.fiber.dispose()
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
})
it('downloads only for an export execution acknowledged by this browser client', async () => {
const fetcher = vi.fn(async () => new Response('', { status: 500 }))
vi.stubGlobal('fetch', fetcher)
const first = await bench()
const second = await bench()
first.ctx.emit('command/executed', SID, 'plan', { kind: 'success' })
expect(fetcher).not.toHaveBeenCalled()
first.ctx.emit('command/executed', SID, 'export', { kind: 'error', text: 'bad path' })
expect(fetcher).not.toHaveBeenCalled()
first.ctx.emit('command/executed', SID, 'export', { kind: 'success' })
await vi.waitFor(() => {
expect(fetcher).toHaveBeenCalledOnce()
expect(first.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]?.status).toBe('error')
})
expect(second.ctx.sessionLogDownload.store.getSnapshot().bySession[SID]).toBeUndefined()
await first.fiber.dispose()
await second.fiber.dispose()
})
it('re-registers after the declaring Header slot collapses and returns', async () => {
const b = await bench()
b.declaration()
expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0)
const redeclare = declare(b.slots)
await Promise.resolve()
expect(b.slots.entries('conversation.session.header.utilities')[0]?.component).toBe(SessionLogDownloadHeaderAction)
redeclare()
await b.fiber.dispose()
})
})

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { CommandDefinition, CommandInvocation } from '@deepseek-ai/dsh-commands'
import * as SessionLogDownload from '../src/index.ts'
describe('/export Web download command', () => {
it('registers one pathless command and removes it with the plugin fiber', async () => {
let descriptor: CommandDefinition | undefined
const ctx = new Context()
ctx.provide('commands', {
register(next: CommandDefinition) {
descriptor = next
return () => { descriptor = undefined }
},
} as never)
const fiber = await ctx.plugin(SessionLogDownload)
expect(descriptor).toMatchObject({
name: 'export',
description: 'Download this Session log as a ZIP archive',
})
const invoke = (rawInput: string) => descriptor?.handler({ rawInput } as CommandInvocation)
await expect(invoke('')).resolves.toEqual({
kind: 'success', text: 'Session log download requested.',
})
await expect(invoke(' output.zip')).resolves.toEqual({
kind: 'error', text: 'The Web /export command does not accept a path.',
})
await fiber.dispose()
expect(descriptor).toBeUndefined()
})
})

View File

@@ -0,0 +1,146 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import {
downloadUrl, SessionLogDownloadController, sessionLogZipFilename,
} from '../src/client/controller.ts'
const SID = 'session-export-controller' as SessionId
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('SessionLogDownloadController', () => {
it('downloads the host ZIP and publishes one shared success state', async () => {
const fetcher = vi.fn(async () => new Response('zip', { status: 200 }))
const save = vi.fn()
const controller = new SessionLogDownloadController(fetcher, save)
await controller.download(SID)
expect(fetcher).toHaveBeenCalledOnce()
const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]
expect(url.pathname).toBe('/api/session.export')
expect(url.searchParams.get('sessionId')).toBe(SID)
expect(url.searchParams.get('includeDescendants')).toBe('true')
expect(init.method).toBe('HEAD')
expect(init.signal).toBeInstanceOf(AbortSignal)
expect(save).toHaveBeenCalledWith(
url.toString(),
'dsh-session-session-export-controller.zip',
)
expect(controller.store.getSnapshot().bySession[SID]).toEqual({
open: true, status: 'success', error: null,
})
})
it('collapses concurrent gestures and preserves a dismissed dialog', async () => {
const response = Promise.withResolvers<Response>()
const fetcher = vi.fn(() => response.promise)
const controller = new SessionLogDownloadController(fetcher, vi.fn())
const first = controller.download(SID)
const second = controller.download(SID)
expect(first).toBe(second)
controller.dismiss(SID)
response.resolve(new Response('zip', { status: 200 }))
await first
expect(fetcher).toHaveBeenCalledOnce()
expect(controller.store.getSnapshot().bySession[SID]?.open).toBe(false)
controller.dismiss(SID)
})
it('publishes HTTP and transport failures without leaking rejections', async () => {
const http = new SessionLogDownloadController(
async () => new Response('backend unavailable', { status: 500 }), vi.fn(),
)
await http.download(SID)
expect(http.store.getSnapshot().bySession[SID]).toEqual({
open: true,
status: 'error',
error: 'Export failed: HTTP 500 backend unavailable',
})
const transport = new SessionLogDownloadController(async () => { throw 'offline' }, vi.fn())
await transport.download(SID)
expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline')
transport.dismiss('absent' as SessionId)
const emptyDetail = new SessionLogDownloadController(
async () => ({
ok: false, status: 503, text: async () => { throw new Error('body unavailable') },
}) as unknown as Response,
vi.fn(),
)
await emptyDetail.download(SID)
expect(emptyDetail.store.getSnapshot().bySession[SID]?.error).toBe('Export failed: HTTP 503')
})
it('aborts active fetches on disposal and rejects later requests', async () => {
let signal: AbortSignal | undefined
const fetcher = vi.fn((_input: string | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
signal = init?.signal ?? undefined
signal?.addEventListener('abort', () => {
reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted'))
}, { once: true })
}))
const controller = new SessionLogDownloadController(fetcher, vi.fn())
const pending = controller.download(SID)
await controller.dispose()
await expect(pending).resolves.toBeUndefined()
expect(signal?.aborted).toBe(true)
await expect(controller.download(SID)).resolves.toBeUndefined()
await controller.dispose()
})
it('uses the null-origin fallback and default browser operations', async () => {
vi.stubGlobal('location', { origin: 'null' })
const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip'))
vi.stubGlobal('fetch', fetcher)
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const controller = new SessionLogDownloadController()
await controller.download(SID)
expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal')
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' })
expect(click).toHaveBeenCalledOnce()
})
it('defaults dialog openness when state is externally cleared before settlement', async () => {
const success = Promise.withResolvers<Response>()
const successful = new SessionLogDownloadController(() => success.promise, vi.fn())
const successRun = successful.download(SID)
successful.store.set({ bySession: {} })
success.resolve(new Response('zip'))
await successRun
expect(successful.store.getSnapshot().bySession[SID]?.open).toBe(true)
const failure = Promise.withResolvers<Response>()
const failing = new SessionLogDownloadController(() => failure.promise, vi.fn())
const failureRun = failing.download(SID)
failing.store.set({ bySession: {} })
failure.reject(new Error('failed after clear'))
await failureRun
expect(failing.store.getSnapshot().bySession[SID]?.open).toBe(true)
})
})
describe('browser download helpers', () => {
it('sanitizes the archive filename and hands the URL to a download anchor', () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip')
downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip')
expect(click).toHaveBeenCalledOnce()
const anchor = click.mock.instances[0] as HTMLAnchorElement
expect(anchor.href).toBe('http://host/api/session.export?sessionId=a')
expect(anchor.download).toBe('archive.zip')
})
})

View File

@@ -0,0 +1,76 @@
// @vitest-environment jsdom
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-client-runtime/client'
import { SessionLogDownloadController } from '../src/client/controller.ts'
import { SessionLogDownloadDialog } from '../src/client/Dialog.tsx'
import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx'
import { en } from '../src/client/locales.ts'
const SID = 'session-export-dialog' as SessionId
function bench(
controller = new SessionLogDownloadController(
async () => new Response('zip', { status: 200 }), vi.fn(),
),
) {
const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
function useSessionLogDownload<T>(selector: (state: ReturnType<typeof controller.store.getSnapshot>) => T): T {
return useSyncExternalStore(
listener => controller.store.subscribe(listener),
() => selector(controller.store.getSnapshot()),
)
}
const t = (key: keyof typeof en): string => en[key]
const props = { sessionId: SID, useSessionLogDownload, dismiss, t } as unknown as SessionLogDownloadDialogProps
const view = render(<SessionLogDownloadDialog {...props} />)
return { controller, dismiss, view }
}
afterEach(cleanup)
describe('SessionLogDownloadDialog', () => {
it('shows a controller failure and closes it without reading Session history', async () => {
const b = bench()
act(() => {
b.controller.store.set({
bySession: { [SID]: { open: true, status: 'error', error: 'toolbar failed' } },
})
})
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
expect(dialog.textContent).toContain('toolbar failed')
const close = b.view.getAllByRole('button', { name: 'Close' })[0]
if (close === undefined) throw new Error('Session export dialog has no close button')
fireEvent.click(close)
await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) })
})
it('renders the in-flight state and the settled browser download state', async () => {
let release!: (response: Response) => void
const pending = new Promise<Response>((resolve) => { release = resolve })
const controller = new SessionLogDownloadController(() => pending, vi.fn())
const b = bench(controller)
const download = controller.download(SID)
expect(await b.view.findByRole('dialog', { name: 'Exporting Session' })).toBeTruthy()
release(new Response('zip', { status: 200 }))
await download
expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
})
it('uses fallback copy when a failure has no detail', async () => {
const b = bench()
act(() => {
b.controller.store.set({
bySession: { [SID]: { open: true, status: 'error', error: '' } },
})
})
const dialog = await b.view.findByRole('dialog', { name: 'Session export failed' })
expect(dialog.textContent).toContain('Could not start the Session export.')
const close = b.view.getAllByRole('button', { name: 'Close' }).at(-1)
if (close === undefined) throw new Error('Session export dialog has no footer action')
fireEvent.click(close)
await waitFor(() => { expect(b.dismiss).toHaveBeenCalledWith(SID) })
})
})

View File

@@ -0,0 +1,72 @@
// @vitest-environment jsdom
import { 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-client-runtime/client'
import { SessionLogDownloadController } from '../src/client/controller.ts'
import { SessionLogDownloadHeaderAction } from '../src/client/HeaderAction.tsx'
import type { SessionLogDownloadDialogProps } from '../src/client/Dialog.tsx'
import { en } from '../src/client/locales.ts'
const SID = 'session-export-header' as SessionId
function bindSessionExport(controller: SessionLogDownloadController) {
return function useSessionLogDownload<T>(selector: (state: ReturnType<typeof controller.store.getSnapshot>) => T): T {
return useSyncExternalStore(
listener => controller.store.subscribe(listener),
() => selector(controller.store.getSnapshot()),
)
}
}
function bench() {
const controller = new SessionLogDownloadController(async () => new Response('zip'), vi.fn())
const request = vi.fn((sessionId: SessionId) => controller.download(sessionId))
const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) })
const useSessionLogDownload = bindSessionExport(controller)
const props = {
sessionId: SID,
useSessionLogDownload,
request,
dismiss,
t: (key: keyof typeof en): string => en[key],
} as unknown as SessionLogDownloadDialogProps
const view = render(<SessionLogDownloadHeaderAction {...props} />)
return { controller, request, view }
}
afterEach(cleanup)
describe('Session export Header action', () => {
it('renders the 111×32 text capsule and downloads through the shared controller', async () => {
const b = bench()
const button = b.view.getByRole('button', { name: 'Session log' })
expect(button.querySelector('svg')).not.toBeNull()
fireEvent.click(button)
await waitFor(() => { expect(b.request).toHaveBeenCalledWith(SID) })
expect(await b.view.findByRole('dialog', { name: 'Session download started' })).toBeTruthy()
})
it('disables the capsule while either entry path downloads this Session', async () => {
const b = bench()
let release!: (response: Response) => void
const pending = new Promise<Response>((resolve) => { release = resolve })
const controller = new SessionLogDownloadController(() => pending, vi.fn())
const useSessionLogDownload = bindSessionExport(controller)
b.view.rerender(<SessionLogDownloadHeaderAction {...({
sessionId: SID,
useSessionLogDownload,
request: (sessionId: SessionId) => controller.download(sessionId),
dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) },
t: (key: keyof typeof en): string => en[key],
} as unknown as SessionLogDownloadDialogProps)} />)
const download = controller.download(SID)
const button = b.view.getByRole('button', { name: 'Session log' })
await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('true') })
expect((button as HTMLButtonElement).disabled).toBe(true)
release(new Response('zip'))
await download
await waitFor(() => { expect(button.getAttribute('aria-busy')).toBe('false') })
})
})

View File

@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { apply, inject, name } from '../src/invariant.ts'
describe('@deepseek-ai/dsh-session-log-export/invariant', () => {
it('registers the package-owned empty companion', async () => {
const register = vi.fn(() => vi.fn())
const ctx = new Context()
ctx.provide('invariants', { register })
const dispose = await apply(ctx)
expect(name).toBe('session-export-invariant')
expect(inject).toEqual(['invariants'])
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-session-log-export', expect.any(Function))
dispose()
})
})

View File

@@ -0,0 +1,68 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import type { Agent } from '@deepseek-ai/dsh-agent'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import * as SessionLogDownload from '@deepseek-ai/dsh-session-log-export'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('session-log-download real Loader composition', () => {
it('discovers and executes /export through the assembled command plane', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-session-export-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-commands'",
"- name: '@deepseek-ai/dsh-session-log-export'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-commands', CommandRuntime],
['@deepseek-ai/dsh-session-log-export', SessionLogDownload],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
const session = (context.get('sessions') as unknown as SessionStore)
.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } })
const agent = { session, status: 'idle', options: {} } as unknown as Agent
expect(context.commands.list(agent)).toContainEqual({
name: 'export', description: 'Download this Session log as a ZIP archive',
})
const execution = await context.commands.execute(agent, '/export', new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'Session log download requested.' })
expect(session.events.map(event => event.type)).toEqual(['command/run', 'command/done'])
expect(session.deriveMessages()).toEqual([])
})
})

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../interaction/commands" },
{ "path": "../../client/locale" },
{ "path": "../../client/runtime" },
{ "path": "../../client/ui-commands" },
{ "path": "../../client/ui-conversation" },
{ "path": "../../client/ui-primitives" },
{ "path": "../../client/ui-slots" },
{ "path": "../../runtime-diagnostics/invariants" }
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-session-log-export', ['lib/types/index.js', 'lib/types/invariant.js'])