From e4ca03cb852bbd44119343cce83509691089ce66 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 12 Aug 2026 15:36:27 +0800 Subject: [PATCH 1/7] fix(web): surface max-tokens turn ends as a localized truncation notice A turn the provider ends at its per-request output cap previously produced no visible sign in the Web chat flow: only error-kind turn/end events built a conversation node, so the truncated answer read as a normal completion. Add a turn-max-tokens conversation node Definition over the durable turn/end event, a warning-styled localized notice row distinct from turn-error, a fixture max-tokens sample turn, and an assembled keyless snapshot pinning the dot state, title, and hint. Closes #1522 --- ...08-12-max-tokens-turn-end-notice.i18n.yaml | 6 ++ .../2026-08-12-max-tokens-turn-end-notice.md | 27 +++++++ ...026-08-12-max-tokens-turn-end-notice.zh.md | 27 +++++++ apps/web/tests/image-display.snapshot.ts | 2 +- apps/web/tests/max-tokens-notice.snapshot.ts | 56 +++++++++++++++ .../history-turn.expected.txt | 3 + apps/web/tests/todo-row.snapshot.ts | 2 +- .../client/connection/src/client/fixture.ts | 33 ++++++--- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 3 +- .../src/client/sessions/conversation.ts | 12 ++++ .../src/client/chat/MessageItem.module.css | 6 ++ .../src/client/chat/MessageItem.tsx | 20 ++++++ .../client/chat/register-node-renderers.ts | 4 +- .../chat-snapshot-builder.ts | 1 + .../src/client/conversation-nodes/register.ts | 2 + .../conversation-nodes/turn-max-tokens.ts | 68 ++++++++++++++++++ .../ui-conversation/src/client/locales.ts | 4 ++ .../tests/chat-view.client.spec.tsx | 19 ++++- ...nversation-node-definitions.client.spec.ts | 70 +++++++++++++++++++ 22 files changed, 356 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md create mode 100644 apps/web/tests/max-tokens-notice.snapshot.ts create mode 100644 apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt create mode 100644 packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml new file mode 100644 index 0000000000..efda56abab --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md +2026-08-12-max-tokens-turn-end-notice.md: bdb34d67a6d8271089af04f6bcd0f046ef7cdea6 +2026-08-12-max-tokens-turn-end-notice.zh.md: 9b9c679972570e791b607330195810d68f04c803 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md new file mode 100644 index 0000000000..bdb34d67a6 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.md @@ -0,0 +1,27 @@ +# Agent Note: The chat flow surfaces a max-tokens turn end + +Status: implemented + +English | [中文](2026-08-12-max-tokens-turn-end-notice.zh.md) + +## Problem + +The agent loop records `max-tokens` as its own `turn/end` reason, but no user surface consumed it. In the Web chat flow only `reason.kind === 'error'` built a conversation node, and the unknown-surface fallback claims append-surface events only, so a turn the provider cut at its output cap ended with no visible sign: the truncated answer read as a normal completion, and the user had no way to tell why the run stopped (issue #1522). + +## Decision + +A `turn-max-tokens` conversation node Definition matches `turn/end` with `reason.kind === 'max-tokens'` and materializes a persistent chat row at the turn position: a warning StateDot, a localized title, and guidance that the truncated output is preserved and sending "continue" resumes in a new turn. The node derives from the durable session event alone, so refresh, restore, and history replay rebuild it identically. It shows no token numbers: the event carries none, and the notice must not fabricate budget data the provider did not report. + +The renderer registers under the keyed `conversation.chat.node` seat like every chat row, and the legacy chat-snapshot contribution includes the node. The fixture history gained a max-tokens sample turn (72; the image and todo turns shifted to 73 and 74), and an assembled keyless snapshot pins the dot state, title, and hint, so a regression that routes max-tokens through the error presentation or silences it again changes a golden. + +## Alternatives considered + +**Extending `turn-error` with a max-tokens arm** — rejected: the acceptance for issue #1522 requires that max-tokens not read as a provider error; a shared node kind couples the two presentations, and the two reasons carry different data (an error payload versus nothing). + +**A turn-tail marker instead of a flow row** — rejected: the tail renders closing chrome for a finished turn and its actions collapse on later turns, while the truncation notice must stay at the turn that was cut and remain visible in history without interaction. + +**A continue or retry action button on the notice** — deferred: resuming has open semantics (new turn versus same-turn splice, old-output retention rules) that issue #1522 explicitly leaves out of scope; guidance text carries the safe next step without committing to an action contract. + +## Consequences + +Max-tokens turn ends are visible, localized, and distinct from both errors and normal completion across live streaming, reload, and replay. The fixture renumbering cost two comment updates in dependent snapshots, and anything pinning fixture turn numbers must count from the new layout. Surfaces other than the Web chat flow (ACP and SDK consumers) keep mapping the reason through their own presentations and are unchanged. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md new file mode 100644 index 0000000000..9b9c679972 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-12-max-tokens-turn-end-notice.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 聊天流展示 max-tokens 结束的轮次 + +Status: implemented + +[English](2026-08-12-max-tokens-turn-end-notice.md) | 中文 + +## Problem + +agent loop 已把 `max-tokens` 记录为独立的 `turn/end` 原因,但没有任何用户表面消费它。Web 聊天流中只有 `reason.kind === 'error'` 会生成会话节点,unknown-surface 兜底又只接管 append-surface 事件,于是被提供方在输出上限处截断的轮次没有任何可见迹象:被截断的回答看起来和正常完成一样,用户无从得知运行为何停止(issue #1522)。 + +## Decision + +新增 `turn-max-tokens` 会话节点 Definition,匹配 `reason.kind === 'max-tokens'` 的 `turn/end`,在该轮位置生成一条持久聊天行:warning 状态的 StateDot、本地化标题,以及说明已截断输出会保留、发送“继续”可在新一轮接着输出的指引。节点只从持久会话事件推导,因此刷新、恢复和历史回放会重建出完全一致的结果。提示不显示任何 token 数字:事件本身不携带数量,提示也不得伪造提供方未报告的预算数据。 + +渲染器与其他聊天行一样注册在按 kind 分发的 `conversation.chat.node` 槽位下,legacy chat-snapshot 投影也包含该节点。fixture 历史新增了一个 max-tokens 样本轮(72,图片轮和 todo 轮顺移为 73、74),并有一条 assembled keyless snapshot 钉住圆点状态、标题和指引文案,把 max-tokens 路由回错误样式或再次静默的回归都会改动 golden。 + +## Alternatives considered + +**在 `turn-error` 上加一个 max-tokens 分支** — 否决:issue #1522 的验收要求 max-tokens 不得呈现为普通 provider error;共用节点会耦合两种呈现,且两种原因携带的数据不同(一个有错误负载,一个没有)。 + +**用 turn-tail 标记代替独立聊天行** — 否决:turn-tail 渲染的是完成轮次的收尾信息,其操作会在后续轮次折叠,而截断提示必须停留在被截断的那一轮,并且在历史中无需交互即可看到。 + +**在提示上放继续或重试按钮** — 暂缓:恢复输出的语义尚未确定(新开一轮还是同轮续写、旧输出保留规则),issue #1522 明确把它排除在范围外;指引文字已给出安全的下一步,不必先固定一个操作契约。 + +## Consequences + +max-tokens 结束在实时流、刷新和回放中都可见、已本地化,并与错误和正常完成明确区分。fixture 重编号需要更新两处依赖 snapshot 的注释,之后钉 fixture 轮次号的改动要按新布局计数。Web 聊天流之外的表面(ACP 和 SDK 消费方)仍按各自的呈现映射该原因,本次不变。 diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts index 2d2dda42d6..71389fb908 100644 --- a/apps/web/tests/image-display.snapshot.ts +++ b/apps/web/tests/image-display.snapshot.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the code-mode-fixture // idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). -// Opens the fixture history session whose turn 72 carries an image in BOTH a +// Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized // sessions.attachment route, the single-click ImageLightbox, and the composer diff --git a/apps/web/tests/max-tokens-notice.snapshot.ts b/apps/web/tests/max-tokens-notice.snapshot.ts new file mode 100644 index 0000000000..45432e702b --- /dev/null +++ b/apps/web/tests/max-tokens-notice.snapshot.ts @@ -0,0 +1,56 @@ +// @vitest-environment jsdom +// Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/ +// client.js` bundles through AppWebEntry's ModuleLoader path against the +// keyless FixtureApiClient transport, opens the fixture session, and pins the +// surface its max-tokens turn (72) reaches — the turn-end notice row that a +// provider output-cap truncation must render instead of ending silently. +// +// The dot state is pinned beside the copy on purpose: `dot=warning` is what +// distinguishes this notice from the error row, so a regression that routes +// max-tokens through the turn-error presentation changes this file even when +// its own copy still renders. +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { hasClass, installAssembledBootEnv, mountAssembledApp, REFRESHING_GOLDEN } from './assembled-boot.ts' + +const EXPECTED = join(process.cwd(), 'apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt') + +installAssembledBootEnv() + +/** Normalize the notice row to stable fields: its dot state, title, and hint. */ +function noticeShape(row: Element): string { + const first = (name: string): string => + [...row.querySelectorAll('*')].filter(el => hasClass(el, name))[0]?.textContent?.trim() ?? '' + return [ + `dot=${row.querySelector('[data-state]')?.getAttribute('data-state') ?? ''}`, + `title=${first('maxTokensTitle')}`, + `hint=${first('turnErrorMessage')}`, + ].join('\n') +} + +describe('assembled max-tokens turn-end notice', () => { + it('renders the localized truncation notice after the cut-off answer instead of ending silently', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + fireEvent.click(await within(tree).findByText('Fixture 历史会话')) + // The truncated answer itself stays in the flow: the notice supplements the + // partial output, it never replaces it. + await screen.findByText(/条目 3:这一条写到一半被/, undefined, { timeout: 10_000 }) + const row = await waitFor(() => { + const found = [...document.querySelectorAll('[role="status"]')] + .find(candidate => [...candidate.querySelectorAll('*')].some(el => hasClass(el, 'maxTokensTitle'))) + expect(found).not.toBeUndefined() + return found! + }, { timeout: 10_000 }) + + const shape = noticeShape(row) + if (REFRESHING_GOLDEN) { + mkdirSync(dirname(EXPECTED), { recursive: true }) + writeFileSync(EXPECTED, shape) + } + await expect(shape).toMatchFileSnapshot(EXPECTED) + }) +}) diff --git a/apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt b/apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt new file mode 100644 index 0000000000..d6cc939648 --- /dev/null +++ b/apps/web/tests/snapshots/max-tokens-notice/history-turn.expected.txt @@ -0,0 +1,3 @@ +dot=warning +title=Output token limit reached +hint=The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume. \ No newline at end of file diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index 8186905f62..f39d1ba970 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 74, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 8085c7d323..a5c8480e1f 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -352,7 +352,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage { } } -/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), +/** fx-alpha history script: 75 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { const events: Record[] = [] @@ -488,7 +488,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 73: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 74: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -547,20 +547,35 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') - // Turn 72: user and assistant images share one durable fixture object. - // The todo turn remains last so its standing projection stays visible. + // Turn 72: max-tokens sample — the provider ends the turn at its output cap + // mid-sentence, so the chat flow must render the turn-max-tokens notice + // instead of ending silently. Ordered before the todo turn for the same + // standing-plan reason the bash turn is. push({ type: 'turn/start', data: { turn: 72 } }) + push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text('问题 72:请完整列出全部一百条条目。')) }) + push({ type: 'step/start', data: { turn: 72, step: 0 } }) + push({ + type: 'assistant/message', + surfaceOp: 'append', + data: { turn: 72, step: 0, message: assistantMessage(text('条目 1:第一条。条目 2:第二条。条目 3:这一条写到一半被')) }, + }) + push({ type: 'step/end', data: { turn: 72, step: 0 } }) + push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'max-tokens' } } }) + + // Turn 73: user and assistant images share one durable fixture object. + // The todo turn remains last so its standing projection stays visible. + push({ type: 'turn/start', data: { turn: 73 } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), }) - push({ type: 'step/start', data: { turn: 72, step: 0 } }) + push({ type: 'step/start', data: { turn: 73, step: 0 } }) push({ type: 'assistant/message', surfaceOp: 'append', data: { - turn: 72, + turn: 73, step: 0, message: assistantMessage( [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], @@ -568,11 +583,11 @@ function buildAlphaLog(): SessionEvent[] { ), }, }) - push({ type: 'step/end', data: { turn: 72, step: 0 } }) - push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } }) + push({ type: 'step/end', data: { turn: 73, step: 0 } }) + push({ type: 'turn/end', data: { turn: 73, reason: { kind: 'completed' } } }) const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(74, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index bd5ce42dfe..dbf10f1aa7 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/runtime/README.md -README.md: 44fd9b84e45c0a4d7f5846ce9ba040ef41b8b446 -README.zh.md: 7c5a70ef5d032fab8d3b75e84de6608b43f2e294 +README.md: d9dd7ce339172068e0229f321b4f87a3e17a133e +README.zh.md: a47104fc75f2f2adc94d36a1e849680ae19f9bab diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 44fd9b84e4..d9dd7ce339 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -68,6 +68,8 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error. +A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh, restore, and history replay. + ## Session forking `ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 7c5a70ef5d..a47104fc75 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -68,6 +68,8 @@ Trajectory Definition 组装出一条按时间顺序排列、以用途为判别 Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。 +reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,所以刷新、恢复和历史回放后结束原因保持一致。 + ## 会话 fork `ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 1b176254b9..7f6c06a2f4 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -77,7 +77,8 @@ export type { CommandNode, CompactionSummaryNode, ComposerPhase, ContextMessageNode, ConversationNode, ConversationSnapshot, ModelRetryNode, QueuedMessage, LegacyConversationSlice, PartialAssistant, RunningToolCall, - SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, UnknownSurfaceNode, UserMessageNode, + SteeringMessageNode, TodoItem, ToolCallBlock, ToolResultNode, TurnErrorNode, TurnMaxTokensNode, + UnknownSurfaceNode, UserMessageNode, } from './sessions/conversation.ts' export { EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, toAssistantBlock, toAssistantBlocks, diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index f39156baf2..300b2985ed 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -163,6 +163,17 @@ export interface TurnErrorNode { code?: string } +/** Durable notice for a turn ended by the per-request output-token cap. */ +export interface TurnMaxTokensNode { + kind: 'turn-max-tokens' + /** Seq of the owning turn/end event. */ + seq: number + /** Unix epoch ms from the turn/end event. */ + time: number + turn: number + step: number +} + /** A tool result paired (when in-window) with its call head. */ export interface ToolResultNode { kind: 'tool-result' @@ -268,6 +279,7 @@ export type ConversationNode = | ContextMessageNode | ModelRetryNode | TurnErrorNode + | TurnMaxTokensNode | ToolResultNode | CommandNode | CompactionSummaryNode diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index cc1ded9822..36824d228d 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -244,6 +244,12 @@ font: var(--dsw-font-markdown-code-block-small); } +.maxTokensTitle { + margin-right: 6px; + color: var(--dsw-alias-state-warn-primary); + font-weight: 600; +} + @keyframes retry-shimmer { from { background-position: 100% 50%; diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index d2bfe8868f..00b5110d68 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -130,6 +130,21 @@ function TurnErrorItem({ node, t }: { ) } +/** Persistent, turn-positioned notice for a turn ended at the output-token cap. */ +function TurnMaxTokensItem({ t }: { + t: ChatViewSlotProps['t'] +}) { + return ( +
+ +
+ {t('message.maxTokens')} + {t('message.maxTokens.hint')} +
+
+ ) +} + /** * Display projection of reference forms in a user bubble (free geometry — no * textarea alignment constraint here); everything else stays plain text. The @@ -272,6 +287,11 @@ export const TurnErrorNodeView = memo(function TurnErrorNodeView({ node, t }: Ch return }) +/** Max-tokens turn-end notice keyed Chat renderer. */ +export const TurnMaxTokensNodeView = memo(function TurnMaxTokensNodeView({ t }: ChatNodeViewProps<'turn-max-tokens'>) { + return +}) + /** Explicit unknown-surface keyed Chat renderer. */ export const UnknownNodeView = memo(function UnknownNodeView({ node, t }: ChatNodeViewProps<'unknown'>) { const data = node.data diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts index 78aa36d136..20d42a9a82 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -4,7 +4,7 @@ import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, - UnknownNodeView, UserMessageNodeView, + TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from './MessageItem.tsx' import { TurnTailNodeView } from './TurnTailNodeView.tsx' @@ -35,6 +35,8 @@ export function registerChatNodeRenderers(ctx: Context): void { { name: 'conversation.chat.node', key: 'model-retry', locale: NS }, RetryNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( { name: 'conversation.chat.node', key: 'turn-error', locale: NS }, TurnErrorNodeView)) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register( + { name: 'conversation.chat.node', key: 'turn-max-tokens', locale: NS }, TurnMaxTokensNodeView)) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'turn-tail', diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index 8b5a506030..be4e27111a 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -165,6 +165,7 @@ function legacyContribution(raw: ChatConversationViewNode): LegacyContribution { case 'command': case 'compaction': case 'turn-error': + case 'turn-max-tokens': case 'unknown': return { anchorSeq: node.anchorSeq, nodes: [node.data], partial: null, running: null } case 'assistant-step': { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts index bc911ad5db..5086253e81 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -9,6 +9,7 @@ import { registerMessageConversationNode } from './message.ts' import { registerRetryConversationNode } from './retry.ts' import { registerToolConversationNode } from './tool.ts' import { registerTurnErrorConversationNode } from './turn-error.ts' +import { registerTurnMaxTokensConversationNode } from './turn-max-tokens.ts' import { registerTurnTailConversationNode } from './turn-tail.ts' /** @@ -24,6 +25,7 @@ export function registerConversationNodes(ctx: Context): void { registerCompactionConversationNode(ctx) registerRetryConversationNode(ctx) registerTurnErrorConversationNode(ctx) + registerTurnMaxTokensConversationNode(ctx) registerTurnTailConversationNode(ctx) registerUnknownConversationFallback(ctx) registerChatConversationView(ctx) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts new file mode 100644 index 0000000000..4b1cabf7d1 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts @@ -0,0 +1,68 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { chatNode } from './common.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ChatNodeDataMap { + /** Turn ended by the per-request output-token cap. */ + 'turn-max-tokens': TurnMaxTokensNode + } +} + +interface TurnMaxTokensState { + readonly turn: number + readonly seq: number + readonly time: number +} + +function lastStep(context: ConversationNodeContext): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return 0 + return location.turn.steps.at(-1)?.step ?? 0 +} + +function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined { + if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined + return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time } +} + +/** Notice Definition for a turn the provider ended at its output-token cap. */ +export const turnMaxTokensDefinition: ConversationNodeDefinition = { + kind: 'turn-max-tokens', + target: 'chat', + match: (event) => { + if (event.type === 'turn/end' && event.data.reason.kind === 'max-tokens') { + return { id: String(event.data.turn), role: 'start' } + } + return null + }, + start: (_context, match) => { + const state = stateFrom(match) + if (state === undefined) throw new Error('turn-max-tokens start requires a max-tokens turn/end') + return state + }, + update: context => context.state, + buildViewNode: (context) => { + const state = context.state + ?? context.matches.map(stateFrom).find(candidate => candidate !== undefined) + if (state === undefined) return null + const node: TurnMaxTokensNode = { + kind: 'turn-max-tokens', + seq: state.seq, + time: state.time, + turn: state.turn, + step: lastStep(context), + } + return chatNode(context, 'turn-max-tokens', node.seq, node) + }, +} + +/** + * Register the max-tokens turn-end notice contribution. + * @param ctx - owning UI Conversation context. + */ +export function registerTurnMaxTokensConversationNode(ctx: Context): void { + ctx.conversationEvents.register(turnMaxTokensDefinition) +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 080bb9b67b..be526787b0 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -114,6 +114,8 @@ export const zh = { 'message.retry.delay': '重试延迟:', 'message.retry.failure': '失败原因:', 'message.turnError': '本轮运行失败', + 'message.maxTokens': '已达到输出 token 上限', + 'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', 'message.ranFor': '用时 {duration}', 'message.ttft': '首 token {seconds}秒', 'message.tokensPerSecond': '{tps} tok/s', @@ -273,6 +275,8 @@ export const en = { 'message.retry.delay': 'Retry delay: ', 'message.retry.failure': 'Failure reason: ', 'message.turnError': 'This turn failed', + 'message.maxTokens': 'Output token limit reached', + 'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.', 'message.ranFor': 'Ran for {duration}', 'message.ttft': 'TTFT {seconds}s', 'message.tokensPerSecond': '{tps} tok/s', diff --git a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx index 1dad50cb68..bbb73610c0 100644 --- a/packages/client/ui-conversation/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.client.spec.tsx @@ -9,7 +9,7 @@ import { useEffect } from 'react' import type { AssistantMessageNode, CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolCallBlock, ToolResultNode, TurnErrorNode, - UserMessageNode, WorkspaceListState, + TurnMaxTokensNode, UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { @@ -28,7 +28,7 @@ import { AssistantNodeView } from '../src/client/chat/AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from '../src/client/chat/CommandNodeView.tsx' import { CompactionNodeView, ContextMessageNodeView, RetryNodeView, TurnErrorNodeView, - UnknownNodeView, UserMessageNodeView, + TurnMaxTokensNodeView, UnknownNodeView, UserMessageNodeView, } from '../src/client/chat/MessageItem.tsx' import { TurnTailNodeView } from '../src/client/chat/TurnTailNodeView.tsx' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' @@ -108,6 +108,9 @@ const turnError = (seq: number, code?: string): TurnErrorNode => ({ message: seq === 2 ? 'API key is invalid' : 'plugin exploded', ...(code === undefined ? {} : { code }), }) +const turnMaxTokens = (seq: number): TurnMaxTokensNode => ({ + kind: 'turn-max-tokens', seq, time: seq * 1_000, turn: 1, step: 0, +}) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, @@ -217,6 +220,8 @@ function makeHarness(init?: Partial) { return ()} /> case 'turn-error': return ()} /> + case 'turn-max-tokens': + return ()} /> case 'turn-tail': return ( { ]) }) + it('renders the max-tokens notice with localized guidance, distinct from turn errors', () => { + const h = makeHarness({ nodes: [user(1, 'try'), assistant(2, 'truncated'), turnMaxTokens(3)] }) + const view = render() + const statuses = view.getAllByRole('status') + expect(statuses.map(status => status.textContent)).toEqual([ + '已达到输出 token 上限回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。', + ]) + expect(view.queryByText('本轮运行失败')).toBeNull() + }) + it('hands the trajectory callback to the Tool seat', () => { const h = makeHarness({ nodes: [toolResult(3, 'a')], diff --git a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts index db9c0ccdd4..1c5f750763 100644 --- a/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-node-definitions.client.spec.ts @@ -14,6 +14,7 @@ import { messageDefinition } from '../src/client/conversation-nodes/message.ts' import { retryDefinition } from '../src/client/conversation-nodes/retry.ts' import { toolDefinition } from '../src/client/conversation-nodes/tool.ts' import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts' +import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts' import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts' import type { AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData, @@ -29,6 +30,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [ compactionDefinition, retryDefinition, turnErrorDefinition, + turnMaxTokensDefinition, turnTailDefinition, ] @@ -812,6 +814,74 @@ describe('built-in conversation node Definitions', () => { expect(node(snapshot(value), 'turn-error')).toBeUndefined() }) + it('materializes a max-tokens notice and keeps completed and error turns clean', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/message', { + turn: 1, step: 1, message: assistantMessage('a1', 'truncated answer'), + }, { surfaceOp: 'append' }), + at(4, 'step/end', { turn: 1, step: 1 }), + at(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } }), + ]) + const notice = node(snapshot(value), 'turn-max-tokens') + expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 }) + expect(node(snapshot(value), 'turn-error')).toBeUndefined() + + const completed = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + ]) + expect(node(snapshot(completed), 'turn-max-tokens')).toBeUndefined() + + const failed = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'TRANSPORT', message: 'failed' } }, + }), + ]) + expect(node(snapshot(failed), 'turn-max-tokens')).toBeUndefined() + expect(node(snapshot(failed), 'turn-error')).toBeDefined() + }) + + it('keeps the max-tokens notice when the window starts after the owning turn/start', () => { + const value = assembler([ + at(9, 'turn/end', { turn: 3, reason: { kind: 'max-tokens' } }), + ], true) + const notice = node(snapshot(value), 'turn-max-tokens') + expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 9, turn: 3 }) + }) + + it('pins the max-tokens Definition edges the engine cannot reach', () => { + // The engine only hands start the single matched turn/end and never emits + // update Matches for this kind; these direct calls pin the declared + // behavior of both required Definition members anyway. + const match = (seq: number, type: string, data: unknown) => ({ + event: { seq, time: seq * 1_000, type, data }, + view: undefined, + role: 'start', + location: undefined, + }) as unknown as Parameters[1] + const context = (state: unknown, matches: unknown[] = []) => ({ + key: 'k', kind: 'turn-max-tokens', id: '1', matches, start: undefined, state, current: new Map(), + }) as unknown as Parameters>[0] + const reader = { previous: () => undefined } + + expect(() => turnMaxTokensDefinition.start(context(undefined), match(1, 'turn/start', { turn: 1 }), reader)) + .toThrow('turn-max-tokens start requires a max-tokens turn/end') + const state = { turn: 1, seq: 5, time: 5_000 } + expect(turnMaxTokensDefinition.update( + context(state) as Parameters[0], + match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), + )).toBe(state) + expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull() + expect(turnMaxTokensDefinition.buildViewNode?.(context( + undefined, + [match(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } })], + ))).toMatchObject({ kind: 'turn-max-tokens' }) + }) + it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => { const value = assembler([ at(12, 'tool/code-dispatch-start', { From 941449d92c55f6e366046588a727b4ed6e275016 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 12 Aug 2026 16:04:03 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(web):=20apply=20review=20round=20?= =?UTF-8?q?=E2=80=94=20notice=20anchors=20before=20turn-tail,=20published?= =?UTF-8?q?=20node=20kind,=20fixture=20nextTurn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anchor the max-tokens notice between the closing assistant and the turn-tail so the tail stays the turn's last chat node and its branch action survives; forward the ChatNodeDataMap augmentation through the client entry so built declarations publish the new kind; advance the fixture's nextTurn past the appended history; drop the engine-unreachable buildViewNode fallback; deduplicate the runtime README tail sentence. --- .../client/connection/src/client/fixture.ts | 2 +- packages/client/runtime/README.i18n.yaml | 4 ++-- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../src/client/conversation-nodes/common.ts | 5 ++++- .../conversation-nodes/turn-max-tokens.ts | 20 ++++++++++++++++--- .../ui-conversation/src/client/index.ts | 1 + ...nversation-node-definitions.client.spec.ts | 9 +++++---- 8 files changed, 32 insertions(+), 13 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a5c8480e1f..431ba0c1b9 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1447,7 +1447,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], ]) let fixtureDefaultPreset = 'standard' - const nextTurn = new Map([[sid('fx-alpha'), 74]]) + const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index dbf10f1aa7..a15becc6a0 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/runtime/README.md -README.md: d9dd7ce339172068e0229f321b4f87a3e17a133e -README.zh.md: a47104fc75f2f2adc94d36a1e849680ae19f9bab +README.md: 441f5462d98c8a9e92ebc9f08cdd7568e89fd427 +README.zh.md: 9e31d51410639cdd314ddd37c2c6287f26f8c484 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index d9dd7ce339..441f5462d9 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -68,7 +68,7 @@ Every `ToolCallBlock` recursively owns its children through `subCalls`, in start The Host-owned LLM retry invariant validates provider-routed `llm/retry` and `llm/retry-started` records at the durable append boundary, including their identity, ordering, timer, integer, status, provider-delay, and non-empty diagnostic contracts. In the client, the Retry, Assistant, and Turn Error Definitions fold those records with Assistant and Turn/Step events: a failed step's streaming partial is removed and a durable retry notice appears at the retry event's sequence position. The notice is `scheduled` until the matching started record arrives; closing its owning Step or Turn first marks it `cancelled`, while the started record marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. A terminal `turn/end` error without a retry projects one `turn-error` node from its durable message and optional code; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. A retried failure keeps only the retry notice for that attempt. Window rebuild and history replay use the same Definitions, so refresh neither resurrects discarded chunks nor loses terminal failure feedback. Visible unfinalized output is frozen as an interrupted Assistant node beside the terminal error. -A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh, restore, and history replay. +A `turn/end` whose reason is `max-tokens` projects one `turn-max-tokens` node at the turn position: a warning-styled localized notice that the reply stopped at the per-request output cap, with the truncated output kept in the flow and guidance that sending "continue" resumes in a new turn. The notice carries no token counts because the event reports none. The same Definition rebuilds it on window rebuild and history replay, so the reason survives refresh and restore. ## Session forking diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index a47104fc75..9e31d51410 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -68,7 +68,7 @@ Trajectory Definition 组装出一条按时间顺序排列、以用途为判别 Host 所属的 LLM retry invariant 会在持久追加边界验证按提供方路由的 `llm/retry` 与 `llm/retry-started` 记录,包括标识、顺序、计时器、整数、状态、提供方延迟和非空诊断字段约定。客户端的 Retry、Assistant 与 Turn Error Definition 把这些记录和 Assistant、Turn/Step 事件一起折叠:失败步骤的流式输出片段会被移除,并在 retry 事件的序列位置插入一条持久重试提示。该提示在匹配的 started 记录到达前为 `scheduled`;如果所属 Step 或 Turn 先关闭,则标记为 `cancelled`,started 记录到达后则标记为 `started`。normal mode 提示携带其有限上限;always mode 提示保持显式无界。没有重试的终态 `turn/end` 错误会从持久消息与可选错误码投影出一个 `turn-error` 节点;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。进入重试的失败只保留该次尝试的重试提示。窗口重建与历史回放使用同一组 Definition,因此刷新既不会让已丢弃的分片重新出现,也不会丢失终态失败反馈。可见但尚未定稿的输出会在终态错误旁冻结为中断的 Assistant 节点。 -reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,所以刷新、恢复和历史回放后结束原因保持一致。 +reason 为 `max-tokens` 的 `turn/end` 会在该轮位置投影出一个 `turn-max-tokens` 节点:一条 warning 样式的本地化提示,说明回答在单次请求的输出 token 上限处停止,已截断的输出保留在对话流中,并提示发送“继续”可在新一轮接着输出。事件本身不携带 token 数量,提示因此不显示任何数字。窗口重建与历史回放使用同一 Definition 重建该节点,刷新和恢复后结束原因保持一致。 ## 会话 fork diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts index b6bd01930b..1e2693bfff 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/common.ts @@ -7,11 +7,14 @@ import type { /** * Relative positions in one durable event's seq neighborhood: interrupted - * Assistant, its follow-up Nodes, then follow-ups to an ordinary final. + * Assistant, its follow-up Nodes, then follow-ups to an ordinary final. The + * max-tokens notice sits between a closing Assistant and the turn-tail so the + * tail stays the turn's last node and keeps its branch action enabled. */ export const CHAT_SYNTHETIC_SEQ_OFFSETS = { interruptedAssistant: -0.9, interruptedFollowup: -0.8, + maxTokensNotice: 0.05, finalizedFollowup: 0.1, } as const diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts index 4b1cabf7d1..baf83a1b3c 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-max-tokens.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnMaxTokensNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { chatNode } from './common.ts' +import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts' declare module '@deepseek-ai/dsh-client-ui-conversation/client' { interface ChatNodeDataMap { @@ -23,6 +23,21 @@ function lastStep(context: ConversationNodeContext): number return location.turn.steps.at(-1)?.step ?? 0 } +/** + * Anchor the notice between the closing Assistant and the turn-tail so the + * tail stays the turn's last Chat node and keeps its branch action enabled. + * Without a closing text Assistant there is no branch action to protect, and + * the turn/end seq keeps the notice at the truncation point. + */ +function noticeAnchor(context: ConversationNodeContext, seq: number): number { + const location = context.start?.location ?? context.matches[0]?.location + if (location?.kind !== 'turn' && location?.kind !== 'step') return seq + const closing = location.turn.data.get('turn-tail')?.closing + return closing === null || closing === undefined + ? seq + : closing.finalNode.seq + CHAT_SYNTHETIC_SEQ_OFFSETS.maxTokensNotice +} + function stateFrom(match: ConversationMatch): TurnMaxTokensState | undefined { if (match.event.type !== 'turn/end' || match.event.data.reason.kind !== 'max-tokens') return undefined return { turn: match.event.data.turn, seq: match.event.seq, time: match.event.time } @@ -46,7 +61,6 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition context.state, buildViewNode: (context) => { const state = context.state - ?? context.matches.map(stateFrom).find(candidate => candidate !== undefined) if (state === undefined) return null const node: TurnMaxTokensNode = { kind: 'turn-max-tokens', @@ -55,7 +69,7 @@ export const turnMaxTokensDefinition: ConversationNodeDefinition { const notice = node(snapshot(value), 'turn-max-tokens') expect(notice?.data).toMatchObject({ kind: 'turn-max-tokens', seq: 5, turn: 1, step: 1 }) expect(node(snapshot(value), 'turn-error')).toBeUndefined() + // The tail stays the turn's last node so its branch action survives; the + // notice slots between the truncated closing Assistant and the tail. + const tail = node(snapshot(value), 'turn-tail') + expect(notice?.anchorSeq).toBeLessThan(tail?.anchorSeq ?? Number.NEGATIVE_INFINITY) + expect(notice?.anchorSeq).toBeGreaterThan(3) const completed = assembler([ at(1, 'turn/start', { turn: 1 }), @@ -876,10 +881,6 @@ describe('built-in conversation node Definitions', () => { match(6, 'turn/end', { turn: 1, reason: { kind: 'completed' } }), )).toBe(state) expect(turnMaxTokensDefinition.buildViewNode?.(context(undefined))).toBeNull() - expect(turnMaxTokensDefinition.buildViewNode?.(context( - undefined, - [match(5, 'turn/end', { turn: 1, reason: { kind: 'max-tokens' } })], - ))).toMatchObject({ kind: 'turn-max-tokens' }) }) it('preserves nested Tools and manual compaction evidence when their start events are outside the window', () => { From 8940282aeeb8d8aa59e6104e261bb21956bb8cb6 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 12 Aug 2026 14:23:06 +0800 Subject: [PATCH 3/7] feat(session-export): add command and Header action --- ...11-web-export-command-and-dialog.i18n.yaml | 6 + ...026-08-11-web-export-command-and-dialog.md | 31 ++++ ...-08-11-web-export-command-and-dialog.zh.md | 31 ++++ apps/web/tests/agent-preset-selection.e2e.ts | 1 + apps/web/tests/assembled-boot.ts | 1 + apps/web/tests/navigation-panes.e2e.ts | 33 +++- apps/web/tests/smoke-real.e2e.ts | 4 +- .../agent-preset-selection/header.expected.md | 3 + .../snapshots/bash-abort-row/ui.expected.md | 3 + .../snapshots/code-mode-round/ui.expected.md | 3 + .../cordis-tool-round/ui.expected.md | 3 + .../feedback-command/ack.expected.md | 3 + .../snapshots/fresh-round-trip/ui.expected.md | 3 + .../goal-command-presentation/ui.expected.md | 3 + .../goal-multi-turn-actions/ui.expected.md | 3 + .../lifecycle-chrome/command-menu.expected.md | 1 + .../lifecycle-chrome/reloaded.expected.md | 3 + .../live-interactions/cancel.expected.md | 3 + .../live-interactions/error-auth.expected.md | 3 + .../live-interactions/loading.expected.md | 3 + .../live-interactions/retry.expected.md | 3 + .../markdown-cjk-strong/ui.expected.md | 3 + .../snapshots/markdown-images/ui.expected.md | 3 + .../markdown-inline-code-links/ui.expected.md | 3 + .../snapshots/math-rendering/ui.expected.md | 3 + .../snapshots/message-actions/ui.expected.md | 3 + .../navigation-panes/trajectory.expected.md | 1 - .../plan-review/approved.expected.md | 3 + .../question-composer/answered.expected.md | 3 + .../queue-actions/collapsed.expected.md | 3 + .../queue-actions/editing.expected.md | 3 + .../queue-actions/layout.expected.md | 3 + .../queue-actions/preserved.expected.md | 3 + .../snapshots/queue-actions/ui.expected.md | 3 + .../seeded-history/command-row.expected.md | 3 + .../seeded-history/feedback-row.expected.md | 3 + .../snapshots/seeded-history/ui.expected.md | 3 + .../snapshots/skill-tool-row/ui.expected.md | 3 + .../skill-user-invoke/ui.expected.md | 3 + .../snapshots/steer-all/mid-steer.expected.md | 3 + .../snapshots/steer-all/settled.expected.md | 3 + .../snapshots/steering/mid-steer.expected.md | 3 + .../snapshots/steering/settled.expected.md | 3 + .../subagent-conversation/nested.expected.md | 3 + .../subagent-conversation/ui.expected.md | 3 + .../offline-composer.expected.md | 3 + .../turn-tail-actions/running.expected.md | 3 + .../turn-tail-actions/settled.expected.md | 3 + .../snapshots/web-search-round/ui.expected.md | 3 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 10 ++ docs/module-graph.zh.md | 10 ++ packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 1 + packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 + packages/client/ui-command/README.zh.md | 2 + .../client/ui-command/src/client/service.ts | 24 +++ .../ui-command/tests/service.client.spec.ts | 26 ++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/contract/slots.ts | 7 +- .../skeleton/ConversationRoot.module.css | 23 ++- .../client/skeleton/ConversationSession.tsx | 47 +++--- .../tests/skeleton.client.spec.tsx | 2 + .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryToolbar.module.css | 40 ----- .../src/client/TrajectoryToolbar.tsx | 23 --- .../src/client/TrajectoryView.tsx | 27 +--- .../ui-trajectory/src/client/export-log.ts | 45 ------ .../client/ui-trajectory/src/client/index.ts | 2 - .../ui-trajectory/src/client/locales.ts | 12 -- .../ui-trajectory/src/client/views.module.css | 12 -- .../tests/export-log.client.spec.ts | 51 ------ .../tests/toolbar.client.spec.tsx | 61 ------- .../ui-trajectory/tests/views.client.spec.tsx | 46 ------ packages/session-query/README.i18n.yaml | 4 +- packages/session-query/README.md | 1 + packages/session-query/README.zh.md | 1 + .../session-export/README.i18n.yaml | 6 + .../session-query/session-export/README.md | 48 ++++++ .../session-query/session-export/README.zh.md | 48 ++++++ .../session-query/session-export/package.json | 63 ++++++++ .../session-export/src/client/Dialog.tsx | 49 ++++++ .../src/client/HeaderAction.module.css | 37 +++++ .../src/client/HeaderAction.tsx | 31 ++++ .../session-export/src/client/controller.ts | 148 +++++++++++++++++ .../session-export/src/client/index.ts | 52 ++++++ .../session-export/src/client/locales.ts | 27 ++++ .../session-export/src/css-modules.d.ts | 6 + .../session-query/session-export/src/index.ts | 26 +++ .../session-export/src/invariant.ts | 22 +++ .../tests/client-apply.client.spec.tsx | 88 ++++++++++ .../session-export/tests/command.host.spec.ts | 33 ++++ .../tests/controller.client.spec.ts | 150 ++++++++++++++++++ .../tests/dialog.client.spec.tsx | 68 ++++++++ .../tests/header-action.client.spec.tsx | 72 +++++++++ .../tests/invariant.host.spec.ts | 16 ++ .../tests/loader-composition.host.spec.ts | 67 ++++++++ .../session-export/tsconfig.client.json | 22 +++ .../session-export/tsconfig.host.json | 17 ++ .../session-export/tsconfig.json | 7 + .../session-export/tsdown.config.ts | 3 + pnpm-lock.yaml | 48 ++++++ scripts/gen-cordis-catalog.ts | 2 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 114 files changed, 1533 insertions(+), 368 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md create mode 100644 .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md delete mode 100644 packages/client/ui-trajectory/src/client/export-log.ts delete mode 100644 packages/client/ui-trajectory/tests/export-log.client.spec.ts delete mode 100644 packages/client/ui-trajectory/tests/toolbar.client.spec.tsx create mode 100644 packages/session-query/session-export/README.i18n.yaml create mode 100644 packages/session-query/session-export/README.md create mode 100644 packages/session-query/session-export/README.zh.md create mode 100644 packages/session-query/session-export/package.json create mode 100644 packages/session-query/session-export/src/client/Dialog.tsx create mode 100644 packages/session-query/session-export/src/client/HeaderAction.module.css create mode 100644 packages/session-query/session-export/src/client/HeaderAction.tsx create mode 100644 packages/session-query/session-export/src/client/controller.ts create mode 100644 packages/session-query/session-export/src/client/index.ts create mode 100644 packages/session-query/session-export/src/client/locales.ts create mode 100644 packages/session-query/session-export/src/css-modules.d.ts create mode 100644 packages/session-query/session-export/src/index.ts create mode 100644 packages/session-query/session-export/src/invariant.ts create mode 100644 packages/session-query/session-export/tests/client-apply.client.spec.tsx create mode 100644 packages/session-query/session-export/tests/command.host.spec.ts create mode 100644 packages/session-query/session-export/tests/controller.client.spec.ts create mode 100644 packages/session-query/session-export/tests/dialog.client.spec.tsx create mode 100644 packages/session-query/session-export/tests/header-action.client.spec.tsx create mode 100644 packages/session-query/session-export/tests/invariant.host.spec.ts create mode 100644 packages/session-query/session-export/tests/loader-composition.host.spec.ts create mode 100644 packages/session-query/session-export/tsconfig.client.json create mode 100644 packages/session-query/session-export/tsconfig.host.json create mode 100644 packages/session-query/session-export/tsconfig.json create mode 100644 packages/session-query/session-export/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml new file mode 100644 index 0000000000..5a00974ecb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md +2026-08-11-web-export-command-and-dialog.md: f08a47faab770f59f531076bbb20d3a2e43563ab +2026-08-11-web-export-command-and-dialog.zh.md: 88d5995ad4072eb1dd254dda209fafc6f1b349f6 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md new file mode 100644 index 0000000000..f08a47faab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md @@ -0,0 +1,31 @@ +# Agent Note: Web `/export` shares the streamed Session ZIP download + +Status: implemented + +English | [中文](2026-08-11-web-export-command-and-dialog.zh.md) + +## Problem + +Session export needs a stable Session-level visible action and an equivalent slash-command path. A second backend reader or Host-path writer would duplicate the download implementation and introduce platform-specific file-permission and path-reveal problems. + +## Decision + +`@deepseek-ai/dsh-session-export` registers a Web-only `/export` human command and provides the browser `ctx.sessionExport` controller. The command records an ordinary `command/run` and `command/done`; after `command.execute` returns a successful result, `dsh-client-ui-command` emits a local acknowledgment that asks this browser's controller to download ApiProxy's existing `GET /api/session.export` ZIP. Other clients render the broadcast command nodes without repeating the browser side effect. The 111×32 `Session log` capsule in the Session Header calls that controller directly. Both paths therefore use the same Host endpoint, browser save operation, in-flight state, error handling, and Modal. + +The Header contribution occupies the right-aligned `conversation.session.header.utilities` list and renders the `Session log` text capsule with its trailing download icon plus the shared Modal. The title-adjacent `conversation.session.header.actions` list continues to own mode, Subagent, and Task entries, so mounting Session export does not reorder or move them. The export contribution does not observe Session history. A per-Session controller collapses concurrent gestures, aborts active fetches when its plugin disposes, ignores late requests after disposal, and preserves a user's closed state when the request later completes. + +The ZIP endpoint and persistence `readRaw` capability remain owned by `dsh-host-apiproxy` and the persistence package. The endpoint flushes a live root Session before reading its artifact, so the local acknowledgment cannot race ahead of durable command lifecycle rows. This package does not serialize Session events, write Host files, deliver Host paths, or implement SQLite fallback. + +The package compiles its Host command and invariant through `tsconfig.host.json`, while `tsconfig.client.json` owns the browser controller, Header action, Modal, and their face-named tests. The repository Host and Client aggregates reference only the matching project, so their incompatible Cordis `Context` merges never enter one TypeScript program. + +## Alternatives considered + +**Put the visible action in Trajectory.** Rejected because export is a Session-level operation and must remain discoverable without opening a diagnostic view. + +**Write a Host-side JSONL file from `/export`.** Rejected because it would diverge from the descendant-and-attachment ZIP, require Windows ACL handling, and return a Host path that may be meaningless to a remote browser. + +**Keep both Header and Trajectory buttons.** Rejected because two visible controls for the same Session operation create duplicate ownership and inconsistent placement. + +## Consequences + +The Header action and `/export` download the same ZIP and show the same feedback. An executed command remains visible in the durable transcript without creating a model turn. Deployments whose persistence backend has no raw per-Session artifact receive the endpoint's existing failure; SQLite support remains separate work. Command availability before a Session's first turn is separate work. diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md new file mode 100644 index 0000000000..88d5995ad4 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md @@ -0,0 +1,31 @@ +# Agent Note: Web `/export` 共用流式 Session ZIP 下载 + +Status: implemented + +[English](2026-08-11-web-export-command-and-dialog.md) | 中文 + +## Problem + +Session 导出需要一个稳定的 Session 级外显入口,以及语义等价的斜杠命令路径。第二套后端读取器或 Host 路径写入器会重复下载实现,并引入平台相关的文件权限和路径公开问题。 + +## Decision + +`@deepseek-ai/dsh-session-export` 注册 Web 专用的 `/export` 用户命令,并提供浏览器 `ctx.sessionExport` 控制器。该命令记录普通的 `command/run` 和 `command/done`;`command.execute` 返回成功结果后,`dsh-client-ui-command` 会发布本地确认,请求当前浏览器的控制器下载 ApiProxy 现有的 `GET /api/session.export` ZIP。其他客户端会渲染广播的命令节点,但不会重复执行浏览器副作用。Session Header 中 111×32 的 `Session log` 胶囊按钮会直接调用该控制器。因此,两种入口共用同一个 Host 端点、浏览器保存操作、进行中状态、错误处理和 Modal。 + +Header 贡献占用最右侧的 `conversation.session.header.utilities` 列表,渲染带尾部下载图标的 `Session log` 文字 capsule 和共享 Modal。标题旁的 `conversation.session.header.actions` 列表继续承载模式、Subagent 和 Task 配置项,挂载 Session export 不会改变它们的顺序或位置。导出贡献不观察 Session 历史。逐 Session 控制器会折叠并发操作,在插件释放时取消活动 fetch,忽略释放后的迟到请求,并在请求后来完成时保留用户已经关闭弹窗的状态。 + +ZIP 端点与持久化 `readRaw` 能力仍由 `dsh-host-apiproxy` 和持久化包拥有。端点会在读取工件前 flush 活动的根 Session,因此本地确认不会早于持久命令生命周期行。本包不序列化 Session 事件、不写 Host 文件、不交付 Host 路径,也不实现 SQLite 回退。 + +本包通过 `tsconfig.host.json` 编译 Host 命令和 invariant,`tsconfig.client.json` 则负责浏览器控制器、Header 操作、Modal 及其带编译面后缀的测试。仓库的 Host 和 Client 聚合只引用对应项目,因此两种不兼容的 Cordis `Context` 合并不会进入同一个 TypeScript program。 + +## Alternatives considered + +**把外显入口放进 Trajectory。** 不采用,因为导出是 Session 级操作,用户不应先打开诊断视图才能发现它。 + +**让 `/export` 写入 Host 侧 JSONL 文件。** 不采用,因为这会偏离包含子 Session 与附件的 ZIP,需要处理 Windows ACL,并返回对远程浏览器可能没有意义的 Host 路径。 + +**同时保留 Header 与 Trajectory 按钮。** 不采用,因为两个外显控件执行同一项 Session 操作,会形成重复归属和不一致的位置。 + +## Consequences + +Header 操作与 `/export` 会下载同一个 ZIP,并显示相同反馈。已执行命令保留在持久文本记录中,且不创建模型轮次。持久化后端没有逐 Session 原始工件时,用户会收到端点现有的失败;SQLite 支持保留为独立工作。Session 首轮前的命令可用性属于独立工作。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 4a7f6eb819..9e7dbd493e 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -283,6 +283,7 @@ describe('web e2e: agent-preset selection', () => { expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "Session log"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index dfda1040a6..6a0b737d29 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -43,6 +43,7 @@ const PLUGINS: readonly (WebBootEntry & { bundlePath: string })[] = [ '@deepseek-ai/dsh-client-ui-sidebar', ], }, + { id: '@deepseek-ai/dsh-session-export', bundlePath: 'packages/session-query/session-export/lib/client.js', url: '/plugins/session-export.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-command', '@deepseek-ai/dsh-client-ui-conversation'] }, { id: '@deepseek-ai/dsh-client-ui-trajectory', bundlePath: 'packages/client/ui-trajectory/lib/client.js', url: '/plugins/ui-trajectory.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-conversation'] }, ] diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index dda4ece74f..4e3e2c17b5 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -283,14 +283,29 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) - it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => { + it.skipIf(MODE === 'record')('downloads through the Session Header and /export with one dialog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) await ensureSeedOpen(page) - await page.getByRole('tab', { name: 'Trajectory' }).click() + const exportButton = page.getByRole('button', { name: 'Session log' }) + expect(await exportButton.isDisabled()).toBe(false) + const header = exportButton.locator('xpath=ancestor::header[1]') + const [buttonBox, headerBox] = await Promise.all([ + exportButton.boundingBox(), header.boundingBox(), + ]) + if (buttonBox === null || headerBox === null) { + throw new Error('Session Header export geometry is unavailable') + } + expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32) + const responsePromise = page.waitForResponse(response => + new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) - await page.getByRole('button', { name: 'Export session log' }).click() + await exportButton.click() + const response = await responsePromise + expect(response.status()).toBe(200) const download = await downloadPromise expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/) + const dialog = page.getByRole('dialog', { name: 'Session download started' }) + await dialog.waitFor({ timeout: 30_000 }) // The real host streamed the ZIP; its root entry is the persisted log // text verbatim (the assembled seam: real route, real persistence read). const files = unzipSync(await readFile(await download.path())) @@ -298,6 +313,18 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { const content = strFromU8(files['session.jsonl'] as Uint8Array) expect(content.split('\n')[0]).toContain(SEED_ID) expect(content).toContain('FIRST_DONE') + await dialog.getByText('Close', { exact: true }).click() + + const input = page.locator('textarea').first() + const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await input.fill('/export') + await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) + await input.press('Enter') + const slashDownload = await slashDownloadPromise + expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) + await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 }) + await page.getByRole('dialog', { name: 'Session download started' }) + .getByText('Close', { exact: true }).click() }, 60_000) it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index 87001230d1..aa75346b75 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -138,13 +138,13 @@ async function detailsTrack(page: Page): Promise { return Number(cols.split(' ').pop()!.replace('px', '')) } -// Readiness gate: `dsh web` serves all ten production manifest plugins; until every UI +// Readiness gate: `dsh web` serves every production manifest plugin; until every UI // plugin's client bundle exists and exports apply, the loader fail-louds and // the frame never appears. const UI_PLUGIN_DIRS = [ 'connection', 'runtime', 'ui-theme', 'locale', 'ui-layout', 'ui-sidebar', 'ui-settings', 'ui-settings-general', 'ui-models', 'ui-conversation', - 'ui-model', 'ui-question', 'ui-trajectory', + 'ui-model', 'ui-question', 'ui-trajectory', '../session-query/session-export', ] const ROUND_DONE_MARKER = 'WEB_ROUND_DONE' const notReady = UI_PLUGIN_DIRS.filter((dir) => { diff --git a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md index 5a78c6461e..612edbc213 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/header.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/header.expected.md @@ -5,3 +5,6 @@ - button "1 subagent": - text: 1 subagent - img +- button "Session log": + - text: Session log + - img diff --git a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md index 94b8bf80e9..1081ab6fea 100644 --- a/apps/web/tests/snapshots/bash-abort-row/ui.expected.md +++ b/apps/web/tests/snapshots/bash-abort-row/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - 'button "Run two shell commands: wait" [disabled]' + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/code-mode-round/ui.expected.md b/apps/web/tests/snapshots/code-mode-round/ui.expected.md index b7da854448..d9809ed53f 100644 --- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md +++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md @@ -3,6 +3,9 @@ - 'button "Using ONE run_code program: run" [disabled]' - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md index 7ab633e583..682a910825 100644 --- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md +++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md @@ -3,6 +3,9 @@ - button "Use only Cordis tools. First" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md index b5b308a22e..653faaa02b 100644 --- a/apps/web/tests/snapshots/feedback-command/ack.expected.md +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -3,6 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md index 8a2268f24d..5a1f64faf8 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -3,6 +3,9 @@ - button "Use the bash tool to" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md b/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md index e3f026066f..42811d29ae 100644 --- a/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/snapshots/goal-command-presentation/ui.expected.md @@ -3,6 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md index 9d22c4d767..768be514d0 100644 --- a/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md +++ b/apps/web/tests/snapshots/goal-multi-turn-actions/ui.expected.md @@ -3,6 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md index 7b18ab188b..ed7e837f3f 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md @@ -1,6 +1,7 @@ - listbox "Trigger suggestions": - text: Commands - option "compact Compact older conversation history" [selected] + - option "export Download this Session log as a ZIP archive" - option "feedback record feedback about this session" - option "goal set or view the goal for a long-running task" - option "permission Switch the permission preset (sandbox mode + approval policy)" diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index d4c71faa15..9221d887b0 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -3,6 +3,9 @@ - button "Reply with the single word" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md index 4fa0394693..4ea903679c 100644 --- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md +++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md index 83be4dc961..56f87071a4 100644 --- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md +++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md index c475c05e05..7e6a7af832 100644 --- a/apps/web/tests/snapshots/live-interactions/loading.expected.md +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md index 88463747a0..504c21099f 100644 --- a/apps/web/tests/snapshots/live-interactions/retry.expected.md +++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md index 06c084f747..dbaca89b8a 100644 --- a/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-cjk-strong/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "CJK strong emphasis" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/markdown-images/ui.expected.md b/apps/web/tests/snapshots/markdown-images/ui.expected.md index 319bc6b78b..f90d10f616 100644 --- a/apps/web/tests/snapshots/markdown-images/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-images/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Markdown image policy" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md index ee727ab171..f345357f1b 100644 --- a/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/snapshots/markdown-inline-code-links/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Inline code links" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/math-rendering/ui.expected.md b/apps/web/tests/snapshots/math-rendering/ui.expected.md index 9bbef6740e..0bb9a9b19b 100644 --- a/apps/web/tests/snapshots/math-rendering/ui.expected.md +++ b/apps/web/tests/snapshots/math-rendering/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Math rendering" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md index 7876ec8577..0419f0f1b1 100644 --- a/apps/web/tests/snapshots/message-actions/ui.expected.md +++ b/apps/web/tests/snapshots/message-actions/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index 3476255bab..a9b5dbb982 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,7 +2,6 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls - - button "Export session log": Export - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index d6d48515a2..e2e41dd34c 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -3,6 +3,9 @@ - 'button "Plan a small change: add" [disabled]' - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md index a0a90f9746..7815286fe9 100644 --- a/apps/web/tests/snapshots/question-composer/answered.expected.md +++ b/apps/web/tests/snapshots/question-composer/answered.expected.md @@ -3,6 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index 5438f084ae..150c6060fb 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 186f0b0169..74dcf76b8b 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/layout.expected.md b/apps/web/tests/snapshots/queue-actions/layout.expected.md index 49db39a1a5..e7d6577325 100644 --- a/apps/web/tests/snapshots/queue-actions/layout.expected.md +++ b/apps/web/tests/snapshots/queue-actions/layout.expected.md @@ -3,6 +3,9 @@ - button "workspace" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/preserved.expected.md b/apps/web/tests/snapshots/queue-actions/preserved.expected.md index 54410743dc..7951af37e4 100644 --- a/apps/web/tests/snapshots/queue-actions/preserved.expected.md +++ b/apps/web/tests/snapshots/queue-actions/preserved.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 1467c50d97..2ae4f81331 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -3,6 +3,9 @@ - button "Reply with a one-sentence description" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/seeded-history/command-row.expected.md b/apps/web/tests/snapshots/seeded-history/command-row.expected.md index dbe8db9c55..4402a0c69b 100644 --- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 3e48c3f679..40ecc27c1e 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md index bfbf1c09a2..3ca7fba7ca 100644 --- a/apps/web/tests/snapshots/seeded-history/ui.expected.md +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Use the read tool twice" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md index 24a3fa942f..6dfa55d454 100644 --- a/apps/web/tests/snapshots/skill-tool-row/ui.expected.md +++ b/apps/web/tests/snapshots/skill-tool-row/ui.expected.md @@ -1,6 +1,9 @@ - banner: - navigation "Session hierarchy": - button "Load the snapshot-skill skill with" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md index 26af0930a3..0aa6eeb091 100644 --- a/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/snapshots/skill-user-invoke/ui.expected.md @@ -3,6 +3,9 @@ - button "/user-invoke-demo and confirm the fixtur" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index a8a191b3ba..7084523979 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -3,6 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index 72a4bd8d8f..96da765b9b 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -3,6 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 30d4d7ac4b..e3b82f0964 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -3,6 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index bf4fe45dad..275da95b92 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -3,6 +3,9 @@ - button "Use the ask_user_question tool to" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md index da57314953..0b0c96e887 100644 --- a/apps/web/tests/snapshots/subagent-conversation/nested.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/nested.expected.md @@ -5,6 +5,9 @@ - button "event-sourcing researcher" - text: / - button "example editor" [disabled] + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index a42437cead..55a424f2a7 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -8,6 +8,9 @@ - button "1 subagent": - text: 1 subagent - img + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index 8f4554e47b..f4ca5e1b3d 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -5,6 +5,9 @@ - button "event-sourcing researcher" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md index 8e5d4c5858..8af07a3543 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/running.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/running.expected.md @@ -3,6 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md index 47203f70eb..01e67a351f 100644 --- a/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md +++ b/apps/web/tests/snapshots/turn-tail-actions/settled.expected.md @@ -3,6 +3,9 @@ - button "Begin your reply with the" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/apps/web/tests/snapshots/web-search-round/ui.expected.md b/apps/web/tests/snapshots/web-search-round/ui.expected.md index 44e8382035..ba7d13678c 100644 --- a/apps/web/tests/snapshots/web-search-round/ui.expected.md +++ b/apps/web/tests/snapshots/web-search-round/ui.expected.md @@ -3,6 +3,9 @@ - button "Use web_search to search exactly" [disabled] - img - text: Standard mode + - button "Session log": + - text: Session log + - img - tablist: - tab "Chat" [selected] - tab "Trajectory" diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ceba64b2c0..986eb92808 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/config-catalog.md -config-catalog.md: 369490ec41480b8c46355f0edc3eb97f2f0c76cb -config-catalog.zh.md: a735d7021d44dda3cffa49c31430249f70431720 +config-catalog.md: 3eb59072b712c4386072042ba1c6f4f1f54423ca +config-catalog.zh.md: 63f421f944fcfd69de9235ba09c78b1afe93f952 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 369490ec41..3eb59072b7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2846,6 +2846,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) +- `@deepseek-ai/dsh-session-export` — requires `commands` ([`packages/session-query/session-export/src/index.ts`](../packages/session-query/session-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a735d7021d..63f421f944 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2847,6 +2847,7 @@ export interface Config { - `@deepseek-ai/dsh-pty`([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) +- `@deepseek-ai/dsh-session-export` — 需要 `commands`([`packages/session-query/session-export/src/index.ts`](../packages/session-query/session-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) - `@deepseek-ai/dsh-storage`([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d5ac47cf78..980d198c32 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/module-graph.md -module-graph.md: 56e029df192f28a787748b12074ee4dfe67d1c58 -module-graph.zh.md: 839c5edf758a3076874643cb6bdbd91954ca369e +module-graph.md: 371699d8a4aab83eb8603ce1623373fe56871dd3 +module-graph.zh.md: 381a387ff77ef36dda31655bb9c0e4d5b931e544 diff --git a/docs/module-graph.md b/docs/module-graph.md index 56e029df19..371699d8a4 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,6 +108,7 @@ flowchart TD pkg_hooks_codex["hooks-codex"] end subgraph group_session_query["packages/session-query"] + pkg_session_export["session-export"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] pkg_tool_session_query["tool-session-query"] @@ -1326,6 +1327,14 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_export --> pkg_client_locale + pkg_session_export --> pkg_client_runtime + pkg_session_export --> pkg_client_ui_command + pkg_session_export --> pkg_client_ui_conversation + pkg_session_export --> pkg_client_ui_primitives + pkg_session_export --> pkg_client_ui_slots + pkg_session_export --> pkg_commands + pkg_session_export --> pkg_invariants pkg_client_ui_model --> pkg_api_remotes pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale @@ -1572,6 +1581,7 @@ flowchart TD | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker`](../packages/client/ui-directory-picker), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`session-export`](../packages/session-query/session-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 839c5edf75..381a387ff7 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -110,6 +110,7 @@ flowchart TD pkg_hooks_codex["hooks-codex"] end subgraph group_session_query["packages/session-query"] + pkg_session_export["session-export"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] pkg_tool_session_query["tool-session-query"] @@ -1328,6 +1329,14 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_export --> pkg_client_locale + pkg_session_export --> pkg_client_runtime + pkg_session_export --> pkg_client_ui_command + pkg_session_export --> pkg_client_ui_conversation + pkg_session_export --> pkg_client_ui_primitives + pkg_session_export --> pkg_client_ui_slots + pkg_session_export --> pkg_commands + pkg_session_export --> pkg_invariants pkg_client_ui_model --> pkg_api_remotes pkg_client_ui_model --> pkg_client_connection pkg_client_ui_model --> pkg_client_locale @@ -1574,6 +1583,7 @@ flowchart TD | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker`](../packages/client/ui-directory-picker), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | +| [`session-export`](../packages/session-query/session-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-command`](../packages/client/ui-command), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/interaction/permission) | | [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-tool`](../packages/client/ui-tool), [`invariants`](../packages/support/invariants) | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 8dc7b72c4a..d7a1a864d5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -65,6 +65,10 @@ config: maxNoteBytes: 8192 + # Browser Session export: `/export` command plus the shared download dialog. + - id: session-export + name: '@deepseek-ai/dsh-session-export' + - id: workspace name: '@deepseek-ai/dsh-workspace' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index a317518317..6ef0bdc422 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -92,6 +92,7 @@ "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-export": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index c4bb939e01..e59157b743 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/ui-command/README.md -README.md: 60b70cfbc3784dd5b138f8857270c4bbdc0fd634 -README.zh.md: 639b7f997e967527232bb88116558c5457b4d497 +README.md: 1fa5d6cd38a18303857f4132ea0839822183f76b +README.zh.md: 1af0a17a0a14cf135f7b8b70089d610a1ab71d96 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index 60b70cfbc3..1fa5d6cd38 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -8,6 +8,8 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. +After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. + Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). `PopupSelectController` (`src/client/popup.ts`) is the headless shell state: `PopupSelectView` self-registers into `conversation.input.overlay` (the SlotMap key is ui-conversation's; this package pulls the declaration in with a type-only import — no runtime edge). The shell is a transient layer holding focus while open; token-segment consumption after onSelect runs both branches through `consumeTokenSegment` (menu-path span CAS, enter-path bare-token equality) against the draft face the wiring layer binds via `bindDraft`. diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 639b7f997e..1af0a17a0a 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -8,6 +8,8 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 +`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。 + 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 `PopupSelectController`(`src/client/popup.ts`)是无头的壳状态:`PopupSelectView` 自行注册进 `conversation.input.overlay`(SlotMap key 归 ui-conversation 所有;本包只以 type-only 导入引入该声明——没有运行时依赖边)。壳是打开期间持有焦点的瞬态层;onSelect 之后的 token 片段消费在两条分支上都经 `consumeTokenSegment` 执行(菜单路径做 span CAS,回车路径做裸 token 相等比较),作用于接线层经 `bindDraft` 绑定的草稿表层。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 056067f46f..e78d39e60a 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -12,6 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (`commands/change` rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type { CommandResult } from '@deepseek-ai/dsh-commands' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, @@ -23,6 +24,28 @@ import { CommandDirectory } from './directory.ts' import { PopupSelectController } from './popup.ts' import type { TokenSegment } from './popup.ts' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * This browser client completed one admitted Host command execution. + * Other clients receive the durable command nodes but never this local + * submission acknowledgment. + * @param sessionId - Session addressed by the local submission. + * @param name - Executed command name without the leading slash. + * @param result - Host command result returned to this browser. + * @mode emit + */ + 'command/executed'(sessionId: SessionId, name: string, result: CommandResult): void + } +} + +/** Recover the command name from a line the Host confirmed as executed. */ +function submittedCommandName(line: string): string { + const trimmed = line.trim() + const separator = trimmed.search(/\s/u) + return (separator === -1 ? trimmed : trimmed.slice(0, separator)).slice(1) +} + /** Live mutable state in one holder (service methods run behind the caller-ctx tracker). */ interface LiveState { readonly contributions: Map @@ -351,6 +374,7 @@ export class CommandService extends Service implements CommandServiceContract { const result = await this.ctx.remote.commands.execute(session.sessionId, line) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } + this.ctx.emit('command/executed', session.sessionId, submittedCommandName(line), result.value.result) return { kind: 'success' } } diff --git a/packages/client/ui-command/tests/service.client.spec.ts b/packages/client/ui-command/tests/service.client.spec.ts index c3413317c5..ccdf883d28 100644 --- a/packages/client/ui-command/tests/service.client.spec.ts +++ b/packages/client/ui-command/tests/service.client.spec.ts @@ -9,6 +9,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' +import type { CommandResult } from '@deepseek-ai/dsh-commands' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' @@ -120,6 +121,10 @@ async function bench(opts: BenchOptions = {}) { }, }) ctx.provide('remote.commands', commandsRemote) + const executions: Array<{ sessionId: SessionId; name: string; result: CommandResult }> = [] + ctx.on('command/executed', (sessionId, name, result) => { + executions.push({ sessionId, name, result }) + }) /** Notices the fake conversation face collected (runDetached routing). */ const notices: Array<{ scope: SessionId | undefined; level: 'info' | 'error'; text: string }> = [] ctx.provide('conversation', { @@ -145,7 +150,7 @@ async function bench(opts: BenchOptions = {}) { const warm = async (session: ClientSessionContext) => { await source.candidates(session, { query: '', position: 'leading', signal: new AbortController().signal }) } - return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, registered, notices } + return { ctx, fiber, command, source, mint, warm, listCalls, executeCalls, executions, registered, notices } } function menuPick(source: SlashSource, name: string, session: ClientSessionContext, end?: number) { @@ -367,7 +372,7 @@ describe('dispatch (menu column)', () => { }) it('host bare → consume-token span guard on the session scope + detached execute', async () => { - const { source, mint, warm, executeCalls } = await bench() + const { source, mint, warm, executeCalls, executions } = await bench() const scope = mint('s1') const consumes: ConsumeTokenRequest[] = [] scope.ctx.on('slash/input-consume-token', (r) => { @@ -377,8 +382,14 @@ describe('dispatch (menu column)', () => { await warm(proj('s1')) expect(menuPick(source, 'plan', proj('s1'), 5)).toBe('handled') expect(consumes).toEqual([{ guard: { kind: 'span', span: { start: 0, end: 5, draftRev: 3 } } }]) - await Promise.resolve() - expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + await vi.waitFor(() => { + expect(executeCalls).toEqual([{ sessionId: sid('s1'), line: '/plan' }]) + expect(executions).toEqual([{ + sessionId: sid('s1'), + name: 'plan', + result: { kind: 'success' }, + }]) + }) }) it('a name the directory no longer serves → undefined (snapshot swapped between menu and pick)', async () => { @@ -496,7 +507,7 @@ describe('matchEnter (enter column)', () => { describe('execute payload', () => { it('claim.submit addresses the session; admitted outcomes stay off the composer (flow card owns them)', async () => { - const { source, warm, executeCalls } = await bench({ + const { source, warm, executeCalls, executions } = await bench({ execute: () => Promise.resolve({ matched: true }), }) await warm(proj('s1')) @@ -507,6 +518,11 @@ describe('execute payload', () => { // Pure admission: no outcome text ever rides the submit result — the // durable command lifecycle events render the outcome in the flow. expect(settled).toEqual({ kind: 'success' }) + expect(executions).toEqual([{ + sessionId: sid('s1'), + name: 'goal', + result: { kind: 'success' }, + }]) }) it('maps matched:false to an error outcome and a matched bare result to success', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f48542a153..03526da261 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/ui-conversation/README.md -README.md: e2db456f92151c3602fce0bf40a86e4019cda1cb -README.zh.md: 399228c619d50a7fc1909db81350b4078803f8f7 +README.md: b6a265c9f0a67d31ebeaa88bd59082c4be465983 +README.zh.md: be06e988500e0c3e6fc69409552459b8cfff75e9 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index e2db456f92..b6a265c9f0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ Chat business rows are independent registry contributions rather than a closed b Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission ` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing. -The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. +The session header renders the session-scoped `'conversation.session.header.actions'` list beside the title and the independent `'conversation.session.header.utilities'` list at the right edge. Session context and lineage controls remain in `actions`; optional Session utilities cannot reorder or move them. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 399228c619..be06e98850 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -14,7 +14,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。Client 插件通过 declaration merging 增加类型化 `ChatNodeDataMap` key,在 `ctx.conversationEvents` 上注册 `ConversationNodeDefinition`,再向 `conversation.chat.node` 注册匹配的 keyed renderer;它无须修改 Session fold 或中央 renderer switch。稳定事件 id、append/prepend 回放、Location data 与 renderer 约束见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 -会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 +会话页头会在标题旁渲染 Session scope 的 `'conversation.session.header.actions'` 列表,并在最右侧渲染独立的 `'conversation.session.header.utilities'` 列表。Session 上下文和谱系控件保留在 `actions` 中,可选的 Session 工具不会改变它们的顺序或位置。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 0f7d8acb88..79a2aa0397 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -259,6 +259,7 @@ export function apply(ctx: Context): void { locale: NS, children: { 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + 'conversation.session.header.utilities': { kind: 'list', scope: 'session' }, }, store: chatStore, inject: (): ConversationSessionHeaderInjected => ({ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 83f298a695..0152cdd116 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -45,6 +45,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * context that precedes interactive actions. */ 'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } + /** + * Right-aligned Session utilities kept outside the title-adjacent action + * group, so an optional utility cannot reorder session context or lineage. + */ + 'conversation.session.header.utilities': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps } /** * The conversation view ring: one list entry per view tab (chat here; * trajectory/waterfall from ui-trajectory), rendered one-at-a-time by @@ -525,7 +530,7 @@ export type ConversationSessionSlotProps = /** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */ export type ConversationSessionHeaderSlotProps = PropsRuntime<'conversation.session.header'> - & PropsRenderSlots<'conversation.session.header.actions'> + & PropsRenderSlots<'conversation.session.header.actions' | 'conversation.session.header.utilities'> & PropsStore & ConversationSessionHeaderInjected & PropsLocale<'conversation'> diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 473e2a1fd7..1b72470feb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -53,9 +53,18 @@ .titleRow { display: flex; align-items: center; - gap: 10px; + gap: 0; min-height: 32px; } + +.titleCluster { + display: flex; + flex: 1; + align-items: center; + gap: 10px; + min-width: 0; +} + .crumbs { display: flex; align-items: center; @@ -111,6 +120,18 @@ gap: 8px; } +.headerUtilities { + display: flex; + flex: none; + align-items: center; + gap: 8px; + margin-left: 20px; +} + +.headerUtilities:empty { + display: none; +} + /* figma Tab_Group 34:11441: 35px strip, gap 36, pad-left 8, tabs bottom-aligned. */ .tabs { position: relative; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 80e6ac1202..5c05660601 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -69,27 +69,32 @@ export function ConversationSessionHeader({ {!hideChrome && ( <>
- -
- {renderSlot('conversation.session.header.actions', {})} +
+ +
+ {renderSlot('conversation.session.header.actions', {})} +
+
+
+ {renderSlot('conversation.session.header.utilities', {})}
{tabs.length > 1 && ( diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index be5cd3be28..00f8db396c 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -337,6 +337,8 @@ describe('ConversationRoot resident composer', () => { expect(host?.contains(header)).toBe(false) expect(host?.contains(seat)).toBe(true) expect(seat?.contains(textarea)).toBe(true) + expect(b.slotCalls).toContain('conversation.session.header.actions') + expect(b.slotCalls).toContain('conversation.session.header.utilities') }) it('sticky composer seat wraps the whole overlay chain, not only the fallback stack', () => { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index cad6321870..78082eb1f0 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/ui-trajectory/README.md -README.md: f4b3bd223c2872f0341d49bdaa102440d73b4f29 -README.zh.md: 9bcb3b6ad98d672cc524c168f2024be9ba56b577 +README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4 +README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index f4b3bd223c..d3786b6460 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button hands the session log — the root plus every subagent descendant — directly to the browser download manager as a ZIP streamed by the host (`GET /api/session.export`), so JavaScript never buffers the response: every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/.`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9bcb3b6ad9..5eb1451b9a 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——作为宿主流式返回的 ZIP(`GET /api/session.export`)直接交给浏览器下载管理器,因此 JavaScript 不会缓冲响应:每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/.` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index 6e213604cf..aa38650411 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -164,46 +164,6 @@ font: 14px/14px var(--ds-font-family-code); } -.export { - display: inline-flex; - flex: none; - align-items: center; - height: 20px; - padding: 0 7px; - gap: 4px; - border: 0; - border-radius: 3px; - color: var(--dsw-alias-label-tertiary); - background: transparent; - cursor: pointer; - font: var(--dsw-font-xxs-12); -} - -.export:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); -} - -.export:focus-visible { - outline: 1px solid var(--dsw-alias-state-business-primary); - outline-offset: 1px; -} - -.export:disabled { - color: var(--dsw-alias-label-dimmed); - cursor: wait; -} - -.exportIcon { - flex: none; - width: 12px; - height: 12px; - stroke: currentColor; - stroke-width: 1.25; - stroke-linecap: round; - stroke-linejoin: round; -} - .search { display: flex; flex: 0 1 164px; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index 2ff7a6092c..92d7ce9ab0 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -26,12 +26,6 @@ export interface TrajectoryToolbarProps { searchQuery: string /** Update the live ledger search query. */ onSearchQueryChange: (query: string) => void - /** Whether the session-log export is in flight. */ - exporting: boolean - /** Trigger the session-log export download. */ - onExport: () => void - /** Export failure message, shown while set; null while idle or successful. */ - exportError: string | null /** Translate a toolbar dictionary key. */ t: TranslateNS } @@ -52,9 +46,6 @@ export function TrajectoryToolbar({ onToggleAllAssistants, searchQuery, onSearchQueryChange, - exporting, - onExport, - exportError, t, }: TrajectoryToolbarProps) { return ( @@ -119,20 +110,6 @@ export function TrajectoryToolbar({ {t('toolbar.calls')} -
diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 99eb823ae6..1b23ea8cb2 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -71,8 +71,6 @@ export interface TrajectoryViewInjected { } loadOlder: () => Promise setActualDuration: (actualDuration: boolean) => void - /** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */ - exportLog: () => Promise } interface UsageLike { @@ -120,7 +118,7 @@ function addUsage( } export function TrajectoryView({ - useSession, useDuration, loadOlder, setActualDuration, exportLog, + useSession, useDuration, loadOlder, setActualDuration, inspect, onInspectDone, t, }: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) @@ -130,8 +128,6 @@ export function TrajectoryView({ const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') - const [exporting, setExporting] = useState(false) - const [exportError, setExportError] = useState(null) const [searchIndex] = useState(() => new TrajectorySearchIndex()) const [searchIndexRevision, setSearchIndexRevision] = useState(0) const searchIndexTimer = useRef | null>(null) @@ -447,19 +443,6 @@ export function TrajectoryView({ return loadOlder() }, [loadOlder]) - const onExport = useCallback(() => { - if (exporting) return - setExporting(true) - setExportError(null) - void exportLog().then( - () => { setExporting(false) }, - (error: unknown) => { - setExportError(error instanceof Error ? error.message : String(error)) - setExporting(false) - }, - ) - }, [exportLog, exporting]) - return (
- {exportError !== null && ( -
- {exportError} -
- )} { - return Promise.resolve().then(() => { - const query = new URLSearchParams({ sessionId, includeDescendants: 'true' }) - const anchor = document.createElement('a') - anchor.href = `/api/session.export?${query.toString()}` - anchor.download = sessionLogZipFilename(sessionId) - anchor.click() - }) -} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index c8325f6442..ef789dce30 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -10,7 +10,6 @@ import type {} from '@deepseek-ai/dsh-client-locale/client' // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' -import { downloadSessionLog } from './export-log.ts' import { en, NS, zh } from './locales.ts' import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' @@ -60,7 +59,6 @@ export function apply(ctx: Context): void { return session.getSnapshot().views.get('trajectory') !== before }, setActualDuration: (value) => { duration.set(value) }, - exportLog: () => downloadSessionLog(sessionId), } }, }, TrajectoryView)) diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index b226974160..f8adbde640 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -17,10 +17,6 @@ export type TrajectoryKey = | 'toolbar.calls' | 'toolbar.expandCalls' | 'toolbar.collapseCalls' - | 'toolbar.export' - | 'toolbar.exportAria' - | 'toolbar.exporting' - | 'toolbar.exportTitle' | 'toolbar.search' | 'toolbar.searchPlaceholder' @@ -45,10 +41,6 @@ export const zh: Record = { 'toolbar.calls': 'Calls', 'toolbar.expandCalls': 'Expand calls', 'toolbar.collapseCalls': 'Collapse calls', - 'toolbar.export': 'Export', - 'toolbar.exportAria': 'Export session log', - 'toolbar.exporting': 'Exporting…', - 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', 'toolbar.search': '搜索轨迹', 'toolbar.searchPlaceholder': '搜索', } @@ -67,10 +59,6 @@ export const en: Record = { 'toolbar.calls': 'Calls', 'toolbar.expandCalls': 'Expand calls', 'toolbar.collapseCalls': 'Collapse calls', - 'toolbar.export': 'Export', - 'toolbar.exportAria': 'Export session log', - 'toolbar.exporting': 'Exporting…', - 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', 'toolbar.search': 'Search trajectory', 'toolbar.searchPlaceholder': 'Search', } diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 842486ea93..687f4a4657 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,18 +13,6 @@ background: var(--dsw-alias-bg-layer-1); } -.exportError { - box-sizing: border-box; - flex: none; - width: 100%; - padding: 4px 10px; - border-bottom: 1px solid var(--dsw-alias-border-l2); - color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary)); - background: var(--dsw-alias-bg-layer-2); - font: var(--dsw-font-xxs-12); - overflow-wrap: anywhere; -} - .ledger { position: relative; z-index: 0; diff --git a/packages/client/ui-trajectory/tests/export-log.client.spec.ts b/packages/client/ui-trajectory/tests/export-log.client.spec.ts deleted file mode 100644 index 6ff7d1ddbf..0000000000 --- a/packages/client/ui-trajectory/tests/export-log.client.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -// @vitest-environment jsdom -/** - * Session-log export browser delivery: safe filename derivation and a native - * download handoff that leaves the streamed response outside JavaScript. - */ - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { downloadSessionLog, sessionLogZipFilename } from '../src/client/export-log.ts' - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('sessionLogZipFilename', () => { - it('keeps safe session ids verbatim', () => { - expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip') - }) - - it('neutralizes unsafe id characters that could shape the filename', () => { - expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip') - expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') - }) - - it('strips dots so a dot-only id cannot shape a dot segment', () => { - expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') - }) -}) - -describe('downloadSessionLog', () => { - it('hands the descendant-inclusive endpoint directly to the browser', async () => { - const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) - - await downloadSessionLog('session/with spaces') - - expect(click).toHaveBeenCalledOnce() - const anchor = click.mock.contexts[0] as HTMLAnchorElement - const url = new URL(anchor.href) - expect(url.pathname).toBe('/api/session.export') - expect(url.searchParams.get('sessionId')).toBe('session/with spaces') - expect(url.searchParams.get('includeDescendants')).toBe('true') - expect(anchor.download).toBe('dsh-session-session_with_spaces.zip') - }) - - it('rejects when the browser download handoff fails', async () => { - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => { - throw new Error('download denied') - }) - - await expect(downloadSessionLog('session-root')).rejects.toThrow('download denied') - }) -}) diff --git a/packages/client/ui-trajectory/tests/toolbar.client.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.client.spec.tsx deleted file mode 100644 index e2b761143a..0000000000 --- a/packages/client/ui-trajectory/tests/toolbar.client.spec.tsx +++ /dev/null @@ -1,61 +0,0 @@ -// @vitest-environment jsdom -/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */ - -import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' -import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' -import { zh, type TrajectoryKey } from '../src/client/locales.ts' - -/** Test translator pinned to the Simplified Chinese dictionary. */ -const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key - -afterEach(() => { - cleanup() - vi.restoreAllMocks() -}) - -function baseProps(overrides: Partial = {}): TrajectoryToolbarProps { - return { - actualDuration: false, - onActualDurationChange: vi.fn(), - actualTime: false, - onActualTimeChange: vi.fn(), - allTurnsCollapsed: false, - onToggleAllTurns: vi.fn(), - allAssistantsCollapsed: false, - onToggleAllAssistants: vi.fn(), - searchQuery: '', - onSearchQueryChange: vi.fn(), - exporting: false, - onExport: vi.fn(), - exportError: null, - t: zhT, - ...overrides, - } -} - -describe('TrajectoryToolbar export', () => { - it('renders the export button and dispatches the export callback on click', () => { - const onExport = vi.fn() - render() - const button = screen.getByRole('button', { name: 'Export session log' }) - fireEvent.click(button) - expect(onExport).toHaveBeenCalledTimes(1) - }) - - it('disables the button while an export is in flight and blocks dispatch', () => { - const onExport = vi.fn() - render() - const button = screen.getByRole('button', { name: 'Export session log' }) as HTMLButtonElement - expect(button.disabled).toBe(true) - fireEvent.click(button) - expect(onExport).not.toHaveBeenCalled() - }) - - it('surfaces an export failure as the button title', () => { - render() - const button = screen.getByRole('button', { name: 'Export session log' }) - expect(button.title).toBe('Export failed: internal boom') - }) -}) diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 9a4e6b3ca8..dc4bc1289c 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -136,12 +136,6 @@ function standaloneDuration(): Pick< } } -function standaloneExport( - onExport: () => Promise = vi.fn(() => Promise.resolve()), -): Pick, 'exportLog'> { - return { exportLog: onExport } -} - function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore(historySnapshot(nodes)) return { store, useSession: bindSnapshotSelector(store) } @@ -253,7 +247,6 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES return { loadOlder: trajectory.loadOlder, setActualDuration: trajectory.setActualDuration, - exportLog: trajectory.exportLog, useDuration: bindSnapshotSelector(trajectory.hooks.duration), t: (key: TrajectoryKey) => zh[key], } @@ -1134,7 +1127,6 @@ describe('timeline projection', () => { ...standaloneProps([]), ...standaloneHistory(historySnapshot([])), ...standaloneDuration(), - ...standaloneExport(), }, )) expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() @@ -1142,41 +1134,6 @@ describe('timeline projection', () => { }) }) -describe('session log export', () => { - afterEach(() => { - vi.unstubAllGlobals() - Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click') - }) - - it('downloads the host-streamed ZIP with descendants on click', async () => { - const clickAnchor = vi.fn() - HTMLAnchorElement.prototype.click = clickAnchor - const b = await bench(historySnapshot(NODES)) - mount(b.slots) - fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) - await vi.waitFor(() => { expect(clickAnchor).toHaveBeenCalledOnce() }) - const anchor = clickAnchor.mock.contexts[0] as HTMLAnchorElement - const url = new URL(anchor.href) - expect(url.pathname).toBe('/api/session.export') - expect(url.searchParams.get('sessionId')).toBe(SID) - expect(url.searchParams.get('includeDescendants')).toBe('true') - }) - - it('surfaces a browser handoff failure in the visible alert bar', async () => { - HTMLAnchorElement.prototype.click = vi.fn(() => { throw new Error('download denied') }) - const b = await bench(historySnapshot(NODES)) - mount(b.slots) - fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) - await vi.waitFor(() => { - const alert = screen.queryByRole('alert') - expect(alert).not.toBeNull() - expect(alert!.textContent).toContain('download denied') - }) - }) -}) - describe('TrajectoryView state', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() @@ -1187,7 +1144,6 @@ describe('TrajectoryView state', () => { const first = render( { firstDuration.set(value) }} />, @@ -1203,7 +1159,6 @@ describe('TrajectoryView state', () => { render( { restoredDuration.set(value) }} />, @@ -1228,7 +1183,6 @@ describe('TrajectoryView state', () => { Promise.resolve(false))} />, diff --git a/packages/session-query/README.i18n.yaml b/packages/session-query/README.i18n.yaml index 2922f4eb88..c7acfc6621 100644 --- a/packages/session-query/README.i18n.yaml +++ b/packages/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/README.md -README.md: 3d5db8da74cb825c701fe50cf518505e9e6bcad9 -README.zh.md: fc430cbdc4130f77eb8857c4d88a2435ad0cc97f +README.md: 90b105d44a2affd8d487db20dc51357425446f8b +README.zh.md: 305223e77385f7ee640b54e1d03e8659967da43e diff --git a/packages/session-query/README.md b/packages/session-query/README.md index 3d5db8da74..90b105d44a 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -8,6 +8,7 @@ This family provides authorized retrieval over live and durable session logs, in |---|---|---| | [`session-query/`](session-query/README.md) | Defines trusted reads, relationship queries, and search operations | `ctx.sessionQuery` | | [`session-query-sqlite/`](session-query-sqlite/README.md) | Implements session queries with SQLite full-text search | `ctx.sessionQuery` | +| [`session-export/`](session-export/README.md) | Adds the Web `/export` command, shared browser download state, and result modal over the Host ZIP endpoint | `ctx.sessionExport` | | [`tool-session-query/`](tool-session-query/README.md) | Exposes workspace-authorized session queries to the model | registers on `ctx.tools` | The subsystem reference — logical records, bounded reads, traces, filters, result pages — is [docs/subsystems/session-query.md](../../docs/subsystems/session-query.md). diff --git a/packages/session-query/README.zh.md b/packages/session-query/README.zh.md index fc430cbdc4..305223e773 100644 --- a/packages/session-query/README.zh.md +++ b/packages/session-query/README.zh.md @@ -8,6 +8,7 @@ |---|---|---| | [`session-query/`](session-query/README.md) | 定义可信读取、关系查询和搜索操作 | `ctx.sessionQuery` | | [`session-query-sqlite/`](session-query-sqlite/README.md) | 使用 SQLite 全文搜索实现会话查询 | `ctx.sessionQuery` | +| [`session-export/`](session-export/README.md) | 在 Host ZIP 端点之上增加 Web `/export` 命令、共享浏览器下载状态和结果弹窗 | `ctx.sessionExport` | | [`tool-session-query/`](tool-session-query/README.md) | 向模型公开经过工作区授权的会话查询 | 注册到 `ctx.tools` | 子系统参考——逻辑记录、有界读取、追踪、筛选器、结果页——见 [docs/subsystems/session-query.md](../../docs/subsystems/session-query.md)。 diff --git a/packages/session-query/session-export/README.i18n.yaml b/packages/session-query/session-export/README.i18n.yaml new file mode 100644 index 0000000000..25c992f74d --- /dev/null +++ b/packages/session-query/session-export/README.i18n.yaml @@ -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-export/README.md +README.md: 3df11c1132715dc590d50b53588f8875015f237b +README.zh.md: 008cf433df1104c9f7e45cd04c0e0f256e3682fb diff --git a/packages/session-query/session-export/README.md b/packages/session-query/session-export/README.md new file mode 100644 index 0000000000..3df11c1132 --- /dev/null +++ b/packages/session-query/session-export/README.md @@ -0,0 +1,48 @@ +# @deepseek-ai/dsh-session-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=&includeDescendants=true`. | +| `/export ` | 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, so both entry paths share in-flight collapsing, cancellation on plugin disposal, HTTP 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-export + name: '@deepseek-ai/dsh-session-export' +``` + +The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-command`, 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. diff --git a/packages/session-query/session-export/README.zh.md b/packages/session-query/session-export/README.zh.md new file mode 100644 index 0000000000..008cf433df --- /dev/null +++ b/packages/session-query/session-export/README.zh.md @@ -0,0 +1,48 @@ +# @deepseek-ai/dsh-session-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=&includeDescendants=true`。 | +| `/export ` | 返回错误。浏览器下载通过浏览器的普通下载行为选择目标位置。 | + +该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载;其他标签页仍会渲染持久命令行,但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器,因此两种入口共用并发折叠、插件释放时取消、HTTP 错误处理、浏览器保存行为和同一个 Modal。 + +Host 下载端点会在 `readRaw` 前 flush 活动的根 Session,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对。冷持久化 Session 不需要 flush。 + +弹窗报告准备中、开始下载或失败。关闭弹窗不会取消正在进行的下载;该操作随后完成时也不会重新打开弹窗。每个 Session 同时只允许一项下载,重复操作会共用该任务。 + +## 组合 + +```yaml +- id: session-export + name: '@deepseek-ai/dsh-session-export' +``` + +Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-command` 和 `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 路径或原生文件夹操作。 diff --git a/packages/session-query/session-export/package.json b/packages/session-query/session-export/package.json new file mode 100644 index 0000000000..06547f7586 --- /dev/null +++ b/packages/session-query/session-export/package.json @@ -0,0 +1,63 @@ +{ + "name": "@deepseek-ai/dsh-session-export", + "description": "Web Session-log export command and shared download dialog", + "version": "0.0.1-rc.2", + "publishConfig": { "access": "restricted" }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session-query/session-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-command": "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-command": "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-command", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + } + } +} diff --git a/packages/session-query/session-export/src/client/Dialog.tsx b/packages/session-query/session-export/src/client/Dialog.tsx new file mode 100644 index 0000000000..f829f598e5 --- /dev/null +++ b/packages/session-query/session-export/src/client/Dialog.tsx @@ -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 { SessionExportDownloadState } from './controller.ts' +import { NS } from './locales.ts' + +/** Browser operations and state injected into the Session Header contribution. */ +export interface SessionExportDialogInjected { + hooks: { sessionExport: ObservableSnapshot } + request: (sessionId: SessionId) => Promise + dismiss: (sessionId: SessionId) => void +} + +export type SessionExportDialogProps = + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale + & InjectFace + +/** + * 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 SessionExportDialog({ + sessionId, useSessionExport, dismiss, t, +}: SessionExportDialogProps) { + const entry = useSessionExport(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 ( + { dismiss(sessionId) }} + title={title} + description={description} + closeLabel={t('dialog.close')} + footer={} + /> + ) +} diff --git a/packages/session-query/session-export/src/client/HeaderAction.module.css b/packages/session-query/session-export/src/client/HeaderAction.module.css new file mode 100644 index 0000000000..03cf469b0e --- /dev/null +++ b/packages/session-query/session-export/src/client/HeaderAction.module.css @@ -0,0 +1,37 @@ +/* The 111 px design width is a floor so translated labels do not clip. */ +.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; +} diff --git a/packages/session-query/session-export/src/client/HeaderAction.tsx b/packages/session-query/session-export/src/client/HeaderAction.tsx new file mode 100644 index 0000000000..a1389b178c --- /dev/null +++ b/packages/session-query/session-export/src/client/HeaderAction.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react' +import { IconDownloadOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { SessionExportDialog, type SessionExportDialogProps } 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 SessionExportHeader(props: SessionExportDialogProps): ReactNode { + const { sessionId, useSessionExport, request } = props + const entry = useSessionExport(state => state.bySession[String(sessionId)]) + const busy = entry?.status === 'downloading' + + return ( + <> + + + + ) +} diff --git a/packages/session-query/session-export/src/client/controller.ts b/packages/session-query/session-export/src/client/controller.ts new file mode 100644 index 0000000000..15b260c7b2 --- /dev/null +++ b/packages/session-query/session-export/src/client/controller.ts @@ -0,0 +1,148 @@ +/** 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 SessionExportDownloadStatus = 'downloading' | 'success' | 'error' + +/** One Session's current download-dialog state. */ +export interface SessionExportDownloadEntry { + readonly open: boolean + readonly status: SessionExportDownloadStatus + readonly error: string | null +} + +/** Download states keyed by the Session whose Header owns the dialog. */ +export interface SessionExportDownloadState { + bySession: Record +} + +type Fetch = (input: string | URL, init?: RequestInit) => Promise +type Save = (blob: Blob, filename: string) => void + +const INITIAL: SessionExportDownloadState = { 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` +} + +/** + * Trigger a browser save without copying the response blob. + * @param blob - complete ZIP response body. + * @param filename - browser download filename. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + setTimeout(() => { URL.revokeObjectURL(url) }, 0) +} + +/** 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 SessionExportDownloadController { + /** uSES-safe state source shared by every Session-scoped modal contribution. */ + readonly store: SnapshotStore = createSnapshotStore(INITIAL) + + private readonly active = new Map }>() + 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 = downloadBlob, + ) {} + + /** + * 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 { + 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 + } + + /** + * Present a command failure without issuing an HTTP request. + * @param sessionId - Session whose modal reports the failure. + * @param error - stable command failure text. + */ + fail(sessionId: SessionId, error: string): void { + this.publish(sessionId, { open: true, status: 'error', error }) + } + + /** + * 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 { + 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 { + 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, { signal }) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) + } + this.save(await response.blob(), 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: SessionExportDownloadEntry): void { + this.store.update((state) => { + state.bySession = { ...state.bySession, [String(sessionId)]: entry } + }) + } +} diff --git a/packages/session-query/session-export/src/client/index.ts b/packages/session-query/session-export/src/client/index.ts new file mode 100644 index 0000000000..a48f17d4a0 --- /dev/null +++ b/packages/session-query/session-export/src/client/index.ts @@ -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-command/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionExportDownloadController } from './controller.ts' +import type { SessionExportDialogInjected } from './Dialog.tsx' +import { SessionExportHeader } from './HeaderAction.tsx' +import { en, NS, zh, type SessionExportKey } from './locales.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + sessionExport: SessionExportDownloadController + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + 'session-export': SessionExportKey + } +} + +export type { SessionExportDownloadEntry, SessionExportDownloadState } 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 SessionExportDownloadController() + ctx.provide('sessionExport', controller) + ctx.effect(() => async () => { await controller.dispose() }, 'session-export: browser download lifecycle') + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'session-export: 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-export', + locale: NS, + inject: (): SessionExportDialogInjected => ({ + hooks: { sessionExport: controller.store }, + request: (sessionId: SessionId) => controller.download(sessionId), + dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, + }), + }, SessionExportHeader)) +} + +export type { SessionExportDialogInjected, SessionExportDialogProps } from './Dialog.tsx' diff --git a/packages/session-query/session-export/src/client/locales.ts b/packages/session-query/session-export/src/client/locales.ts new file mode 100644 index 0000000000..86e4ce750b --- /dev/null +++ b/packages/session-query/session-export/src/client/locales.ts @@ -0,0 +1,27 @@ +/** Locale namespace owned by Session export browser feedback. */ +export const NS = 'session-export' + +/** 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 = { + '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 SessionExportKey = keyof typeof zh diff --git a/packages/session-query/session-export/src/css-modules.d.ts b/packages/session-query/session-export/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/session-query/session-export/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/session-query/session-export/src/index.ts b/packages/session-query/session-export/src/index.ts new file mode 100644 index 0000000000..16c4b4d40d --- /dev/null +++ b/packages/session-query/session-export/src/index.ts @@ -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-export' +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-export: command') +} diff --git a/packages/session-query/session-export/src/invariant.ts b/packages/session-query/session-export/src/invariant.ts new file mode 100644 index 0000000000..d89bb93549 --- /dev/null +++ b/packages/session-query/session-export/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package invariant companion for `@deepseek-ai/dsh-session-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-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 */ diff --git a/packages/session-query/session-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-export/tests/client-apply.client.spec.tsx new file mode 100644 index 0000000000..033091dc7d --- /dev/null +++ b/packages/session-query/session-export/tests/client-apply.client.spec.tsx @@ -0,0 +1,88 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { SessionExportHeader } 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: SlotsService): () => 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(SlotsService).await() + const slots = ctx.get('slots') as SlotsService + const declaration = declare(slots) + ctx.provide('locale', new LocaleService(ctx)) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, slots, declaration, fiber } +} + +describe('session-export 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.sessionExport).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(SessionExportHeader) + expect(entry?.options).toMatchObject({ id: 'session-export' }) + const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionExportDialogInjected)() + b.ctx.sessionExport.fail(SID, 'failed') + expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') + injected.dismiss(SID) + expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.open).toBe(false) + await injected.request(SID) + expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') + + 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.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') + }) + expect(second.ctx.sessionExport.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(SessionExportHeader) + redeclare() + await b.fiber.dispose() + }) +}) diff --git a/packages/session-query/session-export/tests/command.host.spec.ts b/packages/session-query/session-export/tests/command.host.spec.ts new file mode 100644 index 0000000000..3b611b197d --- /dev/null +++ b/packages/session-query/session-export/tests/command.host.spec.ts @@ -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 SessionExport 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(SessionExport) + + 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() + }) +}) diff --git a/packages/session-query/session-export/tests/controller.client.spec.ts b/packages/session-query/session-export/tests/controller.client.spec.ts new file mode 100644 index 0000000000..21f1ab24b3 --- /dev/null +++ b/packages/session-query/session-export/tests/controller.client.spec.ts @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import { + downloadBlob, SessionExportDownloadController, sessionLogZipFilename, +} from '../src/client/controller.ts' + +const SID = 'session-export-controller' as SessionId + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('SessionExportDownloadController', () => { + 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 SessionExportDownloadController(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.signal).toBeInstanceOf(AbortSignal) + expect(save).toHaveBeenCalledWith(expect.any(Object), 'dsh-session-session-export-controller.zip') + expect((save.mock.calls[0]?.[0] as Blob).size).toBe(3) + 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() + const fetcher = vi.fn(() => response.promise) + const controller = new SessionExportDownloadController(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, transport, and command failures without leaking rejections', async () => { + const http = new SessionExportDownloadController( + 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 SessionExportDownloadController(async () => { throw 'offline' }, vi.fn()) + await transport.download(SID) + expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline') + + transport.fail(SID, 'command failed') + expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('command failed') + transport.dismiss('absent' as SessionId) + + const emptyDetail = new SessionExportDownloadController( + 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((_resolve, reject) => { + signal = init?.signal ?? undefined + signal?.addEventListener('abort', () => { + reject(signal?.reason instanceof Error ? signal.reason : new Error('aborted')) + }, { once: true }) + })) + const controller = new SessionExportDownloadController(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) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:default') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const controller = new SessionExportDownloadController() + + await controller.download(SID) + + expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal') + }) + + it('defaults dialog openness when state is externally cleared before settlement', async () => { + const success = Promise.withResolvers() + const successful = new SessionExportDownloadController(() => 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() + const failing = new SessionExportDownloadController(() => 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 revokes the object URL after the click', () => { + vi.useFakeTimers() + const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:session') + const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip') + downloadBlob(new Blob(['zip']), 'archive.zip') + expect(create).toHaveBeenCalledOnce() + expect(click).toHaveBeenCalledOnce() + expect(revoke).not.toHaveBeenCalled() + vi.runAllTimers() + expect(revoke).toHaveBeenCalledWith('blob:session') + vi.useRealTimers() + }) +}) diff --git a/packages/session-query/session-export/tests/dialog.client.spec.tsx b/packages/session-query/session-export/tests/dialog.client.spec.tsx new file mode 100644 index 0000000000..cdf2f55fab --- /dev/null +++ b/packages/session-query/session-export/tests/dialog.client.spec.tsx @@ -0,0 +1,68 @@ +// @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 { SessionExportDownloadController } from '../src/client/controller.ts' +import { SessionExportDialog } from '../src/client/Dialog.tsx' +import type { SessionExportDialogProps } from '../src/client/Dialog.tsx' +import { en } from '../src/client/locales.ts' + +const SID = 'session-export-dialog' as SessionId + +function bench( + controller = new SessionExportDownloadController( + async () => new Response('zip', { status: 200 }), vi.fn(), + ), +) { + const dismiss = vi.fn((sessionId: SessionId) => { controller.dismiss(sessionId) }) + function useSessionExport(selector: (state: ReturnType) => 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, useSessionExport, dismiss, t } as unknown as SessionExportDialogProps + const view = render() + return { controller, dismiss, view } +} + +afterEach(cleanup) + +describe('SessionExportDialog', () => { + it('shows a controller failure and closes it without reading Session history', async () => { + const b = bench() + act(() => { b.controller.fail(SID, '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((resolve) => { release = resolve }) + const controller = new SessionExportDownloadController(() => 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.fail(SID, '') }) + 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) }) + }) +}) diff --git a/packages/session-query/session-export/tests/header-action.client.spec.tsx b/packages/session-query/session-export/tests/header-action.client.spec.tsx new file mode 100644 index 0000000000..eb1d7c6a13 --- /dev/null +++ b/packages/session-query/session-export/tests/header-action.client.spec.tsx @@ -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 { SessionExportDownloadController } from '../src/client/controller.ts' +import { SessionExportHeader } from '../src/client/HeaderAction.tsx' +import type { SessionExportDialogProps } from '../src/client/Dialog.tsx' +import { en } from '../src/client/locales.ts' + +const SID = 'session-export-header' as SessionId + +function bindSessionExport(controller: SessionExportDownloadController) { + return function useSessionExport(selector: (state: ReturnType) => T): T { + return useSyncExternalStore( + listener => controller.store.subscribe(listener), + () => selector(controller.store.getSnapshot()), + ) + } +} + +function bench() { + const controller = new SessionExportDownloadController(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 useSessionExport = bindSessionExport(controller) + const props = { + sessionId: SID, + useSessionExport, + request, + dismiss, + t: (key: keyof typeof en): string => en[key], + } as unknown as SessionExportDialogProps + const view = render() + 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((resolve) => { release = resolve }) + const controller = new SessionExportDownloadController(() => pending, vi.fn()) + const useSessionExport = bindSessionExport(controller) + b.view.rerender( controller.download(sessionId), + dismiss: (sessionId: SessionId) => { controller.dismiss(sessionId) }, + t: (key: keyof typeof en): string => en[key], + } as unknown as SessionExportDialogProps)} />) + + 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') }) + }) +}) diff --git a/packages/session-query/session-export/tests/invariant.host.spec.ts b/packages/session-query/session-export/tests/invariant.host.spec.ts new file mode 100644 index 0000000000..b7e7f202ac --- /dev/null +++ b/packages/session-query/session-export/tests/invariant.host.spec.ts @@ -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-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-export', expect.any(Function)) + dispose() + }) +}) diff --git a/packages/session-query/session-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-export/tests/loader-composition.host.spec.ts new file mode 100644 index 0000000000..c82286ea6c --- /dev/null +++ b/packages/session-query/session-export/tests/loader-composition.host.spec.ts @@ -0,0 +1,67 @@ +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 CommandService from '@deepseek-ai/dsh-commands' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import * as SessionExport from '@deepseek-ai/dsh-session-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-export 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-export'", + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-commands', CommandService], + ['@deepseek-ai/dsh-session-export', SessionExport], + ]) + 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 + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + const session = context.sessions.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([]) + }) +}) diff --git a/packages/session-query/session-export/tsconfig.client.json b/packages/session-query/session-export/tsconfig.client.json new file mode 100644 index 0000000000..e664af1549 --- /dev/null +++ b/packages/session-query/session-export/tsconfig.client.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client", + "src/css-modules.d.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../interaction/commands" }, + { "path": "../../client/locale" }, + { "path": "../../client/runtime" }, + { "path": "../../client/ui-command" }, + { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-slots" } + ] +} diff --git a/packages/session-query/session-export/tsconfig.host.json b/packages/session-query/session-export/tsconfig.host.json new file mode 100644 index 0000000000..4b5036bf0b --- /dev/null +++ b/packages/session-query/session-export/tsconfig.host.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../interaction/commands" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/session-query/session-export/tsconfig.json b/packages/session-query/session-export/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/session-query/session-export/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/session-query/session-export/tsdown.config.ts b/packages/session-query/session-export/tsdown.config.ts new file mode 100644 index 0000000000..b5a39efae6 --- /dev/null +++ b/packages/session-query/session-export/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-session-export', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab7b3dae65..364a560e38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1677,6 +1677,9 @@ importers: '@deepseek-ai/dsh-message-feedback': specifier: workspace:^ version: link:../../feedback/message-feedback + '@deepseek-ai/dsh-session-export': + specifier: workspace:^ + version: link:../../session-query/session-export '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache @@ -6076,6 +6079,51 @@ importers: specifier: workspace:^ version: link:../../core/tools + packages/session-query/session-export: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../../client/ui-command + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../../client/ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../../interaction/commands + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/session-query/session-query: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8c873043f1..ed9e1557c5 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -133,6 +133,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the API', modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API', remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API', + sessionExport: 'client-side browser download controller — packages/session-query/session-export/README.md owns the API', slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the API', slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the API', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API', @@ -178,6 +179,7 @@ export const EVENT_SCOPE_PAGE: Record = { * exemption cannot mask another declaration in that scope. */ export const EVENT_WALK_EXEMPTIONS: Record = { + 'command/executed': 'client-face local command acknowledgment — packages/client/ui-command/README.md owns the API', 'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the API', 'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API', 'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the API', diff --git a/tsconfig.client.json b/tsconfig.client.json index 992b9a435f..53bb5868b2 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -81,6 +81,7 @@ { "path": "./packages/client/ui-plugin-config" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, + { "path": "./packages/session-query/session-export/tsconfig.client.json" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 3e25c9c510..4b40cf9fd8 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -140,6 +140,7 @@ { "path": "./packages/session/session-projection-cache" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/session-query/session-export/tsconfig.host.json" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, { "path": "./packages/credentials/credentials" }, From 8c7dab875524724715fcacb990791f5ce9ae5137 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 12 Aug 2026 18:19:25 +0800 Subject: [PATCH 4/7] fix(docs): repair current master gates --- .../feature/2026-08-10-telemetry-default-off.i18n.yaml | 4 ++-- .../implemented/feature/2026-08-10-telemetry-default-off.md | 2 +- .../feature/2026-08-10-telemetry-default-off.zh.md | 2 +- packages/client/ui-settings-general/README.i18n.yaml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 7c4995a88d..492b10cf8a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 4bda346c2b05a94106eb5658c3ee558a4b32407f -2026-08-10-telemetry-default-off.zh.md: 706f2c18fbbf226e0357fa99bf3fd61c39fce08a +2026-08-10-telemetry-default-off.md: 8163079eb5f8d6170e329c164141364681030793 +2026-08-10-telemetry-default-off.zh.md: c8e16f84248bde5bdc2c1d4bdb814f0d80fcf77e diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 4bda346c2b..8163079eb5 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -12,7 +12,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. -The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule supersedes only the default-on launcher consent in the [SDK follow-up proposal](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md); its other capabilities remain proposed. +The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. Telemetry consent is owned here; no SDK project configuration or toolchain may opt in on the launching environment's behalf. The versioned Web welcome notice states that Session Log upload is off by default, names `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` as the two opt-in choices, and discloses that `FULL` also enables dsh-sdk command telemetry. Its version changes with that material privacy statement so every profile acknowledges the current copy. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index 706f2c18fb..c8e16f8424 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -12,7 +12,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.md)继续负责 endpoint、批处理节奏和退出排空设置。 -dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则仅取代 [SDK 后续功能提案](../../proposed/feature/2026-07-17-sdk-follow-up-capabilities.md)中启动器默认允许上报的规则;其余能力仍处于提案状态。 +dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。遥测授权由本说明持有;SDK 项目配置或工具链不得代替启动环境显式启用遥测。 带版本的 Web 欢迎通知说明会话日志上传默认关闭,将 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 列为两种显式启用选项,并披露 `FULL` 同时会启用 dsh-sdk 命令遥测。其版本随这项重要的隐私声明一同变更,使每个 profile 都确认当前文案。 diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 961fb0de13..3a0fae8d41 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/ui-settings-general/README.md -README.md: d8578a7fbd451c1b7ec54dadeb3d391d597cc18e -README.zh.md: 246c04193e79f46f1e8035c6a40f55a20f1d0c26 +README.md: d02230d281482d03545a7dd9bb06fd5f1085d017 +README.zh.md: 9e2011902227c8d656f57813d4ecec92147d0f6f From 785f41daedee446450ad6c28b7a273a28857e382 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 12 Aug 2026 18:44:04 +0800 Subject: [PATCH 5/7] fix(session-export): preserve streamed browser downloads --- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 4 +- .../2026-08-10-web-session-log-export.zh.md | 4 +- ...11-web-export-command-and-dialog.i18n.yaml | 4 +- ...026-08-11-web-export-command-and-dialog.md | 8 +- ...-08-11-web-export-command-and-dialog.zh.md | 8 +- apps/web/tests/navigation-panes.e2e.ts | 74 ++++++++++++++++--- packages/client/ui-command/README.i18n.yaml | 4 +- packages/client/ui-command/README.md | 2 +- packages/client/ui-command/README.zh.md | 2 +- .../client/ui-command/src/client/service.ts | 27 ++++++- .../ui-command/tests/service.client.spec.ts | 25 ++++++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- .../host/apiproxy/src/api/downloads.schema.ts | 2 +- packages/host/apiproxy/src/fetch/handler.ts | 9 ++- .../apiproxy/tests/session-export.spec.ts | 24 ++++++ .../session-export/README.i18n.yaml | 4 +- .../session-query/session-export/README.md | 3 +- .../session-query/session-export/README.zh.md | 3 +- .../session-export/src/client/Dialog.tsx | 2 +- .../src/client/HeaderAction.module.css | 1 - .../session-export/src/client/controller.ts | 25 ++----- .../tests/client-apply.client.spec.tsx | 4 +- ...nd.host.spec.ts => command.client.spec.ts} | 0 .../tests/controller.client.spec.ts | 34 ++++----- .../tests/dialog.client.spec.tsx | 12 ++- ....host.spec.ts => invariant.client.spec.ts} | 0 ...c.ts => loader-composition.client.spec.ts} | 3 +- .../session-export/tsconfig.client.json | 22 ------ .../session-export/tsconfig.host.json | 17 ----- .../session-export/tsconfig.json | 20 ++++- tsconfig.client.json | 2 +- tsconfig.host.json | 1 - 35 files changed, 226 insertions(+), 136 deletions(-) rename packages/session-query/session-export/tests/{command.host.spec.ts => command.client.spec.ts} (100%) rename packages/session-query/session-export/tests/{invariant.host.spec.ts => invariant.client.spec.ts} (100%) rename packages/session-query/session-export/tests/{loader-composition.host.spec.ts => loader-composition.client.spec.ts} (94%) delete mode 100644 packages/session-query/session-export/tsconfig.client.json delete mode 100644 packages/session-query/session-export/tsconfig.host.json diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index e24e2894a5..c4740a54c4 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: 8fa62b877df1be55de2c373d4281672881dc2b9d -2026-08-10-web-session-log-export.zh.md: 3040dda992492187245bfe92d29bc0812ef01ef2 +2026-08-10-web-session-log-export.md: 24703bd5c98a91bf8708ae243ef7a44df4afb8f1 +2026-08-10-web-session-log-export.zh.md: 36e008586ad8e93be9a6e6d590d0e21388d99913 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index 8fa62b877d..24703bd5c9 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -12,8 +12,8 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. -- **The UI just downloads**: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. -- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser. +- **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. +- The current Header and `/export` consumers are defined by the [Web export command and dialog decision](2026-08-11-web-export-command-and-dialog.md). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 3040dda992..36e008586a 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -12,8 +12,8 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 - **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 -- **UI 只负责下载**:「导出」按钮将端点直接交给浏览器原生下载管理器,因此 JavaScript 既不会 fetch 也不会缓冲 ZIP;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 -- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会在交接期间禁用按钮;同步的浏览器交接失败会在可见警示条中显示,而 HTTP 交付由浏览器负责并报告。 +- **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 +- 当前 Header 与 `/export` Consumer 由 [Web 导出命令与弹窗决策](2026-08-11-web-export-command-and-dialog.md)定义。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml index 5a00974ecb..5f491197b0 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml @@ -2,5 +2,5 @@ # 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 .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md -2026-08-11-web-export-command-and-dialog.md: f08a47faab770f59f531076bbb20d3a2e43563ab -2026-08-11-web-export-command-and-dialog.zh.md: 88d5995ad4072eb1dd254dda209fafc6f1b349f6 +2026-08-11-web-export-command-and-dialog.md: 385fc05f42af329e59d0989d5f4b2145a637db11 +2026-08-11-web-export-command-and-dialog.zh.md: a86c003211a83c90a264ab5f17c4357c8da3ccb3 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md index f08a47faab..385fc05f42 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md @@ -10,13 +10,13 @@ Session export needs a stable Session-level visible action and an equivalent sla ## Decision -`@deepseek-ai/dsh-session-export` registers a Web-only `/export` human command and provides the browser `ctx.sessionExport` controller. The command records an ordinary `command/run` and `command/done`; after `command.execute` returns a successful result, `dsh-client-ui-command` emits a local acknowledgment that asks this browser's controller to download ApiProxy's existing `GET /api/session.export` ZIP. Other clients render the broadcast command nodes without repeating the browser side effect. The 111×32 `Session log` capsule in the Session Header calls that controller directly. Both paths therefore use the same Host endpoint, browser save operation, in-flight state, error handling, and Modal. +`@deepseek-ai/dsh-session-export` registers a Web-only `/export` human command and provides the browser `ctx.sessionExport` controller. The command records an ordinary `command/run` and `command/done`; after `command.execute` returns a successful result, `dsh-client-ui-command` emits a local acknowledgment that asks this browser's controller to download ApiProxy's existing `GET /api/session.export` ZIP. Other clients render the broadcast command nodes without repeating the browser side effect. The 111×32 `Session log` capsule in the Session Header calls that controller directly. Both paths use a `HEAD` preflight for preparation errors, then hand the GET URL to the browser download manager so JavaScript never buffers the ZIP; they share the same in-flight state and Modal. -The Header contribution occupies the right-aligned `conversation.session.header.utilities` list and renders the `Session log` text capsule with its trailing download icon plus the shared Modal. The title-adjacent `conversation.session.header.actions` list continues to own mode, Subagent, and Task entries, so mounting Session export does not reorder or move them. The export contribution does not observe Session history. A per-Session controller collapses concurrent gestures, aborts active fetches when its plugin disposes, ignores late requests after disposal, and preserves a user's closed state when the request later completes. +The Header contribution occupies the right-aligned `conversation.session.header.utilities` list and renders the `Session log` text capsule with its trailing download icon plus the shared Modal. The title-adjacent `conversation.session.header.actions` list continues to own mode, Subagent, and Task entries, so mounting Session export does not reorder or move them. The export contribution does not observe Session history. A per-Session controller collapses concurrent gestures, aborts active preflights when its plugin disposes, ignores late requests after disposal, and preserves a user's closed state when the request later completes. The ZIP endpoint and persistence `readRaw` capability remain owned by `dsh-host-apiproxy` and the persistence package. The endpoint flushes a live root Session before reading its artifact, so the local acknowledgment cannot race ahead of durable command lifecycle rows. This package does not serialize Session events, write Host files, deliver Host paths, or implement SQLite fallback. -The package compiles its Host command and invariant through `tsconfig.host.json`, while `tsconfig.client.json` owns the browser controller, Header action, Modal, and their face-named tests. The repository Host and Client aggregates reference only the matching project, so their incompatible Cordis `Context` merges never enter one TypeScript program. +The package is an ordinary Client aggregate project. Its single `tsconfig.json` compiles the Node loader entries and browser contribution together; Host-side tests still exercise the command and invariant through their source entries. ## Alternatives considered @@ -28,4 +28,4 @@ The package compiles its Host command and invariant through `tsconfig.host.json` ## Consequences -The Header action and `/export` download the same ZIP and show the same feedback. An executed command remains visible in the durable transcript without creating a model turn. Deployments whose persistence backend has no raw per-Session artifact receive the endpoint's existing failure; SQLite support remains separate work. Command availability before a Session's first turn is separate work. +The Header action and `/export` download the same ZIP and show the same feedback. An executed command remains visible in the durable transcript without creating a model turn. The preflight reports failures found before streaming starts; failures while the browser consumes the GET remain browser-download failures. Deployments whose persistence backend has no raw per-Session artifact receive the endpoint's existing failure; SQLite support remains separate work. Command availability before a Session's first turn is separate work. diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md index 88d5995ad4..a86c003211 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md @@ -10,13 +10,13 @@ Session 导出需要一个稳定的 Session 级外显入口,以及语义等价 ## Decision -`@deepseek-ai/dsh-session-export` 注册 Web 专用的 `/export` 用户命令,并提供浏览器 `ctx.sessionExport` 控制器。该命令记录普通的 `command/run` 和 `command/done`;`command.execute` 返回成功结果后,`dsh-client-ui-command` 会发布本地确认,请求当前浏览器的控制器下载 ApiProxy 现有的 `GET /api/session.export` ZIP。其他客户端会渲染广播的命令节点,但不会重复执行浏览器副作用。Session Header 中 111×32 的 `Session log` 胶囊按钮会直接调用该控制器。因此,两种入口共用同一个 Host 端点、浏览器保存操作、进行中状态、错误处理和 Modal。 +`@deepseek-ai/dsh-session-export` 注册 Web 专用的 `/export` 用户命令,并提供浏览器 `ctx.sessionExport` 控制器。该命令记录普通的 `command/run` 和 `command/done`;`command.execute` 返回成功结果后,`dsh-client-ui-command` 会发布本地确认,请求当前浏览器的控制器下载 ApiProxy 现有的 `GET /api/session.export` ZIP。其他客户端会渲染广播的命令节点,但不会重复执行浏览器副作用。Session Header 中 111×32 的 `Session log` 胶囊按钮会直接调用该控制器。两种入口通过 `HEAD` 预检获得准备阶段错误,再把 GET URL 交给浏览器下载管理器,因此 JavaScript 不会缓冲 ZIP;两种入口共用进行中状态和 Modal。 -Header 贡献占用最右侧的 `conversation.session.header.utilities` 列表,渲染带尾部下载图标的 `Session log` 文字 capsule 和共享 Modal。标题旁的 `conversation.session.header.actions` 列表继续承载模式、Subagent 和 Task 配置项,挂载 Session export 不会改变它们的顺序或位置。导出贡献不观察 Session 历史。逐 Session 控制器会折叠并发操作,在插件释放时取消活动 fetch,忽略释放后的迟到请求,并在请求后来完成时保留用户已经关闭弹窗的状态。 +Header 贡献占用最右侧的 `conversation.session.header.utilities` 列表,渲染带尾部下载图标的 `Session log` 文字 capsule 和共享 Modal。标题旁的 `conversation.session.header.actions` 列表继续承载模式、Subagent 和 Task 配置项,挂载 Session export 不会改变它们的顺序或位置。导出贡献不观察 Session 历史。逐 Session 控制器会折叠并发操作,在插件释放时取消活动预检,忽略释放后的迟到请求,并在请求后来完成时保留用户已经关闭弹窗的状态。 ZIP 端点与持久化 `readRaw` 能力仍由 `dsh-host-apiproxy` 和持久化包拥有。端点会在读取工件前 flush 活动的根 Session,因此本地确认不会早于持久命令生命周期行。本包不序列化 Session 事件、不写 Host 文件、不交付 Host 路径,也不实现 SQLite 回退。 -本包通过 `tsconfig.host.json` 编译 Host 命令和 invariant,`tsconfig.client.json` 则负责浏览器控制器、Header 操作、Modal 及其带编译面后缀的测试。仓库的 Host 和 Client 聚合只引用对应项目,因此两种不兼容的 Cordis `Context` 合并不会进入同一个 TypeScript program。 +本包是普通的 Client 聚合项目。单一 `tsconfig.json` 会一起编译 Node loader 入口与浏览器贡献;Host 侧测试仍通过源码入口验证命令与 invariant。 ## Alternatives considered @@ -28,4 +28,4 @@ ZIP 端点与持久化 `readRaw` 能力仍由 `dsh-host-apiproxy` 和持久化 ## Consequences -Header 操作与 `/export` 会下载同一个 ZIP,并显示相同反馈。已执行命令保留在持久文本记录中,且不创建模型轮次。持久化后端没有逐 Session 原始工件时,用户会收到端点现有的失败;SQLite 支持保留为独立工作。Session 首轮前的命令可用性属于独立工作。 +Header 操作与 `/export` 会下载同一个 ZIP,并显示相同反馈。已执行命令保留在持久文本记录中,且不创建模型轮次。预检会报告流式传输开始前发现的失败;浏览器消费 GET 时发生的失败仍属于浏览器下载失败。持久化后端没有逐 Session 原始工件时,用户会收到端点现有的失败;SQLite 支持保留为独立工作。Session 首轮前的命令可用性属于独立工作。 diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 4e3e2c17b5..a0b34e53b9 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -52,6 +52,11 @@ async function assertBaselineSucceeded(response: Response, method: string): Prom } async function ensureSeedOpen(page: Page): Promise { + const welcome = page.locator('[class*="onboardingOverlay"]') + if (await welcome.count() > 0) { + await welcome.getByRole('button').click() + await welcome.waitFor({ state: 'detached', timeout: 15_000 }) + } const chat = page.getByRole('tab', { name: 'Chat', exact: true }) // Search is a collapsed header action; expand it so the input is actionable. const searchButton = page.getByRole('button', { name: 'Search sessions' }) @@ -297,7 +302,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { } expect(headerBox.x + headerBox.width - (buttonBox.x + buttonBox.width)).toBeLessThanOrEqual(32) const responsePromise = page.waitForResponse(response => - new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) + response.request().method() === 'HEAD' + && new URL(response.url()).pathname === '/api/session.export', { timeout: 30_000 }) const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) await exportButton.click() const response = await responsePromise @@ -315,17 +321,61 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { expect(content).toContain('FIRST_DONE') await dialog.getByText('Close', { exact: true }).click() - const input = page.locator('textarea').first() - const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) - await input.fill('/export') - await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) - await input.press('Enter') - const slashDownload = await slashDownloadPromise - expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) - await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 }) - await page.getByRole('dialog', { name: 'Session download started' }) - .getByText('Close', { exact: true }).click() - }, 60_000) + const observer = await newEnglishPage(browser) + const observerTripwire = watchConsole(observer) + const observerSlotErrors: string[] = [] + let observerDownloads = 0 + observer.on('download', () => { observerDownloads += 1 }) + observer.on('console', (message) => { + if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) { + observerSlotErrors.push(message.text()) + } + }) + const observerSessionBaseline = baselineResponse(observer, 'session.list') + const observerWorkspaceBaseline = baselineResponse(observer, 'workspace.list') + const [, observerSessionResponse, observerWorkspaceResponse] = await Promise.all([ + observer.goto(scaffold.baseUrl, { waitUntil: 'load' }), + observerSessionBaseline, + observerWorkspaceBaseline, + ]) + await Promise.all([ + assertBaselineSucceeded(observerSessionResponse, 'observer session.list'), + assertBaselineSucceeded(observerWorkspaceResponse, 'observer workspace.list'), + ]) + await observer.getByText('Ungrouped', { exact: true }).waitFor({ timeout: 30_000 }) + await ensureSeedOpen(observer) + + try { + const input = page.locator('textarea').first() + const slashDownloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await input.fill('/export') + await page.getByRole('option', { name: /export/u }).waitFor({ timeout: 10_000 }) + await input.press('Enter') + const slashDownload = await slashDownloadPromise + expect(slashDownload.suggestedFilename()).toBe(download.suggestedFilename()) + const slashFiles = unzipSync(await readFile(await slashDownload.path())) + const slashContent = strFromU8(slashFiles['session.jsonl'] as Uint8Array) + const slashEvents = parseSessionLog(slashContent) + const exportRun = slashEvents.findLast(event => event.type === 'command/run' && event.data.name === 'export') + if (exportRun?.type !== 'command/run') throw new Error('slash ZIP has no export command/run') + const exportDone = slashEvents.find(event => + event.type === 'command/done' && event.data.commandId === exportRun.data.commandId) + expect(exportDone?.type).toBe('command/done') + await page.getByRole('dialog', { name: 'Session download started' }).waitFor({ timeout: 30_000 }) + await page.getByRole('dialog', { name: 'Session download started' }) + .getByText('Close', { exact: true }).click() + await observer.getByText('Session log download requested.', { exact: true }).waitFor({ timeout: 30_000 }) + expect(observerDownloads).toBe(0) + expect(await observer.getByRole('dialog', { name: 'Session download started' }).count()).toBe(0) + expect({ + pageErrors: observerTripwire.pageErrors, + slotErrors: observerSlotErrors, + warnings: observerTripwire.warnings, + }).toEqual({ pageErrors: [], slotErrors: [], warnings: [] }) + } finally { + await observer.close() + } + }, 120_000) it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) diff --git a/packages/client/ui-command/README.i18n.yaml b/packages/client/ui-command/README.i18n.yaml index e59157b743..30a04ad21e 100644 --- a/packages/client/ui-command/README.i18n.yaml +++ b/packages/client/ui-command/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/client/ui-command/README.md -README.md: 1fa5d6cd38a18303857f4132ea0839822183f76b -README.zh.md: 1af0a17a0a14cf135f7b8b70089d610a1ab71d96 +README.md: 0281df76fe601eaad86cefc0dcaecc6d8999df60 +README.zh.md: ace51230d6d250f4a3b09a5212182ccf434f9249 diff --git a/packages/client/ui-command/README.md b/packages/client/ui-command/README.md index 1fa5d6cd38..0281df76fe 100644 --- a/packages/client/ui-command/README.md +++ b/packages/client/ui-command/README.md @@ -8,7 +8,7 @@ Client command API (`ctx.command`): the session-keyed command-directory cache, t `CommandDirectory` (`src/client/directory.ts`) is the one wire-derived cache, keyed by session. Ordinary sessions fetch through `command.list({sessionId})`, and the source's scope-birth `warm` hook prewarms the session's entry. Catalog-addressed continuable children resolve an empty command directory locally: `command.list` is Agent-bound, so prewarming it would activate a child merely to view persisted history. Entries are soft-invalidated by the forwarded `commands/change` owner event (old snapshots serve while the repull flies) and by forwarded `agent-preset/selected` for that one session (recomposing an agent registers nothing, so the registry-wide signal never fires for it), hard-invalidated by `connection/reset`, and epoch-guarded so a superseded pull can never overwrite a newer one. `matchSpace` answers synchronously from this cache only; `matchEnter` strong-waits it on the SubmitAttempt signal and rejects on warmup failure — a `/` line is never silently downgraded to a plain prompt. -After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. +After `command.execute` returns a matched command result, this browser emits local `command/executed(sessionId, name, result)`. Other clients receive the durable command nodes through the Host event stream but never this acknowledgment, so a browser-only side effect can select successful results from the client that submitted the command without treating Session replay as an action request. Listener failures are logged and contained one by one; they cannot change the already-admitted command result or prevent later listeners from running. Menu queries fuzzy-match ordered, case-insensitive subsequences of command names. Prefixes rank first; separator boundaries, adjacent characters, and shorter gaps rank the remaining matches, with directory and contribution order breaking ties. This affects discovery only: space and Enter still require an exact command name. Rationale: [Web slash-command fuzzy discovery](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md). diff --git a/packages/client/ui-command/README.zh.md b/packages/client/ui-command/README.zh.md index 1af0a17a0a..ace51230d6 100644 --- a/packages/client/ui-command/README.zh.md +++ b/packages/client/ui-command/README.zh.md @@ -8,7 +8,7 @@ `CommandDirectory`(`src/client/directory.ts`)是唯一的 wire 派生缓存,以会话为 key。普通会话通过 `command.list({sessionId})` 拉取,source 的 scope 出生 `warm` 钩子会预热该会话的缓存项。由目录寻址的可继续子代理会在客户端解析为空命令目录:`command.list` 绑定 Agent,若预热它,就会仅因查看持久化历史而激活子代理。缓存项由转发的 owner 事件 `commands/change` 软失效(重拉在途期间旧快照继续服务),也由转发的 `agent-preset/selected` 对该会话单独软失效(重组 agent 不产生任何注册,注册表级信号不会为它触发),由 `connection/reset` 硬失效,并以 epoch 把关,被取代的旧拉取永远无法覆盖更新的结果。`matchSpace` 只凭该缓存同步应答;`matchEnter` 在 SubmitAttempt 信号上强等缓存,预热失败即拒绝——`/` 开头的一行绝不会被静默降级为普通提示词。 -`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。 +`command.execute` 返回已匹配的命令结果后,当前浏览器会发布本地 `command/executed(sessionId, name, result)`。其他客户端只会通过 Host 事件流收到持久命令节点,不会收到这条确认,因此浏览器专属副作用可以筛选由实际提交命令的客户端收到的成功结果,而不会把 Session 回放当成操作请求。监听器失败会逐项记录并隔离,不会改变已经准入的命令结果,也不会阻止后续监听器运行。 菜单查询会按顺序且不区分大小写地模糊匹配命令名的子序列。前缀排名最高;其余匹配项按分隔符边界优先、相邻字符优先、间隔越短越优先的规则排序,若仍同分,则以目录顺序和 contribution 顺序打破平局。此行为只影响命令发现:space 和 Enter 仍要求命令名精确匹配。原理:[Web 斜杠命令模糊发现](../../../.agents/notes/implemented/feature/2026-08-04-web-slash-command-fuzzy-discovery.md)。 diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index e78d39e60a..fde1f7975d 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -12,7 +12,7 @@ import type { Context } from '@deepseek-ai/cordis' // Type-only: pulls the ctx.remote merge and the forwarded-event key face // (`commands/change` rides the allowlist) into this program. import type {} from '@deepseek-ai/dsh-api-remotes/client' -import type { CommandResult } from '@deepseek-ai/dsh-commands' +import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { CandidateRequest, ClientSessionContext, CommandClaim, PickOutcome, SlashCandidate, SlashPick, @@ -374,10 +374,33 @@ export class CommandService extends Service implements CommandServiceContract { const result = await this.ctx.remote.commands.execute(session.sessionId, line) if (!result.ok) throw new Error(`command.execute failed: ${result.error.code}: ${result.error.message}`) if (result.value === undefined) return { kind: 'error', text: `unknown or malformed command: ${line}` } - this.ctx.emit('command/executed', session.sessionId, submittedCommandName(line), result.value.result) + this.notifyExecuted(session.sessionId, submittedCommandName(line), result.value.result) return { kind: 'success' } } + /** Publish the local acknowledgment without letting an observer change command admission. */ + private notifyExecuted(sessionId: SessionId, name: string, result: CommandResult): void { + const args = ['command/executed', sessionId, name, result] + for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) { + try { + const returned = listener(sessionId, name, result) + if (returned != null && typeof (returned as PromiseLike).then === 'function') { + void Promise.resolve(returned as PromiseLike).then(undefined, (error: unknown) => { + this.warnExecutedListenerFailure(name, error) + }) + } + } catch (error) { + this.warnExecutedListenerFailure(name, error) + } + } + } + + /** Log one contained `command/executed` observer failure. */ + private warnExecutedListenerFailure(name: string, error: unknown): void { + this.ctx.logger.warn('client command: a command/executed listener for "%s" failed', name) + this.ctx.logger.warn(error) + } + /** * Fire-and-forget execute for the internal ('handled') paths. Outcomes are * NOT surfaced here: the host executor durably logs the command lifecycle diff --git a/packages/client/ui-command/tests/service.client.spec.ts b/packages/client/ui-command/tests/service.client.spec.ts index ccdf883d28..40801cc6a8 100644 --- a/packages/client/ui-command/tests/service.client.spec.ts +++ b/packages/client/ui-command/tests/service.client.spec.ts @@ -9,7 +9,7 @@ */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' -import type { CommandResult } from '@deepseek-ai/dsh-commands' +import type { CommandResult } from '@deepseek-ai/dsh-commands/types' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientSessionContext, ConsumeTokenRequest, SlashPick, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client' @@ -525,6 +525,29 @@ describe('execute payload', () => { }]) }) + it('contains local acknowledgment listeners without changing an admitted result', async () => { + const b = await bench({ execute: () => Promise.resolve({ matched: true }) }) + await b.warm(proj('s1')) + const outcome = b.source.matchSpace!(proj('s1'), '/goal') + if (outcome === undefined || outcome === 'handled' || !('claim' in outcome)) throw new Error('expected claim') + const syncFailure = new Error('sync observer failed') + const asyncFailure = new Error('async observer failed') + const after = vi.fn() + const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined) + b.ctx.on('command/executed', () => { throw syncFailure }) + const rejectingListener = (() => Promise.reject(asyncFailure)) as unknown as () => void + b.ctx.on('command/executed', rejectingListener) + b.ctx.on('command/executed', after) + + await expect(outcome.claim.submit('ship it', new Context())).resolves.toEqual({ kind: 'success' }) + expect(after).toHaveBeenCalledOnce() + await Promise.resolve() + await Promise.resolve() + expect(warn).toHaveBeenCalledWith('client command: a command/executed listener for "%s" failed', 'goal') + expect(warn).toHaveBeenCalledWith(syncFailure) + expect(warn).toHaveBeenCalledWith(asyncFailure) + }) + it('maps matched:false to an error outcome and a matched bare result to success', async () => { const claimOf = async (opts: BenchOptions) => { const b = await bench(opts) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index f0409bebe2..6eb03f5c0f 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # 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/host/apiproxy/README.md -README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8 -README.zh.md: 17bbad0094bfac49d63d6076a01d4c5cd2c5aa6b +README.md: a40cc52e5caf1e4557d17e9fd2c1ac589dce30dd +README.zh.md: 9dbc1cc482d1a0e8756eaa0dab3f2c0e62c07336 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 059c3eacbc..a40cc52e5c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed. -Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 17bbad0094..9dbc1cc482 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。 -会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts index 8a5b371e7f..778225c0cc 100644 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -1,5 +1,5 @@ /** - * downloads domain zod schemas. The GET download surface has no wire + * downloads domain zod schemas. The download surface has no wire * envelope: the request arrives as query parameters (all strings), so its * request schema parses the raw query-parameter object into the method's * exact request shape. SessionId brand cast point: sessionIdSchema, and only diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 4e8800348a..697171ec53 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -249,7 +249,7 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname - // No-envelope GET channel surface (SSE streams + host-only download): + // No-envelope read channels (SSE GET streams + host-only download): // physical routes that answer directly, without a wire envelope. if (path === '/api/events.mux' && req.method === 'GET') { return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) @@ -257,14 +257,17 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { if (path === '/api/events.host' && req.method === 'GET') { return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } - if (path === '/api/session.export' && req.method === 'GET') { + if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) { // Query params are a different boundary from the POST envelope, but // the request still casts its brands only through the domain schema. const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) if (!parsed.success) { return new Response('missing or invalid sessionId query parameter', { status: 400 }) } - return api.downloads.sessionLog(parsed.data, req.signal) + const response = await api.downloads.sessionLog(parsed.data, req.signal) + if (req.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) } if (req.method !== 'POST' || !path.startsWith('/api/')) { diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts index a766c4b4eb..1c0a818bdd 100644 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -152,6 +152,30 @@ describe('session.export download endpoint', () => { expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) }) + it('preflights root preparation through HEAD without streaming a body', async () => { + const readRaw = vi.fn(async () => artifact('session-root')) + const api = await buildApi({}, [], { readRaw }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + expect(response.body).toBeNull() + expect(readRaw).toHaveBeenCalledOnce() + }) + + it('returns a bodyless preparation error from HEAD', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(404) + expect(response.body).toBeNull() + }) + it('uses the resolved compression level for ZIP entries', async () => { const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) diff --git a/packages/session-query/session-export/README.i18n.yaml b/packages/session-query/session-export/README.i18n.yaml index 25c992f74d..65ab7c195b 100644 --- a/packages/session-query/session-export/README.i18n.yaml +++ b/packages/session-query/session-export/README.i18n.yaml @@ -2,5 +2,5 @@ # 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-export/README.md -README.md: 3df11c1132715dc590d50b53588f8875015f237b -README.zh.md: 008cf433df1104c9f7e45cd04c0e0f256e3682fb +README.md: 4abee799c51d50342421ff594e88b03a11e785fa +README.zh.md: 9b3ab129c132f797bbede8d8676bc8af954c1f90 diff --git a/packages/session-query/session-export/README.md b/packages/session-query/session-export/README.md index 3df11c1132..4abee799c5 100644 --- a/packages/session-query/session-export/README.md +++ b/packages/session-query/session-export/README.md @@ -11,7 +11,7 @@ Web Session-log download control over the host-streamed ZIP endpoint owned by `d | `/export` | Record a human-command lifecycle; the submitting browser receives the local execution acknowledgment and downloads `GET /api/session.export?sessionId=&includeDescendants=true`. | | `/export ` | 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, so both entry paths share in-flight collapsing, cancellation on plugin disposal, HTTP error handling, browser save behavior, and the same Modal. +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. @@ -46,3 +46,4 @@ None. The log-only command lifecycle and browser download do not change the deri - 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. diff --git a/packages/session-query/session-export/README.zh.md b/packages/session-query/session-export/README.zh.md index 008cf433df..9b3ab129c1 100644 --- a/packages/session-query/session-export/README.zh.md +++ b/packages/session-query/session-export/README.zh.md @@ -11,7 +11,7 @@ Web Session 日志下载控制,使用 `dsh-host-apiproxy` 拥有的 Host 流 | `/export` | 记录一组用户命令生命周期;提交命令的浏览器收到本地执行确认后,下载 `GET /api/session.export?sessionId=&includeDescendants=true`。 | | `/export ` | 返回错误。浏览器下载通过浏览器的普通下载行为选择目标位置。 | -该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载;其他标签页仍会渲染持久命令行,但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器,因此两种入口共用并发折叠、插件释放时取消、HTTP 错误处理、浏览器保存行为和同一个 Modal。 +该命令只由 Web bundle 挂载。只有 `/export` 返回成功时,本地 `command/executed` 确认才会在提交命令的浏览器中触发斜杠下载;其他标签页仍会渲染持久命令行,但不会重复执行浏览器副作用。Header 按钮直接调用同一个控制器。两种入口都会先发出 `HEAD` 预检,再把 GET URL 交给浏览器下载管理器,JavaScript 不会缓冲 ZIP;它们共用并发折叠、插件释放时取消预检、准备阶段错误处理、浏览器保存行为和同一个 Modal。 Host 下载端点会在 `readRaw` 前 flush 活动的根 Session,因此斜杠命令触发的 ZIP 会包含启动下载的 `command/run` 与 `command/done` 事件对。冷持久化 Session 不需要 flush。 @@ -46,3 +46,4 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co - 下载端点要求持久化后端具有逐 Session 原始工件。随附 JSONL 后端支持明文和 zstd 工件;本次改动不包含 SQLite 导出。 - 这是浏览器下载,不是 Host 路径写入。目标位置由浏览器选择,不会返回 Host 路径或原生文件夹操作。 +- 预检只报告 ZIP 开始流式传输前发现的失败。浏览器接受 GET 后发生的子 Session 或附件读取失败由浏览器下载管理器报告,不通过弹窗报告。 diff --git a/packages/session-query/session-export/src/client/Dialog.tsx b/packages/session-query/session-export/src/client/Dialog.tsx index f829f598e5..5397781852 100644 --- a/packages/session-query/session-export/src/client/Dialog.tsx +++ b/packages/session-query/session-export/src/client/Dialog.tsx @@ -12,7 +12,7 @@ export interface SessionExportDialogInjected { } export type SessionExportDialogProps = - PropsRuntime<'conversation.session.header.actions'> + PropsRuntime<'conversation.session.header.utilities'> & PropsLocale & InjectFace diff --git a/packages/session-query/session-export/src/client/HeaderAction.module.css b/packages/session-query/session-export/src/client/HeaderAction.module.css index 03cf469b0e..288e7f9bae 100644 --- a/packages/session-query/session-export/src/client/HeaderAction.module.css +++ b/packages/session-query/session-export/src/client/HeaderAction.module.css @@ -1,4 +1,3 @@ -/* The 111 px design width is a floor so translated labels do not clip. */ .sessionLogButton { display: inline-flex; align-items: center; diff --git a/packages/session-query/session-export/src/client/controller.ts b/packages/session-query/session-export/src/client/controller.ts index 15b260c7b2..c983e98561 100644 --- a/packages/session-query/session-export/src/client/controller.ts +++ b/packages/session-query/session-export/src/client/controller.ts @@ -18,7 +18,7 @@ export interface SessionExportDownloadState { } type Fetch = (input: string | URL, init?: RequestInit) => Promise -type Save = (blob: Blob, filename: string) => void +type Save = (url: string, filename: string) => void const INITIAL: SessionExportDownloadState = { bySession: {} } @@ -32,17 +32,15 @@ export function sessionLogZipFilename(sessionId: SessionId): string { } /** - * Trigger a browser save without copying the response blob. - * @param blob - complete ZIP response body. + * Hand a Host download URL to the browser download manager. + * @param url - same-origin Host download URL. * @param filename - browser download filename. */ -export function downloadBlob(blob: Blob, filename: string): void { - const url = URL.createObjectURL(blob) +export function downloadUrl(url: string, filename: string): void { const anchor = document.createElement('a') anchor.href = url anchor.download = filename anchor.click() - setTimeout(() => { URL.revokeObjectURL(url) }, 0) } /** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ @@ -69,7 +67,7 @@ export class SessionExportDownloadController { */ constructor( private readonly fetcher: Fetch = (input, init) => fetch(input, init), - private readonly save: Save = downloadBlob, + private readonly save: Save = downloadUrl, ) {} /** @@ -89,15 +87,6 @@ export class SessionExportDownloadController { return done } - /** - * Present a command failure without issuing an HTTP request. - * @param sessionId - Session whose modal reports the failure. - * @param error - stable command failure text. - */ - fail(sessionId: SessionId, error: string): void { - this.publish(sessionId, { open: true, status: 'error', error }) - } - /** * Close one Session's dialog without cancelling an in-flight browser download. * @param sessionId - Session whose modal closes. @@ -125,12 +114,12 @@ export class SessionExportDownloadController { const url = new URL('/api/session.export', hostBase()) url.searchParams.set('sessionId', sessionId) url.searchParams.set('includeDescendants', 'true') - const response = await this.fetcher(url, { signal }) + 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(await response.blob(), sessionLogZipFilename(sessionId)) + 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) { diff --git a/packages/session-query/session-export/tests/client-apply.client.spec.tsx b/packages/session-query/session-export/tests/client-apply.client.spec.tsx index 033091dc7d..0049d2cb7f 100644 --- a/packages/session-query/session-export/tests/client-apply.client.spec.tsx +++ b/packages/session-query/session-export/tests/client-apply.client.spec.tsx @@ -43,12 +43,10 @@ describe('session-export browser plugin', () => { expect(entry?.component).toBe(SessionExportHeader) expect(entry?.options).toMatchObject({ id: 'session-export' }) const injected = (entry?.inject as unknown as () => import('../src/client/Dialog.tsx').SessionExportDialogInjected)() - b.ctx.sessionExport.fail(SID, 'failed') + await injected.request(SID) expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') injected.dismiss(SID) expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.open).toBe(false) - await injected.request(SID) - expect(b.ctx.sessionExport.store.getSnapshot().bySession[SID]?.status).toBe('error') await b.fiber.dispose() expect(b.slots.entries('conversation.session.header.utilities')).toHaveLength(0) diff --git a/packages/session-query/session-export/tests/command.host.spec.ts b/packages/session-query/session-export/tests/command.client.spec.ts similarity index 100% rename from packages/session-query/session-export/tests/command.host.spec.ts rename to packages/session-query/session-export/tests/command.client.spec.ts diff --git a/packages/session-query/session-export/tests/controller.client.spec.ts b/packages/session-query/session-export/tests/controller.client.spec.ts index 21f1ab24b3..5106b0ca72 100644 --- a/packages/session-query/session-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-export/tests/controller.client.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { - downloadBlob, SessionExportDownloadController, sessionLogZipFilename, + downloadUrl, SessionExportDownloadController, sessionLogZipFilename, } from '../src/client/controller.ts' const SID = 'session-export-controller' as SessionId @@ -25,9 +25,12 @@ describe('SessionExportDownloadController', () => { 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(expect.any(Object), 'dsh-session-session-export-controller.zip') - expect((save.mock.calls[0]?.[0] as Blob).size).toBe(3) + expect(save).toHaveBeenCalledWith( + url.toString(), + 'dsh-session-session-export-controller.zip', + ) expect(controller.store.getSnapshot().bySession[SID]).toEqual({ open: true, status: 'success', error: null, }) @@ -50,7 +53,7 @@ describe('SessionExportDownloadController', () => { controller.dismiss(SID) }) - it('publishes HTTP, transport, and command failures without leaking rejections', async () => { + it('publishes HTTP and transport failures without leaking rejections', async () => { const http = new SessionExportDownloadController( async () => new Response('backend unavailable', { status: 500 }), vi.fn(), ) @@ -65,8 +68,6 @@ describe('SessionExportDownloadController', () => { await transport.download(SID) expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('offline') - transport.fail(SID, 'command failed') - expect(transport.store.getSnapshot().bySession[SID]?.error).toBe('command failed') transport.dismiss('absent' as SessionId) const emptyDetail = new SessionExportDownloadController( @@ -102,14 +103,14 @@ describe('SessionExportDownloadController', () => { vi.stubGlobal('location', { origin: 'null' }) const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip')) vi.stubGlobal('fetch', fetcher) - vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:default') - vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) - vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) const controller = new SessionExportDownloadController() 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 () => { @@ -132,19 +133,14 @@ describe('SessionExportDownloadController', () => { }) describe('browser download helpers', () => { - it('sanitizes the archive filename and revokes the object URL after the click', () => { - vi.useFakeTimers() - const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:session') - const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + 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') - downloadBlob(new Blob(['zip']), 'archive.zip') - expect(create).toHaveBeenCalledOnce() + downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip') expect(click).toHaveBeenCalledOnce() - expect(revoke).not.toHaveBeenCalled() - vi.runAllTimers() - expect(revoke).toHaveBeenCalledWith('blob:session') - vi.useRealTimers() + 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') }) }) diff --git a/packages/session-query/session-export/tests/dialog.client.spec.tsx b/packages/session-query/session-export/tests/dialog.client.spec.tsx index cdf2f55fab..bea21800e9 100644 --- a/packages/session-query/session-export/tests/dialog.client.spec.tsx +++ b/packages/session-query/session-export/tests/dialog.client.spec.tsx @@ -33,7 +33,11 @@ afterEach(cleanup) describe('SessionExportDialog', () => { it('shows a controller failure and closes it without reading Session history', async () => { const b = bench() - act(() => { b.controller.fail(SID, 'toolbar failed') }) + 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] @@ -57,7 +61,11 @@ describe('SessionExportDialog', () => { it('uses fallback copy when a failure has no detail', async () => { const b = bench() - act(() => { b.controller.fail(SID, '') }) + 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) diff --git a/packages/session-query/session-export/tests/invariant.host.spec.ts b/packages/session-query/session-export/tests/invariant.client.spec.ts similarity index 100% rename from packages/session-query/session-export/tests/invariant.host.spec.ts rename to packages/session-query/session-export/tests/invariant.client.spec.ts diff --git a/packages/session-query/session-export/tests/loader-composition.host.spec.ts b/packages/session-query/session-export/tests/loader-composition.client.spec.ts similarity index 94% rename from packages/session-query/session-export/tests/loader-composition.host.spec.ts rename to packages/session-query/session-export/tests/loader-composition.client.spec.ts index c82286ea6c..de0336d59c 100644 --- a/packages/session-query/session-export/tests/loader-composition.host.spec.ts +++ b/packages/session-query/session-export/tests/loader-composition.client.spec.ts @@ -54,7 +54,8 @@ describe('session-export real Loader composition', () => { }) await context.loader.await() - const session = context.sessions.create(SessionId('loader-session-export'), { meta: { createdAt: 1 } }) + 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', diff --git a/packages/session-query/session-export/tsconfig.client.json b/packages/session-query/session-export/tsconfig.client.json deleted file mode 100644 index e664af1549..0000000000 --- a/packages/session-query/session-export/tsconfig.client.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" - }, - "include": [ - "src/client", - "src/css-modules.d.ts" - ], - "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../interaction/commands" }, - { "path": "../../client/locale" }, - { "path": "../../client/runtime" }, - { "path": "../../client/ui-command" }, - { "path": "../../client/ui-conversation" }, - { "path": "../../client/ui-primitives" }, - { "path": "../../client/ui-slots" } - ] -} diff --git a/packages/session-query/session-export/tsconfig.host.json b/packages/session-query/session-export/tsconfig.host.json deleted file mode 100644 index 4b5036bf0b..0000000000 --- a/packages/session-query/session-export/tsconfig.host.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" - }, - "files": [ - "src/index.ts", - "src/invariant.ts" - ], - "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../interaction/commands" }, - { "path": "../../support/invariants" } - ] -} diff --git a/packages/session-query/session-export/tsconfig.json b/packages/session-query/session-export/tsconfig.json index 2a0b0e33f7..7bd930dc5f 100644 --- a/packages/session-query/session-export/tsconfig.json +++ b/packages/session-query/session-export/tsconfig.json @@ -1,7 +1,21 @@ { - "files": [], + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], "references": [ - { "path": "./tsconfig.host.json" }, - { "path": "./tsconfig.client.json" } + { "path": "../../../vendor/cordis" }, + { "path": "../../interaction/commands" }, + { "path": "../../client/locale" }, + { "path": "../../client/runtime" }, + { "path": "../../client/ui-command" }, + { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-slots" }, + { "path": "../../support/invariants" } ] } diff --git a/tsconfig.client.json b/tsconfig.client.json index 53bb5868b2..b010962cfe 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -81,7 +81,7 @@ { "path": "./packages/client/ui-plugin-config" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, - { "path": "./packages/session-query/session-export/tsconfig.client.json" }, + { "path": "./packages/session-query/session-export" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 4b40cf9fd8..3e25c9c510 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -140,7 +140,6 @@ { "path": "./packages/session/session-projection-cache" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, - { "path": "./packages/session-query/session-export/tsconfig.host.json" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-local" }, { "path": "./packages/credentials/credentials" }, From 7110f0f52b0778f024b5dc92d5b4575048d87818 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 12 Aug 2026 20:02:58 +0800 Subject: [PATCH 6/7] test(web): acknowledge current welcome notice --- apps/web/tests/scaffold.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 99d1605a5b..1d437ea97a 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -51,7 +51,7 @@ import { dshHomePath } from '@deepseek-ai/dsh-paths' // } from '@deepseek-ai/dsh-client-ui-settings-general' export const WELCOME_NOTICE_SETTINGS_NAMESPACE = 'ui-onboarding' export const WELCOME_NOTICE_ACK_FIELD = 'welcomeNoticeVersion' -export const WELCOME_NOTICE_VERSION = '2026-07-30.7' +export const WELCOME_NOTICE_VERSION = '2026-08-11.1' export const WELCOME_NOTICE_COPY = { zh: { title: '内测声明', continueLabel: '继续' } } as const import { settingsNamespace } from '@deepseek-ai/dsh-settings' From a3cea842d07f701d1865f80306945a57aebaa3d9 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Wed, 12 Aug 2026 20:14:43 +0800 Subject: [PATCH 7/7] test(web): pin full telemetry in scaffold --- apps/web/tests/scaffold.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 1d437ea97a..125dacc3f2 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -421,7 +421,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise