`. The `diffs` payload — the whole point of the result — was discarded, so a file mutation read as a one-line confirmation with no visible change.
+
+This is the [terminal card](2026-07-28-web-terminal-card.md) done for the `diff` arm: that change made the Web client a consumer of the `terminal` render intent; this one makes it a consumer of the `diff` render intent, reusing the same four-layer shape.
+
+## Decision
+
+`DiffBlock` is a `ui-primitives` component that renders a file mutation as an inline diff surface, and both Web render sites for a write/edit call consume the diff render intent through it: the chat tool row's body and the details panel's Output section. `ui-conversation/src/client/contract/diff-card-model.ts` is the single place that turns the snapshot's `callView`/`resultView` pair into the component's props, so the two sites cannot disagree about a change. It returns null — the generic path — whenever neither side declares `card: 'diff'`, including a `card` value this client version does not know, and whenever a settled call's result view is generic, which is how write/edit keep their execution errors on the generic path. The result side is authoritative once the call settles: the applied hunks replace the call-time diff derived from the arguments alone. A paging window that drops the call head still renders, because the result view carries the whole change.
+
+The component's contract follows the TUI's `diffLines` (`packages/ui/tui/src/components/transcript.ts`) so a diff reads the same shape across front ends:
+
+- **One path header per file.** A new file opens a bold path header; a same-file second hunk (a scattered edit, or a `replace_all`) opens with a `⋯` gap instead of repeating the path. The `N file(s)` footer counts DISTINCT paths on both front ends — this PR moved the TUI footer off `diffs.length` onto the distinct-path count, so two hunks in one file read as `1 file` in both.
+- **The change in the diff's own colors.** A removed line is `- ` on the error token, an added line is `+ ` on the success token, drawn verbatim with `white-space: pre` inside a horizontally scrolling box — a source line is read by its indentation, so it scrolls rather than folds. A create (`oldText: null`) has no removed side.
+- **Height cap with an expand control.** A diff longer than `DEFAULT_DIFF_MAX_LINES` (16) shows `ceil(max/2)` head rows plus the remaining tail rows, with a button between reporting the hidden count. The split arithmetic matches `TerminalBlock` and the TUI's collapsed card, so a long diff's head and tail slices agree across front ends.
+- **Line terminator.** A side's content splits on `\n` under the terminator rule `TerminalBlock` uses: empty text is zero lines (a full deletion's `newText`, a create's absent `oldText` side), a single trailing newline terminates its last line rather than adding a phantom empty one, and an interior blank line survives. This PR applied the same rule to the TUI diff branch, so the `+A -R` footer counts agree on both front ends for the newline-terminated content real write/edit calls carry.
+- **Footer and copy.** A dim `└ +A -R · N file(s)` footer summarizes the change; `+A -R` are the added/removed line counts, the same per-side counts the TUI footer draws. The copy control copies the prefixed diff text (path headers, `- `/`+ ` lines, the `⋯` gap), so a multi-file copy stays attributable.
+
+Geometry, radius, and fonts mirror `CodeBlock`/`TerminalBlock` so a diff card, a terminal card, and a fenced block read as one family; `white-space: pre` plus horizontal scroll is the deliberate divergence. The copy control floats in the card's top-right corner rather than on a banner row of its own, because a banner carrying only a copy button drew an empty band above the first diff line — the TUI diff card has no banner either, only the footer.
+
+The chat row renders the diff resident under its path-link summary, capped at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 — the same inline-output decision and the same in-flow-vs-reading-surface split recorded for the [terminal card](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention). A write/edit row is single-file, so its summary stays an openable path link AND its diff card expands; the two coexist because the card is not the path's args body.
+
+## Alternatives considered
+
+**A side-by-side (two-column) diff.** Rejected for now by the owner: it is denser but does not fit the narrow chat row, and the goal was parity with the TUI's single-column unified form. A two-column mode in the details panel is a later props change, not a redesign.
+
+**Git-style line-number gutters.** The `FileDiff` contract carries only `{ path, oldText, newText }` — `structuredPatch`'s hunk start lines are dropped in `diff.ts`, so no line number reaches the client. Rendering a numbered gutter needs a backend contract change (carry `oldStart`/`newStart`) and a matching TUI upgrade to stay consistent; deferred so this PR stays a pure Web consumer of the existing contract.
+
+**Reuse `CodeBlock`.** Rejected for the same reason the terminal card was: `CodeBlock` soft-wraps and has no per-line `+`/`-` role, no path headers, and no footer. The two share geometry and font tokens, which is the only part where one implementation is correct for both.
+
+## Consequences
+
+`DiffBlock` reads only the diff view's fields, so it stays a pure function of what the render intent carries — replay-safe like the presenters that produce the view. A UI without the diff capability still gets the bridge's generic fallback; nothing about the tool's result shape changed. No new runtime dependency: unlike the terminal card's `anser`, a diff needs no parser.
+
+The multi-file arm of `DiffBlock` (one card, several path headers) has no producer today: `write`/`edit` each mutate one file per call, so a real card shows one file with one or more hunks. The arm is built and tested for a future multi-file mutation tool, not for a current consumer.
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` pins the component: the create arm (added-only, no removed side), the edit arm (removed above added), the same-file `⋯` gap versus a new file's own header, the empty-diffs null render, the footer counts and their singular/plural, the head/tail cap with its `aria-expanded` toggle, and the copy control asserting the prefixed diff text on both the accepted and refused clipboard paths. Per-file 100%.
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` pins the wiring at every render site: `diffCardModel`'s derivation and each of its null arms, the result hunks replacing the call-time diff, a window-truncated call still rendering from the result, the chat row's diff body, `FileMutationRow`'s resident card and its path link opening cwd-resolved through the host, its registration under both `write` and `edit`, and the panel's Output section.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) carries three diff turns so a `?fixture` server and the per-package wiring suite exercise all three arms at both render sites: a single-hunk edit (turn 62, keyed `FileMutationRow`), a create/write (turn 63), and a multi-hunk edit (turn 67, the `⋯` gap between two scattered hunks in one file). The built-boot snapshot (`apps/web/tests/built-boot.snapshot.ts`) is a boot-assembly smoke that asserts only that the graph mounts and reaches chat content (`data-sample="bash-global"`); by its own contract it carries no diff-behavior assertions, which the wiring suite owns.
+
+## Related
+
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the same four-layer shape for the `terminal` arm; this note reuses its inline-output decision and its head/tail cap arithmetic.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary this consumes; the Web client is now a consumer of the `diff` arm too.
+- [Web client architecture](../architecture/2026-07-19-gui-web-client-architecture.md) — the slot and snapshot layering the two render sites sit in.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
new file mode 100644
index 0000000000..afdeafa6e9
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md
@@ -0,0 +1,57 @@
+# Agent Note: Web diff 卡片 —— write/edit 渲染意图抵达浏览器
+
+Status: implemented
+
+[English](2026-07-30-web-diff-card.md) | 中文
+
+## Problem
+
+`write` 和 `edit` 工具为其 call 和 result 都声明了 `card: 'diff'`([render-intent union](../architecture/2026-07-02-tool-render-intent-union.md)):call view 携带从参数推导的预期改动,result view 携带已应用的上下文 hunk(`FileDiff[]`,由 `packages/fs/tool-fs/src/diff.ts` 计算,并持久化在 result `meta` 中以便回放重建)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `callView`/`resultView` 投递到 `ConversationSnapshot` —— TUI 也已将其渲染为按文件分组的 `+`/`-` 块加 `+A -R · N file(s)` 页脚。
+
+Web 客户端忽略了它。write/edit 调用落到 `GenericToolCard`,其行从原始工具参数推导,详情面板把 result 的 content block 摊平进一个 ``。`diffs` 载荷 —— result 的全部意义 —— 被丢弃,于是一次文件改动读起来只是一行确认、看不到任何改动。
+
+这是把 [terminal 卡片](2026-07-28-web-terminal-card.md) 对 `diff` 这一支重做一遍:那次改动让 Web 客户端成为 `terminal` 渲染意图的消费者;这次让它成为 `diff` 渲染意图的消费者,复用同一套四层结构。
+
+## Decision
+
+`DiffBlock` 是一个 `ui-primitives` 组件,把文件改动渲染为内联 diff 表面,write/edit 调用的两个 Web 渲染点都通过它消费 diff 渲染意图:chat 工具行的行体和详情面板的 Output 区。`ui-conversation/src/client/contract/diff-card-model.ts` 是唯一把快照的 `callView`/`resultView` 对转成组件 props 的地方,因此两个渲染点不会对一次改动产生分歧。当两侧都未声明 `card: 'diff'` 时它返回 null —— 走通用路径 —— 包括本客户端版本不认识的 `card` 值,以及已结算调用的 result view 是 generic 的情况(write/edit 的执行错误正是这样留在通用路径上的)。调用结算后 result 侧是权威:已应用的 hunk 替换仅从参数推导的 call 时 diff。分页窗口丢弃了 call 头也仍能渲染,因为 result view 携带完整改动。
+
+组件的契约遵循 TUI 的 `diffLines`(`packages/ui/tui/src/components/transcript.ts`),使 diff 在两个前端读起来是同一形态:
+
+- **每个文件一个路径头。** 新文件开启一个粗体路径头;同文件的第二个 hunk(分散编辑,或 `replace_all`)以一个 `⋯` gap 开启,而非重复路径。`N file(s)` 页脚在两个前端都统计**去重后的路径数** —— 本 PR 把 TUI 页脚从 `diffs.length` 改为去重路径计数,因此同文件两个 hunk 在两端都读作 `1 file`。
+- **改动用 diff 自身的颜色。** 删除行是 error token 上的 `- `,新增行是 success token 上的 `+ `,在横向滚动的盒子里以 `white-space: pre` 逐字绘制 —— 源码行靠缩进阅读,所以滚动而不折行。新建(`oldText: null`)没有删除侧。
+- **高度上限带展开控件。** 长于 `DEFAULT_DIFF_MAX_LINES`(16)的 diff 显示 `ceil(max/2)` 个头部行加剩余尾部行,中间一个按钮报告隐藏行数。分割算术与 `TerminalBlock` 和 TUI 的折叠卡片一致,因此长 diff 的头尾切片在两个前端一致。
+- **行终止符。** 每一侧的内容按 `TerminalBlock` 的终止符规则在 `\n` 上切分:空文本是零行(整文件删除的 `newText`、新建缺失的 `oldText` 侧),单个结尾换行终止其最后一行而非新增一条幻影空行,内部空行保留。本 PR 把同一规则应用到了 TUI diff 分支,因此对于真实 write/edit 调用携带的以换行结尾的内容,两个前端的 `+A -R` 页脚计数一致。
+- **页脚与复制。** 暗色 `└ +A -R · N file(s)` 页脚概括改动;`+A -R` 是新增/删除行数,与 TUI 页脚绘制的每侧计数相同。复制控件复制带前缀的 diff 文本(路径头、`- `/`+ ` 行、`⋯` gap),使多文件复制保持可归属。
+
+几何、圆角、字体镜像 `CodeBlock`/`TerminalBlock`,使 diff 卡片、terminal 卡片、代码块读起来是一家;`white-space: pre` 加横向滚动是刻意的分歧。复制控件浮在卡片右上角,而非占据自己的 banner 行,因为只放一个复制按钮的 banner 会在第一行 diff 上方画出一条空带 —— TUI 的 diff 卡片也没有 banner,只有页脚。
+
+chat 行把 diff 常驻渲染在路径链接摘要之下,上限 `CHAT_DIFF_MAX_LINES`(8),对应面板的 16 —— 与 [terminal 卡片](2026-07-28-web-terminal-card.md#inline-output-in-the-chat-row-reverses-a-stated-convention)记录的内联输出决策、以及流内表面对单调阅读表面的同一划分一致。write/edit 行是单文件的,所以它的摘要既是可打开的路径链接,其 diff 卡片又展开;两者共存,因为卡片不是路径的参数体。
+
+## Alternatives considered
+
+**并排(双栏)diff。** owner 目前拒绝:它更密但不适合狭窄的 chat 行,目标是与 TUI 单栏统一形式对齐。详情面板里的双栏模式是后续的 props 改动,不是重设计。
+
+**git 式行号槽。** `FileDiff` 契约只携带 `{ path, oldText, newText }` —— `structuredPatch` 的 hunk 起始行在 `diff.ts` 里被丢弃,所以没有行号抵达客户端。渲染行号槽需要后端契约改动(携带 `oldStart`/`newStart`)并同步升级 TUI 以保持一致;推迟,使本 PR 保持为对既有契约的纯 Web 消费。
+
+**复用 `CodeBlock`。** 因与 terminal 卡片相同的理由拒绝:`CodeBlock` 会折行,且没有每行 `+`/`-` 角色、没有路径头、没有页脚。两者共享几何与字体 token,那是唯一一处一个实现对两者都正确的部分。
+
+## Consequences
+
+`DiffBlock` 只读 diff view 的字段,因此它是渲染意图所携带内容的纯函数 —— 与产出该视图的 presenter 一样回放安全。没有 diff 能力的 UI 仍得到 bridge 的通用回退;工具的 result 形状没有任何改变。无新增运行时依赖:不同于 terminal 卡片的 `anser`,diff 不需要解析器。
+
+`DiffBlock` 的多文件支路(一张卡、多个路径头)今天没有生产者:`write`/`edit` 每次调用各改一个文件,所以真实卡片显示一个文件带一个或多个 hunk。该支路为将来的多文件改动工具而构建并测试,不是为当前消费者。
+
+## Testing
+
+`packages/client/ui-primitives/tests/diff-block.spec.tsx` 钉住组件:新建支路(只有新增、无删除侧)、编辑支路(删除在新增之上)、同文件 `⋯` gap 对比新文件自己的头、空 diffs 的 null 渲染、页脚计数及其单复数、头尾上限及其 `aria-expanded` 切换、以及复制控件在接受与拒绝两条剪贴板路径上断言带前缀的 diff 文本。Per-file 100%。
+
+`packages/client/ui-conversation/tests/diff-card.spec.tsx` 钉住每个渲染点的接线:`diffCardModel` 的派生及其每个 null 支路、result hunk 替换 call 时 diff、窗口截断的 call 仍从 result 渲染、chat 行的 diff 体、`FileMutationRow` 的常驻卡片及其路径链接经 host 以 cwd 解析打开、其在 `write` 与 `edit` 下的注册、以及面板的 Output 区。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)携带三个 diff turn,使 `?fixture` 服务与 per-package 接线测试套件在两个渲染点演练全部三个支路:单 hunk 编辑(turn 62,keyed `FileMutationRow`)、新建/写入(turn 63)、多 hunk 编辑(turn 67,一个文件内两处分散 hunk 之间的 `⋯` gap)。built-boot snapshot(`apps/web/tests/built-boot.snapshot.ts`)是启动装配 smoke,只断言图挂载并抵达 chat 内容(`data-sample="bash-global"`);按其自身契约它不带 diff 行为断言,那由接线套件负责。
+
+## Related
+
+- [Web terminal 卡片](2026-07-28-web-terminal-card.md) —— `terminal` 支路的同一套四层结构;本 note 复用其内联输出决策与头尾上限算术。
+- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— 本改动消费的 `card` 标签词汇;Web 客户端现在也是 `diff` 支路的消费者。
+- [Web 客户端架构](../architecture/2026-07-19-gui-web-client-architecture.md) —— 两个渲染点所处的 slot 与快照分层。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.i18n.yaml
new file mode 100644
index 0000000000..dae8f18b4d
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.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-07-30-web-result-card-frontend.md
+2026-07-30-web-result-card-frontend.md: d6f4785e83335ca2dd5295516baf47c845ebf5bd
+2026-07-30-web-result-card-frontend.zh.md: ed95cbe39f4f0bf77ba5da64d664705a0841863f
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
new file mode 100644
index 0000000000..d6f4785e83
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md
@@ -0,0 +1,49 @@
+# Agent Note: Web result card frontend — rendering the web render intent in the browser
+
+Status: implemented
+
+English | [中文](2026-07-30-web-result-card-frontend.zh.md)
+
+## Problem
+
+The `web_search` and `web_fetch` tools declare a `card: 'web'` result view ([web result card](2026-07-30-web-result-card.md)): a `kind`-tagged union carrying either the structured cited sources plus an optional provider answer (`kind: 'search'`) or the fetched URL and its HTTP status (`kind: 'fetch'`). That view already reaches the browser — host, connection, and runtime deliver it onto `ConversationSnapshot` as `resultView` — but the Web client ignored it: a completed web call rendered only as its flattened model-facing text, the same lossy render the contract note explains the structured view exists to replace. A `web_search` reached the reader as one free-text markdown line per source rather than a citation list of clickable sources, and a `web_fetch` as its markdown body with no retrieval summary.
+
+## Decision
+
+`WebBlock` is a `ui-primitives` component that renders a completed web retrieval, and every Web render site for a web call consumes the `web` render intent through it: the keyed chat tool rows (`web_search`/`web_fetch`), the `GenericToolCard` render-site fallback, and the details panel's Output section. `ui-conversation/src/client/contract/web-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, mirroring `terminal-card-model.ts`, so no two sites disagree about what a web call shows. It returns null — the generic path — for a running call (the web card is result-only, since the tools keep a generic pending view), for a settled call whose result view is not a web card including a `card` value this client version does not know (which arrives over the wire and so cannot be trusted to be a compiled variant), for a generic result view (a web tool's error path returns the generic card, whose text the generic path preserves), and for a web card whose `kind` this client version does not know (a newer host's value off the wire, which reading as a fetch would draw as an empty URL and `HTTP undefined`).
+
+One component draws both kinds, discriminated by `kind`. A `search` shows the answer as markdown above a citation list; each source is a safe external link labelled by its title, or its hostname when the provider gave none, with the snippet and publication date below it, and a `来源列表已截断` indicator when the tool capped the list. A `fetch` shows a compact summary: the linked final URL, its HTTP status, and a `内容已截断` indicator. One component rather than two because both are web retrieval rendered as one card family, which is exactly the reason the contract carries them under one `card` tag with a `kind` discriminant.
+
+**Links are safe by the http(s) subset of the allowlist MarkdownText applies to untrusted assistant-authored links** — MarkdownText also permits `mailto:`, deliberately excluded here since a retrieval URL is never a mail address. A source or fetch URL becomes a navigable anchor only when its protocol is `http:` or `https:`, with `target="_blank"` and `rel="noopener noreferrer"`; a `javascript:`/`data:`/`file:`/`mailto:` URL or an unparseable string renders as plain text with no href. The result content a web tool returns is model-authored and reaches this component unverified, so it is treated as untrusted exactly as assistant markdown is. The label falls back from title to hostname to the raw URL, so a source always reads as something even when both the title is absent and the URL does not parse.
+
+**Geometry mirrors CodeBlock/TerminalBlock** (12px radius, code-block surface, 16px vertical margin) so a web card reads as one family with them. A long source list caps at `maxSources` with a head/tail collapse using TerminalBlock's exact split arithmetic (`ceil(max/2)` head lines plus the remaining tail), so a long body's slices agree between the two cards. A source list is prose rather than column-aligned output, so it wraps normally instead of scrolling horizontally the way a terminal card's output does — that is the one deliberate divergence from TerminalBlock.
+
+The card is **resident** under the summary row in the chat rows, capped at `CHAT_WEB_MAX_SOURCES` (8) — half the primitive's own default of 16, which the details panel keeps — the same summary-surface-versus-reading-surface split `CHAT_TERMINAL_MAX_LINES` draws for the terminal card, and the same resident posture `BashRow` uses. The keyed rows register one `WebRow` component under both `web_search` and `web_fetch`; the row discriminates on the tool name only to pick its icon (search vs. browse) and its title (`Search`/`Fetch`). A web-declaring tool without its own keyed row lands on `GenericToolCard`, which grows the same resident card. The details panel renders the card at the primitive's full source allowance and, below it, the flattened model-visible result content: a `web_fetch` card carries only the URL and status, so its fetched body is readable only here.
+
+## Consequences
+
+`WebBlock` reads only the web view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view, and unlike the terminal card it needs no cwd resolution because a web view carries no path. A UI without the `web` capability (the TUI) still gets the contract's fallback `content`; nothing about the tools' result shape changed. `MarkdownText` is reused for the answer, so the answer's own untrusted-link handling and GFM rendering come for free.
+
+A separate later PR unifies the whole-row collapse/expand interaction and will flip every resident card (terminal, diff, web) to expand-gated at once; this card follows the current resident convention rather than pre-empting that change.
+
+## Alternatives considered
+
+**Two components, one per kind.** Rejected: the two shapes share their card chrome, their safe-link handling, and their truncation indicator, and the contract already expresses their difference as a `kind` discriminant under one `card` tag; two components would duplicate the shared surface and split the safe-link logic.
+
+**Reparse the model-facing render text instead of consuming the structured view.** Rejected for the same reason the contract note gives: `web_search`'s render collapses each source's fields into one free-text line labelled by title OR hostname, so reparsing cannot recover `{url, title?, snippet?, publishedAt?}`. The structured `resultView` is the only faithful source, which is why the backend PR added it.
+
+**Render plain anchors without the protocol allowlist.** Rejected: the URL is model-authored and unverified at this seam, so an unfiltered href would let a `javascript:` URL execute on click. The allowlist is the http(s) subset of MarkdownText's (which also permits `mailto:`), so untrusted retrieval links behave identically wherever they render.
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` pins the component per-file to the 100% gate: both kinds; the title-or-hostname-or-raw-URL label fallback; the safe-link attributes on both kinds (an http(s) URL becoming an external anchor with `target`/`rel`, a `javascript:`/`file:`/unparseable URL rendering as a plain span with no href); the snippet and date shown or omitted on present/empty/absent; the truncation indicator gated on the flag; and the source-list height cap with its head/tail slice and expand/collapse control including the default cap.
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` mirrors `terminal-card.spec.tsx` at every wiring seam: `webCardModel`'s derivation projecting every source field, its truncation and absent-answer arms, the fetch derivation, and each null arm (running, null result view, generic result view, unknown card tag, unknown web `kind`); the keyed `WebRow`'s resident card for both kinds capped tighter than the panel, its summary-row-alone running and failed arms; the `GenericToolCard` fallback growing the resident card for a web-declaring tool and keeping the plain row for a non-web call; the details panel's Output section for both kinds — including a `web_fetch`'s body flattened below its URL/status card — and its flattened fallback for a non-web result; and the keyed registration under both `web_search` and `web_fetch` with one component. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so a coverage run measures none of it.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) adds turns 66 (`web_search`) and 67 (`web_fetch`), authored inline because the client-side fixture cannot import the web tool: turn 66's result view carries an answer and three sources exercising the citation list (a titled source with a snippet and date, a source with no title so its hostname labels the link, and a source with a date but no snippet) with the capped indicator on; turn 67's carries the fetched URL and a 200 status. Both keep a generic pending call view and add the `web` card only at result time, matching the contract's result-only web shape, and are named after the real tools so they hit the keyed `WebRow`. They are ordered before the todo turn (renumbered to 68) for the same reason the terminal turn is: the standing plan retires at the next `turn/start`, so a turn appended after it would empty the dock's plan strip. This drives the built-boot snapshot and a live `?fixture` server.
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) — the backend PR that added the `card: 'web'` result arm and made the two tools emit it; this is its deferred frontend consumer.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this mirrors: a `ui-primitives` block, a single card-model derivation, keyed and fallback chat rows, and a details-panel arm, for the `terminal` render intent.
+- [Tagged render-intent union for tool-call presentation](../architecture/2026-07-02-tool-render-intent-union.md) — the `card`-tagged vocabulary; the Web client is now a full consumer of the `web` arm.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
new file mode 100644
index 0000000000..ed95cbe39f
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md
@@ -0,0 +1,49 @@
+# Agent Note: Web result 卡片前端 —— 在浏览器渲染 web 渲染意图
+
+Status: implemented
+
+[English](2026-07-30-web-result-card-frontend.md) | 中文
+
+## Problem
+
+`web_search` 和 `web_fetch` 工具声明了 `card: 'web'` result view([web result card](2026-07-30-web-result-card.md)):一个 `kind` 标签联合,携带结构化的被引用 sources 加可选的 provider answer(`kind: 'search'`),或抓取的 URL 及其 HTTP 状态(`kind: 'fetch'`)。该视图早已抵达浏览器 —— host、connection、runtime 将它作为 `resultView` 投递到 `ConversationSnapshot` —— 但 Web 客户端忽略了它:一次已完成的 web 调用只渲染为摊平的模型可见文本,正是契约笔记所解释的、结构化视图要替代的那种有损渲染。`web_search` 到达读者时是每个 source 一行自由文本 markdown,而非可点击 source 的引用列表;`web_fetch` 是它的 markdown 正文,没有检索摘要。
+
+## Decision
+
+`WebBlock` 是一个 `ui-primitives` 组件,渲染一次已完成的 web 检索,web 调用的每个 Web 渲染点都通过它消费 `web` 渲染意图:键控的 chat 工具行(`web_search`/`web_fetch`)、`GenericToolCard` 渲染点兜底,以及详情面板的 Output 区。`ui-conversation/src/client/contract/web-card-model.ts` 是唯一把快照的 `resultView` 转成组件 props 的地方,镜像 `terminal-card-model.ts`,因此没有两个渲染点会对一次 web 调用的显示产生分歧。它返回 null —— 走通用路径 —— 对运行中的调用(web 卡片是 result-only 的,因为工具保留 generic pending 视图)、对 result view 不是 web 卡片的已结算调用(包括本客户端版本不认识的 `card` 值,它经 wire 抵达因而不能被信任为已编译的变体)、对 generic result view(web 工具的错误路径返回 generic 卡片,其文本由通用路径保留)、以及对本客户端版本不认识 `kind` 的 web 卡片(更新的 host 经 wire 发来的值,读作 fetch 会画出空 URL 和 `HTTP undefined`)。
+
+一个组件绘制两种 kind,由 `kind` 判别。`search` 把 answer 作为 markdown 显示在引用列表上方;每个 source 是一个安全外链,以其标题为标签,provider 未给标题时以其主机名为标签,下方是 snippet 与发布日期,工具截断列表时显示 `来源列表已截断` 提示。`fetch` 显示一个紧凑摘要:带链接的最终 URL、其 HTTP 状态、以及 `内容已截断` 提示。用一个组件而非两个,因为两者都是渲染为同一卡片族的 web 检索 —— 这正是契约把它们放在一个 `card` 标签下、用 `kind` 判别的原因。
+
+**链接的安全性沿用 MarkdownText 对不受信任的 assistant 链接所用 allowlist 的 http(s) 子集。** MarkdownText 还允许 `mailto:`,此处刻意排除,因为检索 URL 绝不会是邮件地址。一个 source 或 fetch URL 仅当其协议为 `http:` 或 `https:` 时才成为可导航锚点,带 `target="_blank"` 和 `rel="noopener noreferrer"`;`javascript:`/`data:`/`file:`/`mailto:` URL 或无法解析的字符串渲染为纯文本、无 href。web 工具返回的 result content 是模型创作的,未经验证抵达本组件,因此像 assistant markdown 一样被当作不受信任处理。标签从标题回退到主机名再回退到原始 URL,因此即便标题缺失且 URL 无法解析,source 也总能读作某个东西。
+
+**几何镜像 CodeBlock/TerminalBlock**(12px 圆角、code-block 表面、16px 垂直外边距),使 web 卡片与它们读作一家。长 source 列表在 `maxSources` 处折叠,用 TerminalBlock 完全相同的分割算术做头/尾折叠(`ceil(max/2)` 头部行加剩余尾部),使长正文的切片在两张卡之间一致。source 列表是散文而非按列对齐的输出,所以它正常换行,而不像终端卡片的输出那样横向滚动 —— 这是与 TerminalBlock 唯一刻意的分歧。
+
+卡片在 chat 行中**常驻**于摘要行之下,上限 `CHAT_WEB_MAX_SOURCES`(8)—— 原语自身默认 16 的一半,面板保留 16 —— 与 `CHAT_TERMINAL_MAX_LINES` 为终端卡片所画的摘要面对阅读面的同一划分,以及 `BashRow` 所用的同一常驻姿态。键控行把一个 `WebRow` 组件注册在 `web_search` 与 `web_fetch` 两个键下;行仅根据工具名判别以选取其图标(search 对 browse)与标题(`Search`/`Fetch`)。没有自己键控行的 web 声明工具落到 `GenericToolCard`,它长出同一张常驻卡片。详情面板以原语的完整 source 额度渲染卡片,并在其下方渲染摊平的模型可见结果内容:`web_fetch` 卡片只携带 URL 与状态,因此其抓取正文只在此处可读。
+
+## Consequences
+
+`WebBlock` 只读 web view 的字段,因此它是渲染意图所携带内容的纯函数 —— 无会话查找,与产出该视图的 presenter 一样回放安全,且不同于终端卡片它不需要 cwd 解析,因为 web view 不携带路径。没有 `web` 能力的 UI(TUI)仍得到契约的回退 `content`;工具的 result 形状没有任何改变。answer 复用 `MarkdownText`,因此 answer 自身的不受信任链接处理与 GFM 渲染免费获得。
+
+一条独立的后续 PR 会统一整行折叠/展开交互,并把每张常驻卡片(terminal、diff、web)一次性翻成 expand-gated;本卡片遵循当前的常驻约定,而非抢先做那次改动。
+
+## Alternatives considered
+
+**两个组件,每种 kind 一个。** 拒绝:两种形状共享卡片外框、安全链接处理、截断提示,而契约已经把它们的差异表达为一个 `card` 标签下的 `kind` 判别;两个组件会重复共享表面并拆分安全链接逻辑。
+
+**重解析模型可见的渲染文本,而非消费结构化视图。** 因契约笔记给出的同一理由拒绝:`web_search` 的渲染把每个 source 的字段压缩成一行自由文本、以标题或主机名为标签,所以重解析无法恢复 `{url, title?, snippet?, publishedAt?}`。结构化的 `resultView` 是唯一忠实来源,这正是后端 PR 添加它的原因。
+
+**不加协议 allowlist 直接渲染裸锚点。** 拒绝:URL 在此接缝处是模型创作、未经验证的,所以未过滤的 href 会让 `javascript:` URL 在点击时执行。该 allowlist 是 MarkdownText allowlist(它还允许 `mailto:`)的 http(s) 子集,因此不受信任的检索链接无论在何处渲染都行为相同。
+
+## Testing
+
+`packages/client/ui-primitives/tests/web-block.spec.tsx` 把组件钉到 per-file 100% 门槛:两种 kind;标题-或-主机名-或-原始 URL 的标签回退;两种 kind 上的安全链接属性(http(s) URL 成为带 `target`/`rel` 的外链,`javascript:`/`file:`/无法解析的 URL 渲染为无 href 的纯 span);snippet 与日期在存在/为空/缺失时的显示或省略;由标志位控制的截断提示;以及 source 列表高度上限及其头/尾切片与展开/收起控件,含默认上限。
+
+`packages/client/ui-conversation/tests/web-card.spec.tsx` 在每个接线接缝镜像 `terminal-card.spec.tsx`:`webCardModel` 的派生投影每个 source 字段、其截断与缺失 answer 的支路、fetch 派生、以及每个 null 支路(运行中、null result view、generic result view、未知 card 标签、未知 web `kind`);键控 `WebRow` 对两种 kind 的常驻卡片、比面板收得更紧、其仅摘要行的运行中与失败支路;`GenericToolCard` 兜底为 web 声明工具长出常驻卡片、并为非 web 调用保持纯行;详情面板 Output 区对两种 kind —— 含 `web_fetch` 正文摊平在其 URL/状态卡片下方 —— 及其对非 web 结果的摊平回退;以及在 `web_search` 与 `web_fetch` 两键下用一个组件的键控注册。该文件位于覆盖率 `exclude` 列表(`ui-conversation/src/*`),因此覆盖率运行不度量它。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)添加 turn 66(`web_search`)与 67(`web_fetch`),内联撰写,因为客户端 fixture 无法 import web 工具:turn 66 的 result view 携带一个 answer 与三个 source,演练引用列表(一个带 snippet 与日期的有标题 source、一个无标题因而以主机名标注链接的 source、一个有日期无 snippet 的 source)并开启截断提示;turn 67 携带抓取的 URL 与一个 200 状态。两者都保留 generic pending call view,仅在 result 时添加 `web` 卡片,匹配契约的 result-only web 形状,且以真实工具命名,使其命中键控 `WebRow`。它们被排在 todo turn(重编号为 68)之前,理由与终端 turn 相同:待定计划在下一个 `turn/start` 退休,所以排在其后的 turn 会清空 dock 的 plan strip。这驱动 built-boot snapshot 与一个实时 `?fixture` 服务。
+
+## Related
+
+- [Web result card](2026-07-30-web-result-card.md) —— 添加 `card: 'web'` result 支路并让两个工具发出它的后端 PR;本条是它推迟的前端消费者。
+- [Web terminal card](2026-07-28-web-terminal-card.md) —— 本条所镜像的先例:一个 `ui-primitives` block、一处 card-model 派生、键控与兜底 chat 行、以及一个详情面板支路,用于 `terminal` 渲染意图。
+- [工具调用呈现的标签化 render-intent union](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇;Web 客户端现在是 `web` 支路的完整消费者。
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.i18n.yaml
new file mode 100644
index 0000000000..455c89ebcd
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.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-07-30-web-tool-row-unified-expand-and-inspect.md
+2026-07-30-web-tool-row-unified-expand-and-inspect.md: ba2f4ead8023772fad578ca0b647241ecc332905
+2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md: ac4835c7429a3ff7d3042f73d26d267911533132
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
new file mode 100644
index 0000000000..ba2f4ead80
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.md
@@ -0,0 +1,34 @@
+# Agent Note: Web tool-row unified expand and trajectory Inspect
+
+Status: implemented
+
+English | [中文](2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md)
+
+## Problem
+
+The chat view's tool rows had drifted into per-surface interaction dialects: ToolRow expanded through a leading-icon toggle and only for calls with an args body, the bash sample had its own expand affordance, todo/ask-question rows expanded raw args only, single-file tools were not expandable at all, and a call's OUTPUT was reachable only through the details panel. A failing bash command (exit≠0 settles `isError:false`) showed no collapsed-row failure signal. There was also no path from a chat row to its trajectory record, and switching chat → trajectory → chat lost the reader's scroll position because the tab ring unmounts inactive views.
+
+## Decision
+
+**Every expandable tool row shares one interaction — the whole row toggles (click / Enter / Space) with an icon→chevron hover preview — and one expanded body: an IN/OUT gutter-labeled card with per-section scroll caps; a hover-revealed Inspect pill jumps to the call's trajectory record through a one-shot store handoff; the chat view preserves its scroll offset across view switches through an in-memory per-session map.**
+
+- `toolRowModel` now derives result material alongside args: `output` (the `resultText` flatten, moved from DetailsPanel into the contract), and `errorSummary` (the failure's first line, shown as the collapsed summary in the error color). A row with body, output, or terminal material is expandable; the row itself is the toggle (`role="button"`, `aria-expanded`), and file-path summaries stay independent links via `stopPropagation`.
+- The expanded card (figma 1249:35657) is a column of IN/OUT sections: each section is its own scrollport (max-height 150px) with a sticky gutter label, and the l2 divider spans the full card width. Think prose and the run_code CodeBlock keep their non-card bodies; context injection reuses the row with a label-less `plainBody` card.
+- `terminalFailed` reads a settled terminal card's exit status so BashRow and GenericToolCard surface a failing command as the row's red state dot — the only failure signal the collapsed row has, since the call itself settles `isError:false`.
+- TerminalBlock's banner joins the same reading model: it shares the card surface (no banner token), an l2 hairline separates it from the body, the command column caps at 150px and scrolls with sticky copy/status controls top-aligned to the first prompt row.
+- Inspect: `ToolRowOwnerProps.inspect` (absent for rows without a call identity) renders a pill in real flow under the expanded body's bottom-left, revealed by hovering anywhere on the tool call. Clicking writes `{ callId }` to the chat store's one-shot `inspect` field and switches to the trajectory view; TrajectoryTable finds the record, opens its summary, and acknowledges by clearing the field.
+- Scroll preservation: the chat view saves its offset on every scroll (null when pinned to bottom) into an apply-scope `Map` exposed as `chatScroll` on the injected props; the open-jump branch restores it on remount. Deliberately not persisted — a fresh page load keeps the open-jump-to-bottom default.
+
+## Alternatives considered
+
+**Keeping the leading-icon toggle and per-registrant expand affordances.** Rejected: three surfaces had already diverged; the registrant posture (bash sample replicates CSS locally) makes drift permanent unless the interaction contract itself is uniform and small — whole-row toggle plus hover preview.
+
+**Routing Inspect through a URL or a trajectory-view prop.** Rejected: the view ring renders through the slot registry, so the two views share no parent that could carry a prop; the chat store already crosses that boundary and the one-shot field keeps the handoff replay-safe (persisted snapshots from before the field rehydrate with `?? null`).
+
+**Persisting the chat scroll offset.** Rejected: restoring a days-old offset into a conversation that has since grown reads as a bug; the in-memory map scopes the memory to exactly the view-switch case that loses it.
+
+**A per-row expanded OUTPUT fetched from the details panel's material.** Unnecessary: the settled result node already rides the snapshot's frozen call slice, so the contract-level `resultText` flatten serves both the row and the panel from one derivation.
+
+## Consequences
+
+Any registered toolview gets input AND output inspection in place, with the details panel and trajectory remaining the deep-dive surfaces. The unified interaction is contract-visible (`ToolRowProps.output/errorSummary/inspect`), so third-party rows opt in by passing model fields through. The bash sample intentionally re-replicates the new CSS (registrant posture), so future interaction changes still touch it by hand. `--dsw-font-markdown-code-block-small` (12/18) is a hand-added token pending a design-platform export. The web-cordis `distIndex` fix (plain concatenation, not URL.pathname) unblocks preview boots from a cwd with spaces.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md
new file mode 100644
index 0000000000..ac4835c742
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-tool-row-unified-expand-and-inspect.zh.md
@@ -0,0 +1,34 @@
+# Agent Note:Web 工具行统一展开交互与 trajectory Inspect
+
+状态:已实现
+
+[English](2026-07-30-web-tool-row-unified-expand-and-inspect.md) | 中文
+
+## 问题
+
+聊天视图的工具行交互已经分裂成多种方言:ToolRow 通过前导图标切换展开、且仅限有 args body 的调用,bash 示例有自己的一套展开方式,todo / ask-question 行只能展开原始 args,单文件工具完全不可展开,而调用的 OUTPUT 只能通过右侧详情面板查看。失败的 bash 命令(exit≠0 但结算为 `isError:false`)在折叠行上没有任何失败信号。此外聊天行没有跳转到 trajectory 记录的入口,且 chat → trajectory → chat 切换会丢失阅读位置(标签环会卸载非活跃视图)。
+
+## 决定
+
+**所有可展开工具行共享同一交互——整行即开关(点击 / Enter / 空格),图标 hover 时渐变为 chevron 预览——以及同一展开体:带 IN/OUT 侧栏标签的卡片,各分区独立滚动上限;hover 显示的 Inspect 胶囊通过 store 的一次性交接跳到该调用的 trajectory 记录;聊天视图用内存态的按会话 Map 在视图切换间保留滚动位置。**
+
+- `toolRowModel` 在 args 之外同时派生结果材料:`output`(`resultText` 拍平逻辑从 DetailsPanel 移入 contract)和 `errorSummary`(失败首行,以错误色作为折叠摘要)。有 body、output 或 terminal 材料的行即可展开;行本身是开关(`role="button"`、`aria-expanded`),文件路径摘要通过 `stopPropagation` 保持独立链接。
+- 展开卡片(figma 1249:35657)是 IN/OUT 分区列:每个分区是独立滚动区(max-height 150px),侧栏标签 sticky 固定,l2 分割线横贯整卡宽度。Think 的推理文本和 run_code 的 CodeBlock 保持非卡片体;上下文注入复用此行并以无标签的 `plainBody` 卡片展开。
+- `terminalFailed` 读取已结算 terminal 卡片的退出状态,让 BashRow 和 GenericToolCard 把失败命令显示为行的红色状态点——这是折叠行唯一的失败信号,因为调用本身结算为 `isError:false`。
+- TerminalBlock 的横幅并入同一阅读模型:与卡片共用同一表面(不再用 banner token),与正文之间是 l2 细线,命令列上限 150px 内部滚动,复制/状态控件 sticky 且顶对齐第一行提示符。
+- Inspect:`ToolRowOwnerProps.inspect`(无调用身份的行不提供)在展开体左下角以真实布局位置渲染胶囊,hover 整个 tool call 任意位置显示。点击将 `{ callId }` 写入 chat store 的一次性 `inspect` 字段并切换到 trajectory 视图;TrajectoryTable 找到记录、打开其摘要,并通过清空字段确认。
+- 滚动保留:聊天视图在每次滚动时保存偏移(贴底时为 null)到 apply 作用域的 `Map`,经注入 props 的 `chatScroll` 暴露;重挂载时 open-jump 分支恢复它。刻意不持久化——新页面加载保持打开即贴底的默认行为。
+
+## 曾考虑的替代方案
+
+**保留前导图标开关和各注册方自有的展开方式。** 否决:三个表面已经分化;注册方姿态(bash 示例本地复刻 CSS)意味着除非交互契约本身统一且足够小——整行开关加 hover 预览——否则漂移会永久存在。
+
+**通过 URL 或 trajectory 视图 prop 传递 Inspect。** 否决:视图环经由 slot 注册表渲染,两个视图没有可携带 prop 的共同父级;chat store 本就跨越该边界,一次性字段让交接可安全重放(字段出现之前的持久化快照以 `?? null` 复水)。
+
+**持久化聊天滚动偏移。** 否决:把几天前的偏移恢复到已经增长的会话里读起来像 bug;内存 Map 把记忆精确限定在会丢位置的视图切换场景。
+
+**从详情面板的材料为每行单独取展开 OUTPUT。** 不必要:已结算结果节点本就在快照的冻结调用切片上,contract 层的 `resultText` 拍平让行和面板共用一份派生。
+
+## 后果
+
+任何已注册 toolview 都能就地查看输入与输出,详情面板和 trajectory 仍是深查表面。统一交互契约可见(`ToolRowProps.output/errorSummary/inspect`),第三方行透传模型字段即可接入。bash 示例有意重新复刻新 CSS(注册方姿态),未来交互变更仍需手动同步它。`--dsw-font-markdown-code-block-small`(12/18)是手工补充的 token,待设计平台导出后替换。web-cordis 的 `distIndex` 修复(纯拼接而非 URL.pathname)解除了含空格 cwd 下预览无法启动的问题。
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.i18n.yaml
new file mode 100644
index 0000000000..99ea335b59
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.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-07-31-gui-full-access-confirmation.md
+2026-07-31-gui-full-access-confirmation.md: ca89ed23fb1b5c6ea438dd22fdf20d2b82af754c
+2026-07-31-gui-full-access-confirmation.zh.md: 0f487e41b544718bdf38de611a1ab2d59c44b063
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
new file mode 100644
index 0000000000..ca89ed23fb
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.md
@@ -0,0 +1,31 @@
+# Agent Note: GUI Full access risk confirmation
+
+Status: implemented
+
+English | [中文](2026-07-31-gui-full-access-confirmation.zh.md)
+
+## Problem
+
+Switching the web client to `danger-full-access` was a single click on a permission picker, with the preset shown as the title-cased machine name `Danger Full Access`. Full access reduces confirmation steps and lets the agent run sensitive operations, modify files, or execute external commands, so an accidental pick armed the most dangerous preset with no deliberate acknowledgement step.
+
+## Decision
+
+**Every permission picker gates `danger-full-access` behind the shared in-page `RiskConfirmation` dialog whose enabling action stays disabled until an explicit acknowledgement checkbox is checked; the preset renders under the product label `Full access`; every dismissal path submits nothing.**
+
+- `RiskConfirmation` (ui-primitives) is a controlled Modal composition: title, description, acknowledgement checkbox, cancel, and a confirm button disabled until `acknowledged`. It stays an in-page dialog — the Modal portals to this document's body and never opens a native or separate browser window that could land on another display. `Modal` gains a `contentClassName` seat so the warning body scrolls inside constrained mobile/landscape viewports while the action row stays fixed.
+- The composer chip (`PermissionSelect`, ui-conversation) intercepts a Full-access pick before the `/permission` submit: `confirmation`/`acknowledged` component state opens the dialog, confirm submits `/permission danger-full-access` through the same injected `command` path as every other pick, and cancel/Escape/close/mask leave the current preset untouched with the checkbox reset. The confirmation revokes itself when the session locks (`locked`/value-absent effect) and resets across task switches (`key={sessionId}` remount). Copy rides the standard `conversation` locale seat as `access.confirm.*` keys.
+- The `/permission` popup (ui-permission over the ui-command shell) gates through data, not a second dialog implementation: `SelectOption` grows an optional `confirmation` payload, the popup controller owns the `confirming`/`acknowledged` state transitions, and `PopupSelectView` swaps the picker card for the same `RiskConfirmation` while a gated option is pending.
+- The General-settings Permission row uses the same controlled `RiskConfirmation` before persisting Full access as the default for later sessions. Its warning names that future-session lifetime; cancel, Escape, close, and mask dismissal leave the stored default untouched.
+- `Full access` intentionally overrides the kebab-to-title display transform in every picker; command and Settings writes keep the machine name on the wire, and each warning body remains locale-aware in Chinese and English.
+
+## Alternatives considered
+
+**A native/OS or separate-window confirmation.** Rejected: the dialog must stay inside the current WebUI window; a second window can appear on another display and detaches the decision from the page state it guards.
+
+**One shared locale namespace for every surface's safety copy.** Rejected: the ui-permission bundle and ui-conversation load independently, while the Settings warning names a different future-session lifetime. Each bundle owns its copy, and ui-permission keeps the popup and Settings dictionaries separate rather than importing across bundle boundaries.
+
+**Gating in the host/permission backend.** Out of scope by design: the change is browser-client confirmation flow only; backend permission semantics, defaults, and the safer presets' one-click behavior are unchanged.
+
+## Consequences
+
+Every visible GUI path into Full access requires a deliberate, informed acknowledgement, at the cost of one extra dialog step for users who genuinely want the preset. New pickers reuse the shared dialog through their owning state machine or attach a `confirmation` payload to the popup path. Acceptance: the composer flow's gated cases in `input-bar.spec.tsx`, the popup gate in `popup-view.spec.tsx` and `popup.spec.ts`, the default-setting gate in `permission-row.spec.tsx`, the Modal/RiskConfirmation contract in `atoms.spec.tsx`, and the assembled Web replays.
diff --git a/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
new file mode 100644
index 0000000000..0f487e41b5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-gui-full-access-confirmation.zh.md
@@ -0,0 +1,31 @@
+# Agent Note: GUI Full access 风险确认
+
+Status: implemented
+
+[English](2026-07-31-gui-full-access-confirmation.md) | 中文
+
+## Problem
+
+在 Web 客户端的权限选择器中切换到 `danger-full-access` 只需一次点击,且预设以 Title Case 机器名 `Danger Full Access` 展示。Full access 会减少确认步骤,允许智能体执行敏感操作、修改文件或运行外部命令,误点即在毫无刻意确认环节的情况下启用了最危险的预设。
+
+## Decision
+
+**每个权限选择器都把 `danger-full-access` 关进共享的页面内 `RiskConfirmation` 对话框:启用按钮在用户勾选明确的风险确认复选框前保持禁用;预设以产品标签 `Full access` 展示;所有取消路径都不作任何提交。**
+
+- `RiskConfirmation`(ui-primitives)是受控的 Modal 组合:标题、说明、确认复选框、取消,以及 `acknowledged` 勾选前禁用的确认按钮。它始终是页面内对话框——Modal portal 到本文档 body,绝不打开可能落在另一块显示器上的原生或独立浏览器窗口。`Modal` 新增 `contentClassName` 座位,令警示正文在受限的移动端/横屏视口内滚动,动作行保持固定。
+- 编辑器 chip(ui-conversation 的 `PermissionSelect`)在 `/permission` 提交前拦截 Full-access 选择:`confirmation`/`acknowledged` 组件状态打开对话框,确认后经与其他选择完全相同的注入 `command` 通道提交 `/permission danger-full-access`;取消、Escape、关闭与遮罩点击均保持当前预设不变并重置复选框。会话锁定时确认自行撤销(`locked`/值缺席 effect),切换任务时随 `key={sessionId}` 重挂载而重置。文案经标准 `conversation` locale 座位以 `access.confirm.*` 键供给。
+- `/permission` popup(ui-permission 骑在 ui-command 外壳上)以数据而非第二套对话框实现完成把关:`SelectOption` 新增可选的 `confirmation` 载荷,popup 控制器拥有 `confirming`/`acknowledged` 状态迁移,`PopupSelectView` 在门控选项未决期间把选择卡换成同一个 `RiskConfirmation`。
+- 「通用」设置中的「权限」行在把 Full access 持久化为后续会话的默认值前,也使用同一个受控 `RiskConfirmation`。警示会明确说明该设置只影响后续会话;取消、Escape、关闭与点击遮罩均不会改动已存默认值。
+- `Full access` 在每个选择器中都有意覆盖 kebab 转 Title Case 的显示变换;命令与 Settings 写入在 wire 上保留机器名,每份警示正文都保持中英文 locale 感知。
+
+## Alternatives considered
+
+**原生/操作系统或独立窗口确认。** 已拒:对话框必须留在当前 WebUI 窗口内;第二个窗口可能出现在另一块显示器上,使决策脱离其守护的页面状态。
+
+**每个面的安全文案共享一个 locale namespace。** 已拒:ui-permission bundle 与 ui-conversation 可独立加载,而 Settings 警示说明的是另一种只影响后续会话的生效周期。每个 bundle 各自拥有文案,ui-permission 也将 popup 与 Settings 词典分开,而非跨 bundle 边界 import。
+
+**在 host/权限后端把关。** 设计上即出界:本变更只涉浏览器客户端确认流;后端权限语义、默认值与更安全预设的一键行为均不变。
+
+## Consequences
+
+进入 Full access 的每条可见 GUI 路径现在都要求刻意且知情的确认,代价是真想启用该预设的用户多一步对话框。新的选择器通过各自拥有的状态机复用共享对话框,或在 popup 路径挂 `confirmation` 载荷。验收:`input-bar.spec.tsx` 中编辑器流的门控用例、`popup-view.spec.tsx` 与 `popup.spec.ts` 的 popup 门、`permission-row.spec.tsx` 的默认设置门控、`atoms.spec.tsx` 的 Modal/RiskConfirmation 契约,以及组装态 Web 回放。
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml
new file mode 100644
index 0000000000..8d4467858b
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-07-31-permission-default-for-new-sessions.md
+2026-07-31-permission-default-for-new-sessions.md: 35812b53d0c1448afd95b9a063eda6658fb1bef3
+2026-07-31-permission-default-for-new-sessions.zh.md: a75deaec323b57f2bc88e84dd4d5c7d7d98cd177
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
new file mode 100644
index 0000000000..35812b53d0
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md
@@ -0,0 +1,35 @@
+# Agent Note: Permission Settings default for new sessions
+
+Status: implemented
+
+English | [中文](2026-07-31-permission-default-for-new-sessions.zh.md)
+
+## Problem
+
+The Web General-settings page displayed Permission as a disabled skeleton even though `dsh-permission` already owned the preset table and current-session switch path. The Settings seam could persist a plugin-owned value, but the Web settings API exposed only configurable LLM-provider namespaces. More importantly, treating a user preference as a live global permission would make an existing session's execution policy change outside its durable log.
+
+## Decision
+
+`dsh-permission` owns a `permission` Settings namespace with one `defaultPreset` field. Its base value is `Config.defaultPreset`, or the preset matching the composed sandbox and approval defaults when the config omits it. The schema derives its enum from the configured preset table, so Settings validates stored values and the Web client discovers the deployment's actual choices without duplicating them.
+
+The service reads the current Settings value synchronously at `session/created`. A genuinely fresh session receives three explicit events: `permission/preset`, `sandbox/mode`, and `approval/policy`. Those facts pin the permission selected at creation, so a later Settings change affects only later sessions. A seeded or partially initialized session preserves its effective knobs and receives only missing facts; it never adopts the latest user default while resuming. `Session` marks even an explicitly empty constructor seed with `session/end-seed`, so an empty persisted log cannot be mistaken for a fresh session.
+
+The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows.
+
+ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes emit `host/settings-changed` but not `host/models-changed`.
+
+## Consequences
+
+Changing Permission in Settings updates `settings.yaml` and the selector immediately, but does not alter the open session. Every later session is reconstructable from its three pinned permission facts, including after the user changes the default again or the process restarts. Deployments whose composed sandbox and approval defaults match no preset must configure `defaultPreset` explicitly.
+
+The assembled Web snapshot now contains a functional Permission selector. Its keyless browser scenario writes `read-only`, verifies an existing `danger-full-access` session is unchanged, and verifies a subsequently created session starts with the read-only event triplet.
+
+## Alternatives considered
+
+**Apply the Settings value live to every session.** Rejected because execution policy would change without a session event and replay could not reconstruct which permission governed an earlier tool call.
+
+**Record only `permission/preset` on creation.** Rejected because sandbox and approval are independently owned whole-value knobs; pinning all three facts keeps their consumers independent of future composition-default changes.
+
+**Expose all Settings registrations, or add a generic `local-client` declaration.** Rejected for this change because it expands a security boundary and the Settings contract beyond the one requested preference. The explicit `permission` allowlist entry is sufficient and leaves future namespaces to make their own exposure decision.
+
+**Apply the latest default while resuming a seeded session.** Rejected because resume must preserve the session's prior effective execution policy; missing legacy facts are materialized from that policy instead.
diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
new file mode 100644
index 0000000000..a75deaec32
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md
@@ -0,0 +1,35 @@
+# Agent Note: 新会话的权限 Settings 默认值
+
+Status: implemented
+
+[English](2026-07-31-permission-default-for-new-sessions.md) | 中文
+
+## 问题
+
+Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 `dsh-permission` 已经拥有 preset 表和当前会话的切换路径。Settings seam 可以持久化由插件拥有的值,但 Web Settings API 只暴露可配置 LLM 提供方的 namespace。更重要的是,如果把用户偏好当成实时生效的全局权限,现有会话的执行策略就会在其持久日志之外发生变化。
+
+## 决策
+
+`dsh-permission` 拥有一个 `permission` Settings namespace,其中只有 `defaultPreset` 字段。它的基础值是 `Config.defaultPreset`;省略该配置时,则使用与组合后的沙箱和审批默认值匹配的 preset。schema 的 enum 从已配置的 preset 表派生,因此 Settings 既能校验已存储的值,Web 客户端也能发现部署中的实际选项,而无需重复定义。
+
+服务会在 `session/created` 时同步读取当前 Settings 值。真正的新会话会收到三个显式事件:`permission/preset`、`sandbox/mode` 和 `approval/policy`。这些事实将创建时选中的权限固定下来,因此后续 Settings 变更只影响之后的会话。带 seed 或只完成部分初始化的会话会保留其有效调节项,只补齐缺失的事实;恢复时绝不会采用最新的用户默认值。`Session` 甚至会用 `session/end-seed` 标记显式为空的构造器 seed,因此不能把空的持久化日志误认为新会话。
+
+现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。
+
+ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更会发出 `host/settings-changed`,但不会发出 `host/models-changed`。
+
+## 后果
+
+在 Settings 中更改「权限」会立即更新 `settings.yaml` 和选择器,但不会改变已打开的会话。之后的每个会话都可以从三个已固定的权限事实中重建,即使用户再次更改默认值或进程重启也不受影响。如果部署中组合后的沙箱和审批默认值与任何 preset 都不匹配,则必须显式配置 `defaultPreset`。
+
+组装后的 Web 快照现在包含功能完整的「权限」选择器。其无密钥浏览器场景会写入 `read-only`,验证现有的 `danger-full-access` 会话保持不变,并验证随后创建的会话以 read-only 事件三元组启动。
+
+## 曾考虑的替代方案
+
+**将 Settings 值实时应用于每个会话。** 不予采纳,因为执行策略会在没有会话事件的情况下改变,重放也无法重建先前工具调用采用了哪种权限。
+
+**创建时只记录 `permission/preset`。** 不予采纳,因为沙箱和审批是由不同组件独立拥有的全量值调节项;固定全部三个事实,可以让其消费方不依赖未来的组合默认值变化。
+
+**暴露所有 Settings 注册,或增加通用的 `local-client` 声明。** 本次变更不予采纳,因为这会扩大安全边界,并使 Settings 契约超出所请求的单项偏好。显式加入 `permission` allowlist 已足够,未来的 namespace 可以各自决定是否暴露。
+
+**恢复带 seed 的会话时应用最新默认值。** 不予采纳,因为恢复操作必须保留会话先前的有效执行策略;缺失的旧版事实应从该策略中补齐。
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml
new file mode 100644
index 0000000000..97793a906a
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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-07-31-web-telemetry-default-mount.md
+2026-07-31-web-telemetry-default-mount.md: 6c1fdaa8719ee01726b51db9a469ff659cbac476
+2026-07-31-web-telemetry-default-mount.zh.md: b447832527ba9731097cd0776060db11ee4dfc30
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
new file mode 100644
index 0000000000..6c1fdaa871
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md
@@ -0,0 +1,39 @@
+# Agent Note: Default session-telemetry mount (OTel reporting) in the dsh web composition
+
+Status: implemented
+
+English | [中文](2026-07-31-web-telemetry-default-mount.zh.md)
+
+## Problem
+
+The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry-otel-revival.md)) had never been wired into any deployment composition since completion: no roster row, no switch, no cadence ruling, and zero observability over user sessions for the internal deployment. A deployment decision was needed: which surfaces report, to where, on what cadence, how to opt out, and how CI stays isolated.
+
+## Decision
+
+The shared `dsh` core (`apps/cli/config/base.cordis.yml`) mounts the `telemetry-otel` row by default with a baked-in production endpoint, so every surface — TUI, web, and headless — reports; this is the **internal-testing deployment stance** — reporting is on when an endpoint exists, and users opt out through the environment. Each surface's exit path drains the queue: web/headless dispose on SIGINT/SIGTERM (headless gained those handlers in this change), and the TUI's normal exit runs `disposeRootAndExit` (root dispose, 5s bounded — above the ~1s drain ceiling configured here) while its `/resume` handoff disposes the root before `execve`.
+
+| Ruling | Value | Rationale |
+|---|---|---|
+| Mount surface | base.cordis.yml (TUI + web + headless) | One deployment stance for every surface; per-surface divergence would need a reason, and none exists |
+| Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs |
+| Opt-out switch | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables | A privacy switch prefers off-by-mistake over on-by-mistake; a row can only be disabled at AppCLIEntry's patch layer (config has no disable semantic, and the switch must precede the load-time `exporter.url` validation) |
+| Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval |
+| Exit-drain bound | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048` (== maxQueueSize) + `exportTimeoutMillis: 1500` | Dispose must release within ~1s against an unreachable collector: timeoutMillis doubles as the per-attempt socket timeout and the retry deadline (1s effectively disables the SDK's 5-try backoff), and aligning batch size with the queue cap makes the drain a single batch; SDK defaults can stall 40s+ |
+| Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth |
+| CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in all 8 GitHub workflows | Every CI channel that boots the web composition (e2e/snapshot/built smokes) must not stream test sessions to the production endpoint |
+
+The keyless integration test `apps/cli/tests/telemetry-web.e2e.ts` pins the deployment-level behavior: an in-test OTLP collector plus a mock LLM server, a real `dsh web` boot, asserting ledger coverage, seq monotonicity, the first-of-step chunk projection, and the ops `shutdown` marker arriving through the SIGINT drain.
+
+## Alternatives considered
+
+**No default mount; deployments add the row themselves (continuing the SDK stance).** Rejected for this stage: this repo's web/headless composition IS the internal deployment, and default-on reporting is that deployment's product requirement; the SDK stance survives in the seam packages (unmounted = nothing leaves).
+
+**A config field instead of an env patch for the switch.** Infeasible: cordis rows have no config-level disable semantic, and `exporter.url` validation fails loud at plugin construction, so the switch must take effect before the Loader — AppCLIEntry's patch layer is the only seat.
+
+**A `Promise.race` timeout backstop around exit.** Deferred: the parameter set already bounds the worst-case drain to ~1.5-3s (typically <100ms), measured SIGINT-to-exit 110ms-1.1s; the unbounded drip-feed-response risk stays under observation, and on real evidence the race lands inside the backend's `shutdown()` (never the coordinator — that would decide loss semantics for every backend).
+
+## Consequences
+
+- A developer running `dsh web` without a local collector POSTs to the production endpoint every 10s (silent failure when unreachable; no OTel diag logger is registered); local development sets `DSH_TELEMETRY_DISABLED=1` or points `DSH_TELEMETRY_OTLP_URL` locally.
+- **No redaction rule is mounted yet**: exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `telemetry/record` rules first — the redaction rule, identity Resource attributes (hostname / anonymous user id / surface), and the usage-metrics track are the explicit follow-ups of this decision.
+- Test rigs reusing this tree (e.g. `apps/web/tests/scaffold.ts`) must explicitly disable the row, or fixture sessions stream to whatever collector the environment happens to name.
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
new file mode 100644
index 0000000000..b447832527
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md
@@ -0,0 +1,39 @@
+# Agent Note: dsh web 组合默认挂载会话遥测(OTel 上报)
+
+Status: implemented
+
+[English](2026-07-31-web-telemetry-default-mount.md) | 中文
+
+## Problem
+
+遥测 seam 与 OTel backend([revival Note](2026-07-23-session-telemetry-otel-revival.md))自完成以来从未接入任何部署组合:没有 roster 行、没有开关、没有节奏口径,内部部署对用户会话零可观测。需要一个部署决策:哪些 surface 上报、报到哪、什么节奏、怎么关、CI 怎么隔离。
+
+## Decision
+
+`dsh` 共享核心(`apps/cli/config/base.cordis.yml`)默认挂载 `telemetry-otel` 行,内置生产 endpoint,因此所有 surface——TUI、web、headless——都上报;这是**内部测试期的部署立场**——有 endpoint 就报,用户可经环境变量退出。各 surface 的退出路径都会排空队列:web/headless 在 SIGINT/SIGTERM 上 dispose(headless 的信号处理是本次补上的),TUI 的正常退出走 `disposeRootAndExit`(根 dispose,5s 兜底——高于此处配置的 ~1s drain 上界),其 `/resume` 移交也在 `execve` 前 dispose 根。
+
+| 决策项 | 取值 | 理由 |
+|---|---|---|
+| 挂载面 | base.cordis.yml(TUI + web + headless) | 所有 surface 一个部署立场;按 surface 分化需要理由,而当前没有 |
+| endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 |
+| 退出开关 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即关 | 隐私向开关取「宁关勿误开」;行级 disable 只能在 AppCLIEntry 的 patch 层做(config 无 disable 语义,且必须先于 `exporter.url` 的加载期校验生效) |
+| 上报节奏 | `processor.scheduledDelayMillis: 10000`(10s/批) | 流式回流,非退出才报;崩溃至多丢最后一个未导出间隔 |
+| 退出 drain 上界 | `exporter.timeoutMillis: 1000` + `maxExportBatchSize: 2048(== maxQueueSize)` + `exportTimeoutMillis: 1500` | collector 不可达时 dispose 必须 ~1s 内放行:timeoutMillis 同时是单次 socket 超时与重试 deadline(1s 等效关掉 SDK 5 次 backoff),批大小对齐队列上限使 drain 恒为单批;默认参数下最坏可卡 40s+ |
+| 压缩 | `compression: gzip` | 事件 body 含全文,跨机房带宽 |
+| CI 隔离 | 全部 8 个 GitHub workflow 顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | CI 启动 web 组合的所有通道(e2e/snapshot/built smoke)不得向生产 endpoint 泄测试会话 |
+
+集成测试 `apps/cli/tests/telemetry-web.e2e.ts`(keyless)钉住部署级行为:测试内 OTLP collector + mock LLM,真启动 `dsh web`,断言 ledger 覆盖、seq 单调、chunk 首条投影、以及 SIGINT drain 后 ops `shutdown` 标记到达。
+
+## Alternatives considered
+
+**默认不挂载,部署方自行加行(SDK 立场的延续)。** 否决于当前阶段:本仓的 web/headless 组合就是内部部署本身,「上报默认开」是这个部署的产品要求;SDK 立场仍由 seam 包保持(不挂 = 零外发)。
+
+**开关做成 config 字段而非 env patch。** 不可行:cordis 行没有 config 层的 disable 语义,且 `exporter.url` 校验在插件构造期 fail-loud,开关必须在 Loader 之前生效——AppCLIEntry patch 层是唯一落点。
+
+**退出时 `Promise.race` 兜底超时。** 暂缓:参数组合已把最坏 drain 压到 ~1.5-3s(典型 <100ms),实测 SIGINT→退出 110ms-1.1s;drip-feed 慢滴响应的无界等待风险留观,出现实证再在 backend `shutdown()` 内加 race(不放 coordinator——那会替所有 backend 决定丢失语义)。
+
+## Consequences
+
+- 无本地 collector 的开发者跑 `dsh web` 会对生产 endpoint 每 10s 发一次 POST(联不通则静默失败,OTel diag logger 未注册);本地开发设 `DSH_TELEMETRY_DISABLED=1` 或 `DSH_TELEMETRY_OTLP_URL` 指本地。
+- **当前零脱敏规则挂载**:导出即原始捕获副本(用户/助手消息全文、工具参数与结果、system prompt、`session.cwd` 本地路径)。跨信任边界前必须挂 `telemetry/record` 规则——脱敏规则、身份 Resource 维度(hostname/匿名 user id/surface)、使用数据 metrics 轨三件是本决策明确的后续工作。
+- 复用这棵树的测试载具(如 `apps/web/tests/scaffold.ts`)须显式关停该行,否则 fixture 会话会流向 env 里碰巧存在的 collector。
diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
index 2cb7f1009d..50d3e498a5 100644
--- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
+++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.i18n.yaml
@@ -1,6 +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
-2026-07-06-node-engine-floor.md: f1754ea7ca32452a04c6cd8a0599568f602e47dd
-2026-07-06-node-engine-floor.zh.md: 9d376a639378d3a0b9b645aa36c1a5d320d1d147
+# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-06-node-engine-floor.md
+2026-07-06-node-engine-floor.md: ef047d885a442106a35922f4716d2996d8a98ca7
+2026-07-06-node-engine-floor.zh.md: a0281addf7d4327d7f6ea30e3a3f0f40d6782bd0
diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md
index f1754ea7ca..ef047d885a 100644
--- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md
+++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.md
@@ -10,7 +10,7 @@ The Node 22 branch of the root `engines.node` range is a contract for the instal
## Decision
-Set `engines.node` to `^22.19.0 || >=24.0.0` and test the keyless CI compatibility matrix on `['22.19', 24, 26]`. Every matrix leg runs the TypeScript typecheck plus a keyless source-mode worker smoke, so the floor is exercised through both a complete source typecheck and a real unbuilt runtime path. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
+Set `engines.node` to `^22.19.0 || >=24.0.0` and test keyless CI on `['22.19', 24, 26]`. The primary Node 24 jobs own the complete typecheck and unit coverage inventory; every version runs focused source-worker, Zstandard, source-launch, and [jsdom storage](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) smokes without repeating that inventory. The real-API e2e workflow stays on Node 24 because it exercises API integration rather than the runtime floor.
Two Node features gate the source runtime:
@@ -24,7 +24,7 @@ Those source features clear on the 22.x line at **22.18**, but the installed Pi
## Consequences
- The advertised LTS branch no longer undercuts the Pi adapter dependency floor.
-- CI proves the Node 22 LTS floor directly with Node 22.19, keeps the Node 24 branch on `node: 24`, and keeps Node 26 for the next even line; each leg typechecks the source graph and launches the unbuilt workflow worker for real.
+- CI proves the Node 22 LTS floor directly with Node 22.19, keeps primary coverage on `node: 24`, and exercises Node 26 as the next even line; focused compatibility smokes run on all three versions.
- The built-bin smoke needs no version-conditional flag: at 22.19 type-stripping is already the default, so the test stays the plain `node lib/bin.js` path it documents.
- A future dependency or source API that raises the runtime floor must move `engines.node`, the compatibility matrix, and this Agent Note in the same change.
diff --git a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
index 9d376a6393..a0281addf7 100644
--- a/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
+++ b/.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md
@@ -10,7 +10,7 @@ Status: implemented
## 决策
-将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 keyless CI 兼容性矩阵中测试 `['22.19', 24, 26]`。每条矩阵分支都运行 TypeScript 类型检查加一次 keyless 的源码模式 worker 冒烟测试,因此引擎下限通过完整的源码类型检查和真实的未构建运行时路径两条路径得到验证。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。
+将 `engines.node` 设为 `^22.19.0 || >=24.0.0`,并在 `['22.19', 24, 26]` 上运行 keyless CI。主要的 Node 24 任务负责整套类型检查和单元测试覆盖率任务;三个版本均运行 source-worker、Zstandard、source-launch 和 [jsdom 存储](../testing/2026-07-30-vitest-jsdom-webstorage-ownership.md) 专项冒烟测试,不重复这套类型检查和覆盖率任务。真实 API 的 e2e 工作流保持在 Node 24 上,因为它验证的是 API 集成而非运行时下限。
两个 Node 特性决定了源码运行时的门槛:
@@ -24,7 +24,7 @@ Status: implemented
## 后果
- 宣传的 LTS 分支不再低于 Pi 适配器依赖的下限。
-- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,Node 24 分支保持 `node: 24`,Node 26 用于下一个偶数线;每条分支都对源码图执行类型检查,并实际启动未构建的工作流 worker。
+- CI 通过 Node 22.19 直接验证 Node 22 LTS 下限,将主要覆盖率任务保留在 `node: 24`,并用 Node 26 验证下一个偶数线;三个版本均运行聚焦的兼容性冒烟测试。
- built-bin 冒烟测试无需版本条件标志:在 22.19 上类型剥离已是默认行为,因此测试保持其文档所述的纯 `node lib/bin.js` 路径。
- 未来若依赖或源码 API 提高运行时下限,必须在同一变更中同步调整 `engines.node`、兼容性矩阵和本 Agent Note(agent 决策记录)。
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.i18n.yaml
new file mode 100644
index 0000000000..70c5ce5a90
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-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 .agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
+2026-07-22-product-first-root-readme.md: 32542a45019d64ed1826d4eb21e68c67c3c3d52e
+2026-07-22-product-first-root-readme.zh.md: 1c4d5fa53854bfcade9742da1fb74d9636909f84
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
new file mode 100644
index 0000000000..32542a4501
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.md
@@ -0,0 +1,33 @@
+# Agent Note: Product-first root README
+
+Status: implemented
+
+English | [中文](2026-07-22-product-first-root-readme.zh.md)
+
+## Problem
+
+The root README is the repository's product front door. Its product-first structure and established voice remain useful, but concrete entry points and capability claims drift as the runtime grows. Rewriting sections whose facts remain correct increases the review surface and discards language that already works.
+
+## Decision
+
+The root README preserves its existing structure, order, and wording wherever the underlying fact remains correct. A refresh changes only stale claims and adds material needed to represent shipped surfaces; it does not use repository growth as a reason to reframe the whole page.
+
+A note before installation thanks internal testers, states that features and experience remain unfinished, and asks for direct reports of failures, confusion, and friction through the WeCom group. The existing development-stage statement identifies DeepSeek Harness as being in internal testing.
+
+The user-surface section adds the ACP automation server and Python/JSON-RPC SDK beside the existing Web, TUI, and headless entries. The installed TUI remains the single `dsh` command; the Web instructions build the active checkout before running `dsh web`, and custom or reused checkout paths stay explicit. These launch paths must remain executable through a real PTY and a production build/HTTP smoke, respectively. The capability paragraph keeps its compact inventory style while adding the shipped PTY, LSP, web, goal, planning, task, sandbox, approval, settings, credentials, session-query, and telemetry families and stating that compositions select subsets. One adjacent bullet records the authoritative-session-log rule because persistence, replay, queries, telemetry, and interfaces depend on it.
+
+Detailed package and service inventories remain at their owning documentation. The English and Chinese README sides share the same technical structure, while their community sections continue to point to the primary channel for each language audience. The documentation website keeps its separate user-guide landing page.
+
+## Alternatives considered
+
+**Rewrite the README around a new product narrative.** A complete rewrite can make every current surface prominent, but it replaces accurate, reviewed copy and creates unnecessary churn. Current facts fit the established product-first structure.
+
+**Present the repository as an SDK and package catalog.** This exposes implementation breadth immediately but makes a new reader reconstruct the product from package names. The package map and generated capability graph remain the authoritative inventories.
+
+**Use a long marketing page with screenshots, badges, and duplicated tutorials.** Rich media can demonstrate a stable product journey, but it ages separately from commands and source contracts. The root stays compact and links to runnable examples and owned guides.
+
+**Project the root README as the documentation website home page.** A single landing page avoids two narratives, but the website's user guide and the repository's product/developer front door have different navigation and maintenance needs.
+
+## Consequences
+
+Reviewers can distinguish factual refreshes from editorial rewrites, and future updates retain established wording unless its meaning becomes false or incomplete. The README must still change with affected commands, entry points, release-stage claims, or high-level capability families, while exhaustive detail remains linked rather than copied.
diff --git a/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
new file mode 100644
index 0000000000..1c4d5fa538
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-22-product-first-root-readme.zh.md
@@ -0,0 +1,33 @@
+# Agent Note: 产品优先的根 README
+
+Status: implemented
+
+[English](2026-07-22-product-first-root-readme.md) | 中文
+
+## 问题
+
+根 README 是仓库的产品入口。其产品优先的结构和既有语气仍然有效,但随着运行时不断扩展,具体入口和能力声明会逐渐陈旧。重写事实仍然正确的章节,会扩大评审范围,也会丢弃已经行之有效的措辞。
+
+## 决策
+
+只要背后的事实仍然正确,根 README 就保留既有结构、顺序和措辞。刷新时只修正陈旧声明,并补充呈现已交付内容所需的信息;不会因为仓库规模增长就重构整篇叙事。
+
+安装说明之前的一则文字感谢内测用户,说明功能和体验仍待完善,并邀请大家通过企业微信群直接反馈失败、困惑和不顺手之处。既有的开发阶段声明明确说明 DeepSeek Harness 处于内测阶段。
+
+用户入口章节在已有的 Web、TUI 和 Headless 入口旁补充 ACP(Agent Client Protocol)自动化服务器和 Python/JSON-RPC SDK。安装后的 TUI 仍只需执行一条 `dsh` 命令;Web 说明要求先构建当前检出,再运行 `dsh web`,并明确处理自定义或复用的检出路径。这两条启动路径必须分别能在真实 PTY 与生产构建/HTTP 冒烟中原样执行。能力段落沿用简洁清单的写法,补充已经交付的 PTY、LSP、Web、目标、规划、任务、沙箱、审批、设置、凭据、会话查询和遥测等能力类别,并说明不同组合只选用其中一部分。相邻的一条列表项说明权威会话日志规则,因为持久化、回放、查询、遥测和各类接口都依赖它。
+
+包(package)与服务的完整清单仍由各自的归属文档维护。中英文 README 采用相同的技术结构,但社区章节仍分别指向各自语言受众的主要交流渠道。文档网站继续使用独立的用户指南首页。
+
+## 考虑过的替代方案
+
+**围绕新的产品叙事重写 README。** 完整重写能够突出所有现有入口和能力,但也会替换准确且已经过评审的文案,造成不必要的变动。现有事实能够纳入既有的产品优先结构。
+
+**将仓库呈现为 SDK 和包清单。** 这样能立即展现实现广度,却会迫使新读者从包名反推出产品。包索引与生成的能力图仍是权威清单。
+
+**使用包含截图、徽章和重复教程的长篇营销页面。** 富媒体能够展示稳定的产品使用路径,但其内容会独立于命令和源码契约而逐渐陈旧。根 README 保持紧凑,并链接到可运行示例和各自维护的指南。
+
+**将根 README 投影为文档网站首页。** 使用同一个首页可以避免两套叙事,但文档网站的用户指南与仓库面向产品和开发者的入口在导航和维护需求上并不相同。
+
+## 结果
+
+评审者可以区分事实更新与编辑性重写;今后的更新会保留既有措辞,除非其含义已经不再正确或完整。受影响的命令、入口、发布阶段声明或高层能力类别发生变化时,README 仍须同步更新;完整细节则继续以链接方式提供,而不是复制到正文。
diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.i18n.yaml
new file mode 100644
index 0000000000..9d3ac28ed2
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.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/process/2026-07-31-coverage-exempt-heavy-suites.md
+2026-07-31-coverage-exempt-heavy-suites.md: 7235a5193554947ecf71f62d522d09f4e21cb1da
+2026-07-31-coverage-exempt-heavy-suites.zh.md: b739e4494ae8d240b0e35109920a49876ebd222d
diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md
new file mode 100644
index 0000000000..7235a51935
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.md
@@ -0,0 +1,61 @@
+# Agent Note: Coverage-exempt heavy suites
+
+Status: implemented
+
+English | [中文](2026-07-31-coverage-exempt-heavy-suites.zh.md)
+
+## Problem
+
+The CI coverage lane (`check:ci:coverage`) had its wall clock pinned by a handful of heavy test files: in a local 6-worker full-suite profile, 555 test files aggregated 1595 seconds, with `packages/typert/generator/tests/type-model.spec.ts` alone at 885 seconds and the top 10 files holding 84% of the aggregate. These suites share one shape — every case performs whole-workspace compiler analysis or drives real subprocess fixtures — and v8 instrumentation multiplies exactly that kind of runtime.
+
+The decisive waste: the instrumentation tax these suites paid contributed **nothing** to the per-file 100% thresholds — the measured code they execute in-process is either outside the threshold scope already or independently fully covered by other suites. Running them instrumented traded lane time for zero information.
+
+## Decision
+
+The `ci-coverage` aggregate splits into two parallel gates; every test still runs, and only the heavy suites stop paying the instrumentation tax:
+
+- **Instrumented gate** (`test:coverage`): sets `DSH_COVERAGE_EXEMPT_HEAVY=1`, which makes `vitest.config.ts` drop the exempt suites from both projects' excludes; every remaining file runs instrumented and carries the entire threshold proof. The variable is injected through the gate's own env (the existing `Gate.env` mechanism), not the workflow-global environment, so the uninstrumented gate beside it and any local `vitest run` never see it and behave unchanged.
+- **Uninstrumented gate** (`test:coverage-exempt-heavy`): runs exactly the exempt suites through paired positional filters, keeping the correctness signal whole.
+
+`scripts/coverage-exempt.ts` is the single roster point, holding the membership contract and the filter/exclude pairs so the two sides cannot drift.
+
+### The roster, reconciled entry by entry
+
+A suite contributes to coverage exactly when it executes measured files in-process (`coverage.include` spans the package src trees). The current roster, audited:
+
+| Exempt suite | Measured code executed in-process | Who carries the coverage |
+| --- | --- | --- |
+| All 6 typert generator specs | The generator's own src | Generator src is threshold-excluded as a package (`vitest.config.ts`) — outside the threshold scope to begin with |
+| tools-catalog.spec additionally imports | `typert-registry` and `tool-cordis` src | Each package's own tests cover them fully (verified with focused coverage runs, zero threshold errors) |
+| `scripts/install-lefthook.spec.ts`, `scripts/oxlint-contract.spec.ts`, `scripts/change-scope.spec.ts` | None — they test `scripts/` sources (never in `coverage.include`) and work by spawning child processes | Nothing to carry |
+
+### Membership contract
+
+A new exemption must satisfy both: every measured file the suite executes in-process is already fully covered by other suites (or threshold-excluded), and the filter and exclude select exactly the same file set. The contract text lives beside the roster in the same file.
+
+### The gate polices the roster automatically
+
+The per-file 100% thresholds are themselves the roster's guard; a wrong roster cannot pass silently:
+
+- If a future exempt suite in fact solely covers some measured file, the instrumented gate goes red on the spot (that file drops below 100%).
+- The converse holds too: new code covered only by an exempt suite turns the gate red immediately.
+
+Coverage-result invariance therefore does not rest on humans maintaining the roster, in line with the misconfiguration-fails-loud convention. The only thing given up is that the exempt suites' own execution no longer produces coverage data — the table above shows that data was entirely redundant, so the final report is file-for-file identical in threshold terms.
+
+## Alternatives considered
+
+- **CLI `--exclude` to drop the exempt suites from the instrumented gate.** Proven ineffective: vitest 4's `cliExclude` does not participate in per-project include resolution, so under a multi-project config the exempt suites stayed selected; the env + config route replaced it.
+- **Lowering worker counts or raising gate concurrency.** Measured ineffective during the incident: the lane's wall clock was pinned by the longest tail files (aggregate/wall ≈ 4× effective parallelism), and the concurrency knobs moved nothing in either direction.
+- **Cross-runner sharding (`--shard` + blob merge).** Would compress the wall clock further but adds matrix, artifact-pipeline, and merge-job complexity; with the split landed the lane sits near 2 minutes, which does not justify the cost. Revisit if the suite grows substantially.
+- **Deleting or skipping the heavy suites.** Rejected: they are the sole correctness evidence for the typert generator and the scripts tooling; running them uninstrumented in parallel preserves the full signal.
+
+## Verification
+
+Measured on CI (16-core runner): the gate segment went from 424 seconds to the two gates in parallel — `test:coverage` 95.9 s + `test:coverage-exempt-heavy` 71.1 s — with the lane converging on the slower at about 96 seconds; the instrumented gate reported zero threshold errors both before and after the split. `vitest list` verifies the env toggle adds and removes exactly the exempt set; `run-gates.spec.ts` covers the aggregate graph construction.
+
+## Consequences
+
+- The coverage lane's gate segment drops from about 7 minutes to about 96 seconds with no change in threshold outcome or executed test set.
+- `DSH_GATE_CONCURRENCY` has two schedulable gates in this lane again, so the aggregate scheduler is no longer a pass-through.
+- Adding a heavy suite to the roster requires the membership audit above; a wrong entry fails the instrumented gate loudly rather than eroding coverage silently.
+- The exempt suites no longer appear in the coverage report's file list of contributors; their correctness signal lives solely in the uninstrumented gate's pass/fail.
diff --git a/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md
new file mode 100644
index 0000000000..b739e4494a
--- /dev/null
+++ b/.agents/notes/implemented/process/2026-07-31-coverage-exempt-heavy-suites.zh.md
@@ -0,0 +1,61 @@
+# Agent Note: 覆盖率豁免重型套件
+
+Status: implemented
+
+[English](2026-07-31-coverage-exempt-heavy-suites.md) | 中文
+
+## Problem
+
+CI 覆盖率 lane(`check:ci:coverage`)的墙钟被少数几个重型测试文件钉死:本地 6-worker 全量剖析中,555 个测试文件聚合 1595 秒,其中 `packages/typert/generator/tests/type-model.spec.ts` 一个文件占 885 秒,前 10 个文件占聚合时长的 84%。这类套件的共同点是每个用例都做全工作区编译器分析或真实子进程 fixture,v8 插桩把这类代码的运行时间放大数倍。
+
+关键的浪费在于:这些套件缴纳的插桩税对 per-file 100% 阈值**没有任何贡献**——它们进程内执行的被度量代码,要么本来就不在阈值口径内,要么已由其他套件独立满覆盖。继续在插桩下运行它们,纯粹是用 lane 时长换零信息。
+
+## Decision
+
+`ci-coverage` 聚合拆成两个并行 gate,全部测试仍然执行,只有重型套件不再交插桩税:
+
+- **插桩 gate**(`test:coverage`):设 `DSH_COVERAGE_EXEMPT_HEAVY=1`,`vitest.config.ts` 据此从两个 project 的 exclude 中剔除豁免套件,其余全部文件照旧插桩并承担全部阈值证明。经 gate 自带 env 注入(既有 `Gate.env` 机制),不进 workflow 全局环境,因此并排的无插桩 gate 和本地直跑 `vitest run` 都看不到该变量、行为不变。
+- **无插桩 gate**(`test:coverage-exempt-heavy`):用配对的 positional filter 恰好运行豁免套件,保证正确性信号不缩水。
+
+`scripts/coverage-exempt.ts` 是唯一名单点,集中持有成员资格契约与 filter/exclude 配对,防止两侧漂移。
+
+### 豁免名单与逐项对账
+
+一个套件对覆盖率有贡献,当且仅当它在进程内执行了被度量的文件(`coverage.include` = 包 src 树)。现行名单逐项核对:
+
+| 豁免套件 | 进程内执行的被度量代码 | 覆盖由谁接住 |
+| --- | --- | --- |
+| typert generator 全部 6 个 spec | generator 自身 src | generator src 已整包 threshold-excluded(`vitest.config.ts`),本不在阈值口径内 |
+| 其中 tools-catalog.spec 额外 import | `typert-registry`、`tool-cordis` 的 src | 两包各自的测试独立满覆盖(focused coverage 实测无阈值错误) |
+| `scripts/install-lefthook.spec.ts`、`scripts/oxlint-contract.spec.ts`、`scripts/change-scope.spec.ts` | 无——被测对象是 `scripts/` 源码(从不在 coverage.include),执行方式是 spawn 子进程 | 无需接 |
+
+### 成员资格契约
+
+新增豁免必须同时满足:套件进程内执行的每个被度量文件都已由其他套件满覆盖(或在阈值排除名单内);filter 与 exclude 选中完全相同的文件集。契约文本随名单同文件维护。
+
+### 门禁自动守卫名单正确性
+
+per-file 100% 阈值本身就是豁免名单的守卫,名单错误无法静默通过:
+
+- 若未来某个豁免套件实际独家覆盖着某个被度量文件,插桩 gate 当场红(该文件跌破 100%);
+- 反向同理:出现"只有豁免套件才覆盖"的新代码,同样立刻红。
+
+因此覆盖率结果的不变性不依赖人工维护名单,符合"misconfiguration fails loud"约定。唯一失去的是豁免套件自身的执行不再产出覆盖数据——由上表可知这些数据全部冗余,最终报告在阈值意义上逐文件相同。
+
+## Alternatives considered
+
+- **CLI `--exclude` 从插桩 gate 剔除豁免套件。** 实证无效:vitest 4 的 `cliExclude` 不参与 per-project include 解析,多 project 配置下豁免套件仍被选中,故改走 env + config。
+- **降低 worker 数或提高 gate 并发。** 事故期间实测无效:lane 墙钟被尾部最长文件钉死(聚合/墙钟 ≈ 4× 有效并行),并发旋钮两个方向都动不了尾巴。
+- **跨 runner 分片(`--shard` + blob 合并)。** 能进一步压墙钟但引入 matrix、artifact 管道与合并 job 的复杂度;拆分落地后 lane 已到约 2 分钟,不值得付。若未来套件规模再涨可重新评估。
+- **直接删除或跳过重型套件。** 拒绝:它们是 typert generator 与 scripts 工具的唯一正确性证据,无插桩并排执行保住全部信号。
+
+## Verification
+
+CI 实测(16 核 runner):拆分前 gate 段 424 秒,拆分后两 gate 并行 `test:coverage` 95.9 秒 + `test:coverage-exempt-heavy` 71.1 秒,lane 收敛于较慢者约 96 秒;拆分前后插桩 gate 阈值错误均为零。`vitest list` 验证 env 开关两态恰好增删豁免集;`run-gates.spec.ts` 覆盖聚合图构造。
+
+## Consequences
+
+- 覆盖率 lane 的 gate 段从约 7 分钟降到约 96 秒,阈值结果与执行测试集均无变化。
+- `DSH_GATE_CONCURRENCY` 在本 lane 重新拥有两个可调度对象,聚合调度器不再是直通。
+- 向名单新增重型套件必须完成上述成员资格对账;错误条目会让插桩 gate 大声失败,而不是静默侵蚀覆盖率。
+- 豁免套件不再出现在覆盖率报告的贡献文件列表中;其正确性信号完全由无插桩 gate 的红绿承载。
diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.i18n.yaml
new file mode 100644
index 0000000000..e829874e9d
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.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/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
+2026-07-30-vitest-jsdom-webstorage-ownership.md: 3956a7566fa1c79a767636bce9a19f16588126e2
+2026-07-30-vitest-jsdom-webstorage-ownership.zh.md: 9080ee2762b74bf2efdaccd7a5905672001bc0e8
diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
new file mode 100644
index 0000000000..3956a7566f
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.md
@@ -0,0 +1,26 @@
+# Agent Note: Keep browser storage owned by jsdom in Vitest
+
+Status: implemented
+
+English | [中文](2026-07-30-vitest-jsdom-webstorage-ownership.zh.md)
+
+## Problem
+
+The supported Node range includes releases that reserve a process-wide `globalThis.localStorage`. Node 26 exposes that property as `undefined` without `--localstorage-file`; Vitest sees the reserved key and does not project jsdom's isolated `Storage` object over it. Component suites then fail before exercising product behavior, while the primary Node 24 coverage lane remains green because that runtime does not reserve the key by default.
+
+## Decision
+
+Vitest workers disable Node's process-wide Web Storage when the runtime advertises the `--webstorage` flag. The configuration passes `--no-webstorage` through each test project's `execArgv`; runtimes without that flag receive no argument. Node-environment suites therefore stay browser-free, and files selecting jsdom through `@vitest-environment jsdom` receive jsdom's isolated `localStorage`.
+
+The Node compatibility aggregate runs a dedicated jsdom smoke on every advertised compatibility line. It asserts both the conditional worker argument and usable storage, so a future Node or Vitest change cannot leave the primary Node 24 suite as the only signal.
+
+## Alternatives considered
+
+- **Set `NODE_OPTIONS=--no-webstorage` in package scripts or CI.** Rejected because it leaks test-runner policy into subprocesses and misses direct `pnpm exec vitest` invocations.
+- **Pass `--localstorage-file` to Node.** Rejected because one process-wide persistent store has different ownership and isolation semantics from browser storage created per jsdom environment.
+- **Patch `globalThis.localStorage` in setup code or guard every component test.** Rejected because setup would depend on Vitest's private jsdom projection details, while per-test guards hide a broken browser environment and duplicate policy across suites.
+- **Pin tests to Node 24.** Rejected because the package engine advertises newer even Node lines and the compatibility matrix exists to expose their runtime changes.
+
+## Consequences
+
+The same `pnpm test` command works on Node releases with and without built-in Web Storage. Test workers deliberately cannot exercise Node's process-wide Web Storage; a future product need for that API requires a separate explicit test configuration rather than weakening jsdom isolation. The compatibility lane adds one focused Vitest process instead of duplicating the complete unit inventory on every Node version.
diff --git a/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md
new file mode 100644
index 0000000000..9080ee2762
--- /dev/null
+++ b/.agents/notes/implemented/testing/2026-07-30-vitest-jsdom-webstorage-ownership.zh.md
@@ -0,0 +1,26 @@
+# Agent Note: 在 Vitest 中将浏览器存储交由 jsdom 管理
+
+Status: implemented
+
+[English](2026-07-30-vitest-jsdom-webstorage-ownership.md) | 中文
+
+## 问题
+
+受支持的 Node 版本范围包含会预留进程级 `globalThis.localStorage` 的版本。未设置 `--localstorage-file` 时,Node 26 将该属性暴露为 `undefined`;Vitest 检测到这个预留键后,不会用 jsdom 的隔离 `Storage` 对象覆盖该属性。因此,组件测试套件尚未验证产品行为便会失败,而主要的 Node 24 覆盖率分支仍能通过,因为该运行时默认不会预留此键。
+
+## 决策
+
+当运行时声明支持 `--webstorage` 标志时,Vitest worker 会禁用 Node 的进程级 Web Storage。配置通过每个测试项目的 `execArgv` 传入 `--no-webstorage`;未声明该标志的运行时则不传入此参数。因此,Node 环境测试套件不加载浏览器环境,而通过 `@vitest-environment jsdom` 选择 jsdom 的文件会获得 jsdom 隔离的 `localStorage`。
+
+Node 兼容性汇总任务会在每条声明支持的兼容版本线上运行专用的 jsdom 冒烟测试。该测试同时断言 worker 参数按条件传入且存储可用,因此未来 Node 或 Vitest 的变化不会让主要的 Node 24 测试套件成为唯一检测信号。
+
+## 曾考虑的替代方案
+
+- **在包脚本或 CI 中设置 `NODE_OPTIONS=--no-webstorage`。** 否决:这会将测试运行器策略传播到子进程,也无法覆盖直接调用 `pnpm exec vitest` 的情况。
+- **向 Node 传入 `--localstorage-file`。** 否决:单个进程级持久化存储与每个 jsdom 环境分别创建的浏览器存储具有不同的归属和隔离语义。
+- **在初始化代码中修改 `globalThis.localStorage`,或为每个组件测试增加保护逻辑。** 否决:初始化逻辑会依赖 Vitest 私有的 jsdom 映射细节,而逐测试添加的保护逻辑会掩盖浏览器环境损坏,并在多个测试套件中重复该策略。
+- **将测试固定在 Node 24。** 否决:包的引擎范围声明支持更新的偶数 Node 版本线,而兼容性矩阵正是为了暴露这些版本的运行时变化。
+
+## 后果
+
+同一条 `pnpm test` 命令在有无内置 Web Storage 的 Node 版本上均可运行。测试 worker 被有意禁止使用 Node 的进程级 Web Storage;未来若产品需要该 API,必须使用独立且显式的测试配置,而不能削弱 jsdom 隔离。兼容性分支只增加一个专项 Vitest 进程,无需在每个 Node 版本上重复整套单元测试。
diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml
index ad6c575d1e..96dc47f9f7 100644
--- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.i18n.yaml
@@ -1,6 +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
-2026-07-25-client-settings-locale-theme.md: 87077b3fd3f0bd8a3375a71aebf947cbd9961799
-2026-07-25-client-settings-locale-theme.zh.md: a64a4afdf6565a527a25136694aa79305eeabb3c
+# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
+2026-07-25-client-settings-locale-theme.md: c86d6ac053f7bb87ce758613a5f3a0a34951e428
+2026-07-25-client-settings-locale-theme.zh.md: 05edbb3c550828832a390e3cf4fad3262b5be196
diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
index 87077b3fd3..c86d6ac053 100644
--- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
+++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.md
@@ -55,7 +55,7 @@ root
└─ models (order 10) ui-models 注册
```
-Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, `refresh()` for localized labels, one-call disposal) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
+Section and item contributions both use declaration-aware deferral (ui-slots' `deferRegistration()`: ledger-judged presence, one-call disposal; localized labels ride the label thunk from the [full-rollout note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md), not `refresh()`) and do not depend on the client manifest's apply order. The SlotMap types split homes: trigger/header/close/section have their canonical home in the ui-settings contract (the consumers, general and models, both depend on the shell — no cycle); `settings.general.item`'s canonical home is the locale package — it is the lowest common dependency of all item registrants (a settings row always carries copy), while the declarer general's contract is unreachable from locale/ui-theme (it would form a cycle); ui-theme consumes it through a re-export seam.
### Future work: promote slot declarations to first-class injectable waits
diff --git a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md
index a64a4afdf6..05edbb3c55 100644
--- a/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-25-client-settings-locale-theme.zh.md
@@ -55,7 +55,7 @@ root
└─ models (order 10) ui-models 注册
```
-section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、`refresh()` 换本地化 label、一键 dispose),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
+section/item contribution 均使用 declaration-aware deferral(ui-slots 的 `deferRegistration()`:ledger 判在位、一键 dispose;本地化 label 走 [全量接入 Note](../../implemented/architecture/2026-07-30-client-locale-full-rollout.md) 的 label thunk,不再 `refresh()`),不依赖 client manifest 的 apply 顺序。SlotMap 类型分家:trigger/header/close/section 正家在 ui-settings contract(消费者 general/models 均依赖壳,无环);`settings.general.item` 正家在 locale 包——它是全部 item 注册方的最低公共依赖(设置行必带文案),而声明方 general 的 contract 对 locale/ui-theme 不可达(会成环);ui-theme 经 re-export seam 消费。
### Future work:坑位声明升格为可 inject 的一等等待物
diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml
index 5965707630..017b77ee75 100644
--- a/.github/workflows/build-exe-for-python-sdk.yml
+++ b/.github/workflows/build-exe-for-python-sdk.yml
@@ -28,6 +28,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
# Job-level conditions cannot inspect `matrix`, so validate target names and
# construct the matrix before the dependent jobs.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1f6ec52f0a..6b2a5dd9d5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,6 +24,9 @@ permissions:
env:
PRIMARY_NODE_VERSION: '24'
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
jobs:
diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml
index ab56636fed..6336089a41 100644
--- a/.github/workflows/docs-pages.yml
+++ b/.github/workflows/docs-pages.yml
@@ -23,6 +23,9 @@ permissions:
env:
PRIMARY_NODE_VERSION: '24'
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
jobs:
build:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index c445034a8c..d72e7bfee4 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -46,6 +46,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
e2e:
runs-on: ubuntu-latest
diff --git a/.github/workflows/expected-filenames.yml b/.github/workflows/expected-filenames.yml
index 328da95529..59320b9261 100644
--- a/.github/workflows/expected-filenames.yml
+++ b/.github/workflows/expected-filenames.yml
@@ -10,6 +10,11 @@ on:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
expected-filenames:
name: no golden filenames
diff --git a/.github/workflows/landlock-run.yml b/.github/workflows/landlock-run.yml
index 8916f59a56..dad9638761 100644
--- a/.github/workflows/landlock-run.yml
+++ b/.github/workflows/landlock-run.yml
@@ -19,6 +19,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
defaults:
run:
working-directory: native/landlock-run
diff --git a/.github/workflows/pi-ai-provider-e2e.yml b/.github/workflows/pi-ai-provider-e2e.yml
index 1306754d4c..255c7654e7 100644
--- a/.github/workflows/pi-ai-provider-e2e.yml
+++ b/.github/workflows/pi-ai-provider-e2e.yml
@@ -19,6 +19,11 @@ on:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
e2e:
runs-on: ubuntu-latest
diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml
index 36f58cc75b..939ca2f6ab 100644
--- a/.github/workflows/sandbox.yml
+++ b/.github/workflows/sandbox.yml
@@ -19,6 +19,11 @@ concurrency:
permissions:
contents: read
+env:
+ # CI runs must never report to the production telemetry endpoint baked
+ # into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
+ DSH_TELEMETRY_DISABLED: '1'
+
jobs:
# Keyless real-kernel sandbox proofs (sandbox Agent Note § Testing): each ladder
# rung is only provable on a host where it enforces, so this job fans out
diff --git a/README.i18n.yaml b/README.i18n.yaml
index 7584d4f293..66400262db 100644
--- a/README.i18n.yaml
+++ b/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 README.md
-README.md: f9f7294b42e29132d5cd46c0ab6a5f5265a1d8f3
-README.zh.md: 88cbf8522d8f1a183a48dc7e80858d1a0ced8f0f
+README.md: baf5d79b157ae845cc837261452853afd48dbe46
+README.zh.md: 57d7bcf44cda36b37ae233754dbfba4ead2204fd
diff --git a/README.md b/README.md
index f9f7294b42..baf5d79b15 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,16 @@ DeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Ha
It uses an architecture where **everything is a plugin**.
+## Internal testing notice
+
+Thank you for making time to try DeepSeek Harness.
+
+This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.
+
+“As one cuts and files, as one carves and polishes.” Products grow through repeated encounters with real use and candid feedback. The problems you uncover in practice may lead us to re-examine, or even discard, existing designs.
+
+We especially want to hear about moments of failure, confusion, or friction. If DeepSeek Harness does not help—or instead makes your work harder—please leave a message in our WeCom group and tell us about your experience. Every report will help us refine it.
+
## Install
Install `dsh` with one command:
@@ -22,20 +32,14 @@ The installer keeps every checkout under `~/.dsh/source`: the master clone at `~
### Web UI
-For the recommended local interface, build the frontend after installation and after each update, then start the Web UI. Resolve the running checkout from the `dsh` launcher so the command holds regardless of which staging worktree is current (the launcher resolves through the stable `current` symlink):
+For the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:
```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
- link=$(readlink "$dsh_bin")
- case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
-The Web UI is served at `http://127.0.0.1:3080` by default.
+The full build produces the library and client bundles plus the frontend dist. The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.
### TUI
@@ -53,11 +57,22 @@ Run one task, print the final answer, and exit:
dsh -p "summarize this workspace"
```
+### Automation and SDKs
+
+From a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:
+
+```sh
+pnpm run demo:acp
+```
+
+The [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.
+
## Why DeepSeek Harness
-Built-in capabilities cover file reading, editing, and search; shell execution; reusable skills; task tracking; subagents and workflows; persistent sessions; and context compaction. The TUI also includes Plan Mode.
+Built-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The TUI and Web UI both include Plan Mode.
- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.
+- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).
- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).
- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/cordis/tool-cordis/README.md).
@@ -76,7 +91,7 @@ Start with the [development guide](docs/development.md) and read the [architectu
For agents, follow [AGENTS.md](AGENTS.md).
-DeepSeek Harness is currently pre-release.
+DeepSeek Harness is currently in internal testing.
## License
diff --git a/README.zh.md b/README.zh.md
index 88cbf8522d..57d7bcf44c 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -6,6 +6,16 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
它采用了**一切皆插件**的架构。
+## 内测声明
+
+感谢您愿意拨冗试用 DeepSeek Harness。
+
+目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。
+
+“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。
+
+我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
+
## 安装
使用一条命令安装 `dsh`:
@@ -22,20 +32,14 @@ curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/m
### Web UI
-推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建前端,再启动 Web UI。通过 `dsh` 启动器解析当前运行的检出,这样无论当前是哪个 staging worktree,命令都成立(启动器会经由稳定的 `current` 符号链接解析):
+推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:
```sh
-dsh_bin=$(cd "$(dirname "$(command -v dsh)")" && pwd -P)/$(basename "$(command -v dsh)")
-while [ -L "$dsh_bin" ]; do
- link=$(readlink "$dsh_bin")
- case $link in /*) dsh_bin=$link ;; *) dsh_bin=$(cd "$(dirname "$dsh_bin")" && cd "$(dirname "$link")" && pwd -P)/$(basename "$link") ;; esac
-done
-dsh_dir=$(cd "$(dirname "$dsh_bin")/.." && pwd -P)
-pnpm --dir "$dsh_dir" run build && pnpm --dir "$dsh_dir" run build:web
+(cd ~/.dsh/source/current && pnpm run build)
dsh web
```
-Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
+完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。
### TUI
@@ -53,11 +57,22 @@ dsh
dsh -p "summarize this workspace"
```
+### 自动化与 SDK
+
+在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:
+
+```sh
+pnpm run demo:acp
+```
+
+[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。
+
## 为什么选择 DeepSeek Harness
-内置功能涵盖文件读取、编辑与搜索、shell 执行、可复用 skill(技能)、任务跟踪、subagent 与工作流、持久化会话,以及上下文压缩(context compaction)。TUI 还包含 Plan Mode。
+内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。
- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。
+- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。
- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。
- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。
@@ -80,7 +95,7 @@ pnpm run test:coverage
面向 agent:遵循 [AGENTS.md](AGENTS.md)。
-DeepSeek Harness 目前处于预发布阶段。
+DeepSeek Harness 目前处于内测阶段。
## 许可证
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index 256587556f..b2fe93b91a 100644
--- a/apps/cli/README.i18n.yaml
+++ b/apps/cli/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 apps/cli/README.md
-README.md: d783d75cc9747d13887386fcf7609a6778e5dfb5
-README.zh.md: 3f5ce7e7a3a302fd9e255c1042ccb7b03deb59d9
+README.md: c36a75fc61fd7118f48c9b68be3144177df19534
+README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
diff --git a/apps/cli/README.md b/apps/cli/README.md
index d783d75cc9..c36a75fc61 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -17,13 +17,14 @@ The TUI surface:
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection.
-
-The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
+The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then apply `$DSH_HOME/config.yaml`; an explicit `--config ` replaces that personal overlay. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, opt into first-message model titles, use the same bounded transient model-request retry policy as the TUI, and mount a disposable in-memory SQLite content-index service. That service is ACTIVE at boot, while its `node:sqlite` module and database handle open only on the first content search. This keeps Node 22 startup output free of SQLite's experimental warning before search is used; the first actual search may still emit the runtime warning. Each service instance owns its database, so parallel invocations neither share unsupported SQLite state nor leave derived index files behind, and the first search lazily reconciles live and persisted logs. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
`DSH_TOOLS_MODE` selects the tool presentation mode for the whole Web/headless process: `native` (the schema default when unset), `code` (the `run_code`-only Code Mode wire), or `both`; any other value fails loud at boot through the `dsh-tools` config schema. It is a TEMPORARY seam — process-wide because Loader composition is static — and is removed once the web UI owns per-session tool-mode selection; the TUI surface ignores it (its config tree pins its own mode).
+Every `dsh` surface — TUI, Web, and headless — reports session telemetry by default (the row lives in the shared `base.cordis.yml`): every session-log event streams as OTLP/HTTP log records to `https://harness-telemetry.deepseeksvc.com/v1/logs` on a 10-second batch cadence. `DSH_TELEMETRY_OTLP_URL` points the exporter at a different collector; setting `DSH_TELEMETRY_DISABLED` to ANY non-empty value — including `0` or `false` — disables the row before it loads (a privacy switch prefers off-by-mistake over on-by-mistake). No redaction rule is mounted in this composition yet: exported records are the raw captured copy, including message text, tool arguments and results, and the session's working-directory path. The deployment rulings live in the [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md).
+
## Install (developer machine)
Symlink the source-running launcher onto your PATH; it resolves the checkout through its own real path, so code changes apply on the next launch with no build step:
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index 3f5ce7e7a3..e926fa99c4 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -17,13 +17,14 @@ TUI 界面:
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。
-
-Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
+Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用 `$DSH_HOME/config.yaml`;显式的 `--config ` 会替代该个人覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,选用首条消息模型标题,采用与 TUI 相同的有界暂时性模型请求重试策略,并挂载一个可丢弃的内存 SQLite 内容索引服务。该服务在启动时处于 ACTIVE 状态,但其 `node:sqlite` 模块与数据库句柄分别要到首次内容搜索才会导入和打开。这样可使 Node 22 在尚未使用搜索时的启动输出不出现 SQLite 实验性警告;首次实际搜索仍可能发出运行时警告。每个服务实例独占自己的数据库,因此并行调用既不会共享不受支持的 SQLite 状态,也不会留下派生索引文件,首次搜索还会惰性对账实时日志与持久化日志。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
`DSH_TOOLS_MODE` 为整个 Web/无头进程选择工具呈现模式:可选值为 `native`(未设置时的 schema 默认值)、`code`(仅含 `run_code` 的 Code Mode 协议接口)或 `both`;任何其他值都会经由 `dsh-tools` 配置 schema 在启动时明确报错。它是一个临时 seam:Loader 组合是静态的,因此该设置作用于整个进程;待 Web UI 负责逐会话工具模式选择后便会移除。TUI 界面会忽略该变量(其配置树固定了自身模式)。
+每个 `dsh` 界面——TUI、Web 与无头——都默认上报会话遥测(该行位于共享的 `base.cordis.yml`):每条会话日志事件以 OTLP/HTTP 日志记录的形式、按 10 秒批处理节奏流向 `https://harness-telemetry.deepseeksvc.com/v1/logs`。`DSH_TELEMETRY_OTLP_URL` 可将 exporter 指向其他 collector;将 `DSH_TELEMETRY_DISABLED` 设为**任意非空值**——包括 `0` 或 `false`——都会在该行加载前将其关停(隐私开关取「宁可误关、不可误开」)。该组合当前未挂载任何脱敏规则:导出记录即原始捕获副本,包含消息正文、工具参数与结果、以及会话工作目录路径。部署口径见 [web-telemetry-default-mount Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)。
+
## 安装(开发机)
将从源码运行的启动器符号链接到 PATH 上;它通过自身真实路径解析 checkout,因此代码更改会在下次启动时生效,无需构建:
diff --git a/apps/cli/composition.md b/apps/cli/composition.md
index 2e71c6c07b..870b926054 100644
--- a/apps/cli/composition.md
+++ b/apps/cli/composition.md
@@ -38,6 +38,8 @@ flowchart LR
cfg --> plugin_tui_session_persistence_jsonl
plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"]
cfg --> plugin_tui_session_query_sqlite
+ plugin_tui_telemetry_otel["telemetry-otel
@deepseek-ai/dsh-session-telemetry-otel"]
+ cfg --> plugin_tui_telemetry_otel
plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"]
cfg --> plugin_tui_subprocess
plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"]
@@ -123,6 +125,7 @@ flowchart LR
| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` |
| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` |
+| `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` |
| `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
| `bash-local` | `@deepseek-ai/dsh-bash-local` |
| `tool-bash` | `@deepseek-ai/dsh-tool-bash` |
diff --git a/apps/cli/config/base.cordis.yml b/apps/cli/config/base.cordis.yml
index 16d4e12471..5e0940d241 100644
--- a/apps/cli/config/base.cordis.yml
+++ b/apps/cli/config/base.cordis.yml
@@ -88,12 +88,45 @@
(() => { const path = process.getBuiltinModule('node:path'); const home = process.getBuiltinModule('node:os').homedir(); const configured = process.env.DSH_HOME; const selected = configured !== undefined && configured.trim().length > 0 ? configured : path.join(home, '.dsh'); const expanded = selected === '~' ? home : selected.startsWith('~/') || selected.startsWith('~\\') ? path.join(home, selected.slice(2)) : selected; return path.join(path.resolve(expanded), 'sessions') })()
# TUI consumes this shared session capability. Its launcher supplies a unique
-# process-local path; non-TUI surfaces disable the row in their overlay.
+# process-local path; other surfaces repoint or disable the row in their
+# overlay (web patches it to an ephemeral in-memory index).
- id: session-query-sqlite
name: '@deepseek-ai/dsh-session-query-sqlite'
config:
path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db'
+# Session telemetry, on for every dsh surface: mirrors every session-log
+# event (assistant/chunk projected to first-of-step) plus ops markers onto
+# OTLP/HTTP log records, streaming on the batch processor's cadence
+# (10s/batch here) — not at exit; a crash loses at most the last unexported
+# interval. No telemetry/record redaction rule is mounted yet, so exports
+# are the raw captured copy; the deployment stance, env seams, and
+# follow-ups are pinned in the web-telemetry-default-mount Agent Note.
+# DSH_TELEMETRY_OTLP_URL overrides the production endpoint, and a non-empty
+# DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the
+# process out (the launchers patch the row disabled; config cannot disable
+# a row). The exporter/processor values bound the shutdown drain to ~1s
+# against an unreachable collector: exporter.timeoutMillis is both the
+# per-attempt socket timeout and the retry deadline (1s effectively
+# disables the SDK's 5-try backoff), maxExportBatchSize == maxQueueSize
+# (both explicit) makes the drain a single batch, and exportTimeoutMillis
+# is the processor's own cap on that one export cycle — the second bound
+# when the exporter's clock alone does not fire. Every surface's exit path
+# drains it: web/headless dispose on SIGINT/SIGTERM, and the TUI's normal
+# exit and /resume handoff both dispose the root.
+- id: telemetry-otel
+ name: '@deepseek-ai/dsh-session-telemetry-otel'
+ config:
+ exporter:
+ url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs'
+ compression: gzip
+ timeoutMillis: 1000
+ processor:
+ scheduledDelayMillis: 10000
+ maxQueueSize: 2048
+ maxExportBatchSize: 2048
+ exportTimeoutMillis: 1500
+
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
diff --git a/apps/cli/config/tui.cordis.yml b/apps/cli/config/tui.cordis.yml
index 556f9540a0..980f9d80ad 100644
--- a/apps/cli/config/tui.cordis.yml
+++ b/apps/cli/config/tui.cordis.yml
@@ -36,8 +36,8 @@
Verify your work by running the code or tests. Keep answers brief and
factual.
-# Shipped default: full thinking at max effort on every request (wire-only
-# defaults; they never enter the request header).
+# Shipped default: full thinking at max effort on every request. Exact-model
+# resolution materializes request defaults before the request header is logged.
- id: llm-deepseek
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml
index a2fc10804d..a67d95ff6b 100644
--- a/apps/cli/config/web.cordis.yml
+++ b/apps/cli/config/web.cordis.yml
@@ -14,9 +14,14 @@
- id: hmr
disabled: true
-# Session query is a TUI capability; Web owns its own session presentation.
+# Web content search runs on an ephemeral in-memory index. The service
+# activates at boot, while first-search defers the node:sqlite import and
+# in-memory handle so Node 22 startup stays quiet until content search
+# actually uses SQLite. That search then reconciles this boot's sources.
- id: session-query-sqlite
- disabled: true
+ config:
+ path: ':memory:'
+ openAt: first-search
- id: tools
config:
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 94cf6da9c2..787682ce04 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -79,8 +79,10 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+ "@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
+ "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-settings-local": "workspace:^",
@@ -123,7 +125,9 @@
"js-yaml": "^4.2.0"
},
"devDependencies": {
+ "@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@types/js-yaml": "^4.0.9",
+ "execa": "^10.0.0",
"node-pty": "1.1.0"
}
}
diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts
index f18399e27b..54ade122c7 100644
--- a/apps/cli/src/app-cli-entry.ts
+++ b/apps/cli/src/app-cli-entry.ts
@@ -3,7 +3,7 @@
* for the Web/headless surface.
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (profile json + CLI
- * flags + the resolved frontend dist), and the fail-loud triple after the tree
+ * flags + the resolved frontend dist), and the fail-loud activation audit after the tree
* settles. The environment is what the bin already loaded (ambient plus the
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
* provider and is never hoisted here.
@@ -24,6 +24,9 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
+/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
+const TELEMETRY_ROW_ID = 'telemetry-otel'
+
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
@@ -59,6 +62,38 @@ export function resolveLanTrust(
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
+/**
+ * Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
+ * value (including `'0'`/`'false'`) disables: a privacy switch prefers
+ * off-by-mistake over on-by-mistake. Throws when the switch is set but the
+ * row is absent — a silently no-op "disabled" privacy switch would keep
+ * exporting while the user believes it is off.
+ * @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
+ * @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
+ * @returns the disable patch, or `undefined` when telemetry stays enabled.
+ */
+export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
+ if ((disabledEnv ?? '') === '') return undefined
+ if (!hasRow) {
+ throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
+ }
+ return { id: TELEMETRY_ROW_ID, disabled: true }
+}
+
+/**
+ * Whether a config file carries the telemetry row, parsed under the same
+ * `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
+ * that compose their patch lists outside {@link AppCLIEntry} (the TUI).
+ * @param file - absolute path of the config or overlay file.
+ * @returns true when a top-level (or inserted) row has the telemetry id.
+ */
+export function configHasTelemetryRow(file: string): boolean {
+ const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
+ if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
+ return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
+ row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
+}
+
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -203,9 +238,15 @@ export class AppCLIEntry {
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record, ...bag } }
})
+
+ // Telemetry opt-out: a row can only be turned off at the patch layer
+ // (config cannot disable an entry), and the switch must hold BEFORE the
+ // plugin constructs — its exporter.url validation is load-time fail-loud.
+ const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
+ if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
- /** Shared Loader boot; the dev HMR row mounts before await so the fail-loud sweep covers it. */
+ /** Shared Loader boot; the dev HMR row mounts before await so the activation audit covers it. */
private async bootTree(): Promise {
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts
index 5fef797cc5..3ec2792e8e 100644
--- a/apps/cli/src/headless.ts
+++ b/apps/cli/src/headless.ts
@@ -82,6 +82,17 @@ export async function runHeadless(task: string): Promise {
})
const { ctx, port } = await entry.run()
const dispose = async (): Promise => { await ctx.fiber.dispose() }
+ // Signal exits must still dispose the tree: the composition mounts
+ // exit-drained plugins (telemetry's queued tail and shutdown marker would
+ // otherwise be lost), and Node's default signal exit skips disposal.
+ let signalled = false
+ const disposeAndExit = (code: number): void => {
+ if (signalled) return
+ signalled = true
+ void dispose().finally(() => { process.exit(code) })
+ }
+ process.on('SIGTERM', () => { disposeAndExit(143) })
+ process.on('SIGINT', () => { disposeAndExit(130) })
// The headless session is web-observable while it runs (same composition).
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts
index 3469a737c1..dee865a0e1 100644
--- a/apps/cli/src/tui.ts
+++ b/apps/cli/src/tui.ts
@@ -31,6 +31,7 @@ import {
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { SessionId } from '@deepseek-ai/dsh-session'
+import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import type { Context } from 'cordis'
@@ -196,16 +197,26 @@ export async function runTui(
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
const replaceTree = configReplace !== undefined
- const patches = replaceTree ? [] : [
- ...loadOverlayPatches(NAME, TUI_OVERLAY),
- ...resolvedConfig === undefined
- ? loadPersonalPatches(NAME) ?? []
- : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
+ const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
+ // Same opt-out semantics as the web surface (resolveTelemetryPatch: any
+ // non-empty value disables; setting the switch against a tree without the
+ // row fails loud rather than silently no-opping a privacy switch). The row
+ // presence is checked against the tree actually booting, so a
+ // --config-replace tree is judged on its own rows, not the shipped base's.
+ const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
+ const patches = [
+ ...replaceTree ? [] : [
+ ...loadOverlayPatches(NAME, TUI_OVERLAY),
+ ...resolvedConfig === undefined
+ ? loadPersonalPatches(NAME) ?? []
+ : loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
+ ],
+ ...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
- resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined),
+ bootConfig,
patches,
(hostCtx) => {
// The launcher owns session identity and the exit line: a config-mounted
diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts
index 6c37598e11..9c6a2ec94c 100644
--- a/apps/cli/src/web.ts
+++ b/apps/cli/src/web.ts
@@ -57,12 +57,14 @@ export async function runWeb(
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
+ // Install shutdown handling before publishing readiness: supervisors may
+ // send a signal as soon as they observe the URL line.
+ process.on('SIGTERM', () => { shutdown(0) })
+ process.on('SIGINT', () => { shutdown(130) })
+
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
-
- process.on('SIGTERM', () => { shutdown(0) })
- process.on('SIGINT', () => { shutdown(130) })
}
diff --git a/apps/cli/tests/lazy-search-startup.compat.spec.ts b/apps/cli/tests/lazy-search-startup.compat.spec.ts
new file mode 100644
index 0000000000..6e6d0b6e85
--- /dev/null
+++ b/apps/cli/tests/lazy-search-startup.compat.spec.ts
@@ -0,0 +1,112 @@
+/**
+ * Node 22 startup-output smoke for the shipped Web CLI composition.
+ *
+ * Only the dedicated Node compatibility gate opts this test in after building
+ * both artifacts; ordinary Vitest inventory deterministically skips it.
+ * The child runs built artifacts under plain Node with the real shipped
+ * config (base.cordis.yml + the web.cordis.yml overlay).
+ * Its URL line follows AppCLIEntry's settled boot; SIGTERM then exercises the
+ * shipped quiescent disposer.
+ */
+
+import { spawn } from 'node:child_process'
+import { existsSync } from 'node:fs'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import yaml from 'js-yaml'
+import { describe, expect, it } from 'vitest'
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const builtBin = join(repoRoot, 'apps/cli/lib/bin.js')
+const webDist = join(repoRoot, 'apps/web/dist/index.html')
+// The web overlay owns the session-query-sqlite lazy-open patch row.
+const configPath = join(repoRoot, 'apps/cli/config/web.cordis.yml')
+const requireBuiltArtifacts = process.env.DSH_REQUIRE_BUILT_CLI_SMOKE === '1'
+
+interface ConfigRow {
+ id?: string
+ config?: { openAt?: unknown }
+}
+
+const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
+ kind: 'scalar',
+ construct: value => String(value),
+})
+const configSchema = yaml.JSON_SCHEMA.extend(jsExprType)
+
+/** Boot the built Web CLI, wait for its settled URL, then dispose through SIGTERM. */
+function runBuiltWeb(cwd: string): Promise<{ stdout: string; stderr: string; code: number }> {
+ return new Promise((resolveRun, rejectRun) => {
+ const env: NodeJS.ProcessEnv = {
+ ...process.env,
+ DEEPSEEK_API_KEY: 'dsh-cli-smoke-dummy-key',
+ DSH_HOME: join(cwd, '.dsh'),
+ }
+ delete env.DEEPSEEK_BASE_URL
+ delete env.NODE_OPTIONS
+ delete env.NODE_NO_WARNINGS
+ const child = spawn(process.execPath, [
+ builtBin,
+ 'web',
+ '--host',
+ '127.0.0.1',
+ '--port',
+ '0',
+ ], {
+ cwd,
+ env,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ })
+ let stdout = ''
+ let stderr = ''
+ let settled = false
+ child.stdout.setEncoding('utf8')
+ child.stderr.setEncoding('utf8')
+ child.stdout.on('data', (chunk: string) => {
+ stdout += chunk
+ if (!settled && /dsh web: http:\/\/127\.0\.0\.1:\d+/u.test(stdout)) {
+ settled = true
+ child.kill('SIGTERM')
+ }
+ })
+ child.stderr.on('data', (chunk: string) => { stderr += chunk })
+ const timer = setTimeout(() => {
+ child.kill('SIGKILL')
+ rejectRun(new Error(`built Web CLI did not settle and dispose within 60s\nstdout:\n${stdout}\nstderr:\n${stderr}`))
+ }, 60_000)
+ child.on('error', (error) => {
+ clearTimeout(timer)
+ rejectRun(error)
+ })
+ child.on('close', (code) => {
+ clearTimeout(timer)
+ if (!settled) {
+ rejectRun(new Error(`built Web CLI exited before settled startup (code ${String(code)})\nstdout:\n${stdout}\nstderr:\n${stderr}`))
+ return
+ }
+ resolveRun({ stdout, stderr, code: code ?? -1 })
+ })
+ })
+}
+
+describe.skipIf(!requireBuiltArtifacts)('built CLI lazy-search startup', () => {
+ it('boots and disposes the shipped composition without a SQLite startup warning', async () => {
+ expect(existsSync(builtBin), `missing built CLI ${resolve(builtBin)}; run pnpm build`).toBe(true)
+ expect(existsSync(webDist), `missing Web dist ${resolve(webDist)}; run pnpm run build:web`).toBe(true)
+ const rows = yaml.load(await readFile(configPath, 'utf8'), { schema: configSchema }) as ConfigRow[]
+ const searchRow = rows.find(row => row.id === 'session-query-sqlite')
+ expect(searchRow?.config?.openAt).toBe('first-search')
+
+ const cwd = await mkdtemp(join(tmpdir(), 'dsh-cli-lazy-search-'))
+ try {
+ const result = await runBuiltWeb(cwd)
+ expect(result.stdout).toMatch(/dsh web: http:\/\/127\.0\.0\.1:\d+/u)
+ expect(result.code).toBe(0)
+ expect(result.stderr).not.toMatch(/ExperimentalWarning: SQLite/u)
+ } finally {
+ await rm(cwd, { recursive: true, force: true })
+ }
+ }, 70_000)
+})
diff --git a/apps/cli/tests/telemetry-switch.spec.ts b/apps/cli/tests/telemetry-switch.spec.ts
new file mode 100644
index 0000000000..0735aa93c7
--- /dev/null
+++ b/apps/cli/tests/telemetry-switch.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { resolveTelemetryPatch } from '../src/app-cli-entry.ts'
+
+describe('resolveTelemetryPatch', () => {
+ it('keeps telemetry enabled when the switch is unset or empty', () => {
+ expect(resolveTelemetryPatch(undefined, true)).toBeUndefined()
+ expect(resolveTelemetryPatch('', true)).toBeUndefined()
+ })
+
+ it('disables on ANY non-empty value, including falsy-looking ones', () => {
+ for (const value of ['1', '0', 'false', 'no']) {
+ expect(resolveTelemetryPatch(value, true)).toEqual({ id: 'telemetry-otel', disabled: true })
+ }
+ })
+
+ it('fails loud when the switch is set but the row is absent', () => {
+ expect(() => resolveTelemetryPatch('1', false)).toThrow('DSH_TELEMETRY_DISABLED is set but row "telemetry-otel" is not in this composition')
+ })
+
+ it('ignores a missing row while the switch is unset', () => {
+ expect(resolveTelemetryPatch(undefined, false)).toBeUndefined()
+ })
+})
diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts
index d8966ee081..359fe6338e 100644
--- a/apps/cli/tests/tui-keyless-smoke.e2e.ts
+++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts
@@ -130,7 +130,9 @@ function smoke(overrides: Partial & { label: string }): Prom
tempDirPrefix: 'dsh-tui-smoke-',
binScript: dshBinScript,
tsconfigPath,
- env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' },
+ // Telemetry now mounts in the shared base: keep fixture sessions from
+ // POSTing to the production endpoint when run outside CI's workflow env.
+ env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call', DSH_TELEMETRY_DISABLED: '1' },
// Artifact CI builds and smokes concurrently on a contended runner.
...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}),
...overrides,
diff --git a/apps/web/index.html b/apps/web/index.html
index fe5901f353..c9fc7d124c 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -3,6 +3,7 @@
+
DeepSeek Harness
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg
new file mode 100644
index 0000000000..8a8fc56752
--- /dev/null
+++ b/apps/web/public/favicon.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts
new file mode 100644
index 0000000000..89b383da85
--- /dev/null
+++ b/apps/web/tests/access-confirmation.e2e.ts
@@ -0,0 +1,104 @@
+// Web e2e scenario: every visible permission picker gates Full access behind
+// the same locale-aware, in-page risk confirmation. Zero model calls: the
+// scenario boots the shipped Web composition and exercises the real
+// permission projection, client command path, HTTP RPC, and pushed update.
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+ assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+ launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot } from './support.ts'
+
+/**
+ * connectFreshWorkspace twin over the product default Chinese locale (the
+ * shared helper's anchors assume the English page every other scenario
+ * boots; this scenario deliberately keeps zh, so the localized picker
+ * copy is the anchor set).
+ */
+async function connectFreshWorkspaceZh(page: Page, name = 'workspace'): Promise {
+ await page.getByRole('button', { name: '选择工作区' }).click()
+ await page.getByRole('menuitem', { name: '新建工作区' }).click()
+ const dialog = page.getByRole('dialog', { name: '新建工作区' })
+ await dialog.waitFor({ timeout: 10_000 })
+ await dialog.getByLabel('新工作区名称').fill(name)
+ await dialog.getByRole('button', { name: '创建工作区' }).click()
+ await page.locator('textarea:enabled[placeholder="描述你想要构建的内容"]')
+ .waitFor({ timeout: 15_000 })
+}
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/access-confirmation', import.meta.url))
+const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
+const MODE = webSnapshotMode()
+
+describe('web e2e: Full access confirmation', () => {
+ let scaffold: WebScaffold
+ let browser: Browser
+ let page: Page
+ let tripwire: ReturnType
+
+ beforeAll(async () => {
+ scaffold = await launchWebScaffold({})
+ // CI uses Playwright's pinned browser. A developer may point this one
+ // scenario at an installed Chromium when the matching browser download
+ // is temporarily unavailable.
+ const executablePath = process.env.DSH_PLAYWRIGHT_EXECUTABLE_PATH
+ browser = await chromium.launch(executablePath === undefined ? {} : { executablePath })
+ // Keep the product default Chinese locale: the golden pins the actual
+ // registered dictionary rather than a test-local translation callback.
+ page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
+ tripwire = watchConsole(page)
+ await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+ await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspaceZh(page)
+ }, 120_000)
+
+ afterAll(async () => {
+ await browser?.close()
+ await scaffold?.close()
+ })
+
+ it('requires acknowledgement before the composer picker can enable Full access', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-full-access-confirmation'))
+ const access = page.locator('button[aria-label^="访问模式"]').first()
+ await access.waitFor({ timeout: 10_000 })
+
+ // Normalize the starting preset through the real command path. The
+ // shipped web config may already start at Full access.
+ if ((await access.getAttribute('aria-label'))?.endsWith('Full access') === true) {
+ await access.click()
+ await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('访问模式,当前:Workspace Write')
+ }
+
+ await access.click()
+ await page.getByRole('menuitem', { name: 'Full access' }).click()
+ const dialog = page.getByRole('dialog', { name: '确认启用 Full access?' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const enable = dialog.getByRole('button', { name: '启用 Full access' })
+ expect(await enable.isDisabled()).toBe(true)
+
+ // The modal is in this page's body (not a native/new window) and escapes
+ // the sticky composer's stacking context.
+ expect(await dialog.evaluate(node => node.parentElement?.parentElement === document.body)).toBe(true)
+ const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+
+ await dialog.getByRole('checkbox', { name: '我已了解风险,并愿意继续' }).check()
+ expect(await enable.isEnabled()).toBe(true)
+ await enable.click()
+ await expect.poll(() => access.getAttribute('aria-label'), { timeout: 10_000 })
+ .toBe('访问模式,当前:Full access')
+ expect(await dialog.count()).toBe(0)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('keeps its snapshot inventory closed', async () => {
+ expect(tripwire.warnings).toEqual([])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
+ })
+})
diff --git a/apps/web/tests/approval-composer.e2e.ts b/apps/web/tests/approval-composer.e2e.ts
index 2c6a78d2e5..b66277e0b2 100644
--- a/apps/web/tests/approval-composer.e2e.ts
+++ b/apps/web/tests/approval-composer.e2e.ts
@@ -151,7 +151,7 @@ describe('web e2e: approval takeover keeps its actions reachable', () => {
await page.setViewportSize(original)
}
- await panel.getByRole('button', { name: '允许一次' }).click()
+ await panel.getByRole('button', { name: 'Allow once' }).click()
const sessionId = await settled
if (MODE === 'record') {
diff --git a/apps/web/tests/built-boot.snapshot.ts b/apps/web/tests/built-boot.snapshot.ts
index 69d5d5cfae..6fa5aec1ab 100644
--- a/apps/web/tests/built-boot.snapshot.ts
+++ b/apps/web/tests/built-boot.snapshot.ts
@@ -60,6 +60,9 @@ let unmount: (() => void) | undefined
beforeEach(() => {
localStorage.clear()
+ // English pinned before boot: role/text locators stay deterministic across
+ // localized component migrations (the newEnglishPage e2e convention).
+ localStorage.setItem('dsh.locale', 'en')
document.title = 'DeepSeek Harness'
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
@@ -108,6 +111,29 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
}, { timeout: 10_000 })
+ // The write/edit turns render a real diff card through the assembled graph
+ // (the keyed FileMutationRow + DiffBlock), not just the fixture's raw text.
+ // The write turn's `hello fixture\n` proves the terminator rule end to end: a
+ // trailing newline terminates its line, so the footer reads `+1` (not a
+ // phantom `+2`) and one distinct file. The `+ ` prefix is a CSS ::before, so
+ // it is absent from textContent — assert on the line body and the footer.
+ const diffCards = [...document.querySelectorAll('[data-diff]')]
+ expect(diffCards.length).toBeGreaterThan(0)
+ const footers = diffCards.map(card => card.textContent ?? '')
+ expect(footers.some(text => text.includes('hello fixture') && text.includes('+1 -0 · 1 file'))).toBe(true)
+
+ // The web render intent reaches the assembled boot graph: the fixture's
+ // web_search / web_fetch turns render their keyed WebRow cards, proving the
+ // registration, wire projection, and card rendering survive the real bundle
+ // path (not just the per-package src benches). The selector pins the KEYED
+ // WebRow (its own `data-variant="web"` wrapper), not the `[data-web]` attribute
+ // WebBlock draws — the generic fallback renders the same WebBlock, so a silent
+ // keyed-registration failure would still satisfy a bare `[data-web]` check.
+ await waitFor(() => {
+ expect(document.querySelector('[data-variant="web"][data-tool="web_search"]')).not.toBeNull()
+ expect(document.querySelector('[data-variant="web"][data-tool="web_fetch"]')).not.toBeNull()
+ }, { timeout: 10_000 })
+
// Every bundle injected its plugin-owned style tag (the loader's CSS path).
const styleOwners = [...document.head.querySelectorAll('style[data-plugin]')]
.map(style => style.getAttribute('data-plugin'))
diff --git a/apps/web/tests/cordis-tool-round.e2e.ts b/apps/web/tests/cordis-tool-round.e2e.ts
index e0ad2d26e1..66bc9c6900 100644
--- a/apps/web/tests/cordis-tool-round.e2e.ts
+++ b/apps/web/tests/cordis-tool-round.e2e.ts
@@ -107,7 +107,8 @@ describe('web e2e: Cordis tools use the generic row variants', () => {
const mountRow = page.locator('[data-tool="cordis_mount"]').filter({ hasText: 'Mount temporary Plugin' }).first()
await mountRow.waitFor({ timeout: 10_000 })
- await mountRow.locator('button[aria-expanded]').click()
+ // The whole summary row is the expand toggle (unified tool-row interaction).
+ await mountRow.locator('[aria-expanded]').first().click()
await expect.poll(() => mountRow.locator('pre.shiki').textContent(), { timeout: 10_000 })
.toContain(MOUNT_CODE)
diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts
index 8105519338..ce97cfb2a1 100644
--- a/apps/web/tests/details-session-lifecycle.e2e.ts
+++ b/apps/web/tests/details-session-lifecycle.e2e.ts
@@ -98,7 +98,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
- expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
+ expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await compareOrRefreshGolden(HANDLES_EXPECTED, await handleSnapshot(page), MODE)
const sidebarBefore = await sidebarTrack(page)
@@ -118,18 +118,18 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
await appFrame(page).waitFor({ timeout: 30_000 })
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
- expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
+ expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
await page.getByRole('button', { name: /^(?:New session|新.*会话)$/ }).last().click()
await page.getByText("Let's start building", { exact: false }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
- expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
+ expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
const original = page.locator('[role=treeitem]').filter({ hasText: 'Reply with the single word' }).first()
await original.click()
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
await expect.poll(() => detailsTrack(page), { timeout: 5_000 }).toBe(0)
- expect(await page.getByText('详情', { exact: true }).isVisible()).toBe(false)
+ expect(await page.getByText('Details', { exact: true }).isVisible()).toBe(false)
const ungrouped = page.getByText('Ungrouped', { exact: true })
const ungroupedRow = ungrouped.locator('..').locator('..')
diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts
index d4d684b1de..97ffefccf7 100644
--- a/apps/web/tests/lifecycle-chrome.e2e.ts
+++ b/apps/web/tests/lifecycle-chrome.e2e.ts
@@ -25,6 +25,8 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url))
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
+const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
+const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim.
const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
@@ -56,6 +58,88 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await scaffold?.close()
})
+ it.skipIf(MODE === 'record')('opens the shared slash menu from plus with only Command candidates', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-command-menu-launcher'))
+ const launcher = page.getByRole('button', { name: 'Commands' })
+ await launcher.click()
+ const menu = page.getByRole('listbox', { name: 'Trigger suggestions' })
+ await menu.waitFor({ timeout: 10_000 })
+ const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd)
+ await compareOrRefreshGolden(COMMAND_MENU_EXPECTED, snapshot, MODE)
+ expect(snapshot).toContain('text: Commands')
+ expect(snapshot).not.toContain('text: Skills')
+ expect(snapshot).not.toContain('text: Subagents')
+ const launchedBox = await menu.boundingBox()
+ await page.locator('textarea').first().press('Escape')
+ await expect.poll(() => menu.count()).toBe(0)
+ const input = page.locator('textarea').first()
+ await input.fill('/')
+ await menu.waitFor({ timeout: 10_000 })
+ const typedBox = await menu.boundingBox()
+ expect(launchedBox).not.toBeNull()
+ expect(typedBox).not.toBeNull()
+ expect(Math.abs(launchedBox!.x - typedBox!.x)).toBeLessThan(1)
+ expect(Math.abs(
+ launchedBox!.y + launchedBox!.height - typedBox!.y - typedBox!.height,
+ )).toBeLessThan(1)
+ await input.fill('')
+ await expect.poll(() => menu.count()).toBe(0)
+ })
+
+ it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
+ const activeScaffold = await launchWebScaffold()
+ const activePage = await newEnglishPage(browser)
+ const activeTripwire = watchConsole(activePage)
+ try {
+ await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
+ await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+ await connectFreshWorkspace(activePage)
+ const input = activePage.locator('textarea').first()
+ await activePage.getByRole('button', { name: 'Commands' }).click()
+ const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
+ await menu.waitFor({ timeout: 10_000 })
+ await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
+ await expect.poll(() => input.inputValue()).toBe('/plan ')
+ await input.press('Enter')
+ const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
+ await planButton.waitFor({ timeout: 10_000 })
+ const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
+ await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
+ const planStyle = await planButton.evaluate((element) => {
+ const probe = document.createElement('span')
+ probe.style.color = 'var(--dsw-alias-state-warn-label)'
+ probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
+ document.body.append(probe)
+ const actual = getComputedStyle(element)
+ const reference = getComputedStyle(probe)
+ const result = {
+ color: actual.color,
+ backgroundColor: actual.backgroundColor,
+ borderRadius: actual.borderRadius,
+ fontSize: actual.fontSize,
+ referenceColor: reference.color,
+ referenceBackgroundColor: reference.backgroundColor,
+ }
+ probe.remove()
+ return result
+ })
+ expect(planStyle.color).toBe(planStyle.referenceColor)
+ expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
+ expect(planStyle.borderRadius).toBe('999px')
+ expect(planStyle.fontSize).toBe('13px')
+ await planButton.click()
+ await expect.poll(() => planButton.count()).toBe(0)
+ expect(activeTripwire.pageErrors).toEqual([])
+ expect(activeTripwire.warnings).toEqual([])
+ } catch (error) {
+ await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
+ throw error
+ } finally {
+ await activePage.close()
+ await activeScaffold.close()
+ }
+ })
+
it('sends the first prompt from the empty-state hero (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
if (MODE !== 'record') {
@@ -152,6 +236,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([])
- await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'hero.expected.md', 'reloaded.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, [
+ 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
+ ])
})
})
diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts
index c563638e80..2a4107e128 100644
--- a/apps/web/tests/live-interactions.e2e.ts
+++ b/apps/web/tests/live-interactions.e2e.ts
@@ -221,8 +221,8 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// only on change, so attempt count is invisible there).
expect(sessionEvents.filter(e => e.type === 'llm/retry').length).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('event sourcing', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThan(0)
- // Golden of the recovered end-state: indistinguishable from a clean
- // completion — retries are deliberately invisible in the transcript.
+ // Golden of the recovered end-state: the discarded partial stays absent,
+ // while the settled retry row remains as durable recovery context.
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(RETRY_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts
index 650c089d32..4d798e11ed 100644
--- a/apps/web/tests/message-actions.e2e.ts
+++ b/apps/web/tests/message-actions.e2e.ts
@@ -64,14 +64,14 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// Focus-reveal the footers (hover:hover keeps them opacity-hidden until
- // hover/focus-within). User has three actions; each finalized assistant
- // text node has copy + branch.
- const copyButtons = page.getByRole('button', { name: '复制' })
+ // hover/focus-within). User has three actions; each turn's last content
+ // assistant has copy + branch.
+ const copyButtons = page.getByRole('button', { name: 'Copy' })
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
await copyButtons.first().focus()
- await expect.poll(() => page.getByRole('button', { name: '在新对话中分支' }).count(), { timeout: 5_000 })
+ await expect.poll(() => page.getByRole('button', { name: 'Branch into a new conversation' }).count(), { timeout: 5_000 })
.toBeGreaterThanOrEqual(2)
- await expect.poll(() => page.getByRole('button', { name: '编辑' }).count(), { timeout: 5_000 }).toBe(1)
+ await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(1)
}, 60_000)
it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => {
@@ -81,7 +81,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
}).waitFor({ timeout: 10_000 })
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
// as an active/focused control during the capture.
- await page.getByRole('button', { name: '复制' }).first().focus()
+ await page.getByRole('button', { name: 'Copy' }).first().focus()
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
@@ -91,7 +91,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
// Exercise the assistant action specifically; package coverage pins the
// user action separately at its own event seq.
- await page.getByRole('button', { name: '在新对话中分支' }).last().click()
+ await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
await expect.poll(
() => scaffold.ctx.agents.list().find(agent => agent.session.header.parentSession === SessionId(SEED_ID)),
{ timeout: 15_000 },
diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts
index 28c423b0a0..9c2215ed7f 100644
--- a/apps/web/tests/models-settings.e2e.ts
+++ b/apps/web/tests/models-settings.e2e.ts
@@ -1,14 +1,15 @@
// Web e2e scenario: the Models settings page end to end through the real
// wire — the add card offers the dormant pi-ai catalog, typing an API key
// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
-// while the settings document records only that reference, and the saved
-// route registers live (the row's 已启用 badge is the topology invalidation
-// landing). The customized-settings fold writes the curated reasoning field
-// as a merge patch. Zero model calls: configuration is pure
+// while the settings document records only that reference; the saved row
+// appears after the route topology invalidation without presenting liveness
+// as provider status. The customized-settings fold writes the curated
+// reasoning field as a merge patch. Zero model calls: configuration is pure
// settings/credentials/llm-domain traffic, so there is no fixture and a
// stray stream would fail loud on the open seam. The provider under test is
// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
-// never shadow the derived reference.
+// never shadow the derived reference. Removing that row is guarded by the
+// localized provider-confirmation dialog before the unset reaches the wire.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
@@ -24,6 +25,7 @@ import { saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url))
const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md')
const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md')
+const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md')
const MODE = webSnapshotMode()
describe('web e2e: Models settings page configures a dormant provider', () => {
@@ -82,7 +84,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
// registers, and the topology frame invalidates the page into the row.
const row = dialog.getByText('minimax-cn', { exact: true }).first()
await row.waitFor({ timeout: 10_000 })
- await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
expect(document).toContain('minimax-cn:')
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
@@ -109,11 +110,42 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
+ it('confirms provider deletion before removing its settings profile', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete'))
+ const settingsDialog = page.getByRole('dialog', { name: '设置' })
+ await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
+ const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' })
+ await deleteDialog.waitFor({ timeout: 10_000 })
+ const snapshot = await captureStableAria(
+ page,
+ '[role="dialog"][aria-label="删除模型提供方?"]',
+ scaffold.workspaceCwd,
+ )
+ await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE)
+
+ await deleteDialog.getByRole('button', { name: '取消', exact: true }).click()
+ expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:')
+ await settingsDialog.getByRole('button', { name: '删除', exact: true }).click()
+ await page.getByRole('dialog', { name: '删除模型提供方?' })
+ .getByRole('button', { name: '删除提供方', exact: true }).click()
+ await expect.poll(
+ async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'),
+ { timeout: 10_000 },
+ ).not.toContain('minimax-cn:')
+ expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8'))
+ .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
+ await expect.poll(
+ async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(),
+ { timeout: 10_000 },
+ ).toBe(0)
await page.keyboard.press('Escape')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
- await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'empty.expected.md'])
+ await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md'])
})
})
diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts
index e88cfa8857..8ffeb25ad1 100644
--- a/apps/web/tests/navigation-panes.e2e.ts
+++ b/apps/web/tests/navigation-panes.e2e.ts
@@ -23,6 +23,7 @@ import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', import.meta.url))
const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
+const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md')
const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'navigation-panes-web-e2e'
@@ -95,39 +96,39 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(calls.map(e => e.data.name).sort()).toEqual(['bash', 'read', 'read'])
}, 400_000)
- it.skipIf(MODE === 'record')('opens the seeded session and renders both turns from the log', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-open'))
- // Expand the collapsed group row, then open the revealed session row.
- const groupRow = page.locator('[role="treeitem"]').first()
- await groupRow.waitFor({ timeout: 15_000 })
- await groupRow.click()
- const sessionRow = page.locator('[role="treeitem"]').nth(1)
- await sessionRow.waitFor({ timeout: 10_000 })
- await sessionRow.click()
+ it.skipIf(MODE === 'record')('finds an unopened seeded session by message content and opens it', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
+ const search = page.getByPlaceholder('Search name, keywords', { exact: false })
+ // The cold row has not been opened, so only the persisted log can satisfy
+ // this query. First search lazily reconciles the SQLite content index.
+ await search.fill('zzzqx-no-such-session')
+ await page.getByText('No matching sessions').waitFor({ timeout: 30_000 })
+ await expect.poll(
+ () => page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem').count(),
+ { timeout: 10_000 },
+ ).toBe(0)
+
+ await search.fill('WATERFALL')
+ const resultTree = page.getByRole('tree', { name: 'Search results' })
+ const result = resultTree.getByRole('treeitem')
+ await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1)
+ await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), {
+ timeout: 10_000,
+ }).toBeGreaterThanOrEqual(1)
+ const snapshot = (await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd))
+ .split(SEED_ID).join('{{seededId}}')
+ await compareOrRefreshGolden(SEARCH_EXPECTED, snapshot, MODE)
+
+ await result.click()
+ // Search navigation addresses the session, not a specific event, and the
+ // query remains until the user explicitly clears it.
+ await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('WATERFALL')
await expect.poll(() => page.getByText('FIRST_DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByRole('heading', { name: 'Navigation Summary' }).count(), { timeout: 15_000 }).toBe(1)
- }, 90_000)
-
- it.skipIf(MODE === 'record')('filters the sidebar tree by title through the search box', async () => {
- onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-search'))
- // Runs after the session is open: a cold summary carries no title (the
- // sidebar shows the cwd basename), and the durable title lands with the
- // attach subscription's baseline — which is itself worth pinning: search
- // matches the title the user sees, not a hidden cold field.
- const search = page.getByPlaceholder('Search name, keywords', { exact: false })
- await expect.poll(() => page.getByText('NavScenario', { exact: false }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
- // Negative: a garbage query empties the tree (group rows hide too).
- await search.fill('zzzqx-no-such-session')
- await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBe(0)
- // Positive: a title word narrows to the matched session + its group,
- // force-expanded by search mode (case-insensitive client-side filter).
- await search.fill('navscenario')
- await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
- // Clear restores the unfiltered tree.
await page.getByRole('button', { name: 'Clear search' }).click()
await expect.poll(() => search.inputValue(), { timeout: 5_000 }).toBe('')
await expect.poll(() => page.locator('[role="treeitem"]').count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
- }, 60_000)
+ }, 90_000)
it.skipIf(MODE === 'record')('renders the trajectory ledger and opens its local record inspector', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-trajectory'))
@@ -180,11 +181,13 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await bashRow.waitFor({ timeout: 15_000 })
const frame = page.locator('[style*="grid-template-columns"]').first()
expect(await frame.getAttribute('data-details-collapsed')).toBe('true')
+ // The row click is the card's expand toggle (unified tool-row
+ // interaction); it must not drive layout geometry either way.
await bashRow.click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// The card's own controls are outside the summary row and must not open
- // details either — the terminal card is read in place.
- await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
+ // details either — the expanded terminal card is read in place.
+ await page.locator('[data-sample="bash-global"] ~ div [data-terminal] [class*="_copyButton_"]').first().click()
await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true')
// Read summaries are host-open file links; they also must not open details.
const fileLink = page.locator('[data-variant="read"] button').first()
@@ -196,10 +199,14 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
await page.getByRole('tab', { name: 'Chat' }).click()
- // The card is resident in the keyed bash row (no expand gesture): the
- // recorded command's own output sits in the message flow, derived from the
- // logged call/result presentations alone.
- const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
+ // The card is expand-gated behind the whole-row toggle (the unified
+ // tool-row interaction): open it if a previous case left it collapsed.
+ // Expanded, the recorded command's own output sits in the message flow,
+ // derived from the logged call/result presentations alone.
+ const bashRow = page.locator('[data-sample="bash-global"]').first()
+ await bashRow.waitFor({ timeout: 15_000 })
+ if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click()
+ const card = page.locator('[data-sample="bash-global"] ~ div [data-terminal]').first()
await card.waitFor({ timeout: 15_000 })
// Real layout, not jsdom's stub (which computes no geometry at all):
// squeeze the output pane below its content width and the line must keep
@@ -253,7 +260,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
}
})
expect(dot.state).toBe('done')
- expect(dot.label).toBe('已完成')
+ expect(dot.label).toBe('Done')
expect(dot.beforePrompt).toBe(true)
expect(dot.insideCard).toBe(true)
expect(dot.leftOfPrompt).toBe(true)
@@ -270,7 +277,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await card.locator('[class*="_copyButton_"]').first().click()
await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
- .toBe('复制成功')
+ .toBe('Copied')
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
}, 60_000)
@@ -279,7 +286,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
expect(slotErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [
- 'seed.jsonl', 'trajectory.expected.md', 'terminal-card.expected.md',
+ 'seed.jsonl', 'search-results.expected.md', 'trajectory.expected.md',
+ 'terminal-card.expected.md',
])
})
})
diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts
index 1c2ca271aa..9993f18613 100644
--- a/apps/web/tests/queue-actions.e2e.ts
+++ b/apps/web/tests/queue-actions.e2e.ts
@@ -81,7 +81,7 @@ describe('web e2e: queue row actions', () => {
await input.fill(text)
await input.press('Enter')
}
- const queueHeader = page.getByRole('button', { name: '2 条排队消息' })
+ const queueHeader = page.getByRole('button', { name: '2 queued messages' })
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
.toBe('false')
const collapsedSnapshot = await captureStableAria(
@@ -92,21 +92,41 @@ describe('web e2e: queue row actions', () => {
await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE)
await queueHeader.click()
await expect.poll(
- () => page.getByRole('button', { name: '删除排队消息' }).count(),
+ () => page.getByRole('button', { name: 'Remove queued message' }).count(),
{ timeout: 10_000 },
).toBe(2)
+ await page.setViewportSize({ width: 640, height: 1000 })
+ const queueBox = await page.locator('[data-queue-dock]').boundingBox()
+ const composerBox = await page.locator('[data-composer-card]').boundingBox()
+ expect(queueBox).not.toBeNull()
+ expect(composerBox).not.toBeNull()
+ expect(queueBox!.x).toBeGreaterThanOrEqual(composerBox!.x)
+ expect(queueBox!.x + queueBox!.width)
+ .toBeLessThanOrEqual(composerBox!.x + composerBox!.width)
+ const queueLeftInset = queueBox!.x - composerBox!.x
+ const queueRightInset = composerBox!.x + composerBox!.width - queueBox!.x - queueBox!.width
+ const composerMetrics = await page.locator('[data-composer-card]').evaluate((element) => {
+ const style = getComputedStyle(element)
+ return {
+ dockInset: Number.parseFloat(style.getPropertyValue('--dsh-composer-dock-inset')),
+ }
+ })
+ expect(queueLeftInset).toBeCloseTo(composerMetrics.dockInset, 1)
+ expect(queueRightInset).toBeCloseTo(composerMetrics.dockInset, 1)
+ await page.setViewportSize({ width: 1680, height: 1000 })
+
const editRow = page.getByText(EDIT, { exact: true }).locator('..')
- await editRow.getByRole('button', { name: '编辑排队消息' }).click()
- const editor = page.getByRole('textbox', { name: '编辑排队消息' })
+ await editRow.getByRole('button', { name: 'Edit queued message' }).click()
+ const editor = page.getByRole('textbox', { name: 'Edit queued message' })
await editor.fill(EDITED)
const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE)
- await page.getByRole('button', { name: '保存排队消息' }).click()
+ await page.getByRole('button', { name: 'Save queued message' }).click()
await page.getByText(EDITED, { exact: true }).waitFor()
const removeRow = page.getByText(REMOVE, { exact: true }).locator('..')
- await removeRow.getByRole('button', { name: '删除排队消息' }).click()
+ await removeRow.getByRole('button', { name: 'Remove queued message' }).click()
await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
@@ -116,7 +136,7 @@ describe('web e2e: queue row actions', () => {
expect(tripwire.warnings).toEqual([])
const editedRow = page.getByText(EDITED, { exact: true }).locator('..')
- await editedRow.getByRole('button', { name: '删除排队消息' }).click()
+ await editedRow.getByRole('button', { name: 'Remove queued message' }).click()
await expect.poll(() => page.getByText(EDITED, { exact: true }).count()).toBe(0)
await page.getByRole('button', { name: 'Stop generating' }).click()
await settled
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index 0bab153419..ef60146d7d 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -199,6 +199,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise {
}],
},
}))
- await page.getByRole('button', { name: '上下文注入' }).waitFor({ timeout: 10_000 })
+ await page.getByRole('button', { name: 'Context injection' }).waitFor({ timeout: 10_000 })
}, 60_000)
it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => {
@@ -165,7 +165,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
it.skipIf(MODE === 'record')('matches the Figma context disclosure geometry', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-injection'))
- const disclosure = page.getByRole('button', { name: '上下文注入' })
+ const disclosure = page.getByRole('button', { name: 'Context injection' })
expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
const collapsedIcon = disclosure.locator('svg').first()
const collapsedIconBox = await collapsedIcon.boundingBox()
@@ -238,7 +238,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
// where neither half repeats the other (the dispatched `/` and its
// argument stay out of the title, and the settlement text never restates
// the command's own name).
- await page.getByRole('button', { name: 'Access mode, current: Danger Full Access' }).click()
+ await page.getByRole('button', { name: 'Access mode, current: Full access' }).click()
await page.getByRole('menuitem', { name: 'Workspace Write' }).click()
await page.getByRole('button', { name: 'Access mode, current: Workspace Write' }).waitFor({ timeout: 10_000 })
// Scoped to the row itself, so unrelated page text that happens to read
diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts
index 775f1596df..a5aced7de2 100644
--- a/apps/web/tests/settings-chrome.e2e.ts
+++ b/apps/web/tests/settings-chrome.e2e.ts
@@ -2,15 +2,18 @@
// section switching, both close paths), the Appearance preference row (the
// real theme gesture — click 深色 and the whole cascade runs: ThemeService preference -> localStorage dsh.theme
// -> theme/change -> ui-layout's presenter -> body attribute -> alias token)
-// and the Language row (settings-scoped localization + persisted dsh.locale).
+// and the Language row (settings-scoped localization + persisted dsh.locale),
+// plus Permission as the persisted default for subsequently created sessions.
// Zero model calls: everything is pure client + persistence state on a blank
// frame, so there is no fixture and a stray stream would fail loud on the
// open llm seam.
+import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { join } from 'node:path'
+import { SessionId } from '@deepseek-ai/dsh-session'
import {
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
@@ -21,7 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/settings-chrome', import
const DIALOG_EXPECTED = join(SNAPSHOT_DIR, 'dialog.expected.md')
const MODE = webSnapshotMode()
-describe('web e2e: settings modal, appearance gesture, language switch', () => {
+describe('web e2e: settings modal and General preferences', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -50,9 +53,9 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
const dialog = page.getByRole('dialog', { name: '设置' })
await dialog.waitFor({ timeout: 10_000 })
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
- // General is the active section by default; its skeleton rows plus the
- // functional Language and Appearance rows render.
+ // General is active by default; Permission, Language and Appearance are functional.
expect(await dialog.getByRole('button', { name: '通用设置' }).getAttribute('aria-current')).toBe('true')
+ await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
await expect.poll(() => dialog.getByText('语言', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
await expect.poll(() => dialog.getByText('外观', { exact: true }).count(), { timeout: 5_000 }).toBe(1)
// Golden of the freshly opened dialog (default zh, General active).
@@ -73,6 +76,55 @@ describe('web e2e: settings modal, appearance gesture, language switch', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
+ it('stores Permission as the default for future sessions without changing an existing session', async () => {
+ onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-permission'))
+ const existing = scaffold.ctx.sessions.create(SessionId('settings-permission-before'))
+ expect(existing.events.find(event => event.type === 'permission/preset')?.data)
+ .toEqual({ preset: 'danger-full-access' })
+
+ await page.getByRole('button', { name: '设置', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: '设置' })
+ await dialog.waitFor({ timeout: 10_000 })
+ const selector = dialog.getByRole('button', { name: 'Full access' })
+ await selector.waitFor({ timeout: 10_000 })
+ await expect.poll(() => selector.isEnabled(), { timeout: 5_000 }).toBe(true)
+ await selector.click()
+ await page.getByRole('menuitem', { name: 'Read Only' }).click()
+ await dialog.getByRole('button', { name: 'Read Only' }).waitFor({ timeout: 10_000 })
+
+ const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(document).toContain('permission:')
+ expect(document).toContain('defaultPreset: read-only')
+ expect(existing.events.find(event => event.type === 'permission/preset')?.data)
+ .toEqual({ preset: 'danger-full-access' })
+
+ const created = scaffold.ctx.sessions.create(SessionId('settings-permission-after'))
+ expect(created.events.map(event => [event.type, event.data])).toEqual([
+ ['permission/preset', { preset: 'read-only' }],
+ ['sandbox/mode', { mode: 'read-only' }],
+ ['approval/policy', { policy: 'ask' }],
+ ])
+
+ await dialog.getByRole('button', { name: 'Read Only' }).click()
+ await page.getByRole('menuitem', { name: 'Full access' }).click()
+ const confirmation = page.getByRole('dialog', { name: '确认启用 Full access?' })
+ const enable = confirmation.getByRole('button', { name: '启用 Full access' })
+ expect(await enable.isDisabled()).toBe(true)
+ await confirmation.getByRole('checkbox').click()
+ await enable.click()
+ await dialog.getByRole('button', { name: 'Full access' }).waitFor({ timeout: 10_000 })
+ const confirmedDocument = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+ expect(confirmedDocument).toContain('defaultPreset: danger-full-access')
+ const confirmed = scaffold.ctx.sessions.create(SessionId('settings-permission-confirmed'))
+ expect(confirmed.events.map(event => [event.type, event.data])).toEqual([
+ ['permission/preset', { preset: 'danger-full-access' }],
+ ['sandbox/mode', { mode: 'danger-full-access' }],
+ ['approval/policy', { policy: 'never' }],
+ ])
+ await page.keyboard.press('Escape')
+ expect(tripwire.pageErrors).toEqual([])
+ }, 60_000)
+
it('flips the theme through the Appearance cubes and persists across reload', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance'))
const readState = async (): Promise<{ attr: boolean; token: string; stored: string | null }> =>
diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts
index d2b2d32f21..8bcb8da23a 100644
--- a/apps/web/tests/smoke-real.e2e.ts
+++ b/apps/web/tests/smoke-real.e2e.ts
@@ -267,6 +267,98 @@ describe('dsh web keyless CLI smoke', () => {
}
})
+ it('retries a partial transport failure through the shipped Web composition', async () => {
+ requireDist()
+ const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-retry-'))
+ const promptMarker = 'WEB_RETRY_REQUEST'
+ const recoveredMarker = 'WEB_RETRY_RECOVERED'
+ let mainAttempts = 0
+ const provider = createServer((request, response) => {
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk: string) => { body += chunk })
+ request.on('end', () => {
+ const parsed = JSON.parse(body) as { max_tokens?: number; messages?: unknown[] }
+ const titleRequest = parsed.max_tokens === 64
+ const mainRequest = !titleRequest && body.includes(promptMarker)
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ if (!mainRequest) {
+ response.end([
+ 'data: {"choices":[{"delta":{"content":"Web retry title"}}]}',
+ 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}',
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ return
+ }
+ mainAttempts++
+ if (mainAttempts === 1) {
+ response.write('data: {"choices":[{"delta":{"content":"WEB_RETRY_DISCARDED"}}]}\n\n')
+ setTimeout(() => { response.destroy() }, 20)
+ return
+ }
+ response.end([
+ `data: {"choices":[{"delta":{"content":"${recoveredMarker}"}}]}`,
+ 'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ })
+ })
+ await new Promise(resolve => provider.listen(0, '127.0.0.1', resolve))
+ const address = provider.address()
+ if (address === null || typeof address === 'string') throw new Error('mock provider did not bind a TCP port')
+ const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
+ const child = spawn(
+ process.execPath,
+ ['--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', '0'],
+ {
+ cwd: workspace,
+ env: {
+ ...process.env,
+ DEEPSEEK_API_KEY: 'keyless-web-retry',
+ DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`,
+ DSH_HOME: join(workspace, '.dsh'),
+ TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ },
+ )
+ try {
+ const baseUrl = await waitForReadyLine(child)
+ const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
+ await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
+ sessionId: created.sessionId,
+ mode: 'queue',
+ content: [{ type: 'text', text: promptMarker }],
+ })
+ let page: HistoryPage | undefined
+ await expect.poll(async () => {
+ page = await history(baseUrl, created.sessionId)
+ return hasAssistantMarker(page, recoveredMarker)
+ }, { timeout: 20_000 }).toBe(true)
+ if (page === undefined) throw new Error('retry history was not observed')
+ const retry = page.events.find(({ event }) => event.type === 'llm/retry')?.event
+ expect(mainAttempts).toBe(2)
+ expect(retry?.data).toMatchObject({
+ turn: 1,
+ step: 1,
+ retry: 1,
+ maxRetries: 2,
+ failure: { code: 'TRANSPORT' },
+ })
+ expect(JSON.stringify(page.events)).toContain('WEB_RETRY_DISCARDED')
+ } finally {
+ const closed = child.exitCode === null
+ ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) })
+ : Promise.resolve()
+ if (child.exitCode === null) child.kill('SIGTERM')
+ await closed
+ await new Promise(resolveClose => provider.close(() => { resolveClose() }))
+ rmSync(workspace, { recursive: true, force: true })
+ }
+ }, 30_000)
+
it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => {
requireDist()
const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-'))
diff --git a/apps/web/tests/snapshots/access-confirmation/ui.expected.md b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
new file mode 100644
index 0000000000..1287e6e565
--- /dev/null
+++ b/apps/web/tests/snapshots/access-confirmation/ui.expected.md
@@ -0,0 +1,10 @@
+- dialog "确认启用 Full access?":
+ - heading "确认启用 Full access?" [level=2]
+ - button "Close":
+ - img
+ - img
+ - paragraph: 启用 Full access 后,agent 将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。
+ - checkbox "我已了解风险,并愿意继续"
+ - text: 我已了解风险,并愿意继续
+ - button "取消"
+ - button "启用 Full access" [disabled]
diff --git a/apps/web/tests/snapshots/approval-composer/ui.expected.md b/apps/web/tests/snapshots/approval-composer/ui.expected.md
index 469ca78790..ef615091df 100644
--- a/apps/web/tests/snapshots/approval-composer/ui.expected.md
+++ b/apps/web/tests/snapshots/approval-composer/ui.expected.md
@@ -1,4 +1,4 @@
-- text: 等待审批
-- group "审批详情": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt"
-- button "拒绝"
-- button "允许一次"
+- text: Waiting for approval
+- group "Approval details": "escalate sandbox to workspace-write: Need to write the notes.txt file as requested by the user. echo 'tok63z tokc7y tokibx tokofw tokujv tok10nu tok16rt tok1cvs tok1izr tok1p3q tok1v7p tok21bo tok2a4 tok8e3 tokei2 tokkm1 tokqq0 tokwtz tok12xy tok191x tok1f5w tok1l9v tok1rdu tok1xht tok23ls tok4k8 tokao7 tokgs6 tokmw5 tokt04 tokz43 tok1582 tok1bc1 tok1hg0 tok1njz tok1tny tok1zrx tokqd tok6uc tokcyb tokj2a tokp69 tokva8 tok11e7 tok17i6 tok1dm5 tok1jq4 tok1pu3 tok1vy2 tok2221 tok30h tok94g tokf8f toklce tokrgd tokxkc tok13ob tok19sa tok1fw9 tok1m08 tok1s47 tok1y86 tok24c5 tok5al tokbek tokhij toknmi toktqh tokzug tok15yf tok1c2e tok1i6d tok1oac tok1ueb tok20ia tok1gq tok7kp tokdoo tokjsn tokpwm tokw0l tok124k tok188j tok1eci tok1kgh tok1qkg tok1wof tok22se tok3qu tok9ut tokfys tokm2r toks6q tokyap tok14eo tok1ain tok1gmm tok1mql tok1suk tok1yyj tok252i tok60y tokc4x toki8w tokocv tokugu tok10kt tok16os tok1csr tok1iwq tok1p0p tok1v4o tok218n tok273 tok8b2 tokef1 tokkj0 tokqmz tokwqy tok12ux tok18yw tok1f2v tok1l6u tok1rat tok1xes tok23ir tok4h7 tokal6 tokgp5 tokmt4 toksx3 tokz12 tok1551 tok1b90 tok1hcz tok1ngy tok1tkx tok1zow toknc tok6rb tokcva tokiz9 tokp38 tokv77 tok11b6 tok17f5 tok1dj4 tok1jn3 tok1pr2 tok1vv1 tok21z0 tok2xg tok91f tokf5e tokl9d tokrdc tokxhb tok13la tok19p9 tok1ft8 tok1lx7 tok1s16 tok1y55 tok2494 tok57k tokbbj tokhfi toknjh tokktng tokzrf tok15ve tok1bzd tok1i3c tok1o7b tok1uba tok20f9 tok1dp tok7ho tokdln tokjpm tokptl tokvxk tok121j tok185i tok1e9h tok1kdg tok1qhf tok1wle tok22pd tok3nt tok9rs tokfvr toklzq toks3p toky7o tok14bn tok1afm tok1gjl tok1mnk tok1srj tok1yvi tok24zh tok5xx tokc1w toki5v toko9u tokudt tok10hs tok16lr tok1cpq tok1itp tok1oxo tok1v1n tok215m tok242 tok881 tokec0 tokfz tokqjy tokwnx' > notes.txt"
+- button "Reject"
+- button "Allow once"
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 0282a16f80..5d1979eadf 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -5,38 +5,39 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- 'button "Think The user wants me to write a single `run_code` program that:"':
- img
- img
- text: "Think The user wants me to write a single `run_code` program that:"
-- button:
+- button "Code Run bash echo and catch missing file read":
- img
- img
-- text: Code Run bash echo and catch missing file read
+ - text: Code Run bash echo and catch missing file read
- img
-- text: Bash Echo CODE_ROUND_OK Read
-- button "missing.txt"
+- text: Bash Echo CODE_ROUND_OK
+- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"':
+ - img
+ - text: "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"
- button "Think The program ran successfully. Let me now reply DONE as instructed.":
- img
- img
- text: Think The program ran successfully. Let me now reply DONE as instructed.
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
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 5b51e47cf4..3ccbdfb332 100644
--- a/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
+++ b/apps/web/tests/snapshots/cordis-tool-round/ui.expected.md
@@ -5,52 +5,54 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use only Cordis tools. First call cordis_inspect with what \"temporary\". Then call cordis_mount with this exact code: \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\". Read its returned id and call cordis_unmount with that exact id. After all three calls succeed, reply exactly CORDIS_UI_DONE and stop. {{clock}}"
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to:":
- img
- img
- text: "Think The user wants me to:"
-- button:
+- button "Inspect temporary":
- img
- img
-- text: Inspect temporary
+ - text: Inspect temporary
- 'button "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."':
- img
- img
- text: "Think Good, no temporary plugins running. Now step 2: call cordis_mount with the exact code."
-- button [expanded]:
+- 'button "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }" [expanded]':
- img
-- text: Mount temporary Plugin typescript
-- button "复制"
+ - text: "Mount temporary Plugin return { name: \"snapshot-noop\", apply(ctx) {} }"
+- text: typescript
+- button "Copy"
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
+- text: OUT Temporary Plugin dyn-1 is running (plugin "snapshot-noop"; available until unmounted or DSH restarts).
+- button "Inspect"
- 'button "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."':
- img
- img
- text: "Think The id is \"dyn-1\". Now step 3: call cordis_unmount with that id."
-- button:
+- button "Unmount temporary Plugin dyn-1":
- img
- img
-- text: Unmount temporary Plugin dyn-1
+ - text: Unmount temporary Plugin dyn-1
- button "Think All three calls succeeded. I should now reply exactly \"CORDIS_UI_DONE\" and stop.":
- img
- img
- text: Think All three calls succeeded. I should now reply exactly "CORDIS_UI_DONE" and stop.
- paragraph: CORDIS_UI_DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
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 49c7958292..039ecc99ea 100644
--- a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
+++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md
@@ -5,35 +5,34 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop. {{clock}}"
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to run a simple bash command and reply with \"DONE\".":
- img
- img
- text: Think The user wants me to run a simple bash command and reply with "DONE".
-- img
-- text: Bash Echo the test string 已完成 workspace echo WEB_E2E_OK
-- button "复制"
-- text: WEB_E2E_OK
+- button "Bash Echo the test string":
+ - img
+ - img
+ - text: Bash Echo the test string
- button "Think The command executed successfully and output \"WEB_E2E_OK\". I just need to reply with \"DONE\".":
- img
- img
- text: Think The command executed successfully and output "WEB_E2E_OK". I just need to reply with "DONE".
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md
new file mode 100644
index 0000000000..47ba98cf05
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/command-menu.expected.md
@@ -0,0 +1,6 @@
+- listbox "Trigger suggestions":
+ - text: Commands
+ - option "goal set or view the goal for a long-running task" [selected]
+ - option "permission Switch the permission preset (sandbox mode + approval policy)"
+ - option "plan Enter or leave plan mode"
+ - option "model Select the model for this conversation"
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
index 65abda0dba..7024719a3a 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/hero.expected.md
@@ -26,14 +26,13 @@
- text: workspace
- img
- textbox "Describe what you want to build"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
-- text: 详情
-- button "关闭详情"
-- text: 点击消息流中的工具行查看详情
+- text: Details
+- button "Close details"
+- text: Click a tool row in the message flow to view its details
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
new file mode 100644
index 0000000000..15bee7afe4
--- /dev/null
+++ b/apps/web/tests/snapshots/lifecycle-chrome/plan-active.expected.md
@@ -0,0 +1,39 @@
+- button "New session"
+- button "Collapse sidebar":
+ - img
+- button "New session":
+ - img
+ - text: New Session
+- text: Workspaces
+- button "Group by":
+ - img
+- button "Create workspace":
+ - img
+- button "Search sessions":
+ - img
+- textbox "Search name, keywords..."
+- tree "Sessions":
+ - treeitem "workspace 1 session" [expanded]:
+ - img
+ - text: workspace 1 session
+ - treeitem "New Session now" [selected]
+- button "Settings":
+ - img
+ - text: Settings
+- text: Let's start building
+- button "Choose workspace":
+ - img
+ - text: workspace
+ - img
+- textbox "Describe what you want to build"
+- button "Commands":
+ - img
+- 'button "Access mode, current: Full access"': Full access
+- button "Plan mode on, press to turn off": Plan
+- button "Select model, current deepseek-v4-flash":
+ - text: deepseek-v4-flash
+ - img
+- button "Send message" [disabled]
+- text: Details
+- button "Close details"
+- text: Click a tool row in the message flow to view its details
diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
index 45e3514fa4..113f81f9eb 100644
--- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
+++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md
@@ -5,27 +5,26 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to reply with a single word. Let me comply.":
- img
- img
- text: Think The user wants me to reply with a single word. Let me comply.
- paragraph: LIGHTHOUSE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/cancel.expected.md b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
index 4323c94285..eb3742eb34 100644
--- a/apps/web/tests/snapshots/live-interactions/cancel.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/cancel.expected.md
@@ -5,24 +5,23 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- paragraph: partial
-- text: 已停止
-- button "复制":
+- text: Stopped
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
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 1d78e91c73..9dcca575de 100644
--- a/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/error-auth.expected.md
@@ -5,17 +5,16 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/live-interactions/retry.expected.md b/apps/web/tests/snapshots/live-interactions/retry.expected.md
index 6a9c808342..4ed6a10c6f 100644
--- a/apps/web/tests/snapshots/live-interactions/retry.expected.md
+++ b/apps/web/tests/snapshots/live-interactions/retry.expected.md
@@ -5,27 +5,28 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
+- group:
+ - status: Retried model request (1/2) · {{duration}}
- button "Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.":
- img
- img
- text: Think The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls.
- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures.
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index 19ba02d99d..9aed20cfce 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -4,39 +4,42 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
-- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
-- button "复制":
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
+- button "Copy":
- img
-- tooltip "复制"
-- button "在新对话中分支":
+- tooltip "Copy"
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- img
-- text: Read
-- button "a.txt"
-- img
-- text: Read
-- button "b.txt"
+- button "Read a.txt":
+ - img
+ - img
+ - text: Read
+ - button "a.txt"
+- button "Read b.txt":
+ - img
+ - img
+ - text: Read
+ - button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- text: {{clock}}
+- text: 7/25 {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md
index 8b9c4ad6e1..251352ee00 100644
--- a/apps/web/tests/snapshots/models-settings/configured.expected.md
+++ b/apps/web/tests/snapshots/models-settings/configured.expected.md
@@ -14,7 +14,7 @@
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- list:
- listitem:
- - text: minimax-cn 已启用
+ - text: minimax-cn
- button "编辑"
- button "删除"
- button "+ 添加提供方"
diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md
new file mode 100644
index 0000000000..afb0cb5fd2
--- /dev/null
+++ b/apps/web/tests/snapshots/models-settings/delete.expected.md
@@ -0,0 +1,7 @@
+- dialog "删除模型提供方?":
+ - heading "删除模型提供方?" [level=2]
+ - button "关闭":
+ - img
+ - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。
+ - button "取消"
+ - button "删除提供方"
diff --git a/apps/web/tests/snapshots/navigation-panes/search-results.expected.md b/apps/web/tests/snapshots/navigation-panes/search-results.expected.md
new file mode 100644
index 0000000000..49de115594
--- /dev/null
+++ b/apps/web/tests/snapshots/navigation-panes/search-results.expected.md
@@ -0,0 +1,2 @@
+- tree "Search results":
+ - 'treeitem "{{workspace}} {{workspace}} ## Navigation Summary - alpha nav - beta nav ``` echo WATERFALL ```"'
diff --git a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
index 298bf764b6..464e48628e 100644
--- a/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
+++ b/apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
@@ -1,3 +1,3 @@
-- text: 已完成 {{workspace}} echo NAVIGATION_OK
-- button "复制"
+- text: Done {{workspace}} echo NAVIGATION_OK
+- button "Copy"
- text: NAVIGATION_OK
diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md
index cb905c0b8b..a81ed0ecd4 100644
--- a/apps/web/tests/snapshots/plan-review/approved.expected.md
+++ b/apps/web/tests/snapshots/plan-review/approved.expected.md
@@ -6,11 +6,11 @@
- tab "Trajectory"
- img
- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}"
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- 'button "Think The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."':
- img
@@ -20,30 +20,24 @@
- text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with
- code: exit_plan_mode
- text: .
-- button "复制":
- - img
-- button "在新对话中分支":
- - img
-- text: {{clock}}
-- button:
+- 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"':
- img
- img
-- text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
+ - text: "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"
- 'button "Think The plan was approved. The user''s last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."':
- img
- img
- text: "Think The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/question-composer/answered.expected.md b/apps/web/tests/snapshots/question-composer/answered.expected.md
index 36752c783a..03c5d84005 100644
--- a/apps/web/tests/snapshots/question-composer/answered.expected.md
+++ b/apps/web/tests/snapshots/question-composer/answered.expected.md
@@ -5,35 +5,34 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: "Use the ask_user_question tool to ask me exactly one question with id \"color\", question \"Which color do you prefer?\", header \"Pick one\", and two options: label \"Blue\" with description \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\", and label \"Green\" with description \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\" After I answer, reply with the single word DONE and stop. {{clock}}"
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that.
-- button:
+- button "Ask question 1/1 answered":
- img
- img
-- text: Ask question 1/1 answered
+ - text: Ask question 1/1 answered
- button "Think The user answered \"Blue\". I should now reply with the single word DONE and stop.":
- img
- img
- text: Think The user answered "Blue". I should now reply with the single word DONE and stop.
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
index cdbf6fc64b..f09469c98c 100644
--- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md
@@ -5,19 +5,18 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- paragraph: partial
-- button "2 条排队消息"
+- button "2 queued messages"
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md
index cf287b5006..cd211013c8 100644
--- a/apps/web/tests/snapshots/queue-actions/editing.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md
@@ -5,32 +5,31 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- paragraph: partial
-- button "2 条排队消息" [disabled] [expanded]
+- button "2 queued messages" [disabled] [expanded]
- list:
- listitem:
- text: Queue item to remove
- - button "编辑排队消息":
+ - button "Edit queued message":
- img
- - button "删除排队消息":
+ - button "Remove queued message":
- img
- listitem:
- - textbox "编辑排队消息": Edited queue item
- - button "保存排队消息":
+ - textbox "Edit queued message": Edited queue item
+ - button "Save queued message":
- img
- - button "取消编辑":
+ - button "Cancel editing":
- img
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md
index 919617bdab..197e1cd622 100644
--- a/apps/web/tests/snapshots/queue-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md
@@ -5,25 +5,24 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- paragraph: partial
- list:
- listitem:
- text: Edited queue item
- - button "编辑排队消息":
+ - button "Edit queued message":
- img
- - button "删除排队消息":
+ - button "Remove queued message":
- img
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
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 87ffd9fc55..f722bb36ae 100644
--- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md
@@ -4,44 +4,47 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
-- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
-- button "复制":
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- img
-- text: Read
-- button "a.txt"
-- img
-- text: Read
-- button "b.txt"
+- button "Read a.txt":
+ - img
+ - img
+ - text: Read
+ - button "a.txt"
+- button "Read b.txt":
+ - img
+ - img
+ - text: Read
+ - button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- text: {{clock}}
-- button "上下文注入":
+- text: 7/25 {{clock}}
+- button "Context injection":
- img
- img
- - text: 上下文注入
+ - text: Context injection
- img
- text: permission preset workspace-write
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
-- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index efc43a272e..42455b1231 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -4,42 +4,45 @@
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
-- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
-- button "复制":
+- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. 7/25 {{clock}}"
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel.":
- img
- img
- text: Think The user wants me to read a.txt and b.txt, then reply with "DONE". Let me do both reads in parallel.
-- img
-- text: Read
-- button "a.txt"
-- img
-- text: Read
-- button "b.txt"
+- button "Read a.txt":
+ - img
+ - img
+ - text: Read
+ - button "a.txt"
+- button "Read b.txt":
+ - img
+ - img
+ - text: Read
+ - button "b.txt"
- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". I'll now reply with DONE as instructed.":
- img
- img
- text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". I'll now reply with DONE as instructed.
- paragraph: DONE
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- text: {{clock}}
-- button "上下文注入":
+- text: 7/25 {{clock}}
+- button "Context injection":
- img
- img
- - text: 上下文注入
+ - text: Context injection
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
index 75959994f1..e782d25c05 100644
--- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
+++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md
@@ -10,11 +10,11 @@
- button "关闭":
- img
- text: 关闭
- - text: 权限 选择默认权限模式
- - button "Read only" [disabled]:
- - text: Read only
+ - text: 权限 选择新会话的默认权限模式
+ - button "Full access":
+ - text: Full access
- img
- - text: 工具调用 Schema mode Traditional function calling — invoke tools one at a time Code mode Chain multiple tools with code — multi-step orchestration 语言
+ - text: 语言
- button "中文":
- text: 中文
- img
diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md
index 28127fd73b..31c5b2b1dc 100644
--- a/apps/web/tests/snapshots/steering/mid-steer.expected.md
+++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md
@@ -5,20 +5,20 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
-- button:
+- button "Ask question waiting":
- img
- img
-- text: Ask question waiting
+ - text: Ask question waiting
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]
diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md
index 5efbcf385d..ba2adad29f 100644
--- a/apps/web/tests/snapshots/steering/settled.expected.md
+++ b/apps/web/tests/snapshots/steering/settled.expected.md
@@ -5,35 +5,35 @@
- tab "Chat" [selected]
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
-- button "编辑":
+- button "Edit":
- img
- button "Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.":
- img
- img
- text: Think The user wants me to use the ask_user_question tool to ask them a specific question with the given parameters. Let me do exactly that.
-- button:
+- button "Ask question 1/1 answered":
- img
- img
-- text: "Ask question 1/1 answered 插话 Interjection: include the word BANANA in your final reply."
+ - text: Ask question 1/1 answered
+- text: "Interjection Interjection: include the word BANANA in your final reply."
- button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.":
- img
- img
- text: Think The user selected "Yes" and wants me to include the word "BANANA" in my final reply. Let me acknowledge their answer.
- paragraph: Great, let's move forward. BANANA!
-- button "复制":
+- button "Copy":
- img
-- button "在新对话中分支":
+- button "Branch into a new conversation":
- img
- text: {{clock}}
- textbox "Message the agent"
-- button "Add attachment":
+- button "Commands":
- img
-- 'button "Access mode, current: Danger Full Access"': Danger Full Access
-- button "Plan mode off, press to turn on": Plan off
+- 'button "Access mode, current: Full access"': Full access
- button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash
- img
diff --git a/apps/web/tests/steering.e2e.ts b/apps/web/tests/steering.e2e.ts
index b36e001dd1..3cbae0b9ee 100644
--- a/apps/web/tests/steering.e2e.ts
+++ b/apps/web/tests/steering.e2e.ts
@@ -121,9 +121,9 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
// exists yet and no interjection bubble renders — the composer still
// blocks, alone. The DOM is stable here (no further SSE frames can
// arrive until the question is answered), making this state capturable.
- expect(await page.getByText('插话').count()).toBe(0)
+ expect(await page.getByText('Interjection', { exact: true }).count()).toBe(0)
expect(await page.getByText(STEER, { exact: true }).count()).toBe(0)
- expect(await page.getByRole('button', { name: '编辑排队消息' }).count()).toBe(0)
+ expect(await page.getByRole('button', { name: 'Edit queued message' }).count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(MID_EXPECTED, snapshot, MODE)
}
@@ -157,7 +157,7 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
// Visible: the badged interjection bubble plus the reply that obeys it
// (steer text + final reply each contain the marker word).
- await expect.poll(() => page.getByText('插话').count(), { timeout: 15_000 }).toBe(1)
+ await expect.poll(() => page.getByText('Interjection', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => page.getByText('Interjection:', { exact: false }).count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('BANANA', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
expect(await page.locator('[data-question-key]').count()).toBe(0)
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index f5774e1545..d7e48e4e6b 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -43,7 +43,8 @@
"tests/cordis-tool-round.e2e.ts",
"tests/message-actions.e2e.ts",
"tests/queue-actions.e2e.ts",
- "tests/skill-invocation-policy.e2e.ts"
+ "tests/skill-invocation-policy.e2e.ts",
+ "tests/access-confirmation.e2e.ts"
],
"references": [
{
diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml
index d7054b8b3c..470ae7f7cf 100644
--- a/docs/architecture.i18n.yaml
+++ b/docs/architecture.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/architecture.md
-architecture.md: bfea67b9f83958e16b58e63b99e326349f6eff15
-architecture.zh.md: c2fd6cdd84ad2f6435faebffa0c4c1a6da0ade96
+architecture.md: c6e14fac6436b2401509aaf8bb20ccaf29aeeafc
+architecture.zh.md: 2e85f25eb3f40f58c8ffbfa7691bb793638c8b37
diff --git a/docs/architecture.md b/docs/architecture.md
index bfea67b9f8..c6e14fac64 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -96,7 +96,7 @@ forever:
assemble system prompt and tool schemas
snapshot the derived messages (the reconstruction boundary)
'step/start'
- agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound)
+ agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound)
'assistant/chunk'
'assistant/message'
schedule tool calls by ctx.tools.executionMode:
@@ -145,7 +145,7 @@ Each agent owns scoped `agent.ctx`; shared storage overlays its tool, prompt, an
The session log is authoritative. `deriveMessages()` projects model history; raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcript rendering, telemetry, and persistence derive from this stream.
-**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; package-owned `dsh-agent-loop/invariant` can assert this through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
+**Model-visible ⟺ logged**: messages at `step/start` plus the folded `request/header` reconstruct every request; the header also marks adapter-materialized defaults so the next proposal can discard them and resolve the selected route without losing explicit conversation settings. Package-owned `dsh-agent-loop/invariant` can assert reconstructability through `ctx.invariants` ([reconstructability](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)).
Durability is a plugin concern. Backends eagerly drain synchronous `session/event` notifications. `session/flush` barriers precede each request and top-level tool dispatch, then follow `turn/end` before another queued turn or idle observation. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, while SQLite shares the contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)).
diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md
index c2fd6cdd84..2e85f25eb3 100644
--- a/docs/architecture.zh.md
+++ b/docs/architecture.zh.md
@@ -96,7 +96,7 @@ forever:
assemble system prompt and tool schemas
snapshot the derived messages (the reconstruction boundary)
'step/start'
- agent/request (config only) -> prepare reasoning/default under turn signal -> log request/header -> llm/stream (frozen, registration-bound)
+ agent/request (config only) -> prepare adapter defaults/provenance under turn signal -> log request/header -> llm/stream (frozen, registration-bound)
'assistant/chunk'
'assistant/message'
schedule tool calls by ctx.tools.executionMode:
@@ -145,7 +145,7 @@ idle inject:
会话日志是权威依据。`deriveMessages()` 投影出模型历史;原始 `assistant/chunk` 事件保证回放和 UI 保真。fork、恢复、transcript(文本记录)渲染、遥测和持久化均派生自该事件流。
-**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言这一点([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
+**模型可见 ⟺ 已记录**:`step/start` 时的消息与折叠后的 `request/header` 可以重建每个请求;该 header 还会标记适配器填入的默认值,使下一次提议可以丢弃这些值并解析所选路由,同时不丢失显式对话设置。该包的 `dsh-agent-loop/invariant` 可通过 `ctx.invariants` 断言可重建性([可重建性](../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md))。
持久性由插件负责。后端会尽快排空同步的 `session/event` 通知。`session/flush` 屏障位于每次请求与顶层工具分发之前,并在 `turn/end` 之后、处理另一个已排队轮次或观察到空闲状态之前执行。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index e8600f8e55..f3d855bcd5 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -638,7 +638,9 @@ export interface Config {
thinking?: 'enabled' | 'disabled'
/** Default thinking effort (default `high`); `off` disables thinking per request. */
reasoningEffort?: 'off' | 'high' | 'max'
- /** Positive context capacity used when the selected model has no exact value. */
+ /** Default per-request output cap (default 256,000); explicit request values win. */
+ maxTokens?: number
+ /** Positive context capacity used when the selected model has no exact value (default 1,000,000). */
defaultContextWindow?: number
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
models?: DeepSeekCatalogModel[]
@@ -663,7 +665,7 @@ export interface DeepSeekCatalogModel {
Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts)
-Source: [`packages/llm/llm-deepseek/src/index.ts:50`](../packages/llm/llm-deepseek/src/index.ts)
+Source: [`packages/llm/llm-deepseek/src/index.ts:60`](../packages/llm/llm-deepseek/src/index.ts)
## `@deepseek-ai/dsh-llm-pi-ai`
@@ -774,7 +776,7 @@ Requires: `agents`
export type Config = Readonly>
```
-Source: [`packages/llm/llm-retry/src/index.ts:45`](../packages/llm/llm-retry/src/index.ts)
+Source: [`packages/llm/llm-retry/src/index.ts:47`](../packages/llm/llm-retry/src/index.ts)
## `@deepseek-ai/dsh-lsp-local`
@@ -869,10 +871,10 @@ Source: [`packages/mcp/mcp-client/src/index.ts:93`](../packages/mcp/mcp-client/s
## `@deepseek-ai/dsh-permission`
-Requires: `bash` · `approval`
+Requires: `bash` · `approval` · `sessions`
```ts config-catalog
-/** The {@link PermissionService} config: the deployment's preset table. */
+/** The {@link PermissionService} config: preset table and composition default. */
export interface Config {
/**
* The preset table: name → knob bundle. Defaults to `workspace-write`
@@ -880,6 +882,11 @@ export interface Config {
* never). The name `custom` is reserved for the derived not-a-preset state.
*/
presets?: Record
+ /**
+ * Default for new sessions. When omitted, the preset matching the composed
+ * sandbox and approval defaults is used.
+ */
+ defaultPreset?: string
}
/** One preset's sandbox/approval bundle and optional client presentation. */
@@ -897,7 +904,7 @@ export interface PresetSpec {
Depends on: [`ApprovalPolicy`](core-data-structures/approval.md) · [`SandboxMode`](core-data-structures/sandbox.md)
-Source: [`packages/ui/permission/src/index.ts:130`](../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:140`](../packages/ui/permission/src/index.ts)
## `@deepseek-ai/dsh-plan-mode`
@@ -1142,11 +1149,13 @@ Requires: `sessions`
/** Combined session-query configuration backed by SQLite full-text search. */
export interface Config extends SessionQueryConfig {
/**
- * Dedicated derived-index path; `:memory:` is supported for tests. Missing
- * directories and database files are created owner-only on POSIX filesystems;
- * existing modes are preserved.
+ * Dedicated derived-index path; `:memory:` is supported for ephemeral
+ * indexes. Missing directories and database files are created owner-only on
+ * POSIX filesystems; existing modes are preserved.
*/
path: string
+ /** Open the SQLite module and handle at service activation or the first search. Defaults to `startup`. */
+ openAt?: OpenAt
/** SQLite journal mode. Defaults to `wal`. */
journalMode?: JournalMode
/** Page size when a request omits `limit`. At most `Number.MAX_SAFE_INTEGER - 1`; defaults to 20. */
@@ -1159,13 +1168,16 @@ export interface Config extends SessionQueryConfig {
persistedInspectConcurrency?: number
}
+/** SQLite module/handle opening phase. */
+export type OpenAt = 'startup' | 'first-search'
+
/** Supported SQLite journal modes. */
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
```
Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts)
-Source: [`packages/session-query/session-query-sqlite/src/index.ts:86`](../packages/session-query/session-query-sqlite/src/index.ts)
+Source: [`packages/session-query/session-query-sqlite/src/index.ts:89`](../packages/session-query/session-query-sqlite/src/index.ts)
## `@deepseek-ai/dsh-session-reference`
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index 541cbb6016..0adc848ea5 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -328,7 +328,7 @@ Source: [`packages/bash/tool-bash/src/index.ts:104`](../../packages/bash/tool-ba
## `ctx.clientModuleHost` — `ClientModuleHostService`
-The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot sweep reports it).
+The web plugin table service: incremental dshClient scan + wire composition + bundle route + index tap. Construction runs the activation scan synchronously — a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud throw (FAILED fiber; the boot activation audit reports it).
```ts cordis-catalog
/**
@@ -368,7 +368,7 @@ onRebuilt(listener: (id: string, rev: string) => void): () => void
onGraphChanged(listener: () => void): () => void
```
-Source: [`packages/client/modules/src/index.ts:143`](../../packages/client/modules/src/index.ts)
+Source: [`packages/client/modules/src/index.ts:184`](../../packages/client/modules/src/index.ts)
## `ctx.codeRuntime` — `CodeRuntime` (abstract seam)
@@ -844,7 +844,7 @@ async resolveModelInfo( provider: string, model: string, signal?: AbortSignal, )
/**
* Validate a conversation call config against its exact model capability and
- * materialize an adapter-configured default. Unsupported explicit efforts
+ * materialize adapter-configured defaults. Unsupported explicit efforts
* reject before provider I/O; no clamping or aliasing is performed. This
* standalone query does not bind a later dispatch; use {@link prepareCall}
* when logging and streaming must share one adapter registration.
@@ -882,7 +882,7 @@ stream(options: GenerateOptions): AsyncIterable
Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
-Source: [`packages/llm/llm/src/index.ts:227`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:229`](../../packages/llm/llm/src/index.ts)
## `ctx.permission` — `PermissionService`
@@ -934,7 +934,7 @@ set(session: Session, name: string): void
Types: [Session](../core-data-structures/session.md) · [SessionEvent](../core-data-structures/core.md)
-Source: [`packages/ui/permission/src/index.ts:144`](../../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:159`](../../packages/ui/permission/src/index.ts)
## `ctx.planMode` — `PlanModeService`
@@ -1652,7 +1652,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
-Source: [`packages/core/session/src/index.ts:714`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:741`](../../packages/core/session/src/index.ts)
## `ctx.sessionTitle` — `SessionTitleService`
diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml
index 9a5d7a3f3b..82ef063bec 100644
--- a/docs/core-data-structures/core.i18n.yaml
+++ b/docs/core-data-structures/core.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/core-data-structures/core.md
-core.md: 7176a6949211566c5f162bc3289189d047c6fc85
-core.zh.md: de06529d602fc0871688d0b8e038c8d1c3ac2ef7
+core.md: 5ed6a47c5488005d41fdac9349e4c9d1c550d13d
+core.zh.md: 1b16b7ec994c6fccd6fedf1508dec6b1b057edf3
diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md
index 7176a69492..5ed6a47c54 100644
--- a/docs/core-data-structures/core.md
+++ b/docs/core-data-structures/core.md
@@ -259,7 +259,7 @@ interface LlmModelInfo {
}
```
-Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution.
+Correctness-sensitive metadata is resolved separately from the advisory catalog and is owned by the adapter serving the exact route. Context capacity, adapter call defaults, and reasoning choices share one exact-model result so consumers do not repeat authoritative model resolution.
```ts type-equiv
/** Provider-owned context capacity for one exact provider/model route. */
@@ -306,6 +306,8 @@ interface LlmModelReasoningInfo {
interface LlmResolvedModelInfo extends LlmModelInfo {
/** Provider-owned context capacity when known. */
context?: LlmModelContext
+ /** Adapter-configured per-request output cap materialized when callers omit one. */
+ defaultMaxTokens?: number
/** Adapter-owned selectable reasoning levels when exposed. */
reasoning?: LlmModelReasoningInfo
}
@@ -392,9 +394,9 @@ The model-facing `ToolSchema` is the wire shape; the registered `ToolDefinition`
### The request envelope: `LlmCallConfig` and the logged header
-The loop builds each request from logged state. `EpochHeader` records call config, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
+The loop builds each request from logged state. `EpochHeader` records call config, adapter-default provenance, rendered prompt, and authoritative returned tool order (configured by `toolOrder`, or lexicographic when unset) through full `request/header` snapshots. Together with derived history, this makes the request reconstructable from the session log. See [session.md](session.md#the-request-header-event-requestheader) and the [reconstructability Agent Note](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md).
-`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. After the waterfall, the loop prepares the exact model capability under the turn signal, rejects unsupported explicit effort ids without clamping, materializes an adapter-configured default, and logs the effective value. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
+`agent/request` receives a frozen call-config seed and may return a replacement to switch provider, model, reasoning effort, or sampling. Before the waterfall, the loop removes values marked as adapter defaults so exact-model preparation materializes the selected route's current values; unmarked explicit settings remain in the proposal. After the waterfall, preparation rejects unsupported explicit effort ids without clamping and logs the effective config plus provenance under the turn signal. The prepared call keeps one adapter registration through dispatch. Requests reaching `llm/stream` are deep-frozen, so mutation throws, and carry a process-local loop identity so observers do not confuse separately logged frozen auxiliary calls with conversation requests.
On the wire, a loop-built request reads the `system` slot (the rendered prompt assembly) followed by the derived history — the boundary snapshot, whose tail is the newest `user/message` on a turn's first step and the previous step's tool results on later steps. The dev invariant recomputes exactly this equation against every loop-built request.
@@ -417,6 +419,17 @@ interface LlmCallConfig {
}
```
+```ts type-equiv
+/**
+ * Effective config fields supplied by exact-model adapter resolution rather
+ * than by the caller's request proposal.
+ */
+interface LlmCallConfigAdapterDefaults {
+ reasoningEffort?: true
+ maxTokens?: true
+}
+```
+
## Sessions
A `Session` is an **append-only log** of typed `SessionEvent`s — the single source of truth. The LLM message history is *derived* from the log (`deriveMessages()`), not stored separately. The event vocabulary derives from `SessionEventMap`:
@@ -658,7 +671,7 @@ interface Agent {
}
```
-`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission leaves the provider default in control. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
+`AgentStatus` is `'idle' | 'running'`, and `SessionId` is branded. Disposal removes the agent from the registry and emits `agent/disposed`; it is not a terminal status value. `running` describes the driver-wide drain interval and may span consecutive queued turns; it does not prove a turn is still open. `acceptsNextStep` is the narrower routing predicate for callers that must choose between steering the current admission/turn and submitting a fresh admitted prompt. `AgentOptions` is merge-extensible: core declares `provider?`, `model?`, and `maxTokens?` (dispatch requires provider and model after `agent/request`). When present, `maxTokens` must be a positive safe integer and caps every conversation-model request; omission allows the exact-model adapter default to materialize before the request header, or otherwise leaves provider behavior unchanged. Persona belongs to `dsh-system-prompt`: an agent-scoped `deployment:persona` may shadow the global default.
The cause is a TypeScript-enforced same-process input. An active `TurnCancellation` holder copies its discriminant into the runtime-only `AbortSignal.reason` and is retired before `turn/end` publication; the frozen `AbortSignal.reason` remains readable after that retirement. Only the loop reads the cause (`user`, `parent`, or lifecycle-only `disposed`) back off its own machine-private signal at settlement — there is no public reader, and a signal grants cooperating listeners no classification authority. Durable `turn/end` retains the coarse `{ kind: 'aborted' }` outcome; request provenance would require a separate durable event rather than overloading the terminal result.
diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md
index de06529d60..1b16b7ec99 100644
--- a/docs/core-data-structures/core.zh.md
+++ b/docs/core-data-structures/core.zh.md
@@ -265,7 +265,7 @@ interface LlmModelInfo {
}
```
-对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。
+对正确性敏感的元数据与参考目录分开解析,并归服务该确切路由的适配器所有。上下文容量、适配器调用默认值和推理选项共用同一个确切模型结果,消费方因而无需重复执行权威模型解析。
```ts type-equiv
/** Provider-owned context capacity for one exact provider/model route. */
@@ -312,6 +312,8 @@ interface LlmModelReasoningInfo {
interface LlmResolvedModelInfo extends LlmModelInfo {
/** Provider-owned context capacity when known. */
context?: LlmModelContext
+ /** Adapter-configured per-request output cap materialized when callers omit one. */
+ defaultMaxTokens?: number
/** Adapter-owned selectable reasoning levels when exposed. */
reasoning?: LlmModelReasoningInfo
}
@@ -398,9 +400,9 @@ interface ToolSchema {
### 请求信封:`LlmCallConfig` 与记录的 header
-循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
+循环从已记录状态构建每个请求。`EpochHeader` 通过完整的 `request/header` 快照记录调用配置、适配器默认值来源、渲染后的提示词以及权威返回工具顺序(由 `toolOrder` 配置;未配置时按字典序)。结合派生历史,请求便可由会话日志重建。见 [session.md](session.md#the-request-header-event-requestheader) 与[可重建性 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md)。
-`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 结束后,循环会在轮次信号控制下完成确切模型的能力准备,拒绝显式指定但不受支持的推理强度 ID(不自动调整),填入适配器配置的默认值,并记录最终生效值。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
+`agent/request` 接收冻结的调用配置种子,并可返回替代值以切换提供方、模型、推理强度或采样参数。waterfall 开始前,循环会移除标记为适配器默认值的值,使确切模型准备过程填入所选路由的当前值;未带标记的显式设置仍保留在提议中。waterfall 结束后,准备过程会在轮次信号控制下拒绝显式指定但不受支持的推理强度 ID(不自动调整),并记录生效配置及其来源。准备完成的调用直至分派完成始终持有同一项适配器注册。到达 `llm/stream` 的请求会被深度冻结,因此变更会抛异常;请求还携带进程本地循环标识,使观察者不会把单独记录的冻结辅助调用误认成对话请求。
在协议格式上,循环构建的请求先读取 `system` 槽位(渲染后的提示词组装),再读取派生历史——边界快照,其尾部在轮次首步是最新的 `user/message`,在后续步骤是上一步的工具结果。开发不变式针对每个循环构建的请求精确重算此等式。
@@ -423,6 +425,17 @@ interface LlmCallConfig {
}
```
+```ts type-equiv
+/**
+ * Effective config fields supplied by exact-model adapter resolution rather
+ * than by the caller's request proposal.
+ */
+interface LlmCallConfigAdapterDefaults {
+ reasoningEffort?: true
+ maxTokens?: true
+}
+```
+
## 会话
`Session` 是一份类型化 `SessionEvent` 的**仅追加日志**——唯一的真源。LLM(大语言模型)消息历史从日志*派生*(`deriveMessages()`),而非单独存储。事件词汇从 `SessionEventMap` 派生:
@@ -666,7 +679,7 @@ interface Agent {
}
```
-`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时由提供方默认值控制。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。
+`AgentStatus` 为 `'idle' | 'running'`,`SessionId` 是品牌类型。dispose(资源释放)会把 agent 从注册表移除并发出 `agent/disposed`;它不是一个终态 status 值。`running` 描述整个驱动器的排空区间,可能跨越连续的排队轮次;它不能证明某个轮次仍然打开。对于需要在把输入作为 steering 加入当前提示词准入/轮次,还是提交为一个新的待准入提示词之间做选择的调用方,`acceptsNextStep` 才是更窄且准确的路由判断条件。`AgentOptions` 可合并扩展:core 声明 `provider?`、`model?` 与 `maxTokens?`(在 `agent/request` 后,分发要求 provider 与 model 都存在)。提供 `maxTokens` 时,它必须是正安全整数,并限制每次对话模型请求的输出;省略时,系统会在写入请求 header 前填入确切模型的适配器默认值,否则提供方行为保持不变。Persona 归 `dsh-system-prompt` 所有:agent 作用域的 `deployment:persona` 可以遮蔽全局默认值。
cause 是由 TypeScript 强制约束的同进程输入。活跃的 `TurnCancellation` 持有者会把其判别字段复制到仅运行时的 `AbortSignal.reason`,并在发布 `turn/end` 前退役;冻结后的 `AbortSignal.reason` 仍可读取。只有 loop 会在结算时从自己机器私有的 signal 上读回 cause(`user`、`parent` 或仅用于生命周期的 `disposed`)——不存在公开的读取器,signal 也不授予协作监听器任何分类权限。持久 `turn/end` 保留粗粒度 `{ kind: 'aborted' }` 结果;若需记录请求 provenance,应使用单独的持久事件,而不是让终态结果承担额外含义。
diff --git a/docs/core-data-structures/llm-streaming.i18n.yaml b/docs/core-data-structures/llm-streaming.i18n.yaml
index c3924f184b..7e1ba8b7bb 100644
--- a/docs/core-data-structures/llm-streaming.i18n.yaml
+++ b/docs/core-data-structures/llm-streaming.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/core-data-structures/llm-streaming.md
-llm-streaming.md: 6811611768a0ec577360a8ff82792899cf3b8fec
-llm-streaming.zh.md: 35374af6a20086f15384840689dba5aa24351750
+llm-streaming.md: e7500a7985ea1916e206c41e05855701b48fcf00
+llm-streaming.zh.md: 2b61815f2730afdfb93bc06b8ee8925d2f4cac25
diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md
index 6811611768..e7500a7985 100644
--- a/docs/core-data-structures/llm-streaming.md
+++ b/docs/core-data-structures/llm-streaming.md
@@ -162,13 +162,15 @@ declare class BlockAssembler {
## The seam
-`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or capability, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. The service validates and materializes reasoning through `resolveCallConfig()` at the final adapter boundary, so direct calls cannot bypass unsupported-effort rejection; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
+`LlmAdapter` is the provider seam: subclass, implement `stream()`, and register one adapter instance with `ctx.llm.registerAdapter(providers, adapter)`. `GenerateOptions.provider` selects the registered adapter; `GenerateOptions.model` is passed to that adapter and need not be registered at lifecycle start. Duplicate provider routes fail atomically. Optional `providerRetryPolicy()` is captured per route with normal defaults, while `providerInfo()` and asynchronous `listModels()` feed `LlmService.listProviders()` / `listModels()` with detached selector metadata. That catalog is advisory rather than a request whitelist: the adapter remains authoritative and may accept unlisted model ids. One asynchronous `resolveModel()` query returns exact model identity plus optional correctness-sensitive context capacity, an adapter-configured `defaultMaxTokens`, and ordered model-owned reasoning ids with an optional deployment default; absent fields mean unavailable metadata or provider-owned behavior, not invalid catalog membership. The resolver receives optional cancellation and must settle promptly after abort. `LlmService.resolveModelInfo()` validates and detaches the aggregate. At the final adapter boundary, `resolveCallConfig()` materializes the output default only when `maxTokens` is absent and validates and materializes reasoning, so direct calls cannot bypass either configured behavior; direct dispatch captures one registration before awaiting that resolution. The agent loop instead uses `prepareCall()` to keep the same registration across model resolution, durable header logging, and dispatch. Adapter lookup happens at the terminal continuation of the `llm/stream` waterfall, so a listener may short-circuit the call or route a mutable one-shot request before lookup. The `block-start` / `block-end` `index` correlation and the assembler together mean an adapter only has to emit well-formed chunks — block reassembly is not each adapter's problem. The consumer surface (`ctx.llm.stream()`) and the `llm/stream` waterfall are described in [architecture.md § Content blocks and streaming](../architecture.md#content-blocks-and-streaming-dsh-llm).
```ts type-equiv
/** One model call whose config and adapter registration were resolved together. */
interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
+ /** Config fields materialized by the captured adapter rather than proposed by the caller. */
+ readonly adapterDefaults: LlmCallConfigAdapterDefaults
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
@@ -215,7 +217,7 @@ declare abstract class LlmAdapter {
* @param model - exact model id passed to {@link GenerateOptions.model}.
* @param _signal - cancellation for this exact-model lookup; asynchronous
* implementations must settle promptly after it aborts.
- * @returns provider/model identity plus any context and reasoning metadata.
+ * @returns provider/model identity plus any context, call-default, and reasoning metadata.
*/
resolveModel(
provider: string,
diff --git a/docs/core-data-structures/llm-streaming.zh.md b/docs/core-data-structures/llm-streaming.zh.md
index 35374af6a2..2b61815f27 100644
--- a/docs/core-data-structures/llm-streaming.zh.md
+++ b/docs/core-data-structures/llm-streaming.zh.md
@@ -162,13 +162,15 @@ declare class BlockAssembler {
## seam
-`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据或能力不可用,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。服务通过最终适配器边界的 `resolveCallConfig()` 校验推理强度并填入默认值,因此直接调用也无法绕过对不支持推理强度的拒绝;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。
+`LlmAdapter` 是提供方 seam:创建子类、实现 `stream()`,再用 `ctx.llm.registerAdapter(providers, adapter)` 注册一个适配器实例。`GenerateOptions.provider` 选择已注册适配器;`GenerateOptions.model` 会传给该适配器,无需在生命周期启动时注册。重复提供方路由会原子失败。可选的 `providerRetryPolicy()` 会按路由捕获并填入 normal 默认值,`providerInfo()` 与异步 `listModels()` 方法则为 `LlmService.listProviders()` / `listModels()` 提供分离的 selector 元数据。该目录仅供参考,不是请求白名单:适配器仍是权威,并可接受未列出的模型 id。单次异步 `resolveModel()` 查询返回确切模型身份,以及可选的对正确性敏感的上下文容量、适配器配置的 `defaultMaxTokens`、由模型持有的有序推理强度 ID 和部署默认值;字段缺失表示元数据不可用或保留提供方持有的行为,而不表示目录成员关系无效。解析器会接收可选的取消信号,并且必须在信号中止后迅速完成结算。`LlmService.resolveModelInfo()` 会校验聚合结果并返回分离值。在最终适配器边界,`resolveCallConfig()` 仅在 `maxTokens` 缺失时填入输出默认值,并校验和填入推理强度,因此直接调用也无法绕过任何一项已配置行为;直接分派会在等待解析前捕获一项适配器注册。agent loop 则使用 `prepareCall()`,使模型解析、请求头持久记录和分派全程使用同一项注册。适配器查找发生在 `llm/stream` waterfall(瀑布式事件)的终端 continuation,因此 listener 可以在查找前短路调用,或路由一个可变的一次性请求。`block-start` / `block-end` 的 `index` 关联与 assembler 共同意味着适配器只需 emit 格式正确的分片——块重组不是每个适配器各自的问题。消费方 surface(`ctx.llm.stream()`)与 `llm/stream` waterfall 见 [architecture.md § 内容块与流式传输](../architecture.md#content-blocks-and-streaming-dsh-llm)。
```ts type-equiv
/** One model call whose config and adapter registration were resolved together. */
interface PreparedLlmCall {
/** Detached, deep-frozen config with any adapter-owned default materialized. */
readonly config: LlmCallConfig
+ /** Config fields materialized by the captured adapter rather than proposed by the caller. */
+ readonly adapterDefaults: LlmCallConfigAdapterDefaults
/**
* Dispatch this call once through the registration captured during
* preparation. The request's call-config fields must match {@link config};
@@ -215,7 +217,7 @@ declare abstract class LlmAdapter {
* @param model - exact model id passed to {@link GenerateOptions.model}.
* @param _signal - cancellation for this exact-model lookup; asynchronous
* implementations must settle promptly after it aborts.
- * @returns provider/model identity plus any context and reasoning metadata.
+ * @returns provider/model identity plus any context, call-default, and reasoning metadata.
*/
resolveModel(
provider: string,
diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml
index 17c4ab68ba..05398d5e7f 100644
--- a/docs/core-data-structures/session.i18n.yaml
+++ b/docs/core-data-structures/session.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/core-data-structures/session.md
-session.md: 5389c2e2114094df5afdca25afd904b2cbbf8270
-session.zh.md: 29c4853213dfc886155e3f8d0991fa161e05c71a
+session.md: f337a6ffb200ffbe8146ee168b1aa0c4030defe0
+session.zh.md: 1637efedbacd76cfd651badbaf699655ad8e94fb
diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md
index 5389c2e211..f337a6ffb2 100644
--- a/docs/core-data-structures/session.md
+++ b/docs/core-data-structures/session.md
@@ -94,7 +94,9 @@ interface SessionEventMap {
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
- * produced none of them. This log-only event is the durable projection of
+ * produced none of them. An explicitly supplied empty seed puts the marker
+ * at seq 0, distinguishing an empty resumed session from a fresh session.
+ * This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
@@ -144,7 +146,7 @@ interface TodoItem {
### The request header event: `request/header`
-The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
+The request envelope — the `EpochHeader` (call config + adapter-default provenance + rendered system prompt + assembled tool schemas) — is logged session state, so every conversation request is a pure function of the log (the reconstructability Agent Note). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message.
```ts type-equiv
/**
@@ -155,6 +157,8 @@ The request envelope — the `EpochHeader` (call config + rendered system prompt
interface EpochHeader {
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
+ /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
+ adapterDefaults?: LlmCallConfigAdapterDefaults
/** Rendered system prompt text; absent for a system-less request. */
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
@@ -350,7 +354,9 @@ declare class Session {
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
- * in-process construction fact.
+ * in-process construction fact. An explicitly supplied empty seed has the
+ * same value as no seed (0); its `session/end-seed` event preserves the
+ * lifecycle distinction.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
@@ -546,7 +552,7 @@ The optional `dsh-session/invariant` companion enforces the relations owned by c
A seeded session — resume, fork, or replay — appends this log-only event immediately after its constructor seed, as its first live write. Events before it have smaller seq values and came from the seed. It is the durable projection of `firstLiveSeq`: that field answers where this lifecycle's writes start for a consumer holding the object, while the event answers the same question for one holding only stored bytes. The payload is empty, so position and `time` carry the whole meaning, and it produces no message. `Session`'s constructor is the only legitimate writer.
-An empty seed writes nothing, and a seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
+An explicitly supplied empty seed writes `session/end-seed` at seq 0, which distinguishes an empty resumed session from a fresh one. A seed already ending in `session/end-seed` is not re-marked, so reopening an untouched session does not grow its log per pickup. Locate the LAST `session/end-seed` in stored history rather than assuming one exists at `firstLiveSeq`: after a pickup with no work, the event has a smaller seq than the next lifecycle's `firstLiveSeq`.
It exists because seed history and live work are otherwise byte-identical, which defeats any plugin owning a standalone open/close bracket: an unmatched `compact/start` reads the same whether the writer crashed mid-compaction or is compacting right now. An opening marker before `session/end-seed` came from the constructor seed and belongs to an ended lifecycle, whatever ended it (a crash, a succeeding process, or a fork out of a still-running parent), so its owner may treat it as dead. That covers only brackets *this* session inherited: a concurrently live session holding an open bracket over the same history has its own boundary elsewhere, so tolerating concurrent writers needs a liveness signal beyond the log. Core writes the boundary and reads nothing from it — a bracket's vocabulary stays with its owning plugin, which is why crash repair closes turn/step/tool boundaries and never `compact/*`.
diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md
index 29c4853213..1637efedba 100644
--- a/docs/core-data-structures/session.zh.md
+++ b/docs/core-data-structures/session.zh.md
@@ -94,7 +94,9 @@ interface SessionEventMap {
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
- * produced none of them. This log-only event is the durable projection of
+ * produced none of them. An explicitly supplied empty seed puts the marker
+ * at seq 0, distinguishing an empty resumed session from a fresh session.
+ * This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
@@ -146,7 +148,7 @@ interface TodoItem {
### 请求头事件:`request/header`
-请求信封(即 `EpochHeader`:调用配置 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
+请求信封(即 `EpochHeader`:调用配置 + 适配器默认值来源 + 渲染后的系统提示词 + 已组装的工具 schema)会作为会话状态写入日志,因此每个对话请求都是日志的纯函数(见可重建性 Agent Note)。带有 reason `'initial'` 或 `'resume'` 的完整 `request/header` 快照记录每个 agent loop 实例的边界;之后请求发生变化时,系统会以 reason `'change'` 记录另一份完整快照。`foldRequestHeader(events)` 通过选择最新快照重建请求头。该事件不是 `SurfaceEventType`,不产生 LLM 消息。
```ts type-equiv
/**
@@ -157,6 +159,8 @@ interface TodoItem {
interface EpochHeader {
/** The conversation's call configuration (provider, model, reasoning effort, and sampling scalars). */
config: LlmCallConfig
+ /** Effective config fields materialized from the exact adapter rather than proposed by a caller. */
+ adapterDefaults?: LlmCallConfigAdapterDefaults
/** Rendered system prompt text; absent for a system-less request. */
system?: string
/** Assembled tool schemas; absent for a tool-less request. */
@@ -352,7 +356,9 @@ declare class Session {
* start here. Distinct from `header.seedLength`, the DURABLE fork-lineage
* boundary: a resumed session's constructor seed is its full stored log,
* while its header keeps the original fork value — this field is the
- * in-process construction fact.
+ * in-process construction fact. An explicitly supplied empty seed has the
+ * same value as no seed (0); its `session/end-seed` event preserves the
+ * lifecycle distinction.
*
* Not persisted itself: a seeded session projects it into the log as the
* `session/end-seed` event, which is what a consumer reading STORED history
@@ -550,7 +556,7 @@ interface TurnEndReasonMap {
带种子的会话(恢复、fork 或回放)紧接构造种子之后追加这个仅日志事件,作为自己的第一次实时写入。在它之前的事件具有更小的 seq,且来自种子。它是 `firstLiveSeq` 的持久投影:该字段为持有对象的消费方回答本生命周期的写入从哪里开始,该事件则为只持有存储字节的消费方回答同一问题。payload 为空,因此位置与 `time` 承载全部含义,且不产生任何消息。`Session` 的构造函数是唯一合法的写入方。
-空种子不写入任何内容;种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。
+显式传入的空种子会在 seq 0 写入 `session/end-seed`,从而把从空日志恢复的会话与全新会话区分开来。种子本身已以 `session/end-seed` 结尾时不会重复标记,因此重新打开一个未被改动的会话不会每次拾起都增长日志。应定位存储历史中的最后一条 `session/end-seed`,而不是假定 `firstLiveSeq` 处一定有一条:在一次没有产生工作的拾起之后,该事件的 seq 会小于下一个生命周期的 `firstLiveSeq`。
它之所以必要,是因为种子历史与实时工作在字节层面完全相同,这会让任何拥有独立开/闭括号的插件失效:一个未配对的 `compact/start`,无论写入方是在压缩中途崩溃、还是此刻正在压缩,读起来都一样。在 `session/end-seed` 之前的开启标记来自构造种子,并且属于一个已结束的生命周期,无论结束原因为何(崩溃、进程接替,或从仍在运行的父会话 fork 出来),因此其所有方可以视之为已死。这只覆盖*本*会话继承的括号:另一个并发存活的会话可能在同一段历史上持有开放括号,而它自己的边界在别处,因此容忍并发写入方还需要日志之外的存活信号。核心写入该边界但不从中读取任何内容——括号的词汇表仍归其所属插件,这也正是崩溃修复只关闭轮次/步骤/工具边界而从不处理 `compact/*` 的原因。
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 18668b8f2a..f7c0d85c55 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:70`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:59`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
-| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
+| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) |
@@ -66,14 +66,14 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| Event string | Dispatchers | Listeners |
| --- | --- | --- |
| `commands/changed` | `runtime` (`emit`) | `ui-command` |
-| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models` |
+| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission` |
| `credentials/changed` | `runtime` (`emit`) | `ui-models` |
| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) |
| `internal/plugin` | - | `hmr`, `loader`, `modules`, `webserver` |
| `internal/status` | - | [`agent`](../packages/core/agent) |
-| `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` |
+| `locale/change` | `locale` (`emit`) | `locale` |
| `models/changed` | `runtime` (`emit`) | `ui-models` |
-| `settings/changed` | `runtime` (`emit`) | `ui-models` |
+| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission` |
| `slash/input-begin-command` | - | `ui-conversation` |
| `slash/input-consume-token` | - | `ui-conversation` |
| `slash/input-insert-reference` | - | `ui-conversation` |
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 8e8b3130ab..1a75d881e7 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -309,6 +309,7 @@ flowchart TD
pkg_client_test_runtime --> pkg_client_runtime
pkg_client_test_runtime --> pkg_client_ui_slots
pkg_client_test_runtime --> pkg_client_web_react
+ pkg_client_test_runtime --> pkg_host_apiproxy
pkg_client_test_runtime --> pkg_invariants
pkg_client_ui_settings --> pkg_client_runtime
pkg_client_ui_settings --> pkg_client_ui_primitives
@@ -316,10 +317,6 @@ flowchart TD
pkg_client_ui_settings --> pkg_invariants
pkg_client_ui_trajectory --> pkg_client_ui_primitives
pkg_client_ui_trajectory --> pkg_invariants
- pkg_client_ui_workspace --> pkg_client_runtime
- pkg_client_ui_workspace --> pkg_client_ui_primitives
- pkg_client_ui_workspace --> pkg_client_ui_slots
- pkg_client_ui_workspace --> pkg_invariants
pkg_credentials --> pkg_brand
pkg_credentials --> pkg_invariants
pkg_helper --> pkg_brand
@@ -389,20 +386,15 @@ flowchart TD
pkg_client_ui_theme --> pkg_client_ui_primitives
pkg_client_ui_theme --> pkg_client_ui_slots
pkg_client_ui_theme --> pkg_invariants
+ pkg_client_ui_workspace --> pkg_client_locale
+ pkg_client_ui_workspace --> pkg_client_runtime
+ pkg_client_ui_workspace --> pkg_client_ui_primitives
+ pkg_client_ui_workspace --> pkg_client_ui_slots
+ pkg_client_ui_workspace --> pkg_invariants
pkg_credentials_local --> pkg_atomic_write
pkg_credentials_local --> pkg_credentials
pkg_credentials_local --> pkg_invariants
pkg_credentials_local --> pkg_paths
- pkg_host_directory_picker_browse --> pkg_client_locale
- pkg_host_directory_picker_browse --> pkg_client_runtime
- pkg_host_directory_picker_browse --> pkg_client_ui_primitives
- pkg_host_directory_picker_browse --> pkg_client_ui_slots
- pkg_host_directory_picker_browse --> pkg_client_ui_workspace
- pkg_host_directory_picker_browse --> pkg_invariants
- pkg_host_directory_picker_native --> pkg_client_runtime
- pkg_host_directory_picker_native --> pkg_client_ui_slots
- pkg_host_directory_picker_native --> pkg_client_ui_workspace
- pkg_host_directory_picker_native --> pkg_invariants
pkg_lsp --> pkg_brand
pkg_lsp --> pkg_invariants
pkg_lsp --> pkg_llm
@@ -479,10 +471,16 @@ flowchart TD
pkg_code_runtime_worker --> pkg_invariants
pkg_code_runtime_worker --> pkg_session
pkg_code_runtime_worker --> pkg_timeout
- pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
- 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_host_directory_picker_browse --> pkg_client_locale
+ pkg_host_directory_picker_browse --> pkg_client_runtime
+ pkg_host_directory_picker_browse --> pkg_client_ui_primitives
+ pkg_host_directory_picker_browse --> pkg_client_ui_slots
+ pkg_host_directory_picker_browse --> pkg_client_ui_workspace
+ pkg_host_directory_picker_browse --> pkg_invariants
+ pkg_host_directory_picker_native --> pkg_client_runtime
+ pkg_host_directory_picker_native --> pkg_client_ui_slots
+ pkg_host_directory_picker_native --> pkg_client_ui_workspace
+ pkg_host_directory_picker_native --> pkg_invariants
pkg_lsp_local --> pkg_brand
pkg_lsp_local --> pkg_invariants
pkg_lsp_local --> pkg_llm
@@ -560,6 +558,7 @@ flowchart TD
pkg_user_interaction --> pkg_invariants
pkg_user_interaction --> pkg_llm
pkg_client_ui_command --> pkg_client_connection
+ pkg_client_ui_command --> pkg_client_locale
pkg_client_ui_command --> pkg_client_runtime
pkg_client_ui_command --> pkg_client_ui_conversation
pkg_client_ui_command --> pkg_client_ui_primitives
@@ -573,6 +572,10 @@ flowchart TD
pkg_tmux_context --> pkg_bash
pkg_tmux_context --> pkg_invariants
pkg_tmux_context --> pkg_session
+ pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse
+ 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_pty --> pkg_agent
pkg_pty --> pkg_brand
pkg_pty --> pkg_invariants
@@ -649,8 +652,10 @@ flowchart TD
pkg_permission --> pkg_sandbox_policy
pkg_permission --> pkg_session
pkg_permission --> pkg_session_projection
+ pkg_permission --> pkg_settings
pkg_permission --> pkg_user_approval
pkg_client_ui_goal --> pkg_client_connection
+ pkg_client_ui_goal --> pkg_client_locale
pkg_client_ui_goal --> pkg_client_runtime
pkg_client_ui_goal --> pkg_client_ui_conversation
pkg_client_ui_goal --> pkg_client_ui_primitives
@@ -818,9 +823,14 @@ flowchart TD
pkg_tool_ask_user --> pkg_invariants
pkg_tool_ask_user --> pkg_tools
pkg_tool_ask_user --> pkg_user_interaction
+ pkg_client_ui_permission --> pkg_client_connection
+ pkg_client_ui_permission --> pkg_client_locale
pkg_client_ui_permission --> pkg_client_runtime
+ pkg_client_ui_permission --> pkg_client_schema_form
pkg_client_ui_permission --> pkg_client_ui_command
+ pkg_client_ui_permission --> pkg_client_ui_primitives
pkg_client_ui_permission --> pkg_client_ui_slash
+ pkg_client_ui_permission --> pkg_client_ui_slots
pkg_client_ui_permission --> pkg_invariants
pkg_client_ui_permission --> pkg_permission
pkg_session_reference --> pkg_agent
@@ -925,8 +935,10 @@ flowchart TD
pkg_tui --> pkg_tools
pkg_tui --> pkg_user_interaction
pkg_client_ui_plan --> pkg_client_connection
+ pkg_client_ui_plan --> pkg_client_locale
pkg_client_ui_plan --> pkg_client_runtime
pkg_client_ui_plan --> pkg_client_ui_conversation
+ pkg_client_ui_plan --> pkg_client_ui_primitives
pkg_client_ui_plan --> pkg_client_ui_slots
pkg_client_ui_plan --> pkg_invariants
pkg_client_ui_plan --> pkg_plan_mode
@@ -1053,10 +1065,9 @@ flowchart TD
| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
+| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
-| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
@@ -1077,9 +1088,8 @@ flowchart TD
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
-| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
-| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
| [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) |
@@ -1102,7 +1112,8 @@ flowchart TD
| [`client-ui-skill`](../packages/client/ui-skill) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
-| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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) |
+| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
+| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) |
| [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
| [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) |
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) |
@@ -1122,9 +1133,10 @@ flowchart TD
| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) |
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
-| [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`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-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
+| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`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) |
| [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
| [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) |
@@ -1140,8 +1152,8 @@ flowchart TD
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) |
| [`session-title-llm`](../packages/session-title/session-title-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`timeout`](../packages/util/timeout) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) |
-| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) |
-| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
+| [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/ui/user-approval) |
+| [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`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), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
| [`client-ui-model`](../packages/client/ui-model) | `client` | [`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) |
| [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) |
| [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) |
@@ -1168,7 +1180,7 @@ flowchart TD
| [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm) | `session-title` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`session-title-llm`](../packages/session-title/session-title-llm) |
| [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-slash`](../packages/client/ui-slash), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
+| [`client-ui-permission`](../packages/client/ui-permission) | `client` | [`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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`permission`](../packages/ui/permission) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
| [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
| [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) |
@@ -1183,7 +1195,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
| [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) |
-| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) |
+| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`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), [`plan-mode`](../packages/plan/plan-mode) |
| [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) |
| [`sdk-protocol`](../packages/sdk/sdk-protocol) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 524ba6ffd7..2b36c0c3ff 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -78,7 +78,7 @@ export type SessionEvent = {
}[T]
```
-Sources: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:315`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:347`](../packages/core/session/src/types.ts)
+Sources: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:291`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:320`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts)
## Events
@@ -154,7 +154,7 @@ Source: [`packages/ui/user-approval/src/index.ts:67`](../packages/ui/user-approv
Types: [StreamChunk](core-data-structures/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:215`](../packages/core/session/src/types.ts)
#### `assistant/message` — surface
@@ -170,7 +170,7 @@ Source: [`packages/core/session/src/types.ts:212`](../packages/core/session/src/
Types: [TokenUsage](core-data-structures/llm-streaming.md)
-Source: [`packages/core/session/src/types.ts:219`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:222`](../packages/core/session/src/types.ts)
### `command/*`
@@ -350,7 +350,7 @@ Source: [`packages/llm/llm-retry/src/index.ts:18`](../packages/llm/llm-retry/src
'permission/preset': { preset: string }
```
-Source: [`packages/ui/permission/src/index.ts:49`](../packages/ui/permission/src/index.ts)
+Source: [`packages/ui/permission/src/index.ts:50`](../packages/ui/permission/src/index.ts)
### `plan/*`
@@ -379,7 +379,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:51`](../packages/plan/plan-mode/s
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
```
-Source: [`packages/core/session/src/types.ts:252`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:255`](../packages/core/session/src/types.ts)
### `sandbox/*`
@@ -410,7 +410,9 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
/**
* Marks the end of a constructor seed. Events before it have smaller seq
* values and came from the seed (resume, fork, or replay); this lifecycle
- * produced none of them. This log-only event is the durable projection of
+ * produced none of them. An explicitly supplied empty seed puts the marker
+ * at seq 0, distinguishing an empty resumed session from a fresh session.
+ * This log-only event is the durable projection of
* {@link Session.firstLiveSeq}. Its payload is empty — position and `time`
* carry the meaning.
*
@@ -432,7 +434,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
'session/end-seed': Record
```
-Source: [`packages/core/session/src/types.ts:275`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:280`](../packages/core/session/src/types.ts)
#### `session/title` — log-only
@@ -468,7 +470,7 @@ Source: [`packages/session-title/session-title-llm/src/index.ts:43`](../packages
'steering/message': { turn: number; message: UserMessage }
```
-Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:248`](../packages/core/session/src/types.ts)
### `step/*`
@@ -479,7 +481,7 @@ Source: [`packages/core/session/src/types.ts:245`](../packages/core/session/src/
'step/end': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:204`](../packages/core/session/src/types.ts)
#### `step/start` — log-only
@@ -488,7 +490,7 @@ Source: [`packages/core/session/src/types.ts:201`](../packages/core/session/src/
'step/start': { turn: number; step: number }
```
-Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:202`](../packages/core/session/src/types.ts)
### `todo/*`
@@ -501,7 +503,7 @@ Source: [`packages/core/session/src/types.ts:199`](../packages/core/session/src/
Types: [TodoItem](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:250`](../packages/core/session/src/types.ts)
### `tool/*`
@@ -518,7 +520,7 @@ Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/
Types: [CallId](core-data-structures/core.md)
-Source: [`packages/core/session/src/types.ts:225`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:228`](../packages/core/session/src/types.ts)
#### `tool/code-dispatch` — log-only
@@ -591,7 +593,7 @@ Source: [`packages/core/tools/src/code-mode.ts:33`](../packages/core/tools/src/c
}
```
-Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:240`](../packages/core/session/src/types.ts)
### `turn/*`
@@ -609,7 +611,7 @@ Source: [`packages/core/session/src/types.ts:237`](../packages/core/session/src/
Types: [TurnEndReason](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:200`](../packages/core/session/src/types.ts)
#### `turn/start` — log-only
@@ -622,7 +624,7 @@ Source: [`packages/core/session/src/types.ts:197`](../packages/core/session/src/
Types: [TurnTrigger](core-data-structures/session.md)
-Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/types.ts)
### `user/*`
@@ -640,4 +642,4 @@ Source: [`packages/core/session/src/types.ts:190`](../packages/core/session/src/
'user/message': UserMessage
```
-Source: [`packages/core/session/src/types.ts:210`](../packages/core/session/src/types.ts)
+Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts)
diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml
index ed3c8dfc33..6f9f000182 100644
--- a/examples/acp-agent/cordis.yml
+++ b/examples/acp-agent/cordis.yml
@@ -5,7 +5,7 @@
# carries ACP JSON-RPC.
# The DeepSeek adapter. Shipped default: full thinking at max effort on every
-# request (wire-only defaults; they never enter the request header).
+# request; exact-model resolution materializes request defaults before logging.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
@@ -13,7 +13,6 @@
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
- defaultContextWindow: 256000
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml
index 310724bb82..589120c080 100644
--- a/examples/acp-agent/retry.cordis.yml
+++ b/examples/acp-agent/retry.cordis.yml
@@ -17,7 +17,6 @@
baseURL: !!js process.env.DEEPSEEK_BASE_URL
thinking: enabled
reasoningEffort: max
- defaultContextWindow: 256000
retryPolicy:
mode: normal
maxRetries: 2
diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
index 5a087badc4..bb4798fe34 100644
--- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
-{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export type InboxAction = {\n readonly kind: 'edit';\n readonly content: ContentBlock[];\n } | {\n readonly kind: 'remove';\n };\n export type InboxActionResult = 'applied' | 'not-found';\n export type InboxItemId = Branded<'InboxItemId'>;\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n }\n export type SendTarget = 'next-turn' | 'next-step';\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader);\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n trigger: TurnTrigger;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'steering/message': {\n turn: number;\n message: UserMessage;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly delegationDepth?: number;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result' | 'steering/message';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n };\n error: {\n kind: 'error';\n step: number;\n } & ({\n failure: LlmFailure;\n message?: never;\n code?: never;\n } | {\n message: string;\n code?: string;\n failure?: never;\n });\n disposed: {\n kind: 'disposed';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap];\n export interface TurnTriggerMap {\n message: {\n kind: 'message';\n source: MessageSource;\n };\n retry: {\n kind: 'retry';\n };\n injection: {\n kind: 'injection';\n source: MessageSource;\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"1c43b8df-aae8-42e7-8253-5b275edc09bc"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
{"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml
index 29a743d443..3a74bd4976 100644
--- a/examples/headless-agent/cordis.yml
+++ b/examples/headless-agent/cordis.yml
@@ -18,7 +18,8 @@
# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed
# twin (a `providers` dict keyed by route; `reasoning: high` replaces
# thinking/reasoningEffort). Shipped default: full thinking at max effort on
-# every request (wire-only defaults; they never enter the request header).
+# every request. Exact-model resolution materializes request defaults before
+# the request header is logged.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml
new file mode 100644
index 0000000000..cd472f737d
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml
@@ -0,0 +1,17 @@
+- id: base
+ name: '@cordisjs/plugin-include'
+ config:
+ path: ../../cordis.yml
+ patches:
+ - id: llm-deepseek
+ config:
+ apiKey: snapshot-key
+ baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL
+ thinking: disabled
+ - id: cli-agent
+ config:
+ provider: deepseek-official
+ model: deepseek-v4-flash
+ persistenceRoot: './.sessions'
+ workspaceContext: false
+ persona: 'Keyless DeepSeek adapter defaults snapshot.'
diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs
new file mode 100644
index 0000000000..16e5858045
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/startup-activation-error/activation-error.mjs
@@ -0,0 +1,6 @@
+/** Fail activation with a deterministic stack so the user-visible startup diagnostic is snapshot-stable. */
+export function apply() {
+ const failure = new Error('startup activation snapshot failure')
+ failure.stack = 'Error: startup activation snapshot failure\n at activation-error-fixture'
+ throw failure
+}
diff --git a/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml
new file mode 100644
index 0000000000..2738e4a924
--- /dev/null
+++ b/examples/headless-agent/tests/fixtures/startup-activation-error/cordis.yml
@@ -0,0 +1,2 @@
+- id: activation-error
+ name: ./activation-error.mjs
diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
index d9cc454bfb..d1851ac7c9 100644
--- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
+++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml
@@ -8,6 +8,7 @@
- id: telemetry-redact-rule
name: './telemetry-redact-rule.ts'
+# Managed child-process groups required by the bash executor.
- id: subprocess
name: '@deepseek-ai/dsh-subprocess-local'
diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts
index a9cde9532c..8b48165a83 100644
--- a/examples/headless-agent/tests/headless.snapshot.ts
+++ b/examples/headless-agent/tests/headless.snapshot.ts
@@ -1,4 +1,6 @@
import { readFile, readdir, writeFile } from 'node:fs/promises'
+import { createServer } from 'node:http'
+import type { IncomingMessage, ServerResponse } from 'node:http'
import { delimiter, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
@@ -31,9 +33,12 @@ const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
+const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
+const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt')
const binScript = fileURLToPath(new URL('../../../packages/examples/cli-demo/src/bin.ts', import.meta.url))
const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url))
const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url))
+const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url))
const refreshing = process.env.DSH_SNAPSHOT === 'refresh'
interface JsonObject {
@@ -45,6 +50,40 @@ interface PersistedLog {
readonly header: JsonObject
}
+interface DeepSeekDefaultsServer {
+ readonly url: string
+ readonly requests: JsonObject[]
+ close(): Promise
+}
+
+/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */
+async function deepseekDefaultsServer(): Promise {
+ const requests: JsonObject[] = []
+ const server = createServer((request: IncomingMessage, response: ServerResponse) => {
+ let body = ''
+ request.setEncoding('utf8')
+ request.on('data', (chunk: string) => { body += chunk })
+ request.on('end', () => {
+ requests.push(JSON.parse(body) as JsonObject)
+ response.writeHead(200, { 'content-type': 'text/event-stream' })
+ response.end([
+ 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}',
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}',
+ 'data: [DONE]',
+ '',
+ ].join('\n\n'))
+ })
+ })
+ await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
+ const address = server.address()
+ if (address === null || typeof address === 'string') throw new Error('DeepSeek defaults snapshot server has no port')
+ return {
+ url: `http://127.0.0.1:${address.port}`,
+ requests,
+ close: () => new Promise(resolve => server.close(() => { resolve() })),
+ }
+}
+
function parseJsonl(content: string): JsonObject[] {
return content.split('\n')
.filter(line => line.trim().length > 0)
@@ -130,6 +169,20 @@ async function persistedLogs(cwd: string): Promise {
}
describe('headless stream-json snapshots', () => {
+ it('prints the original Loader activation error through the assembled one-shot app', async () => {
+ const result = await runLoaderSmoke({
+ label: 'headless startup activation error snapshot',
+ tempDirPrefix: 'headless-snapshot-startup-error-',
+ binScript,
+ configPath: startupFailureConfigPath,
+ binArgs: ['--config', startupFailureConfigPath, '--output-format', 'stream-json', 'unreachable task'],
+ tsconfigPath,
+ expectedExitCode: 1,
+ })
+ expect(result.stdout).toBe('')
+ await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected)
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
it('retries a transient provider failure through the one-shot app', async () => {
const prompt = await scenarioPrompt(retryScenarioDir, 'provider-retry')
const streamExpected = join(retryScenarioDir, 'stream-json.expected.jsonl')
@@ -244,6 +297,57 @@ describe('headless stream-json snapshots', () => {
`)
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
+ it('logs and sends the DeepSeek adapter maxTokens default through the one-shot app', async () => {
+ const server = await deepseekDefaultsServer()
+ try {
+ const result = await runLoaderSmoke({
+ label: 'DeepSeek adapter defaults headless stream-json snapshot',
+ tempDirPrefix: 'headless-snapshot-deepseek-defaults-',
+ binScript,
+ configPath: deepseekDefaultsConfigPath,
+ binArgs: [
+ '--config',
+ deepseekDefaultsConfigPath,
+ '--output-format',
+ 'stream-json',
+ 'return the deterministic response',
+ ],
+ tsconfigPath,
+ env: {
+ DSH_SNAPSHOT_BASE_URL: server.url,
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+ },
+ })
+
+ expect(result.stderr).toBe('')
+ expect(server.requests).toHaveLength(1)
+ expect(server.requests[0]?.max_tokens).toBe(256_000)
+ const header = (parseJsonl(result.stdout)
+ .map(record => record.event)
+ .find((event): event is JsonObject => (
+ event !== null
+ && typeof event === 'object'
+ && !Array.isArray(event)
+ && 'type' in event
+ && event.type === 'request/header'
+ ))?.data as JsonObject | undefined)?.header as JsonObject | undefined
+ expect(header?.config).toMatchInlineSnapshot(`
+ {
+ "maxTokens": 256000,
+ "model": "deepseek-v4-flash",
+ "provider": "deepseek-official",
+ "reasoningEffort": "off",
+ }
+ `)
+ expect(header?.adapterDefaults).toEqual({
+ maxTokens: true,
+ reasoningEffort: true,
+ })
+ } finally {
+ await server.close()
+ }
+ }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
it('replays the advanced toolchain through the one-shot app', async () => {
const prompt = await scenarioPrompt(advancedScenarioDir, 'advanced-toolchain')
const fixtureFiles = [
diff --git a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
index e0a6f076df..4098de697b 100644
--- a/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
+++ b/examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl
@@ -2,7 +2,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":2,"time":0,"data":{"title":"say pong","messageSeqs":[1],"source":{"kind":"fallback"}}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
-{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"high"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":5,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":6,"time":0,"data":{"turn":1,"reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}}}
{"type":"result","success":false,"sessionId":"{{sessionId}}","turn":1,"result":"","reason":{"kind":"error","step":1,"failure":{"message":"llm-deepseek: no API key for provider route \"deepseek-official\"; store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it), export DEEPSEEK_API_KEY in the launching environment, or — as a last resort — set a literal \"apiKey\" in the llm-deepseek settings section","code":"MISSING_CREDENTIAL"}}}
diff --git a/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt
new file mode 100644
index 0000000000..5896d03464
--- /dev/null
+++ b/examples/headless-agent/tests/snapshots/startup-activation-error/stderr.expected.txt
@@ -0,0 +1,3 @@
+dsh-cli-demo: dsh-cli-demo: 1 entry did not activate
+./activation-error.mjs: Error: startup activation snapshot failure
+ at activation-error-fixture
diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml
index b23dd30b4a..9806413725 100644
--- a/examples/jsonrpc-agent/cordis.yml
+++ b/examples/jsonrpc-agent/cordis.yml
@@ -7,8 +7,8 @@
maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)"
# The DeepSeek adapter. Shipped default: full thinking at max effort on every
-# request (wire-only defaults; they never enter the request header). The model
-# arrives per session over JSON-RPC, so it is not pinned here.
+# request; exact-model resolution materializes request defaults before logging.
+# The model arrives per session over JSON-RPC, so it is not pinned here.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml
index 4cd96e396f..ff857643f4 100644
--- a/examples/web-cordis/cordis.yml
+++ b/examples/web-cordis/cordis.yml
@@ -12,7 +12,10 @@
config:
host: 127.0.0.1
port: 3081
- distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname"
+ # Plain concatenation, not URL.pathname: a cwd with spaces
+ # percent-encodes through the URL round-trip and the encoded
+ # path never resolves.
+ distIndex: !!js "process.cwd() + '/apps/web/dist/index.html'"
- insert:
- id: tool-cordis
diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml
index 70657d55d7..974e3014d6 100644
--- a/packages/client/connection/README.i18n.yaml
+++ b/packages/client/connection/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/connection/README.md
-README.md: d2fda9f15125915594259e01e5b153609ceb21bb
-README.zh.md: 669ae760693b4d98ee873ee5fe323554f58e7ca5
+README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
+README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45
diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md
index d2fda9f151..c8b7c4787c 100644
--- a/packages/client/connection/README.md
+++ b/packages/client/connection/README.md
@@ -10,7 +10,7 @@ The node half guards every request under `/api` before bridging (`src/api-reques
## Keyless fixture
-Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival.
+Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience
diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md
index 669ae76069..693420183f 100644
--- a/packages/client/connection/README.zh.md
+++ b/packages/client/connection/README.zh.md
@@ -10,7 +10,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
## 无密钥 fixture
-任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。
+任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验
diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts
index cc9d2a1eb9..ae47eb9e89 100644
--- a/packages/client/connection/src/client/api.ts
+++ b/packages/client/connection/src/client/api.ts
@@ -1,12 +1,12 @@
// Central contract re-export point: every contract import inside
// web-runtime goes through this single file.
-// Types are type-only imports from the apiproxy api/ layer (zero Node deps, browser-safe);
-// the only runtime values are the RpcId constructor and the AbstractApiClient seam.
+// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer
+// (zero Node deps, browser-safe); AbstractApiClient is the client seam.
// NEVER import the package root: it drags bootHost/cordis into the browser bundle.
// The ./api and ./client subpath exports are the browser-safe channels added for this.
export type {
- ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+ ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
// transportError moved down to the apiproxy api layer (it belongs beside
// RpcResult, its subject); re-exported here so connection consumers keep one
// contract entry point.
-export { RpcId, transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
+export {
+ RpcId,
+ SESSION_SEARCH_RESULT_LIMIT,
+ transportError,
+} from '@deepseek-ai/dsh-host-apiproxy/api'
export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client'
export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types'
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 068fdc8307..45445cc9cf 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -26,13 +26,14 @@ import type {
// Type-only: the brand constructor is host-side; the fixture casts at its
// wire-fabrication boundary (the schema layer's one-cast-point posture).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
+import { foldSurface } from '@deepseek-ai/dsh-session/surface'
import type {
ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt,
ModelProviderGroup, ModelTarget, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary,
ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView,
} from './api.ts'
import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api'
-import { AbstractApiClient, RpcId } from './api.ts'
+import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts'
/** The fake carrier mints like a real one (business code never mints). */
function rpcRequest(payload: P): RpcRequest
{
@@ -136,6 +137,44 @@ const TERMINAL_EXIT_STATUS: Record, 'card' | 'kind'> = {
+ answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.',
+ sources: [
+ {
+ url: 'https://github.com/deepseek-ai/deepseek-harness',
+ title: 'DeepSeek Harness — plugin-based agent harness',
+ snippet: 'Everything is a plugin: session, tools, agent-loop, and LLM adapters all mount on the same Cordis context.',
+ publishedAt: '2026-07-01',
+ },
+ {
+ url: 'https://www.deepseek.com/blog/harness-architecture',
+ snippet: 'The capability-seam pattern splits each capability into interface, implementation, and consumer packages.',
+ },
+ {
+ url: 'https://docs.deepseek.com/harness/plugins',
+ title: 'Writing a harness plugin',
+ publishedAt: '2026-06-15',
+ },
+ ],
+ truncated: true,
+}
+
+/** The `web_fetch` result view for fixture turn 67, authored inline for the same reason. */
+const WEB_FETCH_RESULT: Omit, 'card' | 'kind'> = {
+ url: 'https://www.deepseek.com/blog/harness-architecture',
+ statusCode: 200,
+ truncated: false,
+}
+
const DEEPSEEK_REASONING = {
efforts: [
{ id: 'off', name: 'Off' },
@@ -261,6 +300,13 @@ function buildAlphaLog(): SessionEvent[] {
toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
+ // Turn 67: a multi-hunk edit — two scattered replacements in one file. Named
+ // `edit` so it lands on the keyed FileMutationRow (the resident diff card the
+ // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker
+ // the presenter reads to emit the two-hunk sample: the card draws one path
+ // header, the first hunk, a `⋯` gap, then the second (the same-file
+ // second-hunk arm turns 62/63 cannot reach).
+ toolTurn(67, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑')
// Turn 64: one run_code turn with three logged sub-dispatches — the Code
// Mode acceptance surface (parent code row + nested native-identical rows,
// including an isError sub-call and a bash sub-call that must hit the same
@@ -325,8 +371,20 @@ function buildAlphaLog(): SessionEvent[] {
// strip empty and take the todo surfaces' own coverage with it.
toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
+ // Turns 66-67: the web render intent — a web_search whose result view carries
+ // structured sources plus an answer (the citation list, one source lacking a
+ // title so its hostname labels the link, the capped indicator on), and a
+ // web_fetch whose result view carries the fetched URL and its HTTP status.
+ // Both keep a generic pending call view and add the `web` card only at
+ // result time, which is the contract's result-only web shape. Named after
+ // the real tools so they hit the keyed WebRow registration. Ordered BEFORE
+ // the todo turn for the same reason turn 65 is: the standing plan retires at
+ // the next turn/start, so a turn after it would empty the dock's plan strip.
+ toolTurn(66, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
+ toolTurn(67, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
+
const todoArgs = JSON.stringify({ todos: fixtureTodos })
- toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+ toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 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).
@@ -362,9 +420,33 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
}
case 'edit':
- return { card: 'generic', title: `Edit ${str(args.file_path)}`, kind: 'edit', rawInput: args }
+ // The multi-hunk sample (turn 67) is keyed on its file_path, so the two
+ // scattered hunks share one path header and the card draws the `⋯` gap.
+ if (str(args.file_path) === 'src/config.ts') {
+ return {
+ card: 'diff', title: `Edit ${str(args.file_path)}`,
+ diffs: [
+ { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' },
+ { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' },
+ ],
+ }
+ }
+ return {
+ card: 'diff', title: `Edit ${str(args.file_path)}`,
+ diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }],
+ }
case 'write':
- return { card: 'generic', title: `Write ${str(args.file_path)}`, kind: 'edit', rawInput: args }
+ return {
+ card: 'diff', title: `Write ${str(args.file_path)}`,
+ diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }],
+ }
+ // The web tools keep a GENERIC pending card and add the `web` result card
+ // only at result time (the contract's result-only web shape); their pending
+ // kind matches the result kind so a call and its result read as one category.
+ case 'web_search':
+ return { card: 'generic', title: `Search ${str(args.query)}`, kind: 'search', rawInput: args }
+ case 'web_fetch':
+ return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args }
default:
return undefined // echo et al: the documented no-view fallback path
}
@@ -373,6 +455,17 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined {
const call = presentCall(name, argsRaw)
if (call === undefined) return undefined
+ // The web tools keep a generic pending card, so their result card is chosen
+ // by tool name rather than by the pending card tag: the structured `web` card
+ // the frontend consumes. The view carries no `content` copy (per the contract
+ // and the web-result-card note); a capability-less UI falls back to the raw
+ // `tool/result` content, which this fixture emits from `resultText`.
+ if (name === 'web_search') {
+ return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT }
+ }
+ if (name === 'web_fetch') {
+ return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT }
+ }
switch (call.card) {
case 'terminal':
// The sample's own exit status, authored beside it: re-parsing the
@@ -575,6 +668,144 @@ function pageOf(
return { events, hasMore: start > 0 }
}
+/** Fixture mirror of first-party message extraction used by session-query. */
+function searchBlockText(block: ContentBlock): string[] {
+ switch (block.type) {
+ case 'text':
+ return [block.text]
+ case 'reasoning':
+ return []
+ case 'tool-call':
+ return [block.name, block.arguments]
+ case 'tool-result':
+ return block.content.flatMap(searchBlockText)
+ default:
+ return []
+ }
+}
+
+/** One current-surface user/assistant/steering document, if searchable. */
+function searchEventText(event: SessionEvent): string {
+ const content = event.type === 'user/message'
+ ? event.data.content
+ : event.type === 'assistant/message' || event.type === 'steering/message'
+ ? event.data.message.content
+ : undefined
+ if (content === undefined) return ''
+ return content.flatMap(searchBlockText).map(part => part.trim()).filter(Boolean).join('\n')
+}
+
+interface FixtureSearchToken {
+ value: string
+ /** Inclusive code-point offset in the whitespace-normalized display text. */
+ start: number
+ /** Exclusive code-point offset in the whitespace-normalized display text. */
+ end: number
+}
+
+/**
+ * Browser-safe approximation of SQLite FTS5 unicode61 token boundaries.
+ * Keeping phrase matching token-based prevents the development fixture from
+ * promising arbitrary within-token substring behavior that production lacks.
+ */
+function searchTokenSpans(value: string): { text: string; tokens: FixtureSearchToken[] } {
+ const text = value.replace(/\s+/gu, ' ').trim()
+ const characters = Array.from(text)
+ const tokens: FixtureSearchToken[] = []
+ let start: number | undefined
+ let raw = ''
+ const flush = (end: number): void => {
+ if (start !== undefined) {
+ const folded = raw.normalize('NFD').replace(/\p{M}+/gu, '').toLowerCase()
+ if (folded !== '') tokens.push({ value: folded, start, end })
+ }
+ start = undefined
+ raw = ''
+ }
+ for (let index = 0; index < characters.length; index++) {
+ const character = characters[index] as string
+ const tokenBase = character.normalize('NFD').replace(/\p{M}+/gu, '')
+ if (tokenBase === '') {
+ if (start !== undefined) raw += character
+ continue
+ }
+ if (/^[\p{L}\p{N}\p{Co}]+$/u.test(tokenBase)) {
+ start ??= index
+ raw += character
+ } else {
+ flush(index)
+ }
+ }
+ flush(characters.length)
+ return { text, tokens }
+}
+
+interface FixturePhraseMatch {
+ count: number
+ start: number
+ end: number
+}
+
+/** Count exact contiguous token-phrase occurrences and retain the first display span. */
+function phraseMatch(document: readonly FixtureSearchToken[], phrase: readonly string[]): FixturePhraseMatch {
+ if (phrase.length === 0 || phrase.length > document.length) return { count: 0, start: 0, end: 0 }
+ let count = 0
+ let firstStart = 0
+ let firstEnd = 0
+ for (let start = 0; start <= document.length - phrase.length; start++) {
+ if (!phrase.every((token, offset) => document[start + offset]?.value === token)) continue
+ count++
+ if (count === 1) {
+ firstStart = document[start]?.start ?? 0
+ firstEnd = document[start + phrase.length - 1]?.end ?? firstStart
+ }
+ }
+ return { count, start: firstStart, end: firstEnd }
+}
+
+/** Match-centered fixture excerpt, bounded by Unicode code points for the sidebar. */
+function searchSnippet(value: string, matchStart: number, matchEnd: number): string {
+ const characters = Array.from(value)
+ if (characters.length <= 120) return value
+ const boundedStart = Math.min(Math.max(0, matchStart), characters.length - 1)
+ const boundedEnd = Math.min(
+ characters.length,
+ Math.max(boundedStart + 1, matchEnd),
+ )
+ const center = Math.floor((boundedStart + boundedEnd) / 2)
+ let start = Math.min(
+ characters.length - 118,
+ Math.max(0, center - Math.floor(118 / 2)),
+ )
+ let end = start + 118
+ if (start === 0) {
+ end = 119
+ } else if (end === characters.length) {
+ start = characters.length - 119
+ }
+ return `${start > 0 ? '…' : ''}${characters.slice(start, end).join('')}${end < characters.length ? '…' : ''}`
+}
+
+interface FixtureSearchCandidate {
+ sessionId: SessionId
+ seq: number
+ time: number
+ text: string
+ matchCount: number
+ matchStart: number
+ matchEnd: number
+ documentLength: number
+}
+
+/** Mirrors `packages/session-query/session-query-sqlite/src/index.ts`; update both together. */
+function compareSearchCandidates(a: FixtureSearchCandidate, b: FixtureSearchCandidate): number {
+ if (a.matchCount !== b.matchCount) return b.matchCount - a.matchCount
+ if (a.documentLength !== b.documentLength) return a.documentLength - b.documentLength
+ if (a.time !== b.time) return b.time - a.time
+ if (a.sessionId !== b.sessionId) return a.sessionId < b.sessionId ? -1 : 1
+ return b.seq - a.seq
+}
+
/**
* Current plan projection over the full log (host parallel: latest todo/write
* with no later turn/start; a new turn retires the previous plan).
@@ -919,6 +1150,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
let failNextHistory = false
/** Force-enders for currently open stream generators (timing hook: simulated connection loss). */
const streamBreakers = new Set<() => void>()
+ /** Retry scenarios opened by timing hooks and completed in a later browser assertion phase. */
+ const retryScenarios = new Map()
// Timing-acceptance hooks (browser test backdoor): the in-memory fixture is ideally timed, which
// is exactly what masked the open-window and reconnect-gap bugs (audit S1/S3). These let
@@ -942,6 +1175,89 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const messageSeqs = log.filter(event => event.type === 'user/message').map(event => event.seq)
append(sid(id), { type: 'session/title', data: { title, messageSeqs, source: { kind: 'provider', provider: 'fixture' } } })
},
+ /** Open one failed model step whose partial remains visible until llm/retry arrives. */
+ beginModelRetry(id: string): void {
+ const sessionId = sid(id)
+ const turn = nextTurn.get(sessionId) ?? 0
+ nextTurn.set(sessionId, turn + 1)
+ retryScenarios.set(sessionId, { turn, stepStarted: true })
+ setRunning(sessionId, true)
+ append(sessionId, { type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
+ append(sessionId, { type: 'user/message', surfaceOp: 'append', data: { content: text('请重试这个请求'), source: { kind: 'user' } } })
+ append(sessionId, { type: 'step/start', data: { turn, step: 1 } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn, step: 1, chunk: { type: 'text-delta', index: 0, text: '应撤回的半截回复' } } })
+ append(sessionId, { type: 'step/end', data: { turn, step: 1 } })
+ },
+ /** Record one retry decision, then open the next retry turn. */
+ scheduleModelRetry(id: string, retry = 1, delayMs = 450): void {
+ const sessionId = sid(id)
+ const scenario = retryScenarios.get(sessionId)
+ if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
+ if (!scenario.stepStarted) {
+ append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'block-start', index: 0, blockType: 'text' } } })
+ append(sessionId, { type: 'assistant/chunk', data: { turn: scenario.turn, step: 1, chunk: { type: 'text-delta', index: 0, text: `第 ${String(retry)} 次应撤回的回复` } } })
+ append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
+ scenario.stepStarted = true
+ }
+ const failure = { code: 'TRANSPORT', message: '连接被重置' }
+ append(sessionId, {
+ type: 'llm/retry',
+ data: {
+ turn: scenario.turn, step: 1,
+ provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
+ retry, maxRetries: 2, delayMs, failure,
+ },
+ })
+ append(sessionId, {
+ type: 'turn/end',
+ data: { turn: scenario.turn, reason: { kind: 'error', step: 1, failure } },
+ })
+ const next = nextTurn.get(sessionId) ?? scenario.turn + 1
+ nextTurn.set(sessionId, next + 1)
+ append(sessionId, { type: 'turn/start', data: { turn: next, trigger: { kind: 'retry' } } })
+ scenario.turn = next
+ scenario.stepStarted = false
+ },
+ /** Record one retry decision, then cancel its source turn before the retry starts. */
+ cancelModelRetryDuringBackoff(id: string, delayMs = 450): void {
+ const sessionId = sid(id)
+ const scenario = retryScenarios.get(sessionId)
+ if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
+ const failure = { code: 'TRANSPORT', message: '连接被重置' }
+ append(sessionId, {
+ type: 'llm/retry',
+ data: {
+ turn: scenario.turn, step: 1,
+ provider: 'fixture', mode: 'normal', policyKey: 'fixture-normal',
+ retry: 1, maxRetries: 2, delayMs, failure,
+ },
+ })
+ append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'aborted' } } })
+ retryScenarios.delete(sessionId)
+ setRunning(sessionId, false)
+ },
+ /** Finish the timing-hook retry with a finalized response in the open retry turn. */
+ completeModelRetry(id: string): void {
+ const sessionId = sid(id)
+ const scenario = retryScenarios.get(sessionId)
+ if (scenario === undefined) throw new Error(`fixture: no model retry scenario for ${id}`)
+ retryScenarios.delete(sessionId)
+ append(sessionId, { type: 'step/start', data: { turn: scenario.turn, step: 1 } })
+ append(sessionId, {
+ type: 'assistant/message',
+ surfaceOp: 'append',
+ data: {
+ turn: scenario.turn,
+ step: 1,
+ message: assistantMessage(text('重试后的完整回复')),
+ },
+ })
+ append(sessionId, { type: 'step/end', data: { turn: scenario.turn, step: 1 } })
+ append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } })
+ setRunning(sessionId, false)
+ },
/** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */
appendSilent(id: string, msg: string): void {
const log = logOf(sid(id))
@@ -987,6 +1303,45 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
return {
sessions: {
list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }),
+ search: (request, signal) => {
+ if (signal.aborted) {
+ return err(request, {
+ code: 'cancelled',
+ message: 'fixture session search was aborted',
+ details: {},
+ })
+ }
+ const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value)
+ const matches = sessions.flatMap((summary) => {
+ const log = logs.get(summary.sessionId) ?? []
+ const current = new Set(foldSurface(log).nodes)
+ const best = log.flatMap((event): FixtureSearchCandidate[] => {
+ if (!current.has(event.seq)) return []
+ const eventText = searchEventText(event)
+ const document = searchTokenSpans(eventText)
+ const match = phraseMatch(document.tokens, query)
+ if (match.count === 0) return []
+ return [{
+ sessionId: summary.sessionId,
+ seq: event.seq,
+ time: event.time,
+ text: document.text,
+ matchCount: match.count,
+ matchStart: match.start,
+ matchEnd: match.end,
+ documentLength: Array.from(eventText).length,
+ }]
+ }).sort(compareSearchCandidates)[0]
+ return best === undefined ? [] : [best]
+ }).sort(compareSearchCandidates)
+ return ok(request, {
+ items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({
+ sessionId: match.sessionId,
+ snippet: searchSnippet(match.text, match.matchStart, match.matchEnd),
+ })),
+ hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT,
+ })
+ },
create: async (request) => {
const workspace = request.payload.workspaceId === undefined
? undefined
@@ -1691,20 +2046,30 @@ export class FixtureApiClient extends AbstractApiClient {
protected override async callUnary(
method: K,
payload: RequestPayload,
+ signal?: AbortSignal,
): Promise>> {
const request = rpcRequest(payload)
const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload }
this.onEnvelope(full)
- const response = await this.dispatch(method, request as RpcRequest) as RpcResponse>
+ const response = await this.dispatch(
+ method,
+ request as RpcRequest,
+ signal ?? new AbortController().signal,
+ ) as RpcResponse>
const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result }
this.onEnvelope(fullResponse)
return response
}
/** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */
- private dispatch(method: keyof RpcMethodMap, request: RpcRequest): Promise> {
+ private dispatch(
+ method: keyof RpcMethodMap,
+ request: RpcRequest,
+ signal: AbortSignal,
+ ): Promise> {
switch (method) {
case 'session.list': return this.api.sessions.list(request)
+ case 'session.search': return this.api.sessions.search(request, signal)
case 'session.create': return this.api.sessions.create(request)
case 'session.history': return this.api.sessions.history(request)
case 'session.models': return this.api.sessions.models(request)
@@ -1725,8 +2090,7 @@ export class FixtureApiClient extends AbstractApiClient {
case 'workspace.delete': return this.api.workspace.delete(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
case 'command.list': return this.api.commands.list(request)
- // The in-memory execute never blocks, so a never-aborting signal is faithful here.
- case 'command.execute': return this.api.commands.execute(request, new AbortController().signal)
+ case 'command.execute': return this.api.commands.execute(request, signal)
case 'skill.list': return this.api.skills.list(request)
case 'goal.create': return this.api.goals.create(request)
case 'goal.edit': return this.api.goals.edit(request)
diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts
index 93f5153936..e286157e46 100644
--- a/packages/client/connection/src/client/index.ts
+++ b/packages/client/connection/src/client/index.ts
@@ -11,7 +11,7 @@ import { WebApiClient } from './web-api-client.ts'
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
- ApiProxy, SessionsApi, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
+ ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame,
ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView,
DirectoryEntry, DirectoryListing,
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
@@ -25,7 +25,11 @@ export type {
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,
} from './api.ts'
-export { RpcId, AbstractApiClient, transportError } from './api.ts'
+export {
+ RpcId,
+ AbstractApiClient,
+ transportError,
+} from './api.ts'
// Connection loop types are public through ConnectionHandle.start; the
// controller remains package-internal.
diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts
index 5524c3a20b..0d58800279 100644
--- a/packages/client/connection/tests/fake-api.ts
+++ b/packages/client/connection/tests/fake-api.ts
@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
- RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
+ RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
} from '../src/client/api.ts'
import { RpcId } from '../src/client/api.ts'
@@ -44,6 +44,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] }))
+ onSearch: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId }))
@@ -87,12 +89,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn[] = []
private readonly hostConns: StreamConn[] = []
+ lastSearchSignal: AbortSignal | undefined
// Parameter annotations below are local structural types on purpose: the CI
// lint lane runs without built artifacts, where IApiClient's wire types
// (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
+ search: (payload: unknown, signal?: AbortSignal) => {
+ this.lastSearchSignal = signal
+ return this.record('session.search', payload, this.onSearch(payload))
+ },
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts
index 6dc8d7cb42..f70c4eb421 100644
--- a/packages/client/connection/tests/fixture.spec.ts
+++ b/packages/client/connection/tests/fixture.spec.ts
@@ -19,6 +19,10 @@ interface TimingHooks {
failNextHistory(): void
appendUser(id: string, msg: string): void
appendTitle(id: string, title: string): void
+ beginModelRetry(id: string): void
+ scheduleModelRetry(id: string, retry?: number, delayMs?: number): void
+ cancelModelRetryDuringBackoff(id: string, delayMs?: number): void
+ completeModelRetry(id: string): void
appendSilent(id: string, msg: string): void
breakStreams(): void
}
@@ -48,6 +52,59 @@ describe('createFixtureApi', () => {
expect(response.result.value.items[1]?.parentSessionId).toBe('fx-alpha') // lineage material
})
+ it('searches current message text with literal unicode61-style token phrases', async () => {
+ const api = createFixtureApi()
+ const signal = new AbortController().signal
+ const phrase = await api.sessions.search(req({ query: 'FIXTURE 历史消息' }), signal)
+ expect(phrase.result).toMatchObject({
+ ok: true,
+ value: {
+ items: [{ sessionId: 'fx-alpha' }],
+ hasMore: false,
+ },
+ })
+ if (!phrase.result.ok) throw new Error('search failed')
+ expect(phrase.result.value.items[0]?.snippet).toContain('fixture 历史消息')
+
+ timing().appendUser(
+ 'fx-alpha',
+ `${'leading context '.repeat(20)}late café token${' trailing context'.repeat(20)}`,
+ )
+ const late = await api.sessions.search(req({ query: 'LATE CAFE TOKEN' }), signal)
+ if (!late.result.ok) throw new Error('late search failed')
+ const lateSnippet = late.result.value.items[0]?.snippet ?? ''
+ expect(lateSnippet).toContain('late café token')
+ expect(lateSnippet.startsWith('…')).toBe(true)
+ expect(lateSnippet.endsWith('…')).toBe(true)
+ expect(Array.from(lateSnippet).length).toBeLessThanOrEqual(120)
+
+ timing().appendUser('fx-alpha', 'Greek final sigma: ος')
+ const finalSigma = await api.sessions.search(req({ query: 'ΟΣ' }), signal)
+ if (!finalSigma.result.ok) throw new Error('final sigma search failed')
+ expect(finalSigma.result.value.items[0]?.snippet).toContain('ος')
+
+ const substring = await api.sessions.search(req({ query: 'ixtur' }), signal)
+ expect(substring.result).toEqual({
+ ok: true,
+ value: { items: [], hasMore: false },
+ })
+ const punctuationOnly = await api.sessions.search(req({ query: '*' }), signal)
+ expect(punctuationOnly.result).toEqual({
+ ok: true,
+ value: { items: [], hasMore: false },
+ })
+ const reasoningOnly = await api.sessions.search(req({ query: '思考过程' }), signal)
+ expect(reasoningOnly.result).toEqual({
+ ok: true,
+ value: { items: [], hasMore: false },
+ })
+
+ const aborted = new AbortController()
+ aborted.abort()
+ await expect(api.sessions.search(req({ query: 'fixture' }), aborted.signal))
+ .resolves.toMatchObject({ result: { ok: false, error: { code: 'cancelled' } } })
+ })
+
it('pages history backwards on message-boundary cuts with seq-contiguous stitching', async () => {
const api = createFixtureApi()
const tail = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 10 }))
@@ -761,8 +818,18 @@ describe('createFixtureApi', () => {
hooks.appendSilent('fx-alpha', '静默丢帧')
hooks.appendUser('fx-alpha', '正常直播')
hooks.appendTitle('fx-alpha', 'Fixture 修订标题')
+ hooks.beginModelRetry('fx-alpha')
+ hooks.scheduleModelRetry('fx-alpha')
+ hooks.completeModelRetry('fx-alpha')
+ hooks.beginModelRetry('fx-alpha')
+ hooks.cancelModelRetryDuringBackoff('fx-alpha')
await vi.waitFor(() => {
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true)
+ expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true)
+ expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true)
+ expect(seen.some(f => f.type === 'session/event'
+ && f.event.type === 'turn/end'
+ && f.event.data.reason.kind === 'aborted')).toBe(true)
expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true)
})
expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false)
@@ -819,6 +886,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
it('covers the whole unary dispatch table', async () => {
const client = new FixtureApiClient()
+ expect((await client.sessions.search(
+ { query: 'fixture' },
+ new AbortController().signal,
+ )).result.ok).toBe(true)
const created = await client.sessions.create({})
if (!created.result.ok) throw new Error('create failed')
const id = created.result.value.sessionId
diff --git a/packages/client/modules/README.i18n.yaml b/packages/client/modules/README.i18n.yaml
index 80bf46a996..c3dfc36e65 100644
--- a/packages/client/modules/README.i18n.yaml
+++ b/packages/client/modules/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/modules/README.md
-README.md: efba9e2eb0b148677fc7ac18bfad6333fb6f80da
-README.zh.md: b057bfdd8c0a269252496d0c6a0fc4184932fd72
+README.md: 99565b349d782c58752ac3e73ce7c0be527f78a8
+README.zh.md: a8ed0a4949ccefce53933b4f2fb8f51f5291684f
diff --git a/packages/client/modules/README.md b/packages/client/modules/README.md
index efba9e2eb0..99565b349d 100644
--- a/packages/client/modules/README.md
+++ b/packages/client/modules/README.md
@@ -8,6 +8,8 @@ Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`wi
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → fetch + execute + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the fetch branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (fetch + execute, registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and the materialized record so the next prefetch/import refetches (the HMR hook).
+The Node half scans enabled Loader entries for web `dshClient` packages, resolves each `exports["./client"]`, hashes the built bundle into the boot graph, and serves it under `/plugins`. Source launch maps host imports to TypeScript source but still consumes this built client export; missing files share one build instruction followed by a package/path list, while unrelated filesystem errors remain separate failures.
+
## Model Experience
None, as the module loader is browser-side kernel machinery; nothing here reaches a model request.
diff --git a/packages/client/modules/README.zh.md b/packages/client/modules/README.zh.md
index b057bfdd8c..a8ed0a4949 100644
--- a/packages/client/modules/README.zh.md
+++ b/packages/client/modules/README.zh.md
@@ -8,6 +8,8 @@
解析分支顺序(`import(specifier)`):平台种子词 → 外壳实例;记忆化记录 → 表层;外壳自身的静态注册表(`registerStatic`,app-shell)→ 模块;已注册 factory → 物化;模块图记录(`window.__DSH_BOOT__`)→ 抓取 + 执行 + 物化;其他情况一律抛出异常。这是构建时组合包纯度门禁的运行时镜像。交给 factory 的同步 `require` 采用相同顺序,但不含抓取分支,并把观察到的边记录到模块记录中。`prefetch` 是第一阶段加载钩子(抓取 + 执行,只注册;并发调用共享一个进行中的任务);`invalidate` 会丢弃 factory 与物化记录,使下一次 prefetch/import 重新抓取;它是 HMR(热模块替换)钩子。
+Node 侧会扫描已启用的 Loader 配置项以发现 web `dshClient` 包,解析每个 `exports["./client"]`,把构建后的组合包哈希写入启动图,并通过 `/plugins` 提供该文件。源码启动会把宿主侧导入映射到 TypeScript 源码,但仍消费客户端导出的构建产物;缺失文件共享一条构建要求,随后以 package/path list 列出各项,而无关的文件系统错误仍是独立故障。
+
## 模型体验
无。模块 loader 属于浏览器侧内核机制;这里没有任何内容进入模型请求。
diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts
index ecfc31b77f..694295e7f2 100644
--- a/packages/client/modules/src/index.ts
+++ b/packages/client/modules/src/index.ts
@@ -58,6 +58,47 @@ interface PkgMeta {
immediately: boolean
}
+/** Recovery instruction shared by grouped startup and steady-state bundle diagnostics. */
+const CLIENT_BUNDLE_BUILD_INSTRUCTION = 'run `pnpm run build` before launch'
+
+/** Missing built client export, retained as structured data for activation-error grouping. */
+class MissingClientBundleError extends Error {
+ constructor(
+ readonly packageName: string,
+ readonly clientPath: string,
+ cause: unknown,
+ ) {
+ super(
+ [
+ `client-modules: client bundle not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`,
+ ` package: ${packageName}`,
+ ` path: ${clientPath}`,
+ ].join('\n'),
+ { cause },
+ )
+ }
+}
+
+/** Activation failures grouped by actionable package-build errors and unrelated failures. */
+class ClientPackageCompositionError extends AggregateError {
+ constructor(failures: Error[]) {
+ const missingBundles = failures.filter((error): error is MissingClientBundleError => error instanceof MissingClientBundleError)
+ const otherFailures = failures.filter(error => !(error instanceof MissingClientBundleError))
+ const packageNoun = failures.length === 1 ? 'package' : 'packages'
+ const lines = [`client-modules: ${String(failures.length)} client ${packageNoun} failed to compose:`]
+ if (missingBundles.length > 0) {
+ lines.push(` client bundles not found; ${CLIENT_BUNDLE_BUILD_INSTRUCTION}:`)
+ for (const error of missingBundles) {
+ lines.push(` - package: ${error.packageName}`, ` path: ${error.clientPath}`)
+ }
+ }
+ if (otherFailures.length > 0) {
+ lines.push(' other failures:', ...otherFailures.map(error => ` - ${error.message}`))
+ }
+ super(failures, lines.join('\n'))
+ }
+}
+
/** One composed table row: the wire entry plus its bundle path. */
interface WebPluginRecord {
entry: WebBootEntry
@@ -138,7 +179,7 @@ export function injectBootManifest(html: string, graph: WebBootGraph): string {
* + bundle route + index tap. Construction runs the activation scan
* synchronously — a malformed declaration or missing bundle among the
* already-loaded entries aggregates into one loud throw (FAILED fiber; the
- * boot sweep reports it).
+ * boot activation audit reports it).
*/
export class ClientModuleHostService extends Service {
static inject = ['httpServer', 'loader']
@@ -194,10 +235,7 @@ export class ClientModuleHostService extends Service {
const failures: Error[] = []
this.flush(err => failures.push(err))
if (failures.length > 0) {
- throw new AggregateError(
- failures,
- `client-modules: ${String(failures.length)} client package(s) failed to compose:\n${failures.map(e => ` - ${e.message}`).join('\n')}`,
- )
+ throw new ClientPackageCompositionError(failures)
}
ctx.effect(
@@ -322,6 +360,22 @@ export class ClientModuleHostService extends Service {
return meta
}
+ /**
+ * Read the activation-time bundle revision.
+ * @param pkgName - package that declares the client bundle.
+ * @param clientPath - absolute path of the built client artifact.
+ * @returns the bundle content's short hash for use as its revision.
+ * @throws {MissingClientBundleError} when the read fails with `ENOENT`; other filesystem errors are rethrown unchanged.
+ */
+ private initialBundleRevision(pkgName: string, clientPath: string): string {
+ try {
+ return shortHash(readFileSync(clientPath))
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
+ throw new MissingClientBundleError(pkgName, clientPath, error)
+ }
+ }
+
/** Reconcile one entry name against the live loader entries. @returns whether the table changed. */
private processOne(entryName: string): boolean {
let qualifies = false
@@ -337,7 +391,7 @@ export class ClientModuleHostService extends Service {
if (meta === null) return false
// The rev rides the row from here on: a fiber restart reuses the row (and
// its rev) untouched; only rebuilt() re-reads the bundle.
- const rev = shortHash(readFileSync(meta.clientPath))
+ const rev = this.initialBundleRevision(entryName, meta.clientPath)
this.table.set(entryName, { entry: graphRow(entryName, rev, meta.inject, meta.immediately), clientPath: meta.clientPath })
return true
}
diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts
new file mode 100644
index 0000000000..3eb99c0ead
--- /dev/null
+++ b/packages/client/modules/tests/node-half.spec.ts
@@ -0,0 +1,87 @@
+/** Node-half composition diagnostics for package metadata and built client bundles. */
+
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { Context } from 'cordis'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver'
+import { ClientModuleHostService } from '../src/index.ts'
+
+let root: string | undefined
+
+afterEach(() => {
+ if (root !== undefined) rmSync(root, { recursive: true, force: true })
+ root = undefined
+})
+
+/** Create a resolvable dshClient package whose client export points at the returned path. */
+function writePackage(packageName: string): string {
+ root ??= realpathSync(mkdtempSync(join(tmpdir(), 'dsh-client-modules-')))
+ const pkgRoot = join(root, 'node_modules', ...packageName.split('/'))
+ const clientPath = join(pkgRoot, 'lib', 'client.js')
+ mkdirSync(pkgRoot, { recursive: true })
+ writeFileSync(join(pkgRoot, 'package.json'), JSON.stringify({
+ name: packageName,
+ exports: {
+ './client': './lib/client.js',
+ './package.json': './package.json',
+ },
+ dshClient: { platform: 'web' },
+ }))
+ return clientPath
+}
+
+/** Construct the node-half service over the enabled fixture entries. */
+function construct(packageNames: string[]): ClientModuleHostService {
+ const ctx = new Context()
+ ctx.baseUrl = pathToFileURL(root!).href + '/'
+ ctx.provide('loader', {
+ *entries() {
+ for (const packageName of packageNames) {
+ yield { options: { name: packageName }, fiber: {}, disabled: false }
+ }
+ },
+ })
+ const httpServer: Pick = {
+ port: 0,
+ register: () => () => {},
+ tapIndex: () => () => {},
+ }
+ ctx.provide('httpServer', httpServer as HttpServerService)
+ return new ClientModuleHostService(ctx)
+}
+
+describe('client bundle activation', () => {
+ it('groups missing bundles under one source-build instruction with a package/path list', () => {
+ const firstName = '@fixture/missing-first'
+ const secondName = '@fixture/missing-second'
+ const firstPath = writePackage(firstName)
+ const secondPath = writePackage(secondName)
+ expect(() => construct([firstName, secondName])).toThrow([
+ 'client-modules: 2 client packages failed to compose:',
+ ' client bundles not found; run `pnpm run build` before launch:',
+ ` - package: ${firstName}`,
+ ` path: ${firstPath}`,
+ ` - package: ${secondName}`,
+ ` path: ${secondPath}`,
+ ].join('\n'))
+ })
+
+ it('does not report other bundle read failures as missing builds', () => {
+ const packageName = '@fixture/unreadable-client'
+ const clientPath = writePackage(packageName)
+ mkdirSync(clientPath, { recursive: true })
+ let thrown: unknown
+ try {
+ construct([packageName])
+ } catch (error) {
+ thrown = error
+ }
+ expect(String(thrown)).toContain('client-modules: 1 client package failed to compose:')
+ expect(String(thrown)).toContain(' other failures:')
+ expect(String(thrown)).toContain('EISDIR')
+ expect(String(thrown)).not.toContain('pnpm run build')
+ })
+})
diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml
index 75fa6b2c6c..f42e4f09d0 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: a116a5e4ad3070f20e6d90490f2507c1e2369c37
-README.zh.md: f375811e6f1480d6636fe4eb77746b76d6414b1e
+README.md: 9f2b165f1a98dcecfa3ab82386da9b094cfd2f54
+README.zh.md: 3ed047e65d3bddc14c3b6b84f327bbeebf805d4b
diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md
index a116a5e4ad..9f2b165f1a 100644
--- a/packages/client/runtime/README.md
+++ b/packages/client/runtime/README.md
@@ -12,6 +12,8 @@ Workspace and Session lists have independent monotone `pending` → `ready` base
SlotsService gives the renderer separate bare observables for `useSessions` and `useWorkspaces`; web-react creates the hooks. Workspace business state does not enter `SessionListState` or an entry store.
+`SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it.
+
## New Session and the blank mirror
`WorkspacesService.connectWorkspace(workspaceId)` resolves the session a New Session flow lands in: it reuses the workspace's existing blank session from the list mirror (`blank && cwd == workspace.path`) or calls `session.create({workspaceId})`, returning the session id for the caller to open. `SessionSummary.blank` mirrors the host's derived empty-log bit and only ever lowers on the client: seeded by `session.list` / the `host/session-added` frame, flipped false by the first ACCEPTED local `prompt()` (on the RPC success response — acceptance proves the user message is in the host log; a rejected first prompt keeps the session blank and reusable) and by any `running: true` status frame, re-aligned by every list re-pull. List surfaces hide blank rows; the store carries every row. `SessionsService.create` accepts an optional caller-preallocated SessionId and throws `SessionCreateError` (carrying `requestedSessionId`) on failure.
@@ -28,6 +30,10 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
+## Model retry projection
+
+The Session object validates plugin-owned, provider-routed `llm/retry` payloads at the event wire boundary against the producer's complete field contract, including timer, integer, status, provider-delay, and non-empty diagnostic bounds. A valid event removes the matching failed step's streaming partial and inserts a durable retry notice at the event's sequence position. The notice is `scheduled` until a following retry turn starts; an aborted or disposed source turn marks it `cancelled`, while the retry turn marks it `started`. Normal-mode notices carry their finite maximum; always-mode notices remain explicitly unbounded. Window rebuild and history replay apply the same projection, so logged chunks from the discarded attempt never reappear as an interrupted reply after refresh. A terminal turn without `llm/retry` retains the existing behavior: visible unfinalized output is frozen as an interrupted assistant node.
+
## 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 f375811e6f..3ed047e65d 100644
--- a/packages/client/runtime/README.zh.md
+++ b/packages/client/runtime/README.zh.md
@@ -12,6 +12,8 @@ Workspace 和 Session 列表各自具有单调的 `pending` → `ready` 基线
SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 observable;web-react 创建钩子。Workspace 业务状态不会进入 `SessionListState` 或配置项 store。
+`SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。
+
## New Session 与 blank 镜像
`WorkspacesService.connectWorkspace(workspaceId)` 解析 New Session 流程最终落入的会话:先在列表镜像中复用该 workspace 的既有空会话(`blank && cwd == workspace.path`),未命中则调用 `session.create({workspaceId})`,返回会话 id 由调用方 open。`SessionSummary.blank` 镜像主机派生的空日志位,在客户端只降不升:由 `session.list`/`host/session-added` 帧播种,本地首次获 Host 接受的 `prompt()`(RPC 成功响应时——受理即证明用户消息已入主机日志;首讯被拒则会话保持 blank、保持可复用)与任何 `running: true` 状态帧翻为 false,每次列表重拉重新对齐。列表界面隐藏 blank 行;store 保留全部行。`SessionsService.create` 接受可选的、由调用方预先分配的 SessionId,失败时抛出 `SessionCreateError`(携带 `requestedSessionId`)。
@@ -28,6 +30,10 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更高的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含实际的持久化标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷态持久化会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影由日志支撑的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
+## 模型重试投影
+
+Session 对象会在事件 wire 边界依据生产方的完整字段契约,验证由插件负责、按提供方路由的 `llm/retry` 载荷,包括计时器、整数、状态、提供方延迟和非空诊断字段的边界。有效事件会移除对应失败步骤的流式输出片段,并在该事件的序列位置插入一条持久的重试提示。该提示在后续重试轮次开始前为 `scheduled`;源轮次中止或释放会将其标记为 `cancelled`,重试轮次则会将其标记为 `started`。normal mode 提示携带其有限上限;always mode 提示则保持显式无界。窗口重建与历史回放应用相同的投影,因此刷新后,来自已丢弃尝试的日志分片绝不会重新显示为中断回复。没有 `llm/retry` 的终止轮次保留现有行为:可见但尚未定稿的输出会冻结为中断的 assistant 节点。
+
## 会话 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/package.json b/packages/client/runtime/package.json
index 60e2eddf09..a3897e9523 100644
--- a/packages/client/runtime/package.json
+++ b/packages/client/runtime/package.json
@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
+ "@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
@@ -49,6 +50,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-timeout": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7"
},
diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts
index 280bc602eb..79f20a234c 100644
--- a/packages/client/runtime/src/client/contract/sessions.ts
+++ b/packages/client/runtime/src/client/contract/sessions.ts
@@ -8,8 +8,9 @@
* explicit act of widening what features may do to the sessions domain.
*/
import type { Context } from 'cordis'
-import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
+import type { RpcResult, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import type { HostObservable, SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
+import type { SessionSearchResultItem } from '../sessions/manager.ts'
import type {
SessionBinding, SessionListState, SessionProvideDescriptor,
} from '../sessions/service.ts'
@@ -22,6 +23,12 @@ export interface ISessions {
readonly list: ObservableSnapshot
/** Atomic current-session provide projection (the renderer host's `sessions.provideInfo` feed). */
readonly currentProvideInfo: HostObservable
+ /**
+ * The `session.search` result bound the wire schema fixes, exposed to
+ * presentation as injected data. Not per-connection state: every transport
+ * (fixture included) reports the same number.
+ */
+ readonly searchResultLimit: number
/**
* Select a session as current.
* @param id - session id (must exist in the list; unknown ids fail loud).
@@ -29,6 +36,17 @@ export interface ISessions {
open(id: SessionId): void
/** Clear the current selection into the no-session view state. */
clear(): void
+ /**
+ * Search the Host's visible message-content index. Results stay
+ * request-local; the list snapshot remains the metadata authority.
+ * @param query - non-blank literal phrase.
+ * @param signal - cancellation for a superseded search.
+ * @returns bounded results, or a business/transport error.
+ */
+ search(
+ query: string,
+ signal: AbortSignal,
+ ): Promise>
/**
* Fork a session from a completed-turn prefix of the source; on resolution
* the child is in the list store and `open()` can target it.
diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts
index 9a5b3a535d..68d1a3931f 100644
--- a/packages/client/runtime/src/client/index.ts
+++ b/packages/client/runtime/src/client/index.ts
@@ -31,7 +31,7 @@ export type { IWorkspaces } from './contract/workspaces.ts'
export type {
SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary,
} from './sessions/service.ts'
-export type { SessionListPhase } from './sessions/manager.ts'
+export type { SessionListPhase, SessionSearchResultItem } from './sessions/manager.ts'
export type { WorkspaceListPhase } from './workspaces/manager.ts'
export type { WorkspaceListState } from './workspaces/service.ts'
export type {
@@ -45,7 +45,7 @@ export type {
export type {
AssistantBlock, AssistantMessageNode, AssistantProvenanceView, AssistantRequestConfig,
AssistantTiming, CodeSubCall, CommandNode, ComposerPhase, ContextMessageNode, ConversationNode,
- ConversationSnapshot, QueuedMessage, RunningToolCall,
+ ConversationSnapshot, ModelRetryNode, QueuedMessage, RunningToolCall,
SteeringMessageNode, TodoItem, ToolResultNode, UnknownSurfaceNode, UserMessageNode,
} from './sessions/conversation.ts'
export type {
diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts
index f68a271c63..ff027d0b99 100644
--- a/packages/client/runtime/src/client/sessions/conversation.ts
+++ b/packages/client/runtime/src/client/sessions/conversation.ts
@@ -5,6 +5,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
InboxItemId, RpcError, SessionId, ToolCallView, ToolResultView,
@@ -121,6 +122,19 @@ export interface ContextMessageNode {
source: unknown
}
+/** Durable notice that a closed failed step is waiting for a model-request retry. */
+export type ModelRetryNode = LlmRetryEventData & {
+ kind: 'model-retry'
+ seq: number
+ /** Unix epoch ms from the llm/retry session event. */
+ time: number
+ /**
+ * Client-derived lifecycle: scheduled until a retry turn starts, started
+ * once it does, or cancelled when the failed turn aborts first.
+ */
+ retryState: 'scheduled' | 'started' | 'cancelled'
+}
+
/** A tool result paired (when in-window) with its call head. */
export interface ToolResultNode {
kind: 'tool-result'
@@ -183,6 +197,7 @@ export type ConversationNode =
| AssistantMessageNode
| SteeringMessageNode
| ContextMessageNode
+ | ModelRetryNode
| ToolResultNode
| CommandNode
| UnknownSurfaceNode
@@ -265,7 +280,7 @@ export interface PromptError {
/** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */
export interface ConversationSnapshot {
sessionId: SessionId
- /** Surface fold product (finalized conversation nodes in surface order). */
+ /** Finalized surface events and durable operational notices in event order. */
nodes: readonly ConversationNode[]
/** Fold degradation flag (cross-window replace defense): when true, nodes come from the lenient linear scan. */
foldDegraded: boolean
diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts
index 4ba134b321..a89d8dbc31 100644
--- a/packages/client/runtime/src/client/sessions/manager.ts
+++ b/packages/client/runtime/src/client/sessions/manager.ts
@@ -2,7 +2,10 @@
// dispatch entry + list state, constructed and held by SessionsService (one per client runtime).
// List data never enters zustand; React connects via subscribe/getListSnapshot.
-import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, SessionSummary, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
+import type {
+ IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId,
+ SessionSummary, WorkspaceId,
+} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
@@ -27,6 +30,12 @@ import { Session } from './session.ts'
*/
export type SessionListPhase = 'pending' | 'ready'
+/** Request-local content hit returned to sidebar search consumers. */
+export interface SessionSearchResultItem {
+ sessionId: SessionId
+ snippet: string
+}
+
/** Immutable session-list snapshot for useSessionList. */
export interface SessionListSnapshot {
items: readonly SessionListEntry[]
@@ -248,6 +257,24 @@ export class SessionManager {
return this.listInflight
}
+ /**
+ * Search visible session message content without adding transient query
+ * state to the list snapshot.
+ * @param query - non-blank literal phrase.
+ * @param signal - cancellation for superseded UI queries.
+ * @returns the Host result or a folded transport error.
+ */
+ async search(
+ query: string,
+ signal: AbortSignal,
+ ): Promise> {
+ try {
+ return (await this.api.sessions.search({ query }, signal)).result
+ } catch (error: unknown) {
+ return transportError(error)
+ }
+ }
+
/**
* Contract session.create; on success merge into summaries immediately (no
* wait for the next refresh). A created session is blank by definition
diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts
index 342fc3b62e..93ecb3c791 100644
--- a/packages/client/runtime/src/client/sessions/service.ts
+++ b/packages/client/runtime/src/client/sessions/service.ts
@@ -16,7 +16,12 @@
* survives frozen (read-only view) until the stage moves on.
*/
import type { Context, Fiber } from 'cordis'
-import type { IApiClient, RpcError, SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-connection/client'
+import type {
+ IApiClient, RpcError, RpcResult, SessionId, WorkspaceId,
+} from '@deepseek-ai/dsh-client-connection/client'
+// Value import from the inline-safe wire layer (not the connection plugin):
+// plugin-to-plugin value imports are a bundle purity error.
+import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
HostObservable, SessionMaybeProvideInfo, SessionProvideInfo,
} from '@deepseek-ai/dsh-client-ui-slots'
@@ -26,7 +31,7 @@ import type { SessionFace } from '../contract/session.ts'
import type { ISessions } from '../contract/sessions.ts'
import { createScope, scopeOf as scopeTagOf } from '../agents/scope.ts'
import { SessionManager } from './manager.ts'
-import type { SessionListPhase } from './manager.ts'
+import type { SessionListPhase, SessionSearchResultItem } from './manager.ts'
import { SessionProvideChannel } from './provide.ts'
import type { Session } from './session.ts'
@@ -189,6 +194,13 @@ export interface SessionProvideDescriptor {
/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, ancestry. */
export class SessionsService implements ISessions {
+ /**
+ * The wire schema's own result bound, re-exposed for presentation plugins as
+ * injected data. Not per-connection state: the `session.search` response
+ * schema caps `items` at this constant, so every transport (fixture included)
+ * reports the same number.
+ */
+ readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
/** List snapshot store (list RPC + host stream increments; re-pulled on reconnect) — the useSessions standard feed, current included. */
readonly list: SnapshotStore
/** The object-layer instance cluster and frame dispatch entry. */
@@ -228,7 +240,10 @@ export class SessionsService implements ISessions {
* @param ctx - client root context (scope fibers mount under it).
* @param api - wire client shared with every Session.
*/
- constructor(private readonly rootCtx: Context, api: IApiClient) {
+ constructor(
+ private readonly rootCtx: Context,
+ api: IApiClient,
+ ) {
this.selection = createSnapshotStore<{ sessionId?: SessionId }>(
{},
{ persist: { name: 'dsh.sessions.current' } })
@@ -307,6 +322,20 @@ export class SessionsService implements ISessions {
return this.manager.refreshList()
}
+ /**
+ * Search the Host's visible message-content index. Results stay
+ * request-local; the list snapshot remains the metadata authority.
+ * @param query - non-blank literal phrase.
+ * @param signal - cancellation for a superseded search.
+ * @returns bounded results or a business/transport error.
+ */
+ search(
+ query: string,
+ signal: AbortSignal,
+ ): Promise> {
+ return this.manager.search(query, signal)
+ }
+
/**
* Route a mux stream envelope into the Session object layer.
* @param envelope - validated mux stream envelope.
diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts
index 6850e3e4c9..2b63600a59 100644
--- a/packages/client/runtime/src/client/sessions/session.ts
+++ b/packages/client/runtime/src/client/sessions/session.ts
@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
+import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, InboxItemId, MuxFrame, QueueAction, RpcError,
@@ -12,8 +13,8 @@ import type {
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionFace } from '../contract/session.ts'
import type {
- CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, OpenState,
- PromptError, QueuedMessage, RunningToolCall,
+ CodeSubCall, ComposerPhase, ConversationNode, ConversationSnapshot, ModelRetryNode,
+ OpenState, PromptError, QueuedMessage, RunningToolCall,
} from './conversation.ts'
import type { PendingInteraction } from './pending.ts'
import { PendingWait } from './pending.ts'
@@ -26,6 +27,10 @@ import type { ProjectionsBaseline } from './projection-store.ts'
/** Messages requested per history page. */
export const PAGE_MESSAGES = 50
+// Browser bundles cannot value-import the host timeout library. This protocol
+// bound is pinned to @deepseek-ai/dsh-timeout's MAX_TIMER_DELAY_MS in tests.
+const MAX_RETRY_DELAY_MS = 2_147_483_647
+
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/**
@@ -88,9 +93,9 @@ export class Session implements SessionFace {
private readonly foldAdapter = new FoldAdapter()
private partial: PartialAccumulator | null = null
private openCalls = new Map()
- /** Interrupted-turn terminal nodes (frozen partial text / aborted tool cards), merged into the flow by seq.
- * Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
- private frozenNodes: ConversationNode[] = []
+ /** Operational notices and interrupted-turn terminal nodes merged into the flow by seq.
+ * Derived from window events — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
+ private derivedNodes: ConversationNode[] = []
private pending = new Map()
// Revision counters preserve array identity when derived content is unchanged, so
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
@@ -100,12 +105,12 @@ export class Session implements SessionFace {
private callsCache: { rev: number; value: RunningToolCall[] } | null = null
private pendingRev = 0
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
+ private derivedRev = 0
+ private nodesCache: { folded: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Authoritative stream-only inbox snapshot; pending work never hits history. */
private queued: QueuedMessage[] = []
private queueRev = 0
private queueCache: { rev: number; value: QueuedMessage[] } | null = null
- private frozenRev = 0
- private nodesCache: { folded: readonly ConversationNode[]; frozenRev: number; value: readonly ConversationNode[] } | null = null
/** `run_code` sub-dispatches by parent callId (window-derived, like openCalls). Appends
* copy-on-write the per-parent array so published snapshot references never mutate. */
private codeDispatches = new Map()
@@ -625,8 +630,28 @@ export class Session implements SessionFace {
}
/** Per-event side effects (right column of the §A.9 dispatch table):
- * chunk accumulation / partial clear on finalize / openCalls add-remove. */
+ * chunk/retry projection and openCalls add-remove. */
private applyEventSideEffects(event: SessionEvent, view?: ToolEventView): void {
+ const eventType = event.type as string
+ if (eventType === 'llm/retry') {
+ const data = parseRetryEventData(event.data)
+ if (data === null) {
+ console.error(`[web-runtime] ignored malformed llm/retry event at seq ${event.seq}`)
+ return
+ }
+ if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
+ this.partial = null
+ }
+ this.derivedNodes.push({
+ kind: 'model-retry',
+ seq: event.seq,
+ time: event.time,
+ retryState: 'scheduled',
+ ...data,
+ })
+ this.derivedRev++
+ return
+ }
// The `tool/code-dispatch-start`/`tool/code-dispatch` pair is declared by
// the host-side dsh-tools plugin whose types cannot enter the client
// program (its host Context merges collide with the client's), so this
@@ -687,6 +712,10 @@ export class Session implements SessionFace {
return
}
switch (event.type) {
+ case 'turn/start': {
+ if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
+ return
+ }
case 'assistant/chunk': {
const { turn, step, chunk } = event.data
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
@@ -715,6 +744,9 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
+ if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
+ this.settleScheduledRetry('cancelled', event.data.turn)
+ }
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -724,12 +756,12 @@ export class Session implements SessionFace {
const visible = blocks.some(b => (b.kind === 'text' || b.kind === 'reasoning' ? b.text !== '' : true))
if (visible) {
// Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn.
- this.frozenNodes.push({
+ this.derivedNodes.push({
kind: 'assistant', seq: event.seq - 0.9, time: event.time,
turn: this.partial.turn, step: this.partial.step,
blocks, interrupted: true,
})
- this.frozenRev++
+ this.derivedRev++
}
this.partial = null
}
@@ -739,7 +771,7 @@ export class Session implements SessionFace {
this.openCalls.delete(callId)
this.callsRev++
// The spinner card becomes an interrupted terminal card (never vanishes mid-flow).
- this.frozenNodes.push({
+ this.derivedNodes.push({
kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time,
callId,
call: { name: call.name, argsRaw: call.argsRaw },
@@ -747,7 +779,7 @@ export class Session implements SessionFace {
content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' },
callView: call.callView, resultView: null,
})
- this.frozenRev++
+ this.derivedRev++
}
return
}
@@ -756,15 +788,36 @@ export class Session implements SessionFace {
}
}
- /** Re-derive state (partial/openCalls/frozenNodes) from raw window events after a rebuild — keeps
- * paging/stitching consistent, and makes the live freeze and the history replay converge on the
- * same interrupted nodes (chunks are logged, so the replayed sweep re-freezes identical text). */
+ /**
+ * Settle the newest scheduled retry, optionally restricted to its failed turn.
+ * @param retryState - next client projection state to publish.
+ * @param turn - failed turn required for cancellation; omitted for the next retry turn start.
+ */
+ private settleScheduledRetry(
+ retryState: Exclude,
+ turn?: number,
+ ): void {
+ const index = this.derivedNodes.findLastIndex(node =>
+ node.kind === 'model-retry'
+ && node.retryState === 'scheduled'
+ && (turn === undefined || node.turn === turn))
+ if (index < 0) return
+ const node = this.derivedNodes[index]
+ /* v8 ignore next -- findLastIndex's predicate narrows the indexed node only at runtime. */
+ if (node?.kind !== 'model-retry') return
+ this.derivedNodes[index] = { ...node, retryState }
+ this.derivedRev++
+ }
+
+ /** Re-derive state (partial/openCalls/derivedNodes) from raw window events after a rebuild — keeps
+ * paging/stitching consistent, and makes live handling and history replay converge on the same
+ * retry notices and interrupted nodes. */
private rebuildDerivedFromWindow(): void {
this.partial = null
this.openCalls.clear()
this.callsRev++
- this.frozenNodes = []
- this.frozenRev++
+ this.derivedNodes = []
+ this.derivedRev++
this.codeDispatches = new Map()
this.dispatchesRev++
for (let i = 0; i < this.events.length; i++) {
@@ -781,17 +834,17 @@ export class Session implements SessionFace {
private buildSnapshot(): ConversationSnapshot {
const { nodes: folded, degraded } = this.foldAdapter.nodes()
- // Frozen interrupted nodes ride fractional seqs: a stable merge keeps them in flow order.
- // The merged array is cached on (folded reference, frozenRev) so an unchanged flow keeps its
+ // Derived nodes use their event seq or a nearby fractional seq: a stable merge keeps flow order.
+ // The merged array is cached on (folded reference, derivedRev) so an unchanged flow keeps its
// reference across snapshot swaps (§A.9.4).
let nodes: readonly ConversationNode[]
- if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.frozenRev === this.frozenRev) {
+ if (this.nodesCache !== null && this.nodesCache.folded === folded && this.nodesCache.derivedRev === this.derivedRev) {
nodes = this.nodesCache.value
} else {
- nodes = this.frozenNodes.length === 0
+ nodes = this.derivedNodes.length === 0
? folded
- : [...folded, ...this.frozenNodes].sort((a, b) => a.seq - b.seq)
- this.nodesCache = { folded, frozenRev: this.frozenRev, value: nodes }
+ : [...folded, ...this.derivedNodes].sort((a, b) => a.seq - b.seq)
+ this.nodesCache = { folded, derivedRev: this.derivedRev, value: nodes }
}
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
@@ -835,6 +888,58 @@ export class Session implements SessionFace {
}
}
+/** Validate the plugin-owned payload at the session-event wire boundary. */
+function parseRetryEventData(value: unknown): LlmRetryEventData | null {
+ if (value === null || typeof value !== 'object') return null
+ const data = value as Record
+ const failure = data.failure
+ if (failure === null || typeof failure !== 'object') return null
+ const failureData = failure as Record
+ if (!nonNegativeSafeInteger(data.turn)
+ || !nonNegativeSafeInteger(data.step)
+ || typeof data.provider !== 'string'
+ || data.provider.length === 0
+ || typeof data.policyKey !== 'string'
+ || data.policyKey.length === 0
+ || !positiveSafeInteger(data.retry)
+ || typeof data.delayMs !== 'number'
+ || !Number.isFinite(data.delayMs)
+ || data.delayMs < 0
+ || data.delayMs > MAX_RETRY_DELAY_MS
+ || typeof failureData.message !== 'string'
+ || failureData.message.length === 0
+ || typeof failureData.code !== 'string'
+ || failureData.code.length === 0) return null
+ if (data.mode === 'normal') {
+ if (!positiveSafeInteger(data.maxRetries) || data.retry > data.maxRetries) return null
+ } else if (data.mode === 'always') {
+ if ('maxRetries' in data) return null
+ } else {
+ return null
+ }
+ if (failureData.status !== undefined
+ && (typeof failureData.status !== 'number'
+ || !Number.isInteger(failureData.status)
+ || failureData.status < 100
+ || failureData.status > 599)) return null
+ if (failureData.providerRetryAfterMs !== undefined
+ && (typeof failureData.providerRetryAfterMs !== 'number'
+ || !Number.isFinite(failureData.providerRetryAfterMs)
+ || failureData.providerRetryAfterMs <= 0)) return null
+ if (failureData.requestId !== undefined
+ && (typeof failureData.requestId !== 'string'
+ || failureData.requestId.length === 0)) return null
+ return data as unknown as LlmRetryEventData
+}
+
+function nonNegativeSafeInteger(value: unknown): value is number {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
+}
+
+function positiveSafeInteger(value: unknown): value is number {
+ return nonNegativeSafeInteger(value) && value > 0
+}
+
/**
* The composerPhase judgment — the single site that knows the predicate
* (consumers switch on the result, never re-derive). Monotone per session
diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts
index d5b29f10a9..d389efe319 100644
--- a/packages/client/runtime/tests/client-apply.spec.ts
+++ b/packages/client/runtime/tests/client-apply.spec.ts
@@ -7,6 +7,7 @@ import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client'
+import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import * as RuntimeClient from '../src/client/index.ts'
import type { SessionsService } from '../src/client/sessions/service.ts'
import type { WorkspacesService } from '../src/client/workspaces/service.ts'
@@ -50,6 +51,8 @@ describe('runtime client apply', () => {
const workspaces = bench.ctx.get('workspaces')
expect(sessions !== undefined).toBe(true)
expect(workspaces !== undefined).toBe(true)
+ // The bound the wire schema enforces, not a per-connection negotiation.
+ expect((sessions as SessionsService).searchResultLimit).toBe(SESSION_SEARCH_RESULT_LIMIT)
if (workspaces === undefined) throw new Error('WorkspacesService missing after runtime apply')
expect(bench.sinks).toBeDefined()
diff --git a/packages/client/runtime/tests/event-script.ts b/packages/client/runtime/tests/event-script.ts
index 53f80e0e69..b96fffaae9 100644
--- a/packages/client/runtime/tests/event-script.ts
+++ b/packages/client/runtime/tests/event-script.ts
@@ -63,7 +63,25 @@ export const ev = {
}),
stepEnd: (seq: number, turn: number, step = 0): SessionEvent =>
at(seq, { type: 'step/end', data: { turn, step } }),
- turnEnd: (seq: number, turn: number, reason: 'completed' | 'cancelled' = 'completed'): SessionEvent =>
+ retry: (
+ seq: number,
+ turn: number,
+ step = 0,
+ retry = 1,
+ maxRetries = 2,
+ delayMs = 500,
+ message = 'temporary transport failure',
+ ): SessionEvent =>
+ at(seq, {
+ type: 'llm/retry',
+ data: {
+ turn, step,
+ provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
+ retry, maxRetries, delayMs,
+ failure: { code: 'TRANSPORT', message },
+ },
+ }),
+ turnEnd: (seq: number, turn: number, reason: 'completed' | 'aborted' | 'disposed' = 'completed'): SessionEvent =>
at(seq, { type: 'turn/end', data: { turn, reason: { kind: reason } } }),
commandRun: (seq: number, commandId: string, name: string, args = ''): SessionEvent =>
at(seq, { type: 'command/run', data: { commandId, name, args, source: { kind: 'user' } } }),
diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts
index 041ea02dee..06d948ae83 100644
--- a/packages/client/runtime/tests/fake-api.ts
+++ b/packages/client/runtime/tests/fake-api.ts
@@ -4,7 +4,7 @@
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type {
ClientResponse, CommandDescriptor, HostFrame, IApiClient, ModelTarget, MuxFrame,
- RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SkillEntry,
+ RpcError, RpcReceipt, RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry,
WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { RpcId } from '@deepseek-ai/dsh-client-connection/client'
@@ -61,6 +61,8 @@ export class FakeApiClient implements IApiClient {
// Programmable slots (defaults answer OK-empty); reassign per case.
onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] }))
+ onSearch: (payload: unknown) => Promise> =
+ () => Promise.resolve(ok({ items: [], hasMore: false }))
onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId }))
readonly defaultModel: ModelTarget = { provider: 'deepseek-official', model: 'deepseek-v4-flash' }
onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 }))
@@ -106,12 +108,17 @@ export class FakeApiClient implements IApiClient {
private readonly muxConns: StreamConn[] = []
private readonly hostConns: StreamConn[] = []
+ lastSearchSignal: AbortSignal | undefined
// Parameters carry local structural annotations: the CI lint lane runs
// without built lib/, so IApiClient's indexed-access types collapse to any
// and inferred parameters would trip no-unsafe-argument.
readonly sessions: IApiClient['sessions'] = {
list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)),
+ search: (payload: unknown, signal?: AbortSignal) => {
+ this.lastSearchSignal = signal
+ return this.record('session.search', payload, this.onSearch(payload))
+ },
create: (payload: unknown) => this.record('session.create', payload, this.onCreate(payload)),
history: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) =>
this.record('session.history', payload, this.onHistory(payload)),
diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts
index 6a0f30f4c6..1330a49768 100644
--- a/packages/client/runtime/tests/manager.spec.ts
+++ b/packages/client/runtime/tests/manager.spec.ts
@@ -206,6 +206,49 @@ describe('list lifecycle', () => {
})
})
+describe('search', () => {
+ it('returns bounded Host results and forwards the caller signal', async () => {
+ const api = new FakeApiClient()
+ api.onSearch = () => Promise.resolve(ok({
+ items: [{ sessionId: S1, snippet: 'matching excerpt' }],
+ hasMore: true,
+ }))
+ const manager = new SessionManager(api)
+ const signal = new AbortController().signal
+
+ await expect(manager.search('exact phrase', signal)).resolves.toEqual({
+ ok: true,
+ value: {
+ items: [{ sessionId: S1, snippet: 'matching excerpt' }],
+ hasMore: true,
+ },
+ })
+ expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }])
+ expect(api.lastSearchSignal).toBe(signal)
+ })
+
+ it('preserves business errors and folds transport failures', async () => {
+ const api = new FakeApiClient()
+ const manager = new SessionManager(api)
+ api.onSearch = () => Promise.resolve(err({
+ code: 'internal',
+ message: 'index unavailable',
+ details: {},
+ }))
+ const signal = new AbortController().signal
+ await expect(manager.search('first', signal)).resolves.toMatchObject({
+ ok: false,
+ error: { code: 'internal', message: 'index unavailable' },
+ })
+
+ api.onSearch = () => Promise.reject(new Error('wire down'))
+ await expect(manager.search('second', signal)).resolves.toMatchObject({
+ ok: false,
+ error: { code: 'internal', message: 'wire down' },
+ })
+ })
+})
+
describe('host frame routing', () => {
it('adds/removes/flips sessions from host frames and keeps removed instances resident', async () => {
const api = new FakeApiClient()
diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts
index e62e14bd48..a8b1fbd5dc 100644
--- a/packages/client/runtime/tests/session.spec.ts
+++ b/packages/client/runtime/tests/session.spec.ts
@@ -8,6 +8,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
+import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { FakeApiClient, deferred, err, ok } from './fake-api.ts'
@@ -161,6 +162,214 @@ describe('live event path', () => {
expect((last as { interrupted?: true }).interrupted).toBeUndefined()
})
+ it('retracts the failed step partial on retry and keeps a replayable notice before the recovered response', async () => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ const retryTurn = [
+ ev.turnStart(6, 1),
+ ev.user(7, '请重试'),
+ ev.stepStart(8, 1),
+ ev.chunkStart(9, 1),
+ ev.chunkText(10, 1, '不完整回复'),
+ ev.stepEnd(11, 1),
+ ev.retry(12, 1, 0, 1, 2, 450, '连接被重置'),
+ at(13, {
+ type: 'turn/end',
+ data: {
+ turn: 1,
+ reason: {
+ kind: 'error', step: 0,
+ failure: { code: 'TRANSPORT', message: '连接被重置' },
+ },
+ },
+ }),
+ at(14, { type: 'turn/start', data: { turn: 2, trigger: { kind: 'retry' } } }),
+ ev.stepStart(15, 2),
+ ev.assistant(16, 2, '完整回复'),
+ ev.stepEnd(17, 2),
+ ev.turnEnd(18, 2),
+ ]
+ for (const event of retryTurn.slice(0, 7)) feed(event)
+
+ let snapshot = session.getSnapshot()
+ expect(snapshot.partial).toBeNull()
+ expect(snapshot.nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ turn: 1,
+ step: 0,
+ provider: 'fake',
+ mode: 'normal',
+ policyKey: 'fake-normal',
+ retry: 1,
+ maxRetries: 2,
+ delayMs: 450,
+ failure: { code: 'TRANSPORT', message: '连接被重置' },
+ })
+ expect(JSON.stringify(snapshot.nodes)).not.toContain('不完整回复')
+
+ for (const event of retryTurn.slice(7)) feed(event)
+ snapshot = session.getSnapshot()
+ expect(snapshot.nodes.slice(-2).map(node => node.kind)).toEqual(['model-retry', 'assistant'])
+ expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
+ expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
+
+ const replay = makeSession()
+ replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
+ await replay.session.open()
+ expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
+ expect(replay.session.getSnapshot().partial).toBeNull()
+ })
+
+ it('rejects retry payloads outside the producer contract without retracting the current partial', async () => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ feed(ev.turnStart(6, 1))
+ feed(ev.chunkStart(7, 1))
+ feed(ev.chunkText(8, 1, '仍在生成'))
+ const valid = {
+ turn: 1, step: 0,
+ provider: 'fake', mode: 'normal', policyKey: 'fake-normal',
+ retry: 1, maxRetries: 2, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'temporary failure' },
+ }
+ const invalid = [
+ { ...valid, turn: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, step: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, provider: '' },
+ { ...valid, policyKey: '' },
+ { ...valid, retry: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, maxRetries: Number.MAX_SAFE_INTEGER + 1 },
+ { ...valid, delayMs: -1 },
+ { ...valid, delayMs: Number.POSITIVE_INFINITY },
+ { ...valid, delayMs: MAX_TIMER_DELAY_MS + 1 },
+ { ...valid, failure: { ...valid.failure, message: '' } },
+ { ...valid, failure: { ...valid.failure, code: '' } },
+ { ...valid, failure: { ...valid.failure, status: '429' } },
+ { ...valid, failure: { ...valid.failure, status: 99 } },
+ { ...valid, failure: { ...valid.failure, status: 429.5 } },
+ { ...valid, failure: { ...valid.failure, status: 600 } },
+ { ...valid, failure: { ...valid.failure, providerRetryAfterMs: 0 } },
+ { ...valid, failure: { ...valid.failure, providerRetryAfterMs: Number.POSITIVE_INFINITY } },
+ { ...valid, failure: { ...valid.failure, requestId: 1 } },
+ { ...valid, failure: { ...valid.failure, requestId: '' } },
+ ]
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ try {
+ for (const [index, data] of invalid.entries()) {
+ feed(at(9 + index, { type: 'llm/retry', data }))
+ }
+ expect(session.getSnapshot().partial?.blocks).toEqual([{ kind: 'text', text: '仍在生成' }])
+ expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toEqual([])
+ expect(errorSpy).toHaveBeenCalledTimes(invalid.length)
+ expect(errorSpy).toHaveBeenCalledWith('[web-runtime] ignored malformed llm/retry event at seq 9')
+ } finally {
+ errorSpy.mockRestore()
+ }
+ })
+
+ it('accepts complete retry payloads at the producer field boundaries', async () => {
+ const { session } = await opened()
+ session.handleMuxEnvelope('r' as never, {
+ type: 'session/event',
+ sessionId: SID,
+ event: at(6, {
+ type: 'llm/retry',
+ data: {
+ turn: Number.MAX_SAFE_INTEGER,
+ step: Number.MAX_SAFE_INTEGER,
+ provider: 'fake',
+ mode: 'normal',
+ policyKey: 'fake-normal',
+ retry: Number.MAX_SAFE_INTEGER,
+ maxRetries: Number.MAX_SAFE_INTEGER,
+ delayMs: MAX_TIMER_DELAY_MS,
+ failure: {
+ code: 'RATE_LIMIT',
+ message: 'provider busy',
+ status: 599,
+ providerRetryAfterMs: Number.MIN_VALUE,
+ requestId: 'req-1',
+ },
+ },
+ }),
+ })
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ retry: Number.MAX_SAFE_INTEGER,
+ delayMs: MAX_TIMER_DELAY_MS,
+ failure: { status: 599, providerRetryAfterMs: Number.MIN_VALUE, requestId: 'req-1' },
+ })
+ })
+
+ it('projects always-mode retries and rejects mode-specific maximums or unknown modes', async () => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ try {
+ feed(at(6, {
+ type: 'llm/retry',
+ data: {
+ turn: 1, step: 0,
+ provider: 'fake', mode: 'always', policyKey: 'fake-always',
+ retry: 3, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'retry forever' },
+ },
+ }))
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ mode: 'always',
+ retry: 3,
+ })
+
+ feed(at(7, {
+ type: 'llm/retry',
+ data: {
+ turn: 2, step: 0,
+ provider: 'fake', mode: 'always', policyKey: 'fake-always',
+ retry: 4, maxRetries: 4, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'unexpected maximum' },
+ },
+ }))
+ feed(at(8, {
+ type: 'llm/retry',
+ data: {
+ turn: 2, step: 0,
+ provider: 'fake', mode: 'sometimes', policyKey: 'fake-unknown',
+ retry: 4, delayMs: 500,
+ failure: { code: 'TRANSPORT', message: 'unknown mode' },
+ },
+ }))
+ expect(session.getSnapshot().nodes.filter(node => node.kind === 'model-retry')).toHaveLength(1)
+ expect(errorSpy).toHaveBeenCalledTimes(2)
+ } finally {
+ errorSpy.mockRestore()
+ }
+ })
+
+ it.each(['aborted', 'disposed'] as const)(
+ 'marks a scheduled retry as cancelled when its failed turn ends %s',
+ async (reason) => {
+ const { session } = await opened()
+ const feed = (event: SessionEvent) => {
+ session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event })
+ }
+ feed(ev.turnStart(6, 1))
+ feed(ev.retry(7, 1))
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'scheduled',
+ })
+ feed(ev.turnEnd(8, 1, reason))
+ expect(session.getSnapshot().nodes.at(-1)).toMatchObject({
+ kind: 'model-retry',
+ retryState: 'cancelled',
+ })
+ },
+ )
+
it('freezes an unfinalized partial into an interrupted node on turn/end (cancel path)', async () => {
const { session } = await opened()
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
@@ -168,7 +377,7 @@ describe('live event path', () => {
feed(ev.user(7, '要被打断的'))
feed(ev.chunkStart(8, 1))
feed(ev.chunkText(9, 1, '说到一半'))
- feed(ev.turnEnd(10, 1, 'cancelled')) // no assistant/message ever arrives
+ feed(ev.turnEnd(10, 1, 'aborted')) // no assistant/message ever arrives
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
const frozen = snapshot.nodes.at(-1)
@@ -187,7 +396,7 @@ describe('live event path', () => {
expect(session.getSnapshot().runningCalls).toEqual([])
// Second call never resolves: turn/end freezes it as an error card.
feed(ev.toolCall(9, 1, 'c2', 'slow_tool', '{}'))
- feed(ev.turnEnd(10, 1, 'cancelled'))
+ feed(ev.turnEnd(10, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls).toEqual([])
expect(snapshot.nodes.at(-1)).toMatchObject({
@@ -529,7 +738,7 @@ describe('remaining branches', () => {
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(ev.chunkStart(7, 1)) // empty text block only, no delta
- feed(ev.turnEnd(8, 1, 'cancelled'))
+ feed(ev.turnEnd(8, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.partial).toBeNull()
expect(snapshot.nodes.filter(n => n.kind === 'assistant' && (n as { interrupted?: true }).interrupted)).toEqual([])
@@ -543,7 +752,7 @@ describe('remaining branches', () => {
feed(ev.turnStart(6, 1))
feed(ev.toolCall(7, 1, 'turn1-call', 'echo', '{}'))
feed(ev.toolCall(8, 2, 'turn2-call', 'echo', '{}')) // stray call attributed to a later turn
- feed(ev.turnEnd(9, 1, 'cancelled'))
+ feed(ev.turnEnd(9, 1, 'aborted'))
const snapshot = session.getSnapshot()
expect(snapshot.runningCalls.map(c => c.callId)).toEqual(['turn2-call'])
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'tool-result', callId: 'turn1-call', isError: true })
@@ -637,7 +846,7 @@ describe('remaining branches', () => {
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.turnStart(6, 1))
feed(at(7, { type: 'assistant/chunk', data: { turn: 1, step: 0, chunk: { type: 'tool-call-delta', index: 0, id: 'c1', name: 'echo', argumentsDelta: '{' } } }))
- feed(ev.turnEnd(8, 1, 'cancelled'))
+ feed(ev.turnEnd(8, 1, 'aborted'))
const frozen = session.getSnapshot().nodes.at(-1)
expect(frozen).toMatchObject({ kind: 'assistant', interrupted: true, blocks: [{ kind: 'tool-call', callId: 'c1' }] })
})
diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts
index 8687f208f0..9fabb0d8de 100644
--- a/packages/client/runtime/tests/sessions-service.spec.ts
+++ b/packages/client/runtime/tests/sessions-service.spec.ts
@@ -69,6 +69,29 @@ describe('list store projection', () => {
})
})
+describe('search', () => {
+ it('delegates transient content search without changing the list snapshot', async () => {
+ const b = bench()
+ await feedList(b, [{ id: 's1' }])
+ const before = b.svc.list.getSnapshot()
+ b.api.onSearch = () => Promise.resolve(ok({
+ items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }],
+ hasMore: false,
+ }))
+ const signal = new AbortController().signal
+
+ await expect(b.svc.search('needle', signal)).resolves.toEqual({
+ ok: true,
+ value: {
+ items: [{ sessionId: 's1', snippet: 'matching excerpt' }],
+ hasMore: false,
+ },
+ })
+ expect(b.api.lastSearchSignal).toBe(signal)
+ expect(b.svc.list.getSnapshot()).toBe(before)
+ })
+})
+
describe('scope tree', () => {
it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => {
const b = bench()
diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json
index 7d3f05e6c7..1abbb0414e 100644
--- a/packages/client/runtime/tsconfig.json
+++ b/packages/client/runtime/tsconfig.json
@@ -35,6 +35,9 @@
{
"path": "../../llm/llm"
},
+ {
+ "path": "../../llm/llm-retry"
+ },
{
"path": "../../support/invariants"
}
diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json
index 10b80bdd68..e892d9cd52 100644
--- a/packages/client/test-runtime/package.json
+++ b/packages/client/test-runtime/package.json
@@ -28,6 +28,7 @@
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
+ "@deepseek-ai/dsh-host-apiproxy": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
@@ -37,6 +38,7 @@
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
+ "@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts
index 987039b385..7100e1595e 100644
--- a/packages/client/test-runtime/src/index.ts
+++ b/packages/client/test-runtime/src/index.ts
@@ -37,6 +37,7 @@ export { FixtureSession, TestSessions } from './sessions.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
+export { makeTranslate } from './translate.ts'
/** Erased register face for the internal root call (the public declare seam holds the typing). */
type ErasedRegister = (options: object, component: unknown) => () => void
diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts
index f40f9a14a5..b26c033bb7 100644
--- a/packages/client/test-runtime/src/sessions.ts
+++ b/packages/client/test-runtime/src/sessions.ts
@@ -4,8 +4,11 @@ import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-cl
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import type {
ConversationSnapshot, ISessions, ObservableSnapshot, ProjectionsFace, SessionFace, SessionId,
- SessionListState, SessionProvideDescriptor, SessionSummary, SnapshotStore,
+ SessionListState, SessionProvideDescriptor, SessionSearchResultItem, SessionSummary, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
+// The double reports the wire schema's own search bound, like the production
+// service — a transport-varying limit would be a fiction no client can see.
+import { SESSION_SEARCH_RESULT_LIMIT } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HostObservable, SessionMaybeProvideInfo, SessionProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
import { conversationSnapshot } from './fixtures.ts'
import type { SessionFixture, Stabilizer } from './fixtures.ts'
@@ -151,8 +154,8 @@ export interface TestSessionBinding {
*
* Implements the same ISessions face features receive as `ctx.sessions`, so
* a production face change breaks this double at compile time; the extra
- * members (add/updateSnapshot/setCurrent/remove/behavior/calls and the
- * legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
+ * members (add/updateSnapshot/setCurrent/remove/behavior/calls/stubSearch and
+ * the legacy provideInfo/maybeProvideInfo lookups) are bench-only surface.
*/
export class TestSessions implements ISessions {
/** The useSessions standard feed (list rows + current selection). */
@@ -168,8 +171,14 @@ export class TestSessions implements ISessions {
/** The production provide channel (roster, materialization rules, current projection) — no test-side mirror. */
private readonly channel: SessionProvideChannel
- /** Calls observed on the service-level face (open/clear), newest last. */
- readonly calls: { method: 'open' | 'clear' | 'fork'; args: unknown[] }[] = []
+ /** Calls observed on the service-level face (open/clear/search/fork), newest last. */
+ readonly calls: { method: 'open' | 'clear' | 'search' | 'fork'; args: unknown[] }[] = []
+
+ /** The wire schema's `session.search` result bound (production parity). */
+ readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT
+
+ /** Replaceable search behavior (see {@link TestSessions.stubSearch}). */
+ private searchStub: ((query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }) | undefined
/**
* @param stabilize - the owning runtime's act wrapper.
@@ -392,6 +401,27 @@ export class TestSessions implements ISessions {
this.list.update((draft) => { draft.current = undefined })
}
+ /**
+ * Replace the sidebar-search result page (the call is still recorded).
+ * @param impl - hits for a query, as the Host would rank them.
+ */
+ stubSearch(impl: (query: string, signal: AbortSignal) => { items: SessionSearchResultItem[]; hasMore: boolean }): void {
+ this.searchStub = impl
+ }
+
+ /**
+ * Content search over the fixture corpus (recorded). The default answers an
+ * empty page: content ranking is Host behavior, so a scenario that asserts
+ * hits declares them through {@link TestSessions.stubSearch}.
+ * @param query - non-blank literal phrase.
+ * @param signal - cancellation for a superseded search (recorded and forwarded).
+ * @returns the stubbed or empty result page.
+ */
+ search(query: string, signal: AbortSignal): ReturnType {
+ this.calls.push({ method: 'search', args: [query, signal] })
+ return Promise.resolve({ ok: true, value: this.searchStub?.(query, signal) ?? { items: [], hasMore: false } })
+ }
+
/**
* Recorded fork stub: no child materializes (benches asserting the full
* fork flow drive the production service; this face only proves the call).
diff --git a/packages/client/test-runtime/src/translate.ts b/packages/client/test-runtime/src/translate.ts
new file mode 100644
index 0000000000..65c06d5cee
--- /dev/null
+++ b/packages/client/test-runtime/src/translate.ts
@@ -0,0 +1,32 @@
+/**
+ * Test double of the locale lookup chain: a translate stub over plain
+ * dictionaries, mirroring LocaleService's resolution order (first dictionary
+ * that owns the key wins, then the key itself stays visible) and its
+ * `{name}` template interpolation. Specs stub the framework-injected `t`
+ * seat with `makeTranslate(zh, commonZh)` instead of re-implementing the
+ * chain per suite.
+ */
+
+/**
+ * Build a translate stub resolving through `dicts` in order (namespace
+ * first, then the shared common vocabulary), falling back to the key.
+ * @param dicts - dictionaries consulted in order.
+ * @returns the translate function (assignable to any `XxxProps['t']` seat).
+ */
+export function makeTranslate(
+ ...dicts: readonly Record[]
+): (key: string, params?: Record) => string {
+ return (key, params) => {
+ let template = key
+ for (const dict of dicts) {
+ const hit = dict[key]
+ if (hit !== undefined) {
+ template = hit
+ break
+ }
+ }
+ if (!params) return template
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
+ name in params ? String(params[name]) : match)
+ }
+}
diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx
index 62bab2845a..8909f88162 100644
--- a/packages/client/test-runtime/tests/runtime.spec.tsx
+++ b/packages/client/test-runtime/tests/runtime.spec.tsx
@@ -221,6 +221,28 @@ describe('sessions', () => {
])
await runtime.dispose()
})
+
+ it('answers search with an empty page until a scenario declares hits, recording every call', async () => {
+ const runtime = await runtimeWithFrame()
+ await runtime.sessions.add({ id: 's1' })
+ const signal = new AbortController().signal
+ expect(runtime.sessions.searchResultLimit).toBeGreaterThan(0)
+ await expect(runtime.sessions.search('marker', signal))
+ .resolves.toEqual({ ok: true, value: { items: [], hasMore: false } })
+ runtime.sessions.stubSearch(query => ({
+ items: [{ sessionId: 's1' as SessionId, snippet: `hit: ${query}` }],
+ hasMore: true,
+ }))
+ await expect(runtime.sessions.search('marker', signal)).resolves.toEqual({
+ ok: true,
+ value: { items: [{ sessionId: 's1', snippet: 'hit: marker' }], hasMore: true },
+ })
+ expect(runtime.sessions.calls).toEqual([
+ { method: 'search', args: ['marker', signal] },
+ { method: 'search', args: ['marker', signal] },
+ ])
+ await runtime.dispose()
+ })
})
describe('stores', () => {
diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json
index 3e8a8561f8..6a758c66f9 100644
--- a/packages/client/test-runtime/tsconfig.json
+++ b/packages/client/test-runtime/tsconfig.json
@@ -22,6 +22,9 @@
},
{
"path": "../../support/invariants"
+ },
+ {
+ "path": "../../host/apiproxy"
}
]
}
diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json
index c34f34dc18..8144aea6c8 100644
--- a/packages/client/ui-command/package.json
+++ b/packages/client/ui-command/package.json
@@ -25,6 +25,7 @@
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
+ "@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-slash",
"@deepseek-ai/dsh-client-ui-conversation"
],
@@ -40,6 +41,7 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
+ "@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -51,7 +53,9 @@
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
+ "@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
+ "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
diff --git a/packages/client/ui-command/src/client/PopupSelectView.tsx b/packages/client/ui-command/src/client/PopupSelectView.tsx
index ec0bbdd2bb..456cdd4200 100644
--- a/packages/client/ui-command/src/client/PopupSelectView.tsx
+++ b/packages/client/ui-command/src/client/PopupSelectView.tsx
@@ -12,7 +12,8 @@
import { useEffect, useRef } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
-import { IconCheckOutline16, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { filterOptions } from './popup.ts'
import type { PopupSelectController } from './popup.ts'
import css from './PopupSelectView.module.css'
@@ -26,12 +27,15 @@ export interface PopupSelectInjected {
popup: PopupSelectController
}
+/** Full shell props: injected face + the locale seat. */
+export type PopupSelectViewProps = PopupSelectInjected & PropsLocale<'command'>
+
/**
* Render the popupSelect shell overlay entry.
- * @param props - injected face: the session's shell controller.
+ * @param props - injected face: the session's shell controller; `t` rides the standard locale seat.
* @returns the select card while open; null while closed.
*/
-export function PopupSelectView({ popup }: PopupSelectInjected) {
+export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
const state = useSyncExternalStore(
fn => popup.state.subscribe(fn),
() => popup.state.getSnapshot(),
@@ -56,23 +60,24 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
// closes the shell before its own handlers run; that click's target then
// takes focus naturally, so no focusComposer here.
useEffect(() => {
- if (!state.open) return
+ if (!state.open || state.confirming !== null) return
const onPointerDown = (ev: PointerEvent): void => {
if (cardRef.current !== null && ev.target instanceof Node && cardRef.current.contains(ev.target)) return
popup.dismiss()
}
document.addEventListener('pointerdown', onPointerDown, true)
return () => { document.removeEventListener('pointerdown', onPointerDown, true) }
- }, [state.open, popup])
+ }, [state.open, state.confirming, popup])
// Focus the search input after it mounts (separate effect so the ref is populated).
useEffect(() => {
- if (state.open) searchRef.current?.focus()
- }, [state.open])
+ if (state.open && state.confirming === null) searchRef.current?.focus()
+ }, [state.open, state.confirming])
if (!state.open) return null
const rows = filterOptions(state.options, state.search)
+ const confirmation = state.confirming?.confirmation
const onKeyDown = (ev: React.KeyboardEvent): void => {
// ArrowLeft/ArrowRight fall through on purpose: the search input keeps
@@ -99,55 +104,73 @@ export function PopupSelectView({ popup }: PopupSelectInjected) {
}
return (
-
- { popup.setSearch(ev.currentTarget.value) }}
- />
- {state.error !== null && (
-
- {state.error}
- {state.status === 'failed' && (
-
+ <>
+ {state.confirming === null && (
+
+ { popup.setSearch(ev.currentTarget.value) }}
+ />
+ {state.error !== null && (
+
+ {state.error}
+ {state.status === 'failed' && (
+
+ )}
+
+ )}
+ {state.status === 'pending' && {t('status.loading')}}
+ {state.submitting && {t('status.applying')}}
+ {state.status === 'ready' && rows.length === 0 && {t('status.empty')}}
+ {state.status === 'ready' && (
+
+ {rows.map((option, index) => (
+ { void popup.select(index) }}
+ onMouseEnter={() => { popup.highlight(index) }}
+ >
+ {option.label}
+ {option.detail !== undefined && {option.detail}}
+ {option.active === true && }
+
+ ))}
+
)}
)}
- {state.status === 'pending' && Loading options…}
- {state.submitting && Applying…}
- {state.status === 'ready' && rows.length === 0 && No options}
- {state.status === 'ready' && (
-
- {rows.map((option, index) => (
- { void popup.select(index) }}
- onMouseEnter={() => { popup.highlight(index) }}
- >
- {option.label}
- {option.detail !== undefined && {option.detail}}
- {option.active === true && }
-
- ))}
-
+ {confirmation !== undefined && (
+ { popup.acknowledge(value) }}
+ onCancel={() => { popup.cancelConfirmation() }}
+ onConfirm={() => { void popup.confirm() }}
+ />
)}
-
+ >
)
}
diff --git a/packages/client/ui-command/src/client/contract.ts b/packages/client/ui-command/src/client/contract.ts
index 61ab4de2e2..8498b5def8 100644
--- a/packages/client/ui-command/src/client/contract.ts
+++ b/packages/client/ui-command/src/client/contract.ts
@@ -6,12 +6,23 @@
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext } from '@deepseek-ai/dsh-client-ui-slash/client'
+/** Copy for an option that must be acknowledged before onSelect can run. */
+export interface SelectConfirmation {
+ readonly title: string
+ readonly description: string
+ readonly acknowledgeLabel: string
+ readonly cancelLabel: string
+ readonly confirmLabel: string
+}
+
/** One option row of a popupSelect shell. */
export interface SelectOption {
readonly id: string
readonly label: string
readonly detail?: string
readonly active?: boolean
+ /** Optional in-page risk gate owned by the shared popup shell. */
+ readonly confirmation?: SelectConfirmation
}
/**
diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts
index f40078212e..76f3d8c551 100644
--- a/packages/client/ui-command/src/client/index.ts
+++ b/packages/client/ui-command/src/client/index.ts
@@ -10,19 +10,23 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
// key's owner) into this program so the overlay registration below typechecks
// against the real declaration — no runtime edge to ui-conversation.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
import { CommandService } from './service.ts'
import type { PopupSelectInjected } from './PopupSelectView.tsx'
import { PopupSelectView } from './PopupSelectView.tsx'
+import { en, zh, type CommandKey } from './locales.ts'
export { CommandService } from './service.ts'
export { CommandDirectory } from './directory.ts'
export type { CommandDescriptor, DirectoryStatus } from './directory.ts'
export { filterOptions, PopupSelectController } from './popup.ts'
export type { PopupSelectDeps, PopupSpec, PopupState, TokenSegment } from './popup.ts'
-export type { PopupSelectInjected } from './PopupSelectView.tsx'
+export type { PopupSelectInjected, PopupSelectViewProps } from './PopupSelectView.tsx'
export type {
- CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectOption,
+ CommandContribution, CommandDecoration, CommandServiceContract, CommandUiSpec, SelectConfirmation, SelectOption,
} from './contract.ts'
+export type { CommandKey } from './locales.ts'
declare module 'cordis' {
interface Context {
@@ -30,8 +34,18 @@ declare module 'cordis' {
}
}
-/** Required services: the '/' source registry plus the scope + wire faces the service reads. */
-export const inject = ['slash', 'sessions', 'connection']
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+ interface LocaleNamespaceMap {
+ /** The popupSelect shell's copy. */
+ command: CommandKey
+ }
+}
+
+/** Dictionary namespace owned by this plugin. */
+const NS = 'command'
+
+/** Required services: the '/' source registry plus the scope + wire faces the service reads, and the copy's locale registry. */
+export const inject = ['slash', 'sessions', 'connection', 'locale']
/**
* Client plugin body: mount the service, then register the popupSelect shell
@@ -39,6 +53,7 @@ export const inject = ['slash', 'sessions', 'connection']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-command: dictionaries')
ctx.plugin(CommandService)
// Conditional mount, same seam as ui-slash's MenuView registration:
// 'conversation.input.overlay' is declared by the conversation composer
@@ -51,6 +66,7 @@ export function apply(ctx: ClientContext): void {
name: 'conversation.input.overlay',
id: 'command-popup',
order: 1,
+ locale: NS,
inject: (sessionId): PopupSelectInjected => {
const actx = sessions.scope(sessionId)
if (actx === undefined) throw new Error(`ui-command: session "${String(sessionId)}" resolved no scope`)
diff --git a/packages/client/ui-command/src/client/locales.ts b/packages/client/ui-command/src/client/locales.ts
new file mode 100644
index 0000000000..63c5862cf2
--- /dev/null
+++ b/packages/client/ui-command/src/client/locales.ts
@@ -0,0 +1,26 @@
+/** `command` namespace dictionaries (the popupSelect shell's copy). */
+
+/** Simplified Chinese dictionary (the key-set source of truth). */
+export const zh = {
+ 'search.placeholder': '搜索…',
+ 'search.aria': '筛选选项',
+ 'status.loading': '正在加载选项…',
+ 'status.applying': '正在应用…',
+ 'status.empty': '无选项',
+ 'overlay.aria': '/{command} 选项',
+ 'listbox.aria': '/{command} 匹配项',
+} satisfies Record
+
+/** The command namespace key union. */
+export type CommandKey = keyof typeof zh
+
+/** English dictionary, checked complete against the zh key set. */
+export const en = {
+ 'search.placeholder': 'Search…',
+ 'search.aria': 'Filter options',
+ 'status.loading': 'Loading options…',
+ 'status.applying': 'Applying…',
+ 'status.empty': 'No options',
+ 'overlay.aria': '/{command} options',
+ 'listbox.aria': '/{command} matches',
+} satisfies Record
diff --git a/packages/client/ui-command/src/client/popup.ts b/packages/client/ui-command/src/client/popup.ts
index c2d30f3213..5e20911820 100644
--- a/packages/client/ui-command/src/client/popup.ts
+++ b/packages/client/ui-command/src/client/popup.ts
@@ -67,12 +67,17 @@ export interface PopupState {
readonly active: number
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
readonly submitting: boolean
+ /** Option waiting for explicit risk acknowledgement; null during normal selection. */
+ readonly confirming: SelectOption | null
+ /** Caller-controlled checkbox state for the pending confirmation. */
+ readonly acknowledged: boolean
/** Surfaced settlement failure (options load or onSelect); null when none. */
readonly error: string | null
}
const CLOSED: PopupState = {
- open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
+ open: false, command: null, status: 'pending', options: [], search: '', active: 0,
+ submitting: false, confirming: null, acknowledged: false, error: null,
}
/**
@@ -166,7 +171,7 @@ export class PopupSelectController {
*/
setSearch(search: string): void {
const s = this.state.getSnapshot()
- if (!s.open || s.submitting || search === s.search) return
+ if (!s.open || s.submitting || s.confirming !== null || search === s.search) return
this.state.set({ ...s, search, active: 0 })
}
@@ -177,7 +182,7 @@ export class PopupSelectController {
*/
move(dir: 1 | -1): void {
const s = this.state.getSnapshot()
- if (!s.open || s.status !== 'ready' || s.submitting) return
+ if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const rows = filterOptions(s.options, s.search)
if (rows.length === 0) return
const active = (s.active + dir + rows.length) % rows.length
@@ -191,7 +196,7 @@ export class PopupSelectController {
*/
highlight(index: number): void {
const s = this.state.getSnapshot()
- if (!s.open || s.status !== 'ready' || s.submitting) return
+ if (!s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
this.state.set({ ...s, active: index })
}
@@ -209,10 +214,46 @@ export class PopupSelectController {
async select(index: number): Promise {
const binding = this.binding
const s = this.state.getSnapshot()
- if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
+ if (binding === null || !s.open || s.status !== 'ready' || s.submitting || s.confirming !== null) return
const option = filterOptions(s.options, s.search)[index]
if (option === undefined) return
- this.state.set({ ...s, submitting: true, error: null })
+ if (option.confirmation !== undefined) {
+ this.state.set({ ...s, confirming: option, acknowledged: false, error: null })
+ return
+ }
+ await this.settle(binding, option)
+ }
+
+ /**
+ * Update the explicit checkbox for the currently pending risk gate.
+ * @param acknowledged - whether the user has acknowledged the displayed risk.
+ */
+ acknowledge(acknowledged: boolean): void {
+ const s = this.state.getSnapshot()
+ if (!s.open || s.submitting || s.confirming === null || s.acknowledged === acknowledged) return
+ this.state.set({ ...s, acknowledged })
+ }
+
+ /** Cancel only the risk gate and return to the still-open option picker. */
+ cancelConfirmation(): void {
+ const s = this.state.getSnapshot()
+ if (!s.open || s.submitting || s.confirming === null) return
+ this.state.set({ ...s, confirming: null, acknowledged: false })
+ }
+
+ /** Settle the gated option only after the checkbox is acknowledged. */
+ async confirm(): Promise {
+ const binding = this.binding
+ const s = this.state.getSnapshot()
+ if (binding === null || !s.open || s.submitting || s.confirming === null || !s.acknowledged) return
+ await this.settle(binding, s.confirming)
+ }
+
+ /** Run the business settlement for an already admitted option. */
+ private async settle(binding: OpenBinding, option: SelectOption): Promise {
+ const s = this.state.getSnapshot()
+ if (this.binding !== binding || !s.open || s.submitting) return
+ this.state.set({ ...s, submitting: true, confirming: null, acknowledged: false, error: null })
try {
await binding.spec.onSelect(option, binding.context)
} catch (error) {
diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts
index 03c0df2d50..bb49cdf4d1 100644
--- a/packages/client/ui-command/tests/browser-plugin.spec.ts
+++ b/packages/client/ui-command/tests/browser-plugin.spec.ts
@@ -13,6 +13,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { CommandServiceContract } from '../src/client/contract.ts'
import type { PopupSelectInjected } from '../src/client/PopupSelectView.tsx'
+import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import { apply, CommandService, inject } from '../src/client/index.ts'
const sid = (k: string): SessionId => k as SessionId
@@ -41,6 +42,7 @@ async function bench() {
},
})
ctx.provide('conversation', {})
+ ctx.provide('locale', new LocaleService(ctx))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const mint = (key: string) => {
@@ -53,7 +55,7 @@ async function bench() {
describe('apply', () => {
it('declares the services it binds', () => {
- expect(inject).toEqual(['slash', 'sessions', 'connection'])
+ expect(inject).toEqual(['slash', 'sessions', 'connection', 'locale'])
})
it('mounts ctx.command, registers the source and the overlay entry, and folds up on disposal', async () => {
diff --git a/packages/client/ui-command/tests/popup-view.spec.tsx b/packages/client/ui-command/tests/popup-view.spec.tsx
index 2cd9891478..0bbe01b4a9 100644
--- a/packages/client/ui-command/tests/popup-view.spec.tsx
+++ b/packages/client/ui-command/tests/popup-view.spec.tsx
@@ -14,6 +14,12 @@ import type { SelectOption } from '../src/client/contract.ts'
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
import { PopupSelectController } from '../src/client/popup.ts'
import { PopupSelectView } from '../src/client/PopupSelectView.tsx'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { zh } from '../src/client/locales.ts'
+
+// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
+const t: Parameters[0]['t'] = makeTranslate(zh, commonZh)
// jsdom has no scrollIntoView; the view calls it on the highlighted row.
const scrollIntoView = vi.fn()
@@ -32,6 +38,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
+const GATED: SelectOption = {
+ id: 'full',
+ label: 'Full access',
+ confirmation: {
+ title: 'Enable Full access?',
+ description: 'Sensitive operations.',
+ acknowledgeLabel: 'I understand the risks',
+ cancelLabel: 'Cancel',
+ confirmLabel: 'Enable Full access',
+ },
+}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -47,12 +64,12 @@ async function mountOpen(overrides: Partial> = {}, consumeResu
const consume = vi.fn((_segment: TokenSegment) => consumeResult)
const focusComposer = vi.fn()
const popup = new PopupSelectController({ consume, focusComposer })
- const view = render( )
+ const view = render( )
await act(async () => {
popup.open('theme', spec(overrides), 'ctx-A', SEGMENT)
await Promise.resolve()
})
- return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: 'Filter options' }) }
+ return { popup, view, consume, focusComposer, search: screen.getByRole('textbox', { name: '筛选选项' }) }
}
function rowLabels(): string[] {
@@ -62,13 +79,13 @@ function rowLabels(): string[] {
describe('PopupSelectView', () => {
it('renders null while closed, opens with focus in the search input', async () => {
const popup = new PopupSelectController({ consume: () => true, focusComposer: () => {} })
- const view = render( )
+ const view = render( )
expect(view.container.childElementCount).toBe(0)
await act(async () => {
popup.open('theme', spec(), 'ctx-A', SEGMENT)
await Promise.resolve()
})
- const search = screen.getByRole('textbox', { name: 'Filter options' })
+ const search = screen.getByRole('textbox', { name: '筛选选项' })
expect(document.activeElement).toBe(search)
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
})
@@ -82,7 +99,7 @@ describe('PopupSelectView', () => {
expect(options).toHaveBeenCalledTimes(1)
act(() => { fireEvent.change(search, { target: { value: 'zzz' } }) })
expect(screen.queryByRole('option')).toBeNull()
- expect(screen.queryByText('No options')).not.toBeNull()
+ expect(screen.queryByText('无选项')).not.toBeNull()
})
it('ArrowUp/Down move the filtered highlight; ArrowLeft/Right are left to the native caret', async () => {
@@ -110,13 +127,13 @@ describe('PopupSelectView', () => {
it('caps the card height at the design maximum when the composer sits low enough', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect)
await mountOpen()
- expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('320px')
+ expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('320px')
})
it('clamps the card height to the space above the composer minus the safe margin', async () => {
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect)
await mountOpen()
- expect(screen.getByLabelText('/theme options').style.maxHeight).toBe('188px')
+ expect(screen.getByLabelText('/theme 选项').style.maxHeight).toBe('188px')
})
it('Enter selects the highlighted row: onSelect, consume, close, focusComposer', async () => {
@@ -143,12 +160,43 @@ describe('PopupSelectView', () => {
expect(view.container.childElementCount).toBe(0)
})
+ it('renders a gated option as an in-page modal and requires the checkbox before onSelect', async () => {
+ const onSelect = vi.fn()
+ const { popup, consume } = await mountOpen({
+ options: () => Promise.resolve([GATED]),
+ onSelect,
+ })
+ await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+ expect(screen.queryByLabelText('/theme 选项')).toBeNull()
+ expect(screen.getByRole('dialog', { name: 'Enable Full access?' })).toBeTruthy()
+ const enable = screen.getByRole('button', { name: 'Enable Full access' }) as HTMLButtonElement
+ expect(enable.disabled).toBe(true)
+ expect(onSelect).not.toHaveBeenCalled()
+
+ fireEvent.click(screen.getByRole('checkbox', { name: 'I understand the risks' }))
+ expect(enable.disabled).toBe(false)
+ await act(async () => { fireEvent.click(enable) })
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, 'ctx-A')
+ expect(consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+ expect(popup.state.getSnapshot().open).toBe(false)
+ })
+
+ it('canceling a gated option returns to the picker with acknowledgement reset', async () => {
+ await mountOpen({ options: () => Promise.resolve([GATED]) })
+ await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+ fireEvent.click(screen.getByRole('checkbox'))
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(screen.getByLabelText('/theme 选项')).toBeTruthy()
+ await act(async () => { fireEvent.click(screen.getByRole('option', { name: 'Full access' })) })
+ expect(screen.getByRole('checkbox').checked).toBe(false)
+ })
+
it('submitting shows pending, locks the search input, and further Enter/click no-op', async () => {
let release!: () => void
const onSelect = vi.fn(() => new Promise((resolve) => { release = resolve }))
const { search, consume } = await mountOpen({ onSelect })
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
- expect(screen.queryByText('Applying…')).not.toBeNull()
+ expect(screen.queryByText('正在应用…')).not.toBeNull()
expect((search as HTMLInputElement).readOnly).toBe(true)
await act(async () => {
fireEvent.keyDown(search, { key: 'Enter' })
@@ -162,7 +210,7 @@ describe('PopupSelectView', () => {
expect(consume).toHaveBeenCalledTimes(1)
})
- it('a failed options load shows the error with a Retry button that reloads', async () => {
+ it('a failed options load shows the error with a retry button that reloads', async () => {
let attempts = 0
await mountOpen({
options: () => {
@@ -172,7 +220,7 @@ describe('PopupSelectView', () => {
})
expect(screen.getByRole('alert').textContent).toContain('directory down')
await act(async () => {
- fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
+ fireEvent.click(screen.getByRole('button', { name: '重试' }))
await Promise.resolve()
})
expect(attempts).toBe(2)
@@ -183,7 +231,7 @@ describe('PopupSelectView', () => {
const { search, consume } = await mountOpen({ onSelect: () => Promise.reject(new Error('host rejected')) })
await act(async () => { fireEvent.keyDown(search, { key: 'Enter' }) })
expect(screen.getByRole('alert').textContent).toContain('host rejected')
- expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
+ expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
expect(consume).not.toHaveBeenCalled()
expect(screen.getAllByRole('option').length).toBe(3)
})
diff --git a/packages/client/ui-command/tests/popup.spec.ts b/packages/client/ui-command/tests/popup.spec.ts
index 87a1070a40..68e8084b80 100644
--- a/packages/client/ui-command/tests/popup.spec.ts
+++ b/packages/client/ui-command/tests/popup.spec.ts
@@ -19,6 +19,17 @@ const OPTIONS: SelectOption[] = [
{ id: 'light', label: 'Light', active: true },
{ id: 'sepia', label: 'Sepia', detail: 'warm' },
]
+const GATED: SelectOption = {
+ id: 'full',
+ label: 'Full access',
+ confirmation: {
+ title: 'Enable Full access?',
+ description: 'Sensitive operations.',
+ acknowledgeLabel: 'I understand',
+ cancelLabel: 'Cancel',
+ confirmLabel: 'Enable Full access',
+ },
+}
const SEGMENT: TokenSegment = { via: 'enter', token: '/theme' }
@@ -200,6 +211,38 @@ describe('search / move / highlight over the filtered list', () => {
})
describe('select', () => {
+ it('gates a confirmed option until acknowledgement, then settles through the original binding', async () => {
+ const onSelect = vi.fn()
+ const deps = makeDeps()
+ const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
+ await popup.select(0)
+ expect(popup.state.getSnapshot()).toMatchObject({
+ open: true, confirming: GATED, acknowledged: false, submitting: false,
+ })
+ expect(onSelect).not.toHaveBeenCalled()
+ await popup.confirm()
+ expect(onSelect).not.toHaveBeenCalled()
+ popup.acknowledge(true)
+ await popup.confirm()
+ expect(onSelect).toHaveBeenCalledExactlyOnceWith(GATED, CTX_A)
+ expect(deps.consume).toHaveBeenCalledExactlyOnceWith(SEGMENT)
+ expect(popup.state.getSnapshot().open).toBe(false)
+ })
+
+ it('cancels a confirmation back to the picker without selecting or consuming', async () => {
+ const onSelect = vi.fn()
+ const deps = makeDeps()
+ const { popup } = await readyPopup({ options: () => Promise.resolve([GATED]), onSelect }, deps)
+ await popup.select(0)
+ popup.acknowledge(true)
+ popup.cancelConfirmation()
+ expect(popup.state.getSnapshot()).toMatchObject({
+ open: true, confirming: null, acknowledged: false, submitting: false,
+ })
+ expect(onSelect).not.toHaveBeenCalled()
+ expect(deps.consume).not.toHaveBeenCalled()
+ })
+
it('runs onSelect with the filtered option and the open-time context, consumes, closes, refocuses', async () => {
const seen: Array<{ option: SelectOption; context: Ctx }> = []
const deps = makeDeps()
diff --git a/packages/client/ui-command/tsconfig.json b/packages/client/ui-command/tsconfig.json
index b95692eda1..f83486aa36 100644
--- a/packages/client/ui-command/tsconfig.json
+++ b/packages/client/ui-command/tsconfig.json
@@ -14,6 +14,9 @@
{
"path": "../connection"
},
+ {
+ "path": "../locale"
+ },
{
"path": "../runtime"
},
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index ddb93caa18..28068bdc5f 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: 60000c35c3e30883c4b29cc8410d30e58021318c
-README.zh.md: 8f9fe7278d2d4bf5251582867de44290d0727710
+README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1
+README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index 60000c35c3..7c6e36409e 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -8,23 +8,29 @@ The resident conversation shell survives no-session and session transitions. Wit
The view ring IS a slot: the conversation registration declares the `'conversation.view'` list slot (session scope) in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: `), and view tabs project from the ring ledger's registration options (`id`/`order`/`label`). The chat view is this package's own ring entry; other plugins (ui-trajectory) contribute tabs through plain `ctx.slots.register` — the former package-local view registry (`registerView`/`ViewEntry`/`ConversationViewMap` and the chrome attachment table) is retired, with per-view chrome dissolved into the view components themselves.
-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 sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. 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 (the `/permission` popup's display transform twin), and a pick submits the `/permission ` command line through the bar's injected `command` callback.
+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 sidebar mirrors the blocked state through the manager-tracked `waitingApproval` list bit (lit for uninstantiated sessions too), which outranks the running ring until the question resolves. 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.
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded 141px scrollport shows bounded inline JSON for both `content` and `source`, and no tool state, summary, or keyed toolview dispatch is synthesized ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
-A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
+A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
+
+A tool call declaring the `web` render intent renders its web retrieval inline, at both conversation render sites, through ui-primitives' `WebBlock`. `contract/web-card-model.ts` is the single derivation from the snapshot's `resultView`, mirroring the terminal card, so the sites cannot disagree about what a web call shows; it yields null — the generic path — for a running call, a non-web result view, a generic result view, a `card` tag this client version does not know, or a web card whose `kind` this client version does not know (a newer host's value, which the wire cannot be trusted to be `search` or `fetch`). The keyed `WebRow` registers one component under both `web_search` and `web_fetch`, discriminating on the tool name only for its icon and title; a web-declaring tool without a keyed row lands on the `GenericToolCard` fallback, which grows the same resident card, and the details panel renders it at the primitive's full source allowance and, below the card, the flattened model-visible result content — a fetch body is readable only there, since its card carries only the URL and status. Rows cap at `CHAT_WEB_MAX_SOURCES` (8) against the panel's 16, the same summary-versus-reading split the terminal card draws ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md)).
+
+A tool call declaring the `diff` render intent (the `write`/`edit` tools) renders its applied change inline through ui-primitives' `DiffBlock`, the same four-layer shape. `contract/diff-card-model.ts` is the single derivation from the `callView`/`resultView` pair; the settled result's hunks replace the call-time diff, and it yields null — the generic path — for any other card tag or a generic result view (write/edit's execution errors). The keyed `FileMutationRow` (registered under both `write` and `edit`) carries the card resident below its summary, whose path link still opens the file through the host; the render-site fallback and the details panel are diff-aware too. Rows cap at `CHAT_DIFF_MAX_LINES` (8) against the panel's 16 ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md)).
+
+The chat flow projects consecutive model-retry nodes across retry turns into one stable, muted status row updated to the latest attempt; every retry event remains in the runtime snapshot and session log. Its frontend countdown anchors the scheduled delay to client receipt, avoiding host/browser clock skew, rounds remaining time up to seconds, and has a one-second floor. The latest unresolved retry uses a left-to-right text shimmer. Subsequent turn facts distinguish an attempt that started from one cancelled during backoff, while the Host running bit only controls the live animation; the row then shows a static completed or cancelled label. Normal policy rows show the finite retry maximum; always policy rows show `∞`. Activating the row reveals the latest exact retry delay and failure message. The client runtime removes each failed step's streaming tail before its retry node arrives, while the status remains visible after a later attempt succeeds.
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
-The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`
diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
index 2941348eb1..d509a2e521 100644
--- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx
@@ -30,7 +30,7 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
-import { deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
+import { assistantActionsSeqs, deriveChatFlow, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
@@ -46,6 +46,8 @@ function scrollerOf(from: HTMLElement): HTMLElement {
type OpenFile = (path: string) => void
+type InspectCall = (callId: string) => void
+
/** The declared toolview hole's render share (stable framework binding, passed through memoized rows). */
type RenderToolRow = ChatViewSlotProps['renderSlot']
@@ -53,27 +55,41 @@ type RenderToolRow = ChatViewSlotProps['renderSlot']
* chat view narrows once to the runtime snapshot the binding actually feeds. */
type UseConversation = SnapshotSelectorHook
+function activeRetrySeq(nodes: readonly ConversationNode[], running: boolean): number | null {
+ if (!running) return null
+ for (let index = nodes.length - 1; index >= 0; index -= 1) {
+ const node = nodes[index]
+ if (node === undefined) continue
+ if (node.kind === 'model-retry') return node.retryState === 'cancelled' ? null : node.seq
+ if (node.kind === 'assistant' || node.kind === 'user') return null
+ }
+ return null
+}
+
/** One `run_code` sub-dispatch row: the identical keyed-slot dispatch as a
* top-level call (same registrations, same fallback), nested by the parent.
* A started-but-unsettled sub-call arrives as the RunningToolCall shape and
* renders the running state exactly as a native in-flight row. */
-const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd }: {
+const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, selected, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
node: CodeSubCall
openFile: OpenFile
selected: boolean
cwd: string | undefined
+ inspectCall: InspectCall
+ t: ChatViewSlotProps['t']
}) {
const settled = 'kind' in node
const toolName = settled ? node.call?.name ?? '' : node.name
const owner = useMemo(() => ({
callId: node.callId, toolName, block: node, openFile, cwd,
- }), [node, toolName, openFile, cwd])
+ inspect: () => { inspectCall(node.callId) },
+ }), [node, toolName, openFile, cwd, inspectCall])
return (
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
- fallback: ,
+ fallback: ,
})}
)
@@ -85,7 +101,7 @@ const SubCallRow = memo(function SubCallRow({ renderSlot, node, openFile, select
* renders its logged sub-dispatches as always-visible indented rows —
* each one the same keyed-slot dispatch as a native top-level call. */
const CallRow = memo(function CallRow({
- renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd,
+ renderSlot, callId, toolName, block, openFile, selected, subCalls, selectedCallId, cwd, inspectCall, t,
}: {
renderSlot: RenderToolRow
callId: string
@@ -100,15 +116,18 @@ const CallRow = memo(function CallRow({
selectedCallId?: string | undefined
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
+ inspectCall: InspectCall
+ t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({
callId, toolName, block, openFile, cwd,
- }), [callId, toolName, block, openFile, cwd])
+ inspect: () => { inspectCall(callId) },
+ }), [callId, toolName, block, openFile, cwd, inspectCall])
return (
{renderSlot('conversation.chat.toolview', owner, {
entryKey: toolName,
- fallback: ,
+ fallback: ,
})}
{subCalls !== undefined && subCalls.length > 0 && (
@@ -120,6 +139,8 @@ const CallRow = memo(function CallRow({
openFile={openFile}
selected={node.callId === selectedCallId}
cwd={cwd}
+ inspectCall={inspectCall}
+ t={t}
/>
))}
@@ -129,7 +150,7 @@ const CallRow = memo(function CallRow({
})
/** Consecutive tool results as one step-run group (uniform 16px rhythm). */
-const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd }: {
+const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selectedCallId, codeDispatches, cwd, inspectCall, t }: {
renderSlot: RenderToolRow
results: readonly ToolResultNode[]
openFile: OpenFile
@@ -139,6 +160,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
codeDispatches: ReadonlyMap
/** Session workspace root for path-relative summaries. */
cwd: string | undefined
+ inspectCall: InspectCall
+ t: ChatViewSlotProps['t']
}) {
return (
@@ -154,6 +177,8 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
subCalls={codeDispatches.get(node.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
+ inspectCall={inspectCall}
+ t={t}
/>
))}
@@ -163,16 +188,17 @@ const ToolGroup = memo(function ToolGroup({ renderSlot, results, openFile, selec
/** One command lifecycle row: keyed dispatch on the command name with the
* generic card as the render-site fallback (zero registration required). A
* run-less cross-window node has no name and always lands on the fallback. */
-const CommandRow = memo(function CommandRow({ renderSlot, node }: {
+const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
renderSlot: RenderToolRow
node: CommandNode
+ t: ChatViewSlotProps['t']
}) {
const owner = useMemo(() => ({ node }), [node])
return (
{renderSlot('conversation.chat.commandview', owner, {
entryKey: node.name ?? '',
- fallback: ,
+ fallback: ,
})}
)
@@ -214,23 +240,26 @@ function TurnDots() {
/** The streaming partial, isolated so chunk batches re-render only this tail.
* onGrow lets the scroll owner follow content the parent never re-renders for. */
-function StreamingTail({ useSession, onGrow }: {
+function StreamingTail({ useSession, onGrow, t }: {
useSession: UseConversation
onGrow: () => void
+ t: ChatViewSlotProps['t']
}) {
const partial = useSession(s => s.partial)
useLayoutEffect(() => {
onGrow()
})
if (partial === null) return null
- return
+ return
}
/**
* The chat view slot entry: pure component over the composed props (tool rows
* render through the declared keyed hole's renderSlot share).
*/
-export function ChatView({ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, forkAt }: ChatViewSlotProps) {
+export function ChatView({
+ useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
+}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
// Workspace root off the session list row: path summaries display relative to it.
const cwd = useSessions(s => s.byId[sessionId]?.cwd)
@@ -238,12 +267,16 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const runningCalls = useSession(s => s.runningCalls)
const codeDispatches = useSession(s => s.codeDispatches)
const openState = useSession(s => s.openState)
- const openErrorMessage = useSession(s => s.openError === null ? null : `${s.openError.message}(${s.openError.code})`)
+ const openError = useSession(s => s.openError)
const hasMore = useSession(s => s.hasMore)
const loadingOlder = useSession(s => s.loadingOlder)
const selectedCallId = useStore(s => s.selection?.callId)
const items = useMemo(() => deriveChatFlow(nodes), [nodes])
+ const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
+ // Only the last content assistant of each turn owns IconActions; mid-turn
+ // text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
+ const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const listRef = useRef(null)
const atBottomRef = useRef(true)
@@ -274,10 +307,20 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
/* v8 ignore next -- ref-null guard: React attaches the ref before layout effects run. */
if (local === null) return
const el = scrollerOf(local)
- // Open completed: jump to the bottom once.
+ // Open completed: jump to the bottom once — unless a scroll position
+ // survives from a previous mount (view-tab switch away and back), which
+ // is restored instead of snapping the reader back to the floor.
if (openState === 'open' && !openedRef.current) {
openedRef.current = true
- toBottom(el)
+ const saved = chatScroll.read()
+ if (saved === null) {
+ toBottom(el)
+ } else {
+ el.scrollTop = saved
+ const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
+ atBottomRef.current = isAtBottom
+ setAtBottom(isAtBottom)
+ }
firstSeqRef.current = firstSeq
lastKeyRef.current = lastKey
followSigRef.current = followSig
@@ -315,6 +358,9 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= FOLLOW_THRESHOLD + 1
atBottomRef.current = isAtBottom
setAtBottom(isAtBottom)
+ // Continuous save (unmount happens after ref detach, so saving there is
+ // too late); pinned-to-bottom clears so a remount keeps following.
+ chatScroll.save(isAtBottom ? null : el.scrollTop)
}
// Bind scroll to the resolved scrollport (host or local) once per mount.
@@ -365,6 +411,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
selectedCallId={inGroup ? selectedCallId : undefined}
codeDispatches={codeDispatches}
cwd={cwd}
+ inspectCall={inspectCall}
+ t={t}
/>
)
}
@@ -376,35 +424,48 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
- time={node.time}
+ time={actionSeqs.has(node.seq) ? node.time : undefined}
seq={node.seq}
onFork={forkAt}
+ t={t}
/>
)
}
if (node.kind === 'command') {
- return
+ return
}
/* v8 ignore next -- tool-result never reaches here: deriveChatFlow folds them into groups. */
if (node.kind === 'tool-result') return null
- return
+ return (
+
+ )
}
return (
- {openState === 'loading' && 载入历史…}
- {openState === 'error' && 历史加载失败:{openErrorMessage}}
+ {openState === 'loading' && {t('chat.loadingHistory')}}
+ {openState === 'error' && openError !== null && (
+
+ {t('chat.loadError', { message: openError.message, code: openError.code })}
+
+ )}
{hasMore && (
)}
{items.map(renderItem)}
-
+
{runningCalls.length > 0 && (
{runningCalls.map(call => (
@@ -419,6 +480,8 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
subCalls={codeDispatches.get(call.callId)}
selectedCallId={selectedCallId}
cwd={cwd}
+ inspectCall={inspectCall}
+ t={t}
/>
))}
@@ -435,7 +498,7 @@ export function ChatView({ useSession, useSessions, useStore, renderSlot, sessio
{open && children}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
index 4b42a94c72..aa236c87ca 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericCommandCard.tsx
@@ -6,7 +6,7 @@
import { ToolRow } from './ToolRow.tsx'
import type { ToolRowState } from '../contract/tool-call-model.ts'
-import type { CommandRowOwnerProps } from '../contract/slots.ts'
+import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { IconApiOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
/** Node state → row state semantic (running while unsettled; outcome kind after). */
@@ -15,20 +15,26 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): ToolRowState
return outcome.kind === 'error' ? 'error' : 'ok'
}
-export function GenericCommandCard({ node }: CommandRowOwnerProps) {
+/** Card props: the owner payload plus the render site's locale seat (plain prop). */
+export interface GenericCommandCardProps extends CommandRowOwnerProps {
+ t: ChatViewSlotProps['t']
+}
+
+export function GenericCommandCard({ node, t }: GenericCommandCardProps) {
const text = node.outcome?.text
const summary = node.outcome === null
- ? '执行中…'
- : text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
+ ? t('command.running')
+ : text ?? (node.outcome.kind === 'error' ? t('command.failed') : t('command.done'))
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
- const title = node.name ?? '命令'
+ const title = node.name ?? t('command.title')
return (
}
+ icon={ }
title={title}
summary={summary}
// Expandable only when the outcome text overflows a one-line summary.
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
new file mode 100644
index 0000000000..13a4ef9d62
--- /dev/null
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
@@ -0,0 +1,15 @@
+/* The generic card grows a resident web card under its summary row when the
+ tool declares the `web` render intent but has no keyed row of its own (the
+ web_search/web_fetch rows register their own WebRow). A column around the
+ ToolRow keeps the row's own 24px height. */
+
+.card {
+ display: flex;
+ flex-direction: column;
+}
+
+/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
+ and replaces the primitive's standalone vertical margin with the flow's. */
+.web {
+ margin: 4px 0 4px 22px;
+}
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
index ce55d84f57..faadcf090b 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -7,12 +7,15 @@
import type { ReactNode } from 'react'
import {
IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
- IconThinkOutline14,
+ IconThinkOutline14, WebBlock,
} from '@deepseek-ai/dsh-client-ui-primitives'
-import type { ToolRowOwnerProps } from '../contract/slots.ts'
-import { terminalCardModel } from '../contract/terminal-card-model.ts'
+import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
+import { diffCardModel } from '../contract/diff-card-model.ts'
+import { terminalCardModel, terminalFailed } from '../contract/terminal-card-model.ts'
+import { CHAT_WEB_MAX_SOURCES, webCardModel } from '../contract/web-card-model.ts'
import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
import { ToolRow } from './ToolRow.tsx'
+import css from './GenericToolCard.module.css'
/** Variant leading icons (figma table); all glyphs render at 14 inside the 16px leading box. */
const VARIANT_ICONS: Record = {
@@ -26,12 +29,25 @@ const VARIANT_ICONS: Record = {
others: ,
}
-export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
+/** Card props: the owner payload plus the render site's locale seat (plain prop). */
+export interface GenericToolCardProps extends ToolRowOwnerProps {
+ t: ChatViewSlotProps['t']
+}
+
+export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
const model = toolRowModel(toolName, block, cwd)
const terminal = terminalCardModel(block, cwd)
+ const diff = diffCardModel(block)
+ const web = webCardModel(block)
+ // A failing exit status is the terminal card's own error signal (the call
+ // itself settles isError:false), surfaced as the row's red state dot.
+ const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
+ ? 'error'
+ : model.state
const singleFile = model.filePath !== undefined
- return (
+ const row = (
)
+ // A web-declaring tool without its own keyed row lands here; its card is
+ // resident under the summary, mirroring WebRow (and BashRow's terminal card).
+ if (web === null) return row
+ return (
+
+ {row}
+
+
+ )
}
diff --git a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx
index 3075c258a6..dbb8a8628e 100644
--- a/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx
+++ b/packages/client/ui-conversation/src/client/chat/MessageIconActions.tsx
@@ -6,6 +6,7 @@ import { useCallback } from 'react'
import {
IconBranchOutline16, IconCopyOutline16, IconEditOutline16, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -23,6 +24,8 @@ export interface MessageIconActionsProps {
onBranch?: (() => void) | undefined
/** Parent layout class composed onto the actions row. */
className?: string | undefined
+ /** The owning view's locale seat, passed down as a plain prop. */
+ t: ChatViewSlotProps['t']
}
/**
@@ -31,7 +34,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
- text, time, clock, edit, onBranch, className,
+ text, time, clock, edit, onBranch, className, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const onCopy = useCallback(() => {
@@ -39,25 +42,25 @@ export function MessageIconActions({
}, [text])
const clockEl = (
- {formatMessageClock(time, day)}
+ {formatMessageClock(time, t, day)}
)
return (
{clock === 'start' ? clockEl : null}
-
-