diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index dde9f82a6d..b5068fb402 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 571b81a1a2e6f392f2553070048964d49941aae9 -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 744c30814f84d063f196ce20ba48fb993d0b7713 +2026-07-30-deepseek-onboarding-credential-setup.md: ed53ffe64d3ba27e8746d58ad84d1a4c401f6ce4 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: 340728ab9348e0132954e403f9bdbf561e340247 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 571b81a1a2..ed53ffe64d 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -12,11 +12,11 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma **One readiness projection owns both Models and onboarding facts.** `ui-models` keeps a single store that joins `llm.providers({})`, redacted `settings.describe({})`, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured literal `apiKey` secret sidecar is also ready, so compatibility configuration does not trigger a false prompt; a configured process-environment credential is ready and remains read-only. -**The settings shell contributes navigation state, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and tells registrants whether the current surface is the empty Hero. Its private `openSection(id)` callback opens the settings panel on one registered section. `ui-models` registers the DeepSeek overlay through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract. +**The settings shell contributes ordering and navigation, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-models` registers the DeepSeek step through the same declaration-aware deferred-registration path as its Models section, so plugin load order does not become a contract and independently contributed dialogs cannot stack. The product-wide welcome step that precedes it is owned separately by [the versioned welcome decision](2026-07-30-versioned-gui-welcome-onboarding.md). -**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. +**The prompt routes to the one credential editor.** A mounted, active adapter with a resolved, writable, unconfigured reference presents one action that opens Settings on Models. The existing DeepSeek setup card there exclusively owns the password input, `credentials.set({ref, value})`, write failures, and post-write refresh; the onboarding overlay never holds or submits a secret. An unavailable settings or credential capability keeps its deployment diagnostic and routes to the same page, while an absent adapter remains skipped because navigation cannot mount a Cordis plugin. -**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability suppresses the modal because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later dismisses a missing-credential overlay for the current mounted surface and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update closes an open prompt without a reload. +**Unavailable states do not capture the product.** An absent configurable-provider entry, inactive route, failed initial join, read-only deployment, or unresolved settings or credential capability completes the step without rendering because the onboarding action cannot repair that state. The Models page remains the deployment diagnostic and retry surface. Configure later completes a missing-credential step for the current mounted coordinator pass and writes no completion fact. Settings, credential, provider-topology, and connection invalidations all refresh the shared join, so an external credential update completes an open step without a reload. ## Alternatives considered @@ -30,4 +30,4 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Consequences -The first-run flow leads to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, follows the prompt to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, and external-invalidation behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. +The ordered flow leads from the product notice to the shipped adapter's existing editor without restarting: a keyless browser test boots the real Web composition under an isolated harness home, acknowledges the notice, follows the DeepSeek page to Models, stores a generated key through that page into the home's `.env`, verifies no key reaches DOM, ARIA, or browser console output, and confirms the running page reports configured. The full keyless Web replay lane also pins that a non-configurable replay route with the same provider id does not block unrelated journeys. Pure readiness and React tests pin literal, file, process-environment, missing-provider, missing-capability, navigation, cancellation, external-invalidation, and coordinator-transfer behavior. The flow deliberately inherits the configuration plane's documented base limitations rather than adding local secret storage, redaction, or settings replacement workarounds. diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 744c30814f..340728ab93 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,13 +10,13 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 所有、设置路径为空的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同一提供方 ID 下的存活路由若没有匹配的可配置提供方声明,首次使用引导会将其视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发浮层;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-models` 维护一个 store,把 `llm.providers({})`、脱敏后的 `settings.describe({})` 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,也会判定为就绪,兼容配置因此不会误触发页面;通过进程环境提供的凭据若已配置,同样判定为就绪并保持只读。 -**设置外壳只贡献导航状态,不持有提供方策略。**`ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并告知注册方当前界面是否为空白 Hero。其私有 `openSection(id)` 回调会打开设置面板并切换到一个已注册分区。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 浮层,因此插件加载顺序不会成为契约。 +**设置外壳只贡献排序与导航,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-models` 沿用 Models 分区所使用、感知 slot 声明的延迟注册路径来注册 DeepSeek 步骤,因此插件加载顺序不会成为契约,独立贡献的对话框也无法堆叠。排在它之前的产品级欢迎步骤由[版本化欢迎决策](2026-07-30-versioned-gui-welcome-onboarding.md)单独持有。 -**浮层只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用浮层绝不持有或提交 secret。 +**首次使用页面只负责跳转到唯一的凭据编辑器。**适配器已挂载且处于活跃状态,其引用可解析、可写但尚未配置时,界面会显示一个操作按钮,用于打开「设置」的 Models 分区。该分区已有的 DeepSeek 设置卡片全权负责密码输入框、`credentials.set({ref, value})`、写入失败处理和写入后刷新;首次使用页面绝不持有或提交 secret。 -**不可用状态不会拦截产品交互。**可配置提供方条目缺失、路由未激活、初始联接失败、部署只读、设置能力无法解析或凭据能力无法解析时均不显示模态框,因为首次使用引导的操作无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会在当前已挂载界面中关闭凭据缺失浮层,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可关闭已打开的浮层。 +**不可用状态不会占住产品。** 可配置提供方条目缺失、路由不活跃、初始联接失败、部署只读或设置/凭据能力无法解析时,都会直接完成而不渲染该步骤,因为首次使用引导无法修复这些状态。Models 页仍是部署诊断与重试界面。「稍后配置」只会完成协调器当前这一次缺少凭据的步骤,不写入任何完成状态。设置、凭据、提供方拓扑和连接失效事件都会刷新共享联接,因此外部凭据更新无需重新加载页面即可完成已打开的步骤。 ## 曾考虑的替代方案 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -首次使用流程无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,依照浮层操作前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放链路还固化了同一提供方 ID 下的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消和外部失效行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 +有序流程从产品声明页开始,无需重启即可引导用户前往随产品提供的适配器已有的编辑器:无密钥浏览器测试在隔离的 harness 家目录下启动真实 Web 组合,确认声明后依照 DeepSeek 页面前往 Models,通过该页面把生成的密钥存入该目录的 `.env`,验证密钥未进入 DOM、ARIA 或浏览器控制台输出,并确认运行中的页面报告已配置。完整的无密钥 Web 回放也固定了同 id 的不可配置回放路由不会阻塞无关流程。纯就绪状态测试与 React 测试固化了字面量凭据、文件凭据、进程环境凭据、提供方缺失、能力缺失、导航、取消、外部失效和协调器移交行为。该流程直接继承配置平面已记录的基础限制,不会另加局部的机密存储、脱敏或设置替换变通方案。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.i18n.yaml new file mode 100644 index 0000000000..7efb9e75d5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-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-read-card-frontend.md +2026-07-30-web-read-card-frontend.md: f504cab7705d03f6d3e911da05c509da50bb9abe +2026-07-30-web-read-card-frontend.zh.md: b6314f21ba3eb2283788374b10c77ed22e26d16c diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md new file mode 100644 index 0000000000..f504cab770 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md @@ -0,0 +1,54 @@ +# Agent Note: Web read card frontend — the read tool's line window renders line-numbered and highlighted + +Status: implemented + +English | [中文](2026-07-30-web-read-card-frontend.zh.md) + +## Problem + +The [read backend](2026-07-30-web-read-card.md) added a fourth render-intent card, `card: 'read'`, to `ToolResultView`: a settled read now carries `{ path, lines: [{ number, text }], totalLines, lang? }` onto the conversation snapshot as `resultView`. That data reaches the browser, but the Web client had no consumer for it. Every read row derived from args alone and the details panel flattened the result's content blocks into one `
`, so a read showed as `N: text`-prefixed plain text with no gutter, no syntax highlighting, and no "showing N of M" affordance for a windowed read. The [web terminal card](2026-07-28-web-terminal-card.md) established the pattern for consuming a structured card; the read card follows it, result-side only.
+
+## Decision
+
+`ReadBlock` is a `ui-primitives` component that renders a read result as a line-numbered, optionally syntax-highlighted file view, and both Web render sites for a read consume the read render intent through it: the chat tool row (resident under the summary line) and the details panel's Output section. `ui-conversation/src/client/contract/read-card-model.ts` is the single place that turns the snapshot's `resultView` into the component's props, so the two sites cannot disagree.
+
+**A new `ReadBlock` primitive, not an extension of `CodeBlock`.** `CodeBlock` already does shiki highlighting with a language banner and a copy control, but a read view needs a per-line gutter carrying each line's own file number, which `CodeBlock` renders as a single `
` tree with no per-line structure. Extending `CodeBlock` with an optional gutter would push a read-specific concern (windowed line numbers, a "showing N of M" note, a height cap) onto every markdown fence and every `run_code` body that shares that component. Instead `ReadBlock` reuses the part that is genuinely shared: the shiki grammar singleton in `markdown/highlight.ts`. A new `highlightLines(code, lang)` there tokenizes into shiki's own per-line token arrays (`codeToTokens`) rather than the single-`
` HTML `highlightToHtml` produces, so the block can place one gutter number per line and still color the content through the same `--shiki-*` custom properties on the same grammar allowlist. The height cap and its head/tail expand arithmetic are copied from `TerminalBlock` (`ceil(max/2)` head plus the remaining tail), so a long read and a long command output collapse at the same place. The copy control writes the window's raw text (the lines joined by newlines), never the gutter numbers or the banner.
+
+`readCardModel` is result-side only, mirroring the backend: a read call carries no content until `execute` returns, so the pending call stays a `GenericCallView` (`kind: 'read'`) and this returns null for a running read — the row keeps its args-derived summary until the result arrives. It also returns null for a settled call whose result view is not a read card, including a `card` value this UI version does not know (which arrives over the wire and cannot be trusted to be a compiled variant) and the read tool's own generic fallback for an error result. The card's banner label is the read view's `title` when the tool supplied one (the contract's replacement-title rule), otherwise the file path relativized to the session workspace so a workspace-rooted absolute path shows the same short form the row summary shows. The model copies the frozen line array into the primitive's own line shape, so the card never holds a reference into the runtime's snapshot cache.
+
+The chat row renders the card **resident** under the summary line, capped at `CHAT_READ_MAX_LINES` (8, half the primitive's default), the same posture `BashRow` gives a terminal card — the block's internal expander keeps a long read from taking over the message flow. Two render sites carry it: the keyed `ReadRow` (registered under `read` in `apply.ts`, the load-order seam being `inject: ['slots', 'conversation']` exactly as the bash sample) whose summary is the file path as an openable host link, and `GenericToolCard`'s fallback for a read-declaring tool without its own keyed row (e.g. `web_fetch`, which classifies to the `read` variant). The details panel renders the same card at the primitive's own full-height cap (16), because the panel is the single-call reading surface.
+
+Whole-row collapse/expand (defaulting every tool call to collapsed) is a separate later change that will flip every resident card at once; this note's card is resident, matching the terminal card it sits beside.
+
+**Read-card grammars load lazily; only the boot three stay eager.** `highlight.ts` is a platform seed `ui-primitives` loads on every Web boot, and its warm-up unconditionally builds the shiki singleton. The read card's `langFromPath` hints span the full source/config/markup extension set (python, rust, yaml, html, …); registering all of them eagerly would add ~1.6 MB of grammar modules to the boot chunk and their synchronous init to every session, including sessions that never open a read card. So only the three grammars every session already renders — TypeScript, shell, JSON (the markdown-fence and `run_code` languages) — load at boot. Each read-card extension grammar sits behind a dynamic `import()` in `LAZY_GRAMMARS`, keyed by the grammar id its aliases resolve to. On the first `highlightLines`/`highlightToHtml` call for a lazy language, `ensureGrammar` starts the import (once) and returns not-ready, so the card renders plain that frame; when the import resolves it registers the grammar with `loadLanguageSync`, bumps a load counter, and notifies subscribers. `ReadBlock` and `CodeBlock` subscribe through `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)`, so the card re-renders with highlighting the moment the grammar is ready. An unknown/absent language still returns undefined synchronously (plain, never an error).
+
+**The empty-window copy control is hidden, matching `TerminalBlock`.** A successful read of an empty file returns `lines: []`, `totalLines: 0`, and `presentResult` still projects `card: 'read'`, so the empty-window branch is reachable — the read card is not, as an earlier draft assumed, unreachable for an empty result. `ReadBlock` therefore hides the copy control when `lines` is empty, exactly as `TerminalBlock` hides copy on empty output, so the button can never wipe the clipboard with an empty string.
+
+## Alternatives considered
+
+**Extend `CodeBlock` with an optional line-number gutter and `startLine`.** Rejected: it imposes a read-specific gutter, a windowed-count note, and a height cap on every markdown fence and `run_code` body that shares `CodeBlock`, for no benefit to those callers. The genuinely shared surface is the shiki grammar singleton, which both blocks reuse through `highlight.ts`; the chrome around it differs (a read has a gutter and a window note, a fence has neither), so a second small primitive is the correct split, exactly as `TerminalBlock` is a second primitive over the same tokens rather than a `CodeBlock` mode.
+
+**Reuse `highlightToHtml` and inject gutter numbers with CSS counters.** Rejected: the single-`
` HTML shiki emits has no per-line boundary a gutter can hang a file line number off (a windowed read's numbers start above 1 and are not a simple CSS counter increment), and parsing the numbers back out of the HTML would be fragile. `codeToTokens` gives the per-line token structure directly.
+
+**Register all read-card grammars eagerly in the boot warm-up.** Rejected: it puts ~1.6 MB of grammar modules and their synchronous init on every Web boot for a card most sessions never open. The lazy path costs a single plain-first frame the first time a given language is read, then highlights on the grammar-load re-render; the boot cost is paid only for the three grammars every session already renders.
+
+## Consequences
+
+`ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read 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. A UI without the read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged.
+
+A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/code-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form.
+
+## Testing
+
+`packages/client/ui-primitives/tests/read-block.spec.tsx` pins the primitive and the token path: `highlightLines`' per-line css-variables runs, its trailing-terminator-line drop and the genuinely-blank-final-line case, its `undefined` for an unknown/absent language, and its lazy path (a lazy grammar returns plain on first touch, then highlights after the import registers and the subscriber fires); and `ReadBlock`'s gutter-numbered rows keeping the file's own numbers, the highlighted-vs-plain content arms, the banner (label, language, the count note only when the read is a window), the head/tail height cap with its `aria-expanded` toggle, the copy control writing the window's raw text on both the accepted and refused clipboard paths, and the empty-window arm hiding the copy control. `code-block.spec.tsx` covers `highlightToHtml` including its lazy path over every read-card grammar (each dynamic import thunk touched once). Both `ReadBlock.tsx` and `highlight.ts` (and `CodeBlock.tsx`) hold per-file 100% coverage across the two specs.
+
+`packages/client/ui-conversation/tests/read-card.spec.tsx` pins the wiring at every render site: `readCardModel`'s derivation and each null arm (running read, no view, generic view, unknown card), the result title replacing the relativized path, the path relativization against the workspace, the copy-not-alias of the frozen line array; the resident card in `GenericToolCard`'s fallback and in the keyed `ReadRow` (plus its path link opening the host, its running/error/stopped states, and its `read`-key registration); and the panel's Output section rendering the read card at full height while keeping the JSON Input section, with the running-read placeholder and non-read flattened-pre arms. That file sits on the coverage `exclude` list (`ui-conversation/src/*`), so it is written against no gate pressure.
+
+The fixture (`packages/client/connection/src/client/fixture.ts`) gains turn 66, a `read` call whose result view is a windowed read (lines starting at file line 41, `totalLines` 180, a `ts` hint), so the built-boot snapshot and a live `?fixture` server show the read card with its gutter numbers, highlighting, and count note. It is named `read` to exercise the keyed `ReadRow`. The turn 64 `run_code` sample's nested read sub-dispatches do not exercise the render-site fallback read card: `session.ts` folds them with `resultView: null`, so they cover only the fallback row's generic row shape, not a read card inside it; the fallback-row read card is pinned by `read-card.spec.tsx`'s `web_fetch` case. Turn 66 is ordered before the todo turn (now 67) for the same reason the terminal sample is: the standing plan retires at the next `turn/start`.
+
+## Related
+
+- [Read card backend](2026-07-30-web-read-card.md) — adds the `card: 'read'` result view this consumes; produces the `lines`/`totalLines`/`lang` this renders.
+- [Web terminal card](2026-07-28-web-terminal-card.md) — the precedent this follows: a `ui-primitives` block, a `contract/*-card-model.ts` derivation, a keyed row, and making `GenericToolCard`/`DetailsPanel` card-aware.
+- [Web client syntax highlighting](../process/2026-07-26-web-syntax-highlighting-shiki.md) — owns `CodeBlock` and the shiki `highlight.ts` singleton this extends with a per-line token path.
+- [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 `read` arm.
diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
new file mode 100644
index 0000000000..b6314f21ba
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md
@@ -0,0 +1,54 @@
+# Agent Note: Web 读取卡片前端 —— 读取工具的行窗口以带行号、语法高亮的形式渲染
+
+Status: implemented
+
+[English](2026-07-30-web-read-card-frontend.md) | 中文
+
+## Problem
+
+[读取后端](2026-07-30-web-read-card.md)给 `ToolResultView` 增加了第四种渲染意图卡片 `card: 'read'`:一次已结算的读取现在会把 `{ path, lines: [{ number, text }], totalLines, lang? }` 作为 `resultView` 带到会话快照上。这份数据能到达浏览器,但 Web 客户端没有消费者。每个读取行都仅从参数派生,详情面板把结果的 content block 摊平进一个 `
`,于是读取显示为带 `N: text` 前缀的纯文本,没有行号栏、没有语法高亮,也没有窗口读取的"显示 N / M"提示。[web 终端卡片](2026-07-28-web-terminal-card.md)确立了消费一个结构化卡片的模式;读取卡片沿用它,只在结果侧。
+
+## Decision
+
+`ReadBlock` 是一个 `ui-primitives` 组件,把一次读取结果渲染成带行号、可选语法高亮的文件视图,读取的两个 Web 渲染点都通过它消费读取渲染意图:聊天工具行(常驻在摘要行之下)与详情面板的 Output 区段。`ui-conversation/src/client/contract/read-card-model.ts` 是把快照的 `resultView` 转成组件 props 的唯一位置,因此两个渲染点不会产生分歧。
+
+**新建一个 `ReadBlock` primitive,而不是扩展 `CodeBlock`。** `CodeBlock` 已经带语言横幅和复制控件做 shiki 高亮,但读取视图需要一个每行带该行自身文件行号的行号栏,而 `CodeBlock` 把内容渲染为单个 `
` 树、没有逐行结构。给 `CodeBlock` 加一个可选行号栏会把读取专属的关切(窗口行号、"显示 N / M"提示、高度上限)强加给共享该组件的每个 markdown 代码围栏和每个 `run_code` 程序体。`ReadBlock` 转而复用真正共享的部分:`markdown/highlight.ts` 里的 shiki 语法单例。那里新增的 `highlightLines(code, lang)` 把代码切成 shiki 自己的逐行 token 数组(`codeToTokens`),而不是 `highlightToHtml` 产出的单 `
` HTML,于是该 block 能每行放一个行号、同时用同一套 `--shiki-*` 自定义属性、同一份语法白名单给内容上色。高度上限及其头/尾展开算法照抄自 `TerminalBlock`(`ceil(max/2)` 行头部加剩余的尾部),因此长读取和长命令输出在同一处折叠。复制控件写入窗口的原始文本(各行以换行拼接),绝不含行号栏或横幅。
+
+`readCardModel` 只在结果侧,与后端对称:一次读取调用在 `execute` 返回前不带任何内容,因此挂起中的调用保持为 `GenericCallView`(`kind: 'read'`),本函数对运行中的读取返回 null —— 该行保持其从参数派生的摘要,直到结果到达。它对结果视图不是读取卡片的已结算调用也返回 null,包括本 UI 版本不认识的 `card` 值(它从线路到来、不能被信任为一个已编译的变体)以及读取工具对错误结果自己的通用回退。卡片横幅标签在工具提供 `title` 时取它(契约的替换标题规则),否则取相对于会话工作区化简后的文件路径,使工作区根下的绝对路径显示为与行摘要相同的短形式。该 model 把冻结的行数组复制进 primitive 自己的行形状,因此卡片绝不持有指向运行时快照缓存的引用。
+
+聊天行把卡片**常驻**渲染在摘要行之下,上限 `CHAT_READ_MAX_LINES`(8,是 primitive 默认值的一半),与 `BashRow` 对终端卡片的姿态相同 —— block 的内部展开器让长读取不会占据整个消息流。两个渲染点承载它:keyed `ReadRow`(在 `apply.ts` 里以 `read` 键注册,加载顺序接缝为 `inject: ['slots', 'conversation']`,与 bash 样例完全一致),其摘要是作为可打开的宿主链接的文件路径;以及 `GenericToolCard` 对没有自己 keyed 行的读取声明工具(例如归到 `read` 变体的 `web_fetch`)的回退。详情面板以 primitive 自己的全高上限(16)渲染同一张卡片,因为面板是单次调用的阅读界面。
+
+整行折叠/展开(把每个工具调用默认折叠)是一个单独的后续改动,它会一次性翻转每张常驻卡片;本 note 的卡片是常驻的,与它旁边的终端卡片一致。
+
+**读取卡片的语法按需 lazy 加载,只有 boot 三种保持 eager。** `highlight.ts` 是 `ui-primitives` 在每次 Web 启动都加载的平台 seed,其预热会无条件构建 shiki 单例。读取卡片的 `langFromPath` 提示覆盖完整的源码/配置/标记扩展集(python、rust、yaml、html……);把它们全部 eager 注册会给启动 chunk 增加约 1.6 MB 的语法模块、并把它们的同步初始化摊给每个会话,包括从不打开读取卡片的会话。因此只有每个会话本就渲染的三种语法 —— TypeScript、shell、JSON(markdown 围栏与 `run_code` 语言)—— 在 boot 时加载。每种读取卡片扩展语法置于 `LAZY_GRAMMARS` 中一个动态 `import()` 之后,以其别名解析到的语法 id 为键。对某个 lazy 语言首次调用 `highlightLines`/`highlightToHtml` 时,`ensureGrammar` 启动 import(仅一次)并返回未就绪,于是卡片该帧渲染纯文本;import 解析后用 `loadLanguageSync` 注册该语法、递增一个加载计数、并通知订阅者。`ReadBlock` 与 `CodeBlock` 通过 `useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount)` 订阅,因此语法就绪的那一刻卡片就重渲染带上高亮。未知/缺省语言仍同步返回 undefined(纯文本,绝不报错)。
+
+**空窗口的复制控件被隐藏,与 `TerminalBlock` 对齐。** 成功读取一个空文件会返回 `lines: []`、`totalLines: 0`,且 `presentResult` 仍投出 `card: 'read'`,因此空窗口分支是可达的 —— 读取卡片并非如早前草稿所假设的对空结果不可达。故 `ReadBlock` 在 `lines` 为空时隐藏复制控件,正如 `TerminalBlock` 对空输出隐藏复制,使按钮绝不会用空字符串清空剪贴板。
+
+## Alternatives considered
+
+**给 `CodeBlock` 加一个可选行号栏和 `startLine`。** 拒绝:这会把读取专属的行号栏、窗口计数提示和高度上限强加给共享 `CodeBlock` 的每个 markdown 围栏和 `run_code` 程序体,对那些调用者毫无好处。真正共享的界面是 shiki 语法单例,两个 block 都通过 `highlight.ts` 复用它;围绕它的外壳各不相同(读取有行号栏和窗口提示,围栏两者都没有),因此第二个小 primitive 是正确的切分 —— 正如 `TerminalBlock` 是基于同一套 token 的第二个 primitive,而不是 `CodeBlock` 的一种模式。
+
+**复用 `highlightToHtml`,用 CSS counter 注入行号。** 拒绝:shiki 产出的单 `
` HTML 没有可供行号栏挂上文件行号的逐行边界(窗口读取的行号从大于 1 处开始,不是简单的 CSS counter 自增),而从 HTML 里把行号解析回来又很脆弱。`codeToTokens` 直接给出逐行 token 结构。
+
+**在 boot 预热里 eager 注册所有读取卡片语法。** 拒绝:这会给每次 Web 启动摊上约 1.6 MB 语法模块及其同步初始化,只为一张多数会话从不打开的卡片。lazy 路径的代价是某个语言首次被读取时的一帧纯文本,随后在语法加载的重渲染里高亮;boot 代价只为每个会话本就渲染的三种语法付出。
+
+## Consequences
+
+`ui-primitives` 增加 `ReadBlock` 和 `highlightLines`;没有新的运行时依赖(shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。
+
+Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。`run_code` 子派发在已发布的线路上到不了读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。
+
+## Testing
+
+`packages/client/ui-primitives/tests/read-block.spec.tsx` 固定 primitive 与 token 路径:`highlightLines` 的逐行 css-variables 运行、它对尾部终止行的丢弃与真正空白末行的情形、它对未知/缺省语言返回 `undefined`、以及它的 lazy 路径(lazy 语法首次触碰返回纯文本,import 注册且订阅者触发后再高亮);还有 `ReadBlock` 的带行号行保留文件自身编号、高亮与纯文本两条内容分支、横幅(标签、语言、仅当读取是窗口时的计数提示)、头/尾高度上限及其 `aria-expanded` 切换、复制控件在接受与拒绝两条剪贴板路径上写入窗口原始文本、以及空窗口分支隐藏复制控件。`code-block.spec.tsx` 覆盖 `highlightToHtml`,含它对每种读取卡片语法的 lazy 路径(每个动态 import thunk 各触碰一次)。`ReadBlock.tsx`、`highlight.ts`(及 `CodeBlock.tsx`)在这两个 spec 上均保持每文件 100% 覆盖。
+
+`packages/client/ui-conversation/tests/read-card.spec.tsx` 固定每个渲染点的接线:`readCardModel` 的派生与每条 null 分支(运行中读取、无视图、通用视图、未知卡片)、结果标题替换化简后的路径、路径相对工作区的化简、冻结行数组的复制而非别名;`GenericToolCard` 回退中与 keyed `ReadRow` 中的常驻卡片(外加其路径链接打开宿主、其 running/error/stopped 状态、以及其 `read` 键注册);还有面板 Output 区段以全高渲染读取卡片同时保留 JSON Input 区段,含运行中读取占位与非读取摊平 pre 两条分支。该文件位于覆盖 `exclude` 列表(`ui-conversation/src/*`),因此不承受门槛压力。
+
+fixture(`packages/client/connection/src/client/fixture.ts`)增加 turn 66,一次 `read` 调用,其结果视图是窗口读取(行号从文件行 41 起、`totalLines` 180、`ts` 提示),使内置启动快照和实时 `?fixture` 服务器展示带行号、高亮和计数提示的读取卡片。它命名为 `read` 以驱动 keyed `ReadRow`。turn 64 的 `run_code` 样例中的嵌套读取子派发并不驱动渲染点回退读取卡片:`session.ts` 把它们折叠为 `resultView: null`,因此它们只覆盖回退行的通用行形状,而非回退行内的读取卡片;回退行读取卡片由 `read-card.spec.tsx` 的 `web_fetch` 用例钉住。turn 66 排在 todo turn(现为 67)之前,与终端样例同因:常驻计划在下一次 `turn/start` 退场。
+
+## Related
+
+- [读取卡片后端](2026-07-30-web-read-card.md) —— 增加本文消费的 `card: 'read'` 结果视图;产出本文渲染的 `lines`/`totalLines`/`lang`。
+- [Web 终端卡片](2026-07-28-web-terminal-card.md) —— 本文遵循的先例:一个 `ui-primitives` block、一个 `contract/*-card-model.ts` 派生、一个 keyed 行,以及让 `GenericToolCard`/`DetailsPanel` 感知卡片。
+- [Web 客户端语法高亮](../process/2026-07-26-web-syntax-highlighting-shiki.md) —— 拥有 `CodeBlock` 与 shiki `highlight.ts` 单例,本文以逐行 token 路径扩展它。
+- [工具调用呈现的标签式渲染意图联合](../architecture/2026-07-02-tool-render-intent-union.md) —— `card` 标签词汇表;Web 客户端现在是 `read` 分支的完整消费者。
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
index 9ce321434f..4603c65221 100644
--- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
-2026-07-28-storage-root-and-derived-medium-recovery.md: 3937b17502d0bf640625e73822b758b1862b5391
-2026-07-28-storage-root-and-derived-medium-recovery.zh.md: ff2f196a911986d23b7a102e6336f353c6aef278
+2026-07-28-storage-root-and-derived-medium-recovery.md: 7759d0b6a8d522d6f853be0cd31a0b3909292446
+2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 893b91c2ad3943cce9f64702e52afbb8075b907c
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
index 3937b17502..7759d0b6a8 100644
--- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
@@ -8,7 +8,7 @@ English | [中文](2026-07-28-storage-root-and-derived-medium-recovery.zh.md)
 
 The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-log.md), shipped as `dsh-session-projection-cache`) surfaced two gaps in the storage substrate it landed on. Both are properties of the domain-KV stack ([design](2026-07-24-domain-kv-storage-and-workspace.md)), not of the cache itself, and both bite the cache first because it is the first *derived* medium on that stack.
 
-**Where the files actually live.** The shipped Web overlay gives the json backend a relative root — `root: './.storages'` (`apps/cli/config/web.cordis.yml`) — while the shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`); no equivalent global root exists for `storage-json`. `JsonStorageBackend` never resolves its root either — each unit open joins the still-relative path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts) — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts). Net effect: session logs are global across launch directories, but `workspace.json` and `session_projcache.json` land under `/.storages/`. Two launches from different directories share their sessions yet see different workspace registries and different projection caches — and the cache exists precisely to serve the cross-session cold listing, which now misses for every session last cached under another launch directory.
+**Where the files actually live (root mismatch closed; resolve-once residual still open).** The shared base defaults the session store to the global harness home (`$DSH_HOME/sessions`, default `~/.dsh/sessions`), while the shipped Web overlay used to give the json backend the relative root `./.storages`: `workspace.json` and `session_projcache.json` landed under `/.storages/` — two launches from different directories shared their sessions yet saw different workspace registries and different projection caches, and the cache exists precisely to serve the cross-session cold listing, which missed for every session last cached under another launch directory. That mismatch is now closed: the overlay anchors `storage-json.root` to `$DSH_HOME/storages` with the same `!!js` expression the session root uses (`apps/cli/config/web.cordis.yml`). The residual hazard: `JsonStorageBackend` still never resolves its root — each unit open joins the path against whatever `process.cwd()` is at that moment (packages/storage/storage-json/src/index.ts); the shipped overlay root is already absolute and unaffected, but any relative root (bare Loader boots, tests) still splits on a later cwd change — the exact hazard the JSONL session backend resolves-once to prevent ("later process.cwd() changes cannot split one backend across roots", packages/session-persistence/session-persistence-jsonl/src/index.ts).
 
 **How recovery works today.** Inside a healthy medium the cache is fully self-healing by design: a `stateVersion`-mismatched row is discarded and refolded, a log shrunk below a row's watermark is detected by the anchored restore floor and answered with one full re-read, and every background write is fail-soft. But at the *medium* level there is no recovery at all: a truncated, hand-edited, or version-bumped `session_projcache.json` fails `openJsonUnit` with `malformed-medium`/`version-mismatch` (packages/storage/storage-json/src/format.ts), a schema-drifted record fails domain open with `invalid-record` (packages/storage/storage-domain/src/index.ts), the rejection propagates through `SessionProjectionCache[Service.init]`, and under the CLI's fail-loud boot the assembly refuses to start. A file whose entire content is rebuildable from session logs can brick boot. This contradicts the cache package's own stated stance ("a stale or unreadable cache costs a longer tail replay, never a wrong value") and the cache domain spec's JSDoc ("version bumps discard the whole medium"), which today describes an aspiration, not the implementation. The same fail-loud path is *correct* for `workspace.json` — workspace records are authoritative, not derivable — so the missing concept is a per-domain declaration of authority, not a global behavior change.
 
@@ -16,11 +16,11 @@ The persisted projection cache ([RFC](2026-07-27-session-projection-and-command-
 
 Two independent changes, one per gap.
 
-### One global storage root, resolved once
+### One global storage root (shipped, amended form); resolved once at construction (still open)
 
-- `AppCLIEntry.composePatches` Source 0 additionally patches `storage-json.root` to `join(resolveDshHome(), 'storages')` — `~/.dsh/storages` by default, beside `~/.dsh/sessions` — and `PROFILE_MAPPINGS` gains `storageRoot` → (`storage-json`, `root`), mirroring `persistenceRoot` exactly. The yml keeps `./.storages` as the raw-composition engineering default (tests and bare Loader boots are unaffected), same layering as the session root today.
-- `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
-- Pre-release stance applies: no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
+- **Shipped**: the Web overlay anchors `storage-json.root` to `$DSH_HOME/storages` directly in the row, with the same `!!js` IIFE the session root uses (`~/.dsh/storages` by default, beside `~/.dsh/sessions`; no leading dot — the home is already a hidden tree). The user ruled this form in over this section's original launcher-patch + `storageRoot` profile key (see Alternatives); per-row overrides ride the personal `~/.dsh/config.yaml` patch layer. The verbatim duplication of the expression against the session root (`base.cordis.yml`) is a known cost — acceptable at two consumers; a third `$DSH_HOME`-derived root triggers extracting a single source (a launcher variable or a shared expression). The web e2e scaffold already patches the row to an absolute temp root, so tests never touch the user's home.
+- **Still open**: `JsonStorageBackend` resolves its configured root once at construction (`resolve(config.root)`), adopting the JSONL backend's recorded rationale verbatim: a later `process.cwd()` change must not split one backend across roots. The SQLite storage backend already resolves its path.
+- Pre-release stance applies (and was executed): no migration shim. A deployment that cached under `/.storages` re-derives everything (workspace re-bootstraps from the header index; the projection cache refolds lazily) or moves the two json files by hand once.
 
 ### Declared derived media: reset instead of reject
 
@@ -31,7 +31,9 @@ Two independent changes, one per gap.
 
 ## Alternatives considered
 
-**Keep per-launch-directory `.storages` (status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
+**Keep per-launch-directory `.storages` (the pre-change status quo)** — rejected: sessions are global, so every derived-from-sessions medium splits against its own source of truth; the cache's motivating scenario (one listing over all sessions) structurally misses rows, and the workspace registry indexes sessions it cannot see from another launch directory.
+
+**Launcher patch + a `storageRoot` profile key (this section's original form)** — not taken: one `!!js` yml expression reaches the global root with the same layering the session root already has; a launcher patch adds a second rewrite point, and the profile key is an empty seat until a real consumer exists (per-row overrides already have the personal config.yaml patch layer).
 
 **Patch only the projection cache's route to a global root, leave `workspace.json` per-cwd** — rejected: the workspace registry has the identical global-vs-cwd mismatch, and the user decision that shaped the cache placed it deliberately beside `workspace.json` — one hub root keeps the media co-located and the mental model single.
 
@@ -45,7 +47,7 @@ Two independent changes, one per gap.
 
 ## Acceptance criteria
 
-- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`); the profile key `storageRoot` overrides it; a raw Loader boot of the yml still lands in `./.storages` relative to the boot cwd, resolved once at backend construction.
+- `dsh` launched from any directory reads and writes the same `$DSH_HOME/storages/*.json` (default `~/.dsh/storages`) — already satisfied by the overlay expression; per-row overrides ride the personal config.yaml patch layer; the backend resolves a relative root once at construction (still to do).
 - With a truncated, version-bumped, or schema-drifted `session_projcache.json`, the assembly boots clean: one warning names the discarded medium, the file is gone, the cache rebuilds through normal operation, and the cold listing column reappears as sessions are re-checkpointed.
 - The same damage to `workspace.json` still fails boot loudly.
 - Facility tests cover: each damage class resets a `'reset'` domain exactly once; non-damage failures stay loud on a `'reset'` domain; a `'reject'` domain propagates every failure; `destroy` removes the medium on both shipped backends.
diff --git a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
index ff2f196a91..893b91c2ad 100644
--- a/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
+++ b/.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
@@ -8,7 +8,7 @@ Status: proposed
 
 持久投影缓存([RFC](2026-07-27-session-projection-and-command-log.md),已作为 `dsh-session-projection-cache` 落地)暴露了它所依托的存储基座的两个缺口。二者都是 domain-KV 栈([设计](2026-07-24-domain-kv-storage-and-workspace.md))的属性而非缓存自身的问题,且都首先咬到缓存——因为它是这条栈上第一个*派生*介质。
 
-**文件到底存在哪。** 出厂 Web overlay 给 json 后端的是相对根目录——`root: './.storages'`(`apps/cli/config/web.cordis.yml`)——而共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`);`storage-json` 没有对应的全局根目录。`JsonStorageBackend` 自己也从不 resolve 根——每次打开 unit 都把仍然相对的路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts)——这正是 JSONL 会话后端用「构造时 resolve 一次」防住的那个隐患("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。净效果:会话日志跨启动目录全局共享,但 `workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下。从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份——而缓存存在的意义恰恰是跨会话冷列表,如今凡是上次在别的启动目录下缓存过的会话全部 miss。
+**文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session-persistence/session-persistence-jsonl/src/index.ts)。
 
 **现在是怎么恢复的。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者今天描述的是愿望而非实现。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
 
@@ -16,11 +16,11 @@ Status: proposed
 
 两个独立改动,一个缺口一个。
 
-### 全局唯一存储根,构造时 resolve 一次
+### 全局唯一存储根(已落地,形态修正);构造时 resolve 一次(仍开放)
 
-- `AppCLIEntry.composePatches` 的 Source 0 追加把 `storage-json.root` patch 到 `join(resolveDshHome(), 'storages')`——默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩——并且 `PROFILE_MAPPINGS` 增加 `storageRoot` →(`storage-json`,`root`),与 `persistenceRoot` 完全镜像。yml 保留 `./.storages` 作为裸组合的工程默认(测试和裸 Loader 启动不受影响),分层方式与今天的会话根相同。
-- `JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
-- 适用 pre-release 立场:不做迁移垫片。曾在 `/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
+- **已落地**:出厂 Web overlay 直接在 `storage-json` 行内用与会话根同一段 `!!js` IIFE 把 `root` 锚定到 `$DSH_HOME/storages`(默认 `~/.dsh/storages`,与 `~/.dsh/sessions` 并肩;目录名不带点——home 本身已是隐藏树)。用户拍板采用此形态取代本节初版的「launcher patch + `storageRoot` profile 键」(见 Alternatives);按行覆盖仍走个人 `~/.dsh/config.yaml` patch 层。该表达式与 sessions 根(`base.cordis.yml`)逐字重复是已知代价——两个消费者尚可接受;出现第三个 `$DSH_HOME` 派生根时提取单一来源(launcher 变量或共享表达式)。web e2e scaffold 本就把该行 patch 到临时绝对根,测试不触用户 home。
+- **仍开放**:`JsonStorageBackend` 在构造时对配置根 `resolve` 一次,原样采纳 JSONL 后端已记录的理由:后续 `process.cwd()` 变化不得把一个后端劈到多个根下。SQLite 存储后端已经 resolve 其路径。
+- 适用 pre-release 立场(已按此执行):不做迁移垫片。曾在 `/.storages` 下缓存过的部署要么全部重新派生(工作区从 header 索引重新 bootstrap;投影缓存惰性重折),要么手动把两个 json 文件挪一次。
 
 ### 声明派生介质:损坏时重置而非拒绝
 
@@ -31,7 +31,9 @@ Status: proposed
 
 ## Alternatives considered
 
-**保持按启动目录的 `.storages`(现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
+**保持按启动目录的 `.storages`(改动前现状)**——拒绝:会话是全局的,所以每个从会话派生的介质都与自己的真源劈叉;缓存的动机场景(一次列出全部会话)结构性丢行,工作区注册表索引着从另一个启动目录看不见的会话。
+
+**launcher patch + `storageRoot` profile 键(本节初版提案形态)**——未采:一行 yml `!!js` 表达式即达全局根,与会话根的既有分层完全一致;launcher patch 多引入一个改写点,profile 键在有真实消费者前是空席(按行覆盖已有个人 config.yaml patch 层可用)。
 
 **只把投影缓存的 route 指到全局根,`workspace.json` 留在 per-cwd**——拒绝:工作区注册表有一模一样的全局 vs per-cwd 错位,而且塑造缓存的用户决策就是刻意把它放在 `workspace.json` 旁边——一个 hub 根让介质同址、心智模型单一。
 
@@ -45,7 +47,7 @@ Status: proposed
 
 ## Acceptance criteria
 
-- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`);profile 键 `storageRoot` 可覆盖;裸 Loader 启动 yml 仍落在相对启动 cwd 的 `./.storages`,并在后端构造时 resolve 一次。
+- 从任意目录启动 `dsh` 都读写同一份 `$DSH_HOME/storages/*.json`(默认 `~/.dsh/storages`)——已由 overlay 表达式满足,按行覆盖走个人 config.yaml patch 层;后端对相对根在构造时 resolve 一次(待做)。
 - `session_projcache.json` 被截断、版本 bump 或 schema 漂移时,组装干净启动:一条警告命名被丢弃的介质,文件消失,缓存经正常运转重建,冷列表列随会话重新 checkpoint 逐步回归。
 - 同样的损坏发生在 `workspace.json` 上仍大声拒绝启动。
 - facility 测试覆盖:每个损坏类恰好重置一次 `'reset'` 域;非损坏失败在 `'reset'` 域上保持大声;`'reject'` 域传播一切失败;`destroy` 在两个出厂后端上都移除介质。
diff --git a/.gitattributes b/.gitattributes
index a51c5e7b5e..dc3e3ebbf0 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -5,3 +5,5 @@
 # for cmd.exe), add a `*.bat text eol=crlf` override AFTER this line — the
 # in-repo form stays LF; CRLF becomes checkout-time presentation only.
 * text=auto eol=lf
+
+*.pdf -text
diff --git a/README.i18n.yaml b/README.i18n.yaml
index 66400262db..afdb75f1aa 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: baf5d79b157ae845cc837261452853afd48dbe46
-README.zh.md: 57d7bcf44cda36b37ae233754dbfba4ead2204fd
+README.md: b17098a4fee2354dfb2015afe34582f725b59df1
+README.zh.md: 9a17f76608e23719d27e9eb43d01582987adb3bf
diff --git a/README.md b/README.md
index baf5d79b15..b17098a4fe 100644
--- a/README.md
+++ b/README.md
@@ -8,13 +8,13 @@ It uses an architecture where **everything is a plugin**.
 
 ## Internal testing notice
 
-Thank you for making time to try DeepSeek Harness.
+Thank you for taking the time to try DeepSeek Harness.
 
-This version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.
+This version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little 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.
+“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our 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.
+We especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in our WeCom group. Every piece of feedback helps us refine it.
 
 ## Install
 
diff --git a/README.zh.md b/README.zh.md
index 57d7bcf44c..9a17f76608 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -10,11 +10,11 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
 
 感谢您愿意拨冗试用 DeepSeek Harness。
 
-目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。
+目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。
 
-“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。
+“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。
 
-我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
+我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。
 
 ## 安装
 
diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml
index b2fe93b91a..1a6a21aac6 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: c36a75fc61fd7118f48c9b68be3144177df19534
-README.zh.md: e926fa99c4e483351f52ca4e76b668e26b34d02f
+README.md: fc5bce195fb10872c605cada1bcb3ed79380265c
+README.zh.md: 2fb1272231abb02e145e5c9925362de27307ca86
diff --git a/apps/cli/README.md b/apps/cli/README.md
index c36a75fc61..fc5bce195f 100644
--- a/apps/cli/README.md
+++ b/apps/cli/README.md
@@ -21,6 +21,8 @@ The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`, then
 
 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.
 
+The Web/headless composition also registers only `web_search`. Search uses DeepSeek's Anthropic-compatible Messages endpoint, resolves the same `DEEPSEEK_API_KEY` reference for every call, and accepts the separate `DEEPSEEK_SEARCH_BASE_URL` endpoint override; each search is an auxiliary model request with its own latency and token cost. `web_fetch` remains disabled and the composition mounts no default fetch provider, so deployments that need arbitrary page retrieval must opt in through an overlay. The TUI composition does not mount Web tools by default. The deployment decision and its security boundary live in the [default Web search Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md).
+
 `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).
diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md
index e926fa99c4..2fb1272231 100644
--- a/apps/cli/README.zh.md
+++ b/apps/cli/README.zh.md
@@ -21,6 +21,8 @@ Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`,随后应用
 
 已交付的 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`。
 
+Web/无头组合还只会注册 `web_search`。搜索使用 DeepSeek 的 Anthropic 兼容 Messages 端点,每次调用都会解析同一个 `DEEPSEEK_API_KEY` 凭据引用,并接受独立的 `DEEPSEEK_SEARCH_BASE_URL` 端点覆盖;每次搜索都是一次辅助模型请求,会产生独立的延迟与 token 成本。`web_fetch` 仍处于禁用状态,组合也未挂载默认抓取提供方;需要任意页面抓取能力的部署必须通过覆盖层选择启用。TUI 组合默认不挂载 Web 工具。部署决策及其安全边界见[默认 Web 搜索 Agent Note](../../.agents/notes/implemented/feature/2026-07-31-web-default-search.md)。
+
 `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)。
diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml
index a67d95ff6b..238b3788fe 100644
--- a/apps/cli/config/web.cordis.yml
+++ b/apps/cli/config/web.cordis.yml
@@ -80,6 +80,28 @@
 - id: fs-local
   disabled: true
 
+# The Web/headless product enables only the stable web_search model surface.
+# DeepSeek search resolves the same DEEPSEEK_API_KEY credential the Models page
+# manages for chat, at each search; its Messages endpoint is separate from the
+# chat-completions endpoint. Fetch remains disabled and no default fetch
+# provider is mounted.
+- insert:
+    - id: web
+      name: '@deepseek-ai/dsh-web'
+      config:
+        searchProvider: deepseek-official
+
+    - id: web-search-deepseek
+      name: '@deepseek-ai/dsh-web-search-deepseek'
+      config:
+        apiKeyEnv: DEEPSEEK_API_KEY
+        baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL
+
+    - id: tool-web
+      name: '@deepseek-ai/dsh-tool-web'
+      config:
+        fetch: false
+
 # ── web-only host rows, the transport layer, and the browser roster ─────────
 
 # `dshClient` rows are the browser roster the modules node half scans into
@@ -97,7 +119,8 @@
     - id: storage-json
       name: '@deepseek-ai/dsh-storage-json'
       config:
-        root: './.storages'
+        root: !!js >-
+          (() => { 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), 'storages') })()
 
     - id: storage-domain
       name: '@deepseek-ai/dsh-storage-domain'
diff --git a/apps/cli/package.json b/apps/cli/package.json
index 787682ce04..5556411e15 100644
--- a/apps/cli/package.json
+++ b/apps/cli/package.json
@@ -112,11 +112,14 @@
     "@deepseek-ai/dsh-tool-subagent": "workspace:^",
     "@deepseek-ai/dsh-tool-tasks": "workspace:^",
     "@deepseek-ai/dsh-tool-todo": "workspace:^",
+    "@deepseek-ai/dsh-tool-web": "workspace:^",
     "@deepseek-ai/dsh-tool-workflow": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-tui": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
     "@deepseek-ai/dsh-user-interaction": "workspace:^",
+    "@deepseek-ai/dsh-web": "workspace:^",
+    "@deepseek-ai/dsh-web-search-deepseek": "workspace:^",
     "@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",
     "@deepseek-ai/dsh-workspace-context": "workspace:^",
diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts
index f1cbc77581..1ec36454d0 100644
--- a/apps/web/tests/onboarding-deepseek-config.e2e.ts
+++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts
@@ -9,12 +9,18 @@ import type { Browser, Page } from 'playwright'
 import { chromium } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
-  assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
+  acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
   launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
 } from './scaffold.ts'
 import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
+  WELCOME_NOTICE_VERSION,
+} from '@deepseek-ai/dsh-client-ui-settings-general'
 
 const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/onboarding-deepseek-config', import.meta.url))
+const WELCOME_EXPECTED = join(SNAPSHOT_DIR, 'welcome.expected.md')
 const MISSING_EXPECTED = join(SNAPSHOT_DIR, 'missing.expected.md')
 const MODE = webSnapshotMode()
 
@@ -26,7 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
   const browserConsole: string[] = []
 
   beforeAll(async () => {
-    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
+    scaffold = await launchWebScaffold({ deepSeekMissingCredential: true, welcomeNoticePending: true })
     browser = await chromium.launch()
     // The scenario asserts the shipped Chinese copy, so the browser asks for it.
     page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: ZH_BROWSER_LOCALE })
@@ -43,16 +49,61 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
 
   it('stores a key write-only and observes configured state without restarting', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-onboarding-deepseek-config'))
-    const dialog = page.getByRole('dialog', { name: '添加一个 API Key 开始使用' })
-    await dialog.waitFor({ timeout: 15_000 })
-    expect(await dialog.getByRole('textbox').count()).toBe(0)
-    const initial = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
+    const welcome = page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title })
+    await welcome.waitFor({ timeout: 15_000 })
+    expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(true)
+    const welcomeAria = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
+    await compareOrRefreshGolden(WELCOME_EXPECTED, welcomeAria, MODE)
+    expect(await welcome.getByRole('button').allTextContents()).toEqual([WELCOME_NOTICE_COPY.zh.continueLabel])
+    expect(await welcome.locator('button').count()).toBe(1)
+
+    const mask = page.locator('[class*="onboardingMask"]')
+    expect(await mask.count()).toBe(1)
+    const maskStyles = await mask.evaluate((mask) => {
+      const style = getComputedStyle(mask)
+      const rect = mask.getBoundingClientRect()
+      return {
+        position: style.position,
+        left: style.left,
+        right: style.right,
+        top: style.top,
+        bottom: style.bottom,
+        background: style.backgroundColor,
+        backdropFilter: style.backdropFilter,
+        rect: { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom },
+      }
+    })
+    expect(maskStyles).toEqual({
+      position: 'absolute',
+      left: '0px',
+      right: '0px',
+      top: '80px',
+      bottom: '0px',
+      background: 'rgba(0, 0, 0, 0.24)',
+      backdropFilter: 'blur(2px)',
+      rect: { left: 0, top: 80, right: 1440, bottom: 960 },
+    })
+
+    // Closing the process/page before acknowledgement writes nothing, so the
+    // same durable profile presents the notice again after reload.
+    const firstReloadWarnings = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, firstReloadWarnings)
+    await welcome.waitFor({ timeout: 15_000 })
+
+    await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
+    await welcome.waitFor({ state: 'detached', timeout: 15_000 })
+    const credentialStep = page.getByRole('region', { name: '添加一个 API Key 开始使用' })
+    await credentialStep.waitFor({ timeout: 15_000 })
+    expect(await credentialStep.getByRole('textbox').count()).toBe(0)
+    const initial = await captureStableAria(page, '[role="region"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(MISSING_EXPECTED, initial, MODE)
 
-    await dialog.getByRole('button', { name: '前往配置' }).click()
-    await dialog.waitFor({ state: 'detached', timeout: 15_000 })
+    await credentialStep.getByRole('button', { name: '前往配置' }).click()
+    await credentialStep.waitFor({ state: 'detached', timeout: 15_000 })
     const settings = page.getByRole('dialog', { name: '设置' })
     await settings.waitFor({ timeout: 10_000 })
+    expect(await page.locator('#root').evaluate(root => (root as HTMLElement).inert)).toBe(false)
     const keyInput = settings.getByLabel('API 密钥', { exact: true })
     await keyInput.waitFor({ timeout: 10_000 })
 
@@ -79,6 +130,29 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
       { timeout: 10_000 },
     ).toBe('已配置——输入新值可替换')
 
+    const acknowledgedSettings = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(acknowledgedSettings).toContain(`${WELCOME_NOTICE_ACK_FIELD}: ${WELCOME_NOTICE_VERSION}`)
+
+    const secondReloadWarnings = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, secondReloadWarnings)
+    await page.waitForSelector('[class*="frame"]', { timeout: 15_000 })
+    expect(await page.getByRole('region', { name: WELCOME_NOTICE_COPY.zh.title }).count()).toBe(0)
+    expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
+
+    // A different stored copy version represents an intentional version bump:
+    // the welcome step returns even though the credential is already ready.
+    await scaffold.ctx.settings.mutate(settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE), [{
+      op: 'set', path: [WELCOME_NOTICE_ACK_FIELD], value: 'previous-copy-version',
+    }])
+    const thirdReloadWarnings = tripwire.warnings.length
+    await page.reload({ waitUntil: 'load' })
+    acknowledgeReloadConnectionLoss(tripwire, thirdReloadWarnings)
+    await welcome.waitFor({ timeout: 15_000 })
+    await welcome.getByRole('button', { name: WELCOME_NOTICE_COPY.zh.continueLabel }).click()
+    await welcome.waitFor({ state: 'detached', timeout: 15_000 })
+    expect(await page.getByRole('region', { name: '添加一个 API Key 开始使用' }).count()).toBe(0)
+
     expect((await page.content()).includes(secret)).toBe(false)
     expect((await page.locator('body').ariaSnapshot()).includes(secret)).toBe(false)
     expect(browserConsole.some(line => line.includes(secret))).toBe(false)
@@ -87,6 +161,6 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup
   }, 60_000)
 
   it('keeps the fixture inventory closed', async () => {
-    await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md'])
+    await assertFixtureInventory(SNAPSHOT_DIR, ['missing.expected.md', 'welcome.expected.md'])
   })
 })
diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts
index ef60146d7d..2f6a43ba0e 100644
--- a/apps/web/tests/scaffold.ts
+++ b/apps/web/tests/scaffold.ts
@@ -32,6 +32,10 @@ import Loader from '@cordisjs/plugin-loader'
 import Include, { type PatchOptions } from '@cordisjs/plugin-include'
 import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot'
 import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
+import {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, WELCOME_NOTICE_VERSION,
+} from '@deepseek-ai/dsh-client-ui-settings-general'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay'
 import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
 import SessionStore, {
@@ -135,6 +139,19 @@ export interface LaunchOptions {
    * keyless first-run configuration lane; the default disables the adapter.
    */
   deepSeekMissingCredential?: boolean
+  /**
+   * Patch the shipped DeepSeek search row to a deterministic endpoint and
+   * credential reference. Browser search scenarios keep the real provider and
+   * credentials seam while avoiding external search traffic and ambient keys.
+   */
+  deepSeekSearch?: {
+    /** Anthropic-compatible base URL; the provider appends `/messages`. */
+    baseURL: string
+    /** Credential reference resolved by the shipped search provider. */
+    apiKeyEnv: string
+  }
+  /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
+  welcomeNoticePending?: boolean
 }
 
 /** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */
@@ -200,9 +217,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise {
     mkdirSync(join(workspace, '.git'))
     writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n')
 
-    let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void
-    const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => {
+    interface NativeProviderRequest {
+      messages?: { role?: string; content?: string }[]
+      tools?: { function?: { name?: string } }[]
+    }
+    let resolveProviderRequest!: (request: NativeProviderRequest) => void
+    const providerRequest = new Promise((resolve) => {
       resolveProviderRequest = resolve
     })
     const provider = createServer((request, response) => {
@@ -197,7 +201,7 @@ describe('dsh web keyless CLI smoke', () => {
       request.setEncoding('utf8')
       request.on('data', (chunk: string) => { body += chunk })
       request.on('end', () => {
-        resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] })
+        resolveProviderRequest(JSON.parse(body) as NativeProviderRequest)
         response.writeHead(200, { 'content-type': 'text/event-stream' })
         response.end([
           'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}',
@@ -256,6 +260,13 @@ describe('dsh web keyless CLI smoke', () => {
           "role": "user",
         }
       `)
+      expect(captured.tools?.map(tool => tool.function?.name)
+        .filter(name => name === 'web_search' || name === 'web_fetch'))
+        .toMatchInlineSnapshot(`
+          [
+            "web_search",
+          ]
+        `)
     } finally {
       const closed = child.exitCode === null
         ? new Promise((resolveClose) => { child.once('close', () => { resolveClose() }) })
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 5d1979eadf..8b170486a5 100644
--- a/apps/web/tests/snapshots/code-mode-round/ui.expected.md
+++ b/apps/web/tests/snapshots/code-mode-round/ui.expected.md
@@ -20,10 +20,8 @@
   - img
   - text: Code Run bash echo and catch missing file read
 - img
-- 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"
+- text: Bash Echo CODE_ROUND_OK 失败 Read
+- button "missing.txt"
 - button "Think The program ran successfully. Let me now reply DONE as instructed.":
   - img
   - img
diff --git a/apps/web/tests/snapshots/message-actions/ui.expected.md b/apps/web/tests/snapshots/message-actions/ui.expected.md
index 9aed20cfce..73b7637400 100644
--- a/apps/web/tests/snapshots/message-actions/ui.expected.md
+++ b/apps/web/tests/snapshots/message-actions/ui.expected.md
@@ -16,16 +16,12 @@
   - 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.
-- button "Read a.txt":
-  - img
-  - img
-  - text: Read
-  - button "a.txt"
-- button "Read b.txt":
-  - img
-  - img
-  - text: Read
-  - button "b.txt"
+- img
+- text: Read
+- button "a.txt"
+- 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
diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md
index 102b6a7fab..ed37b0fe4d 100644
--- a/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md
+++ b/apps/web/tests/snapshots/onboarding-deepseek-config/missing.expected.md
@@ -1,6 +1,5 @@
-- dialog "添加一个 API Key 开始使用":
+- region "添加一个 API Key 开始使用":
   - heading "添加一个 API Key 开始使用" [level=2]
-  - button "稍后配置":
-    - img
   - paragraph: 配置 DeepSeek 官方模型,即可开始使用。
+  - button "稍后配置"
   - button "前往配置"
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 f722bb36ae..948d5579de 100644
--- a/apps/web/tests/snapshots/seeded-history/command-row.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/command-row.expected.md
@@ -15,16 +15,12 @@
   - 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.
-- button "Read a.txt":
-  - img
-  - img
-  - text: Read
-  - button "a.txt"
-- button "Read b.txt":
-  - img
-  - img
-  - text: Read
-  - button "b.txt"
+- img
+- text: Read
+- button "a.txt"
+- 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
diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md
index 42455b1231..cd74c1053c 100644
--- a/apps/web/tests/snapshots/seeded-history/ui.expected.md
+++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md
@@ -15,16 +15,12 @@
   - 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.
-- button "Read a.txt":
-  - img
-  - img
-  - text: Read
-  - button "a.txt"
-- button "Read b.txt":
-  - img
-  - img
-  - text: Read
-  - button "b.txt"
+- img
+- text: Read
+- button "a.txt"
+- 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
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index 28720ba896..c15a8bc862 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -42,6 +42,7 @@
     "tests/code-mode-round.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",
     "tests/cordis-tool-round.e2e.ts",
+    "tests/web-search-round.e2e.ts",
     "tests/message-actions.e2e.ts",
     "tests/queue-actions.e2e.ts",
     "tests/skill-invocation-policy.e2e.ts",
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index ce0d6d393e..2288d7ffbe 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -1729,6 +1729,8 @@ export interface Config {
   grepMaxMatches?: number
   /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */
   grepMaxLineBytes?: number
+  /** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */
+  searchMetaMaxBytes?: number
   /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */
   rawOutputMaxBytes?: number
   /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */
@@ -1736,7 +1738,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/fs/tool-fs-search/src/index.ts:62`](../packages/fs/tool-fs-search/src/index.ts)
+Source: [`packages/fs/tool-fs-search/src/index.ts:71`](../packages/fs/tool-fs-search/src/index.ts)
 
 ## `@deepseek-ai/dsh-tool-goal`
 
@@ -1992,7 +1994,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-Source: [`packages/core/tools/src/index.ts:584`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:589`](../packages/core/tools/src/index.ts)
 
 ## `@deepseek-ai/dsh-tui`
 
@@ -2162,8 +2164,10 @@ Requires: `web`
 ```ts config-catalog
 /** Plugin config (all optional — `apply` fills env-var and constant defaults). */
 export interface Config {
-  /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */
+  /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */
   apiKey?: string
+  /** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */
+  apiKeyEnv?: string
   /** Anthropic-compatible endpoint base; `/messages` is appended. */
   baseURL?: string
   /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */
@@ -2177,7 +2181,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/web/web-search-deepseek/src/index.ts:38`](../packages/web/web-search-deepseek/src/index.ts)
+Source: [`packages/web/web-search-deepseek/src/index.ts:43`](../packages/web/web-search-deepseek/src/index.ts)
 
 ## `@deepseek-ai/dsh-web-search-exa`
 
diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml
index 29ad71c634..25611512c3 100644
--- a/docs/cookbook/adding-a-tool.i18n.yaml
+++ b/docs/cookbook/adding-a-tool.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/cookbook/adding-a-tool.md
-adding-a-tool.md: a85de0feeeee307ac645f8c2967bb44521d059a8
-adding-a-tool.zh.md: 8e4e6a1128f4f2ad3b4d42c2b88edaf4d8d89af1
+adding-a-tool.md: 80625b5ec64aca8f8cda8a39048ba1c13fe57b2b
+adding-a-tool.zh.md: 7d426ed7f0e5c9147d29ac7f6deb15ec27e36288
diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md
index a85de0feee..80625b5ec6 100644
--- a/docs/cookbook/adding-a-tool.md
+++ b/docs/cookbook/adding-a-tool.md
@@ -78,6 +78,7 @@ Both methods return a **`card`-tagged render intent** — pick the card kind tha
   - `generic` supplies an optional title and content.
   - `terminal` supplies raw output and optional exit metadata; each UI renders its capable or fallback view.
   - `diff` supplies applied hunks, often derived by `output.presentationMeta` and carried in persisted `result.meta` so replay reproduces them. Mutation tools keep a diff result because the completed view replaces the pending card.
+  - `search` supplies a discovery result reconstructed from persisted `result.meta`: grouped-by-file matches (`shape: 'matches'`, grep) or a flat path list (`shape: 'paths'`, glob), plus `truncated`/`total` so a UI never presents a capped result as complete. The view carries no result text (a UI without a search card falls back to the raw result content), and there is no `search` call view — a discovery call's pending state stays a generic card, since matches exist only after `execute`. (tool-fs-search `grep`/`glob`.)
   - `web` supplies a completed web retrieval, discriminated by `kind: 'search' | 'fetch'` (the structured search sources or the fetch summary), derived from `result.meta`; it carries no body copy, so a UI without the `web` capability falls back to the raw result content. (tool-web `web_search`/`web_fetch`.)
 
 Hard rules (they bite if broken):
diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md
index 8e4e6a1128..7d426ed7f0 100644
--- a/docs/cookbook/adding-a-tool.zh.md
+++ b/docs/cookbook/adding-a-tool.zh.md
@@ -78,6 +78,7 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的
   - `generic` 提供可选的标题和内容。
   - `terminal` 提供原始输出和可选的退出元数据;各 UI 根据自身能力渲染对应视图或回退视图。
   - `diff` 提供已应用的 hunk,通常由 `output.presentationMeta` 派生并通过持久化的 `result.meta` 携带,使回放能重现它们。变更类工具保留 diff 结果,因为完成后的视图会替换 pending 卡片。
+  - `search` 提供从持久化 `result.meta` 重建的发现型结果:按文件分组的匹配(`shape: 'matches'`,grep)或扁平路径列表(`shape: 'paths'`,glob),外加 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现。该视图不携带结果文本(无 search 卡片的 UI 回退到原始结果内容),也没有 `search` 调用视图——发现型调用的 pending 状态保持为 generic 卡片,因为匹配只在 `execute` 之后才存在。(tool-fs-search 的 `grep`/`glob`。)
   - `web` 提供已完成的 web 检索,以 `kind: 'search' | 'fetch'` 区分(结构化的搜索来源或抓取摘要),由 `result.meta` 派生;它不携带正文副本,因此不具备 `web` 能力的 UI 回退到原始结果内容。(tool-web `web_search`/`web_fetch`。)
 
 硬性规则(违反会出问题):
diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md
index 2496c22680..c9d4ad3e5b 100644
--- a/docs/cordis-catalog/events.md
+++ b/docs/cordis-catalog/events.md
@@ -938,7 +938,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:162`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:167`](../../packages/core/tools/src/index.ts)
 
 ### `tools/code-dispatch-log` — waterfall
 
@@ -962,7 +962,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
 
 Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:144`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:149`](../../packages/core/tools/src/index.ts)
 
 ### `tools/execute` — waterfall
 
@@ -984,7 +984,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:119`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:124`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -1007,7 +1007,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 
 Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:136`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
@@ -1028,7 +1028,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
 
 Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:113`](../../packages/core/tools/src/index.ts)
 
 ### `tools/result` — emit
 
@@ -1047,7 +1047,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:157`](../../packages/core/tools/src/index.ts)
 
 ## `workflow/*`
 
diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md
index fa1e4e768c..d92f467469 100644
--- a/docs/cordis-catalog/services.md
+++ b/docs/cordis-catalog/services.md
@@ -2313,7 +2313,7 @@ async execute(exec: ToolExecutionInput): Promise
 
 Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:706`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:711`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.tui` — `TuiExtensionService` (abstract seam)
 
diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml
index e99ea06597..156324472b 100644
--- a/docs/core-data-structures/tools.i18n.yaml
+++ b/docs/core-data-structures/tools.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/tools.md
-tools.md: 62acd00a3afe50c90b2cab0bb0f170ab82852a4f
-tools.zh.md: 1ec638d6062d6496aebc019e531126343d6ead28
+tools.md: 98b642b846b23e2b29e6c6d800fe4106235eda85
+tools.zh.md: 1ef90c1e76ace7485ed6267de5ee82cbb4de6aa6
diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md
index 62acd00a3a..98b642b846 100644
--- a/docs/core-data-structures/tools.md
+++ b/docs/core-data-structures/tools.md
@@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
 How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on:
 
 - `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file).
-- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet.
+- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search → grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob); `truncated`/`total` report whether the inline result was capped so a UI never presents a partial result as complete; the view carries no result text — a UI without a search card falls back to the raw result content), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet; a search and a web retrieval have no `card` call-time analogue (their pending state stays a generic card, since the structured result exists only after `execute`).
 
 `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views.
 
diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md
index 1ec638d606..1ef90c1e76 100644
--- a/docs/core-data-structures/tools.zh.md
+++ b/docs/core-data-structures/tools.zh.md
@@ -447,7 +447,7 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发:
 
 - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。
-- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。
+- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索→`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表;`truncated`/`total` 报告内联结果是否被截断,使 UI 永不把部分结果当作完整结果呈现;该视图不携带结果文本——无 search 卡片的 UI 回退到原始结果内容)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果;搜索和 web 检索都没有 `card` 的调用时对应视图(其 pending 状态保持为 generic 卡片,因为结构化结果只在 `execute` 之后才存在)。
 
 `ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。
 
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index ec5e950ea9..b3aa11c1bd 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -32,7 +32,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `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/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), [`tui`](../packages/ui/tui) |
 | `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), [`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) |
@@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:162`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:119`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:167`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:149`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
+| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:124`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:136`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:157`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
@@ -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`, `ui-permission` |
+| `connection/reset` | `runtime` (`emit`) | `ui-command`, `ui-models`, `ui-permission`, `ui-settings-general` |
 | `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` |
 | `models/changed` | `runtime` (`emit`) | `ui-models` |
-| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission` |
+| `settings/changed` | `runtime` (`emit`) | `ui-models`, `ui-permission`, `ui-settings-general` |
 | `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 004ca5aa03..8a41a2bcaa 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -365,11 +365,13 @@ flowchart TD
   pkg_client_ui_models --> pkg_invariants
   pkg_client_ui_question --> pkg_client_locale
   pkg_client_ui_question --> pkg_invariants
+  pkg_client_ui_settings_general --> pkg_client_connection
   pkg_client_ui_settings_general --> pkg_client_locale
   pkg_client_ui_settings_general --> pkg_client_runtime
   pkg_client_ui_settings_general --> pkg_client_ui_primitives
   pkg_client_ui_settings_general --> pkg_client_ui_settings
   pkg_client_ui_settings_general --> pkg_client_ui_slots
+  pkg_client_ui_settings_general --> pkg_client_web_react
   pkg_client_ui_settings_general --> pkg_invariants
   pkg_client_ui_sidebar --> pkg_client_locale
   pkg_client_ui_sidebar --> pkg_client_runtime
@@ -429,8 +431,6 @@ flowchart TD
   pkg_web_fetch_local --> pkg_invariants
   pkg_web_fetch_local --> pkg_timeout
   pkg_web_fetch_local --> pkg_web
-  pkg_web_search_deepseek --> pkg_invariants
-  pkg_web_search_deepseek --> pkg_web
   pkg_web_search_exa --> pkg_invariants
   pkg_web_search_exa --> pkg_web
   pkg_web_search_perplexity --> pkg_invariants
@@ -526,6 +526,11 @@ flowchart TD
   pkg_compact_basic --> pkg_llm
   pkg_compact_basic --> pkg_session
   pkg_compact_basic --> pkg_token_meter
+  pkg_web_search_deepseek --> pkg_agent
+  pkg_web_search_deepseek --> pkg_credentials
+  pkg_web_search_deepseek --> pkg_invariants
+  pkg_web_search_deepseek --> pkg_session
+  pkg_web_search_deepseek --> pkg_web
   pkg_spill_local --> pkg_invariants
   pkg_spill_local --> pkg_spill
   pkg_hook_protocol --> pkg_bash
@@ -1086,7 +1091,7 @@ flowchart TD
 | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
 | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) |
-| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
+| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) |
 | [`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) |
@@ -1102,7 +1107,6 @@ flowchart TD
 | [`compact`](../packages/compact/compact) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
 | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |
-| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
 | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
 | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/support/invariants), [`web`](../packages/web/web) |
 | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
@@ -1127,6 +1131,7 @@ flowchart TD
 | [`fs-policy`](../packages/fs/fs-policy) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) |
 | [`skill-local`](../packages/skill/skill-local) | `skill` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`skill`](../packages/skill/skill) |
 | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
+| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) |
 | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) |
 | [`hook-protocol`](../packages/hooks/hook-protocol) | `hooks` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
 | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl) | `session-persistence` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence) |
diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md
index 2b36c0c3ff..182f883cc7 100644
--- a/docs/persistence-catalog.md
+++ b/docs/persistence-catalog.md
@@ -643,3 +643,14 @@ Source: [`packages/core/session/src/types.ts:193`](../packages/core/session/src/
 ```
 
 Source: [`packages/core/session/src/types.ts:213`](../packages/core/session/src/types.ts)
+
+### `web/*`
+
+#### `web/deepseek-search-llm-request` — log-only
+
+```ts persistence-catalog
+/** Secret-free auxiliary DeepSeek search request recorded before dispatch. */
+'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest
+```
+
+Source: [`packages/web/web-search-deepseek/src/provider.ts:83`](../packages/web/web-search-deepseek/src/provider.ts)
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 b3b8852848..495a490774 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        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 ReadFileLine {\n        number: number;\n        text: string;\n    }\n    export interface ReadResultView {\n        card: 'read';\n        title?: string;\n        path: string;\n        offset: number;\n        lines: ReadFileLine[];\n        totalLines: number;\n        lang?: string;\n        content?: ContentBlock[];\n    }\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 | ReadResultView | 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 ReadFileLine {\n        number: number;\n        text: string;\n    }\n    export interface ReadResultView {\n        card: 'read';\n        title?: string;\n        path: string;\n        offset: number;\n        lines: ReadFileLine[];\n        totalLines: number;\n        lang?: string;\n        content?: ContentBlock[];\n    }\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 SearchFileMatches {\n        path: string;\n        matches: SearchLineMatch[];\n    }\n    export interface SearchLineMatch {\n        lineNumber: number;\n        line: string;\n    }\n    export interface SearchMatchesResultView {\n        card: 'search';\n        shape: 'matches';\n        title?: string;\n        files: SearchFileMatches[];\n        truncated: boolean;\n        total: number;\n    }\n    export interface SearchPathsResultView {\n        card: 'search';\n        shape: 'paths';\n        title?: string;\n        paths: string[];\n        truncated: boolean;\n        total: number;\n    }\n    export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\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 | SearchResultView | ReadResultView | 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/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
index 8579543459..5c741500c1 100644
--- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
+++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl
@@ -11,7 +11,7 @@
 {"type":"assistant/chunk","seq":9,"time":1785218400010,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":10,"time":1785218400011,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-pro"},"id":"d8c174b5-2f08-49b3-80d5-a69aabefbd7a"},"usage":{"inputTokens":1,"outputTokens":1}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
 {"type":"tool/call","seq":11,"time":1785218400012,"data":{"turn":1,"step":1,"callId":"glob-sampling-call","name":"glob","arguments":"{\"pattern\":\"*\"}"}}
-{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
+{"type":"tool/result","seq":12,"time":1785218400013,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"glob-sampling-call"},"content":[{"type":"tool-result","toolCallId":"glob-sampling-call","content":[{"type":"text","text":"archive/a.ts\nold\\one\nold\\two\nsrc/index.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}],"isError":false}],"role":"user","id":"e9711775-0ea5-4383-a562-76a6b49a4742"},"meta":{"shape":"paths","paths":["archive/a.ts","old\\one","old\\two","src/index.ts"],"truncated":true,"total":8}},"sourceEventSeqs":[11],"surfaceOp":"append"}
 {"type":"step/end","seq":13,"time":1785218400014,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":14,"time":1785218400015,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":15,"time":1785218400016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts
index 49f0aaf9dc..52c05b57fa 100644
--- a/packages/client/connection/src/client/fixture.ts
+++ b/packages/client/connection/src/client/fixture.ts
@@ -138,7 +138,34 @@ const TERMINAL_EXIT_STATUS: Record ({ number: READ_SAMPLE_FIRST_LINE + index, text }))
+const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx'
+const READ_SAMPLE_TOTAL = 180
+const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n')
+
+/**
+ * The structured `web_search` result view for fixture turn 67, authored inline
  * because this client-side fixture cannot import the web tool that projects it.
  * The sources exercise the citation list's features: a titled source with a
  * snippet and a date, a source with no title (its hostname labels the link) and
@@ -168,7 +195,7 @@ const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = {
   url: 'https://www.deepseek.com/blog/harness-architecture',
   statusCode: 200,
@@ -315,8 +342,8 @@ function buildAlphaLog(): SessionEvent[] {
     const turn = 64
     const callId = `fx-call-${turn}`
     const program = 'const listing = await tools.bash({ command: "ls notes", description: "List notes" })\n'
-      + 'const demo = await tools.read({ path: "notes/demo.txt" })\n'
-      + 'await tools.read({ path: "notes/missing.txt" }).catch(() => "tolerated")\n'
+      + 'const demo = await tools.read({ file_path: "notes/demo.txt" })\n'
+      + 'await tools.read({ file_path: "notes/missing.txt" }).catch(() => "tolerated")\n'
       + 'return { listing, demo }'
     const args = JSON.stringify({ code: program, description: 'Read the notes files and summarize' })
     push({ type: 'turn/start', data: { turn, trigger: { kind: 'message', source: { kind: 'user' } } } })
@@ -341,8 +368,8 @@ function buildAlphaLog(): SessionEvent[] {
       })
     }
     dispatchPair(1, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt\nnew-demo.txt')
-    dispatchPair(2, 'read', { path: 'notes/demo.txt' }, 'hello fixture\n')
-    dispatchPair(3, 'read', { path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
+    dispatchPair(2, 'read', { file_path: 'notes/demo.txt' }, 'hello fixture\n')
+    dispatchPair(3, 'read', { file_path: 'notes/missing.txt' }, 'Error: ENOENT: notes/missing.txt not found', true)
     push({
       type: 'tool/result', surfaceOp: 'append',
       data: { turn, step: 0, message: toolResultMessage(callId, text('{"listing":"demo.txt\\nnew-demo.txt","demo":"hello fixture\\n"}'), false) },
@@ -350,7 +377,7 @@ function buildAlphaLog(): SessionEvent[] {
     push({ type: 'step/end', data: { turn, step: 0 } })
     push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   }
-  // Turn 65: todo_write sample — the TodoRow toolview in the flow plus the
+  // Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
   // todo/write snapshot event feeding the TodoPanel plan strip.
   const fixtureTodos = [
     { content: '梳理需求', status: 'completed' },
@@ -371,7 +398,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
+  // Turn 66: the read sample — a WINDOW past an offset so the card draws file
+  // line numbers starting above 1 and a "showing N of M" note (the window is
+  // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path
+  // highlights. Named `read`, so it exercises the keyed ReadRow registration.
+  // The render-site fallback ROW SHAPE (a read call on the generic flattened
+  // path) is covered by the turn 64 run_code read sub-dispatches, which
+  // session.ts folds with resultView: null; the fallback-row + read-CARD
+  // combination is pinned by the web_fetch case in read-card.spec.tsx, not by
+  // this fixture. The read render intent is result-side only, so its pending
+  // call stays a generic `kind: 'read'` card; presentResult carries the
+  // structured window.
+  toolTurn(66, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT)
+
+  // Turns 67-68: 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.
@@ -380,11 +420,11 @@ function buildAlphaLog(): SessionEvent[] {
   // 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.')
+  toolTurn(67, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.')
+  toolTurn(68, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
 
   const todoArgs = JSON.stringify({ todos: fixtureTodos })
-  toolTurn(68, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+  toolTurn(69, '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).
@@ -419,6 +459,12 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
         card: 'diff', title: `Write ${str(args.path)}`,
         diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }],
       }
+    // A read pending call is a GENERIC card (kind: 'read', a follow-along
+    // location): the read render intent is result-side only, because a call
+    // carries no file content until execute returns. The rich read card arrives
+    // in presentResult.
+    case 'read':
+      return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] }
     case 'edit':
       // 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.
@@ -455,6 +501,16 @@ 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 read result is the structured window the tool projects through
+  // `presentationMeta`; the fixture authors it inline (it cannot import the
+  // tool). Keyed on the name because the read pending call is a generic card,
+  // so `call.card` alone does not distinguish it from edit/write.
+  if (name === 'read') {
+    return {
+      card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES,
+      totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText),
+    }
+  }
   // 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
diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts
index cedc7d86e7..ed4af2d21f 100644
--- a/packages/client/connection/src/index.ts
+++ b/packages/client/connection/src/index.ts
@@ -54,6 +54,7 @@ const PRIVILEGED_METHODS = new Set([
   'settings.describe',
   'settings.update',
   'settings.replace',
+  'settings.mutate',
   'credentials.describe',
   'credentials.set',
   'credentials.unset',
diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts
index 6839d8b3ca..4910ba990c 100644
--- a/packages/client/connection/tests/node-half.spec.ts
+++ b/packages/client/connection/tests/node-half.spec.ts
@@ -107,7 +107,7 @@ describe('connection node half', () => {
     // passed), but each privileged method stays loopback-only and 403s.
     for (const method of [
       'host.pickDirectory', 'host.openPath',
-      'settings.describe', 'settings.update', 'settings.replace',
+      'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
       'credentials.describe', 'credentials.set', 'credentials.unset',
     ]) {
       const denied = fakeResponse()
@@ -191,7 +191,7 @@ describe('connection node half over a real HTTP server', () => {
       // Reads are as privileged as writes: describe returns the exposed
       // configuration, and credentials.describe probes arbitrary env-var names.
       for (const method of [
-        'settings.describe', 'settings.update', 'settings.replace',
+        'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
         'credentials.describe', 'credentials.set', 'credentials.unset',
         'host.pickDirectory', 'host.openPath',
       ]) {
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 0be0f4f534..8b6fd54baa 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -20,6 +20,7 @@ import { InputBar } from './skeleton/InputBar.tsx'
 import { ChatView } from './chat/ChatView.tsx'
 import { StatsLine } from './chat/StatsLine.tsx'
 import { bashToolviewSample } from './toolviews/bash-sample.tsx'
+import { readToolview } from './toolviews/read-row.tsx'
 import { fileMutationToolview } from './toolviews/file-mutation-row.tsx'
 import { webToolview } from './toolviews/web-row.tsx'
 import { ApprovalPanel } from './skeleton/ApprovalPanel.tsx'
@@ -320,6 +321,10 @@ export function apply(ctx: Context): void {
   // (ToolRow-matching Bash · {description} chrome; scoped badge in child sessions).
   ctx.plugin(bashToolviewSample)
 
+  // The read row rides the same seam (a product registration, not a sample):
+  // Read · {path} chrome with the file's read card resident below it.
+  ctx.plugin(readToolview)
+
   // The write/edit rows ride the same seam: a file-mutation call declares the
   // diff render intent, so these rows stack the applied diff card under their
   // path-link summary (the terminal card's posture, applied to diffs).
diff --git a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
index 13a4ef9d62..d21f6d3712 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.module.css
@@ -1,7 +1,8 @@
-/* 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. */
+/* GenericToolCard resident cards: a read-declaring or web-declaring tool
+   without its own keyed row (e.g. web_fetch) grows a resident card under its
+   summary row. A column around the ToolRow keeps the row's own 24px height, so
+   the read card renders identically to the keyed ReadRow and the web card to
+   the web_search/web_fetch WebRow. */
 
 .card {
   display: flex;
@@ -10,6 +11,7 @@
 
 /* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
    and replaces the primitive's standalone vertical margin with the flow's. */
+.read,
 .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 faadcf090b..fffd3beb47 100644
--- a/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
+++ b/packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
@@ -7,9 +7,10 @@
 import type { ReactNode } from 'react'
 import {
   IconApiOutline14, IconBrowseOutline16, IconCodeOutline16, IconEditOutline16, IconSearchOutline16, IconSparkle16,
-  IconThinkOutline14, WebBlock,
+  IconThinkOutline14, ReadBlock, WebBlock,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps, ToolRowOwnerProps } from '../contract/slots.ts'
+import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.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'
@@ -37,6 +38,7 @@ export interface GenericToolCardProps extends ToolRowOwnerProps {
 export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }: GenericToolCardProps) {
   const model = toolRowModel(toolName, block, cwd)
   const terminal = terminalCardModel(block, cwd)
+  const read = readCardModel(block, cwd)
   const diff = diffCardModel(block)
   const web = webCardModel(block)
   // A failing exit status is the terminal card's own error signal (the call
@@ -69,6 +71,18 @@ export function GenericToolCard({ toolName, block, cwd, openFile, inspect, t }:
       inspect={inspect}
     />
   )
+  // A read-declaring tool without its own keyed row lands here (e.g. web_fetch),
+  // so the file's read card is resident below the summary row exactly as the
+  // keyed ReadRow draws it. Only wrap when a card is present, so every other
+  // tool keeps the bare ToolRow.
+  if (read !== null) {
+    return (
+      
+ {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 diff --git a/packages/client/ui-conversation/src/client/contract/read-card-model.ts b/packages/client/ui-conversation/src/client/contract/read-card-model.ts new file mode 100644 index 0000000000..62a591bb17 --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/read-card-model.ts @@ -0,0 +1,76 @@ +/** + * Pure derivation of the read-card props from a frozen call slice: the + * `card:'read'` render intent the read tool declares arrives on the snapshot as + * the settled result node's `resultView`, and this is the one place that turns + * it into what {@link ReadBlock} draws. Both conversation render sites (the chat + * tool row's resident body and the details panel's Output section) call this, so + * the path, lines, total, and language they show are derived once. + * + * The read card is result-side only ([read card note](../../../../../../.agents/notes/implemented/feature/2026-07-30-web-read-card.md)): + * a call carries no file content until `execute` returns, so the pending call + * stays a generic card (`kind: 'read'`). A running read therefore has no read + * card, and this returns null for it — the row keeps its args-derived summary + * until the result arrives. + * @module + */ +import type { ReadBlockLine, ReadBlockProps } from '@deepseek-ai/dsh-client-ui-primitives' +import { relativizeToCwd, type ToolCallBlock } from './tool-call-model.ts' + +/** + * Content lines the chat row's resident read body shows before collapsing the + * middle — half the primitive's own default, which the details panel keeps. A + * chat row is a summary surface inside the message flow: the flow must stay + * scannable across many calls, while the details panel is the single-call + * reading surface. A design constant of this UI's row geometry, not a + * deployment choice, so it is fixed here rather than a plugin Config field. The + * same split [`CHAT_TERMINAL_MAX_LINES`](./terminal-card-model.ts) draws for + * terminal output. + */ +export const CHAT_READ_MAX_LINES = 8 + +/** + * The {@link ReadBlock} props this derivation owns. Picked off the primitive's + * props so the two stay in step; `maxLines`/`className` belong to each render + * site. + */ +export type ReadCardModel = Pick + +/** + * Derive the read-card props for a tool call, or null when this call is not a + * read card and belongs on the generic path. + * + * The read card is result-side only, so only a settled call whose result view + * declares `card:'read'` produces one. Every other case is null — the + * documented generic-card default: + * + * - A running call: it has no result view yet, and a read carries no content at + * call time. + * - A settled call whose result view is not a read card — including a `card` + * value this UI version does not know, which arrives over the wire and cannot + * be trusted to be one of the compiled variants, and the read tool's own + * generic fallback for an error result or a non-envelope body. + * + * The label is the read view's `title` when the tool supplied one (the + * presentation contract's replacement-title rule), otherwise the file path + * relativized to the session workspace so a workspace-rooted absolute path + * displays the same short form the row summary shows. + * @param block - RunningToolCall or ToolResultNode off the snapshot caches. + * @param sessionCwd - the session workspace root; a workspace-rooted absolute + * path label displays relative to it. Absent leaves the path as authored. + * @returns the read-card props, or null for the generic path. + */ +export function readCardModel(block: ToolCallBlock, sessionCwd?: string): ReadCardModel | null { + // Running has no result view; a read carries no content until execute returns. + if (!('kind' in block)) return null + const result = block.resultView?.card === 'read' ? block.resultView : null + if (result === null) return null + // Lines arrive frozen off the snapshot; copy into the primitive's own line + // shape so the card never holds a reference into the runtime's cache. + const lines: ReadBlockLine[] = result.lines.map(line => ({ number: line.number, text: line.text })) + return { + label: result.title ?? relativizeToCwd(result.path, sessionCwd), + lines, + totalLines: result.totalLines, + lang: result.lang, + } +} diff --git a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts index d7735fdab2..41688c3de9 100644 --- a/packages/client/ui-conversation/src/client/contract/tool-call-model.ts +++ b/packages/client/ui-conversation/src/client/contract/tool-call-model.ts @@ -133,8 +133,13 @@ const SUMMARY_KEYS: Record = { others: [], } -/** Strip the workspace root from workspace-rooted absolute paths (display only). */ -function relativizeToCwd(text: string, cwd: string | undefined): string { +/** + * Strip the workspace root from a workspace-rooted absolute path (display only). + * @param text - the path to shorten. + * @param cwd - session workspace root; absent or empty leaves the path unchanged. + * @returns the path relative to the workspace root, or unchanged when it is not rooted there. + */ +export function relativizeToCwd(text: string, cwd: string | undefined): string { if (cwd === undefined || cwd === '') return text const root = cwd.replace(/[/\\]+$/, '') if (text.startsWith(`${root}/`) || text.startsWith(`${root}\\`)) return text.slice(root.length + 1) diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css index 4694715fe1..985f768df2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css @@ -108,8 +108,9 @@ margin: 0; } -/* Same rule for the web card: it sits under the section label, so the section - owns the spacing rather than the primitive's own vertical margin. */ +/* The read and web cards sit directly under their section label, same as the + terminal card: drop the primitive's standalone vertical margin. */ +.read, .web { margin: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 9002ef609e..1ca2941c0e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -7,10 +7,11 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. -import { CodeBlock, DiffBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' +import { CodeBlock, DiffBlock, ReadBlock, TerminalBlock, WebBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' +import { readCardModel } from '../contract/read-card-model.ts' import { diffCardModel } from '../contract/diff-card-model.ts' import { terminalBlockLabels, terminalCardModel } from '../contract/terminal-card-model.ts' import { webCardModel } from '../contract/web-card-model.ts' @@ -129,7 +130,9 @@ export function DetailsPanel({ useSession, useSessions, sessionId, useStore, clo * The Output section's body for the selected call. A terminal-card call — a * shell command's call/result views — renders through the shared TerminalBlock * at the primitive's own full height allowance, so column-aligned output keeps - * its alignment and scrolls sideways instead of folding. A diff-card call — a + * its alignment and scrolls sideways instead of folding. A read-card call + * renders through the shared ReadBlock at that same full height, so the whole + * returned window is line-numbered and highlighted. A diff-card call — a * write/edit's applied change — renders through the shared DiffBlock at the same * full height. A web-card call — a `web_search`/`web_fetch` result — renders * through WebBlock at its own full source-list allowance. Every other call, and @@ -153,6 +156,10 @@ function OutputBody({ material, cwd, t }: { material: CallMaterial; cwd: string ) } + const read = readCardModel(material.block, cwd) + // The panel takes the primitive's own default cap, not the row's tighter one: + // it is the single-call reading surface, so the whole window is available. + if (read !== null) return const diff = diffCardModel(material.block) if (diff !== null) return const web = webCardModel(material.block) diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.module.css b/packages/client/ui-conversation/src/client/toolviews/read-row.module.css new file mode 100644 index 0000000000..a03a949451 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.module.css @@ -0,0 +1,119 @@ +/* Read toolview: same geometry/tokens as ToolRow (figma Read · {path}), plus + the read card the row stacks under its summary line. */ + +/* Summary line over the read card; the summary row keeps its own 24px height, + so the card is a column around it rather than a change to it. */ +.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. */ +.read { + margin: 4px 0 4px 22px; +} + +.root { + position: relative; /* sweep-glare overlay anchor */ + overflow: hidden; + display: flex; + align-items: center; + height: 24px; + min-width: 0; +} + +/* Running sweep glare — same pattern as BashRow/ToolRow, so a running read row + gives the same executing feedback a running command row does. The leading + read icon stays static (a read has no per-step state to animate); the sweep + is the row-level running signal. */ +.root[data-state='running']::after { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 300px; + background: linear-gradient( + 90deg, + transparent 0%, + color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%, + transparent 100% + ); + animation: dsh-read-row-sweep 2.6s ease-out infinite; + pointer-events: none; +} + +@keyframes dsh-read-row-sweep { + 0% { left: -300px; } + 90%, 100% { left: 100%; } +} + +.leading { + flex: none; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-right: 6px; + color: var(--dsw-alias-label-tertiary); +} + +.title { + flex: none; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.sep { + flex: none; + width: 2px; + height: 2px; + border-radius: 1px; + margin: 0 8px; + background: var(--dsw-alias-label-caption); +} + +.summary { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); +} + +/* File path: same geometry as .summary; hover underline + pointer. */ +.fileLink { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0; + border: none; + background: none; + text-align: left; + font-size: 14px; + line-height: 24px; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.fileLink:hover { + text-decoration: underline; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-conversation/src/client/toolviews/read-row.tsx b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx new file mode 100644 index 0000000000..4527f85537 --- /dev/null +++ b/packages/client/ui-conversation/src/client/toolviews/read-row.tsx @@ -0,0 +1,105 @@ +// Read toolview registrant: the keyed toolview hole for the read tool +// (ctx.slots.register + ToolRowProps only — never imports the chat domain). +// Product chrome matches ToolRow (figma: Read · {path}); the summary is the +// file path as an openable link, exactly as the generic read row draws it. +// +// A read RESULT declares the read render intent, so this row renders the file's +// own line-numbered, syntax-highlighted content through ReadBlock resident +// below its summary line — the same posture BashRow gives a terminal card. The +// card is capped at CHAT_READ_MAX_LINES (the chat flow's tighter cap over the +// block's own default of 16) with the block's internal expander keeping a long +// read from taking over the message flow. A running read (no result yet) and a +// non-read result both render the summary row alone. The read intent is +// result-side only, so there is no running-state read card to draw. + +import type { Context } from 'cordis' +import { IconBrowseOutline16, ReadBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ToolRowProps } from '../contract/slots.ts' +import { CHAT_READ_MAX_LINES, readCardModel } from '../contract/read-card-model.ts' +import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts' +import css from './read-row.module.css' + +/** Leading-slot state substitution: the tool icon yields to the state dot + * (error = red, interrupted = amber). Running keeps the icon. */ +function leadingFor(state: ToolRowState) { + switch (state) { + case 'error': return + case 'stopped': return + default: return + } +} + +/** Visually hidden status — StateDot is aria-hidden; AT needs a text label. */ +function stateStatus(state: ToolRowState): string | null { + switch (state) { + case 'running': return '运行中' + case 'error': return '失败' + case 'stopped': return '已停止' + default: return null + } +} + +/** + * Read row: icon + Read · {path} in the shared ToolRow chrome, with the file's + * read card resident below it. The summary path is an openable host link when + * the row names a single file; the card's copy and expand controls plus that + * link are the row's only interactions (tool rows are not details-panel + * targets). + */ +export function ReadRow({ toolName, block, sessionId, useSessions, openFile }: ToolRowProps) { + // Session workspace root: the read view's path relativizes against it (a + // workspace-rooted absolute path shows its short form), which the pure + // presenter cannot do. + const cwd = useSessions(list => list.byId[sessionId]?.cwd) + const model = toolRowModel(toolName, block, cwd) + const read = readCardModel(block, cwd) + const status = stateStatus(model.state) + const filePath = model.filePath + return ( +
+ {/* jscpd:ignore-start — the summary-line chrome (leading, status, title, + sep, path-link/summary) is the shared ToolRow row shape every keyed + toolview draws; extracting it into one component is a separate change + tracked for all rows at once, not this read-card PR. */} +
+ {leadingFor(model.state)} + {status !== null && {status}} + {model.title} + + {filePath !== undefined ? ( + + ) : ( + {model.summary} + )} +
+ {/* jscpd:ignore-end */} + {read !== null && ( + + )} +
+ ) +} + +/** + * The read row as a plain registrant plugin. `inject` carries the load-order + * seam: requiring the conversation service guarantees the chat entry (and with + * it the 'conversation.chat.toolview' declaration) is registered — + * ui-conversation's apply mounts the service after the chat entry. + */ +export const readToolview = { + name: 'read-toolview', + inject: ['slots', 'conversation'], + /** + * Register the read row into the chat view's keyed toolview hole. + * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). + */ + apply(ctx: Context): void { + ctx.slots.register({ name: 'conversation.chat.toolview', key: 'read' }, ReadRow) + }, +} diff --git a/packages/client/ui-conversation/tests/chat-apply.spec.tsx b/packages/client/ui-conversation/tests/chat-apply.spec.tsx index c8efd38a71..0d4d6d4354 100644 --- a/packages/client/ui-conversation/tests/chat-apply.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-apply.spec.tsx @@ -88,14 +88,14 @@ describe('apply wiring', () => { await b.runtime.dispose() }) - it('mounts the bash sample, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => { + it('mounts the bash sample, the read row, the file-mutation rows, the web rows, and the product rows as keyed entries through the load-order seam', async () => { const b = await bench() // Every registrant plugin's inject: ['slots', 'conversation'] resolved — the // service being present implies the chat entry declared the hole first. The // file-mutation registrant claims both write and edit for the diff card; the // web rows register one component under both web tool names. const entries = b.slots.entries('conversation.chat.toolview') - expect(entries.map(e => e.options.key)).toEqual(['bash', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) + expect(entries.map(e => e.options.key)).toEqual(['bash', 'read', 'edit', 'write', 'web_search', 'web_fetch', 'todo_write', 'ask_user_question']) // Stats stick with the composer (not inside ChatView). expect(b.slots.entries('conversation.composer.dock').map(e => e.options.id)).toEqual(['stats']) await b.runtime.dispose() diff --git a/packages/client/ui-conversation/tests/read-card.spec.tsx b/packages/client/ui-conversation/tests/read-card.spec.tsx new file mode 100644 index 0000000000..ae458303e8 --- /dev/null +++ b/packages/client/ui-conversation/tests/read-card.spec.tsx @@ -0,0 +1,293 @@ +// @vitest-environment jsdom +// The read render intent on the web side: the pure readCardModel derivation +// over the settled result view, and both conversation render sites that consume +// it — the chat tool row (the keyed ReadRow and the GenericToolCard fallback, +// each with the read card resident under the summary) and the details panel's +// Output section. Also pins the keyed 'read' toolview registration. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { Context } from 'cordis' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import type { + ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { ToolResultView } from '@deepseek-ai/dsh-client-connection/client' +import type { SelectionTarget, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { CHAT_READ_MAX_LINES, readCardModel } from '../src/client/contract/read-card-model.ts' +import { createChatStore } from '../src/client/stores.ts' +import { GenericToolCard, type GenericToolCardProps } from '../src/client/chat/GenericToolCard.tsx' +import { zh } from '../src/client/locales.ts' +import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx' +import { ReadRow, readToolview } from '../src/client/toolviews/read-row.tsx' + +afterEach(cleanup) + +const SID = 's1' as SessionId + +/** The chat-view locale seat: this package's namespace over the common fallback. */ +const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh) + +// The read tool's real schema key is `file_path`; the top-level read samples +// use it so the row exercises a production-shaped call. `web_fetch` (below) has +// its own schema whose key is not `file_path`, so it keeps a `url`-less `path`. +const ARGS = '{"file_path":"src/a.ts","offset":41}' +const WEB_FETCH_ARGS = '{"path":"src/a.ts","offset":41}' + +/** The read block's rendered content cells, one string per row (highlighting + * breaks a line across token spans, so match on the row's textContent). */ +function contentTexts(container: HTMLElement): string[] { + return [...container.querySelectorAll('[data-read] [class^="_content_"]')].map(cell => cell.textContent ?? '') +} + +/** Three windowed lines starting at file line 41 (a read past an offset). */ +const sampleLines = [ + { number: 41, text: 'export const a = 1' }, + { number: 42, text: 'export const b = 2' }, + { number: 43, text: 'export const c = 3' }, +] + +/** The read tool's own result view for a settled file read. */ +const resultRead = (over?: Partial>): ToolResultView => ({ + card: 'read', path: 'src/a.ts', offset: 41, lines: sampleLines, totalLines: 180, lang: 'ts', ...over, +}) + +const running = (over?: Partial): RunningToolCall => ({ + callId: 'c1', name: 'read', argsRaw: ARGS, + turn: 1, step: 1, time: 1_000, callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, ...over, +}) + +const settled = (over?: Partial): ToolResultNode => ({ + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', + call: { name: 'read', argsRaw: ARGS }, + callTime: 1_000, + content: [{ type: 'text', text: '41: export const a = 1' }], isError: false, + callView: { card: 'generic', title: 'Read src/a.ts', kind: 'read' }, resultView: resultRead(), ...over, +}) + +describe('readCardModel', () => { + it('derives the card from a settled read result view', () => { + expect(readCardModel(settled())).toEqual({ + label: 'src/a.ts', lines: sampleLines, totalLines: 180, lang: 'ts', + }) + }) + + it('copies the lines into the primitive shape rather than aliasing the frozen slice', () => { + const model = readCardModel(settled()) + expect(model?.lines).toEqual(sampleLines) + expect(model?.lines).not.toBe(sampleLines) + expect(model?.lines[0]).not.toBe(sampleLines[0]) + }) + + it('takes the result view\'s replacement title over the relativized path', () => { + // The presentation contract defines a result title as REPLACING the pending + // one, so a tool that supplies a label wins over the path here. + expect(readCardModel(settled({ resultView: resultRead({ title: 'Read (head) src/a.ts' }) }))?.label) + .toBe('Read (head) src/a.ts') + }) + + it('relativizes a workspace-rooted path label, and leaves others as authored', () => { + // A workspace-rooted absolute path shows its short form. + expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }), '/w/app')?.label) + .toBe('src/a.ts') + // A path outside the workspace stays as authored. + expect(readCardModel(settled({ resultView: resultRead({ path: '/srv/other.ts' }) }), '/w/app')?.label) + .toBe('/srv/other.ts') + // With no session cwd there is nothing to relativize against. + expect(readCardModel(settled({ resultView: resultRead({ path: '/w/app/src/a.ts' }) }))?.label) + .toBe('/w/app/src/a.ts') + }) + + it('carries an omitted language through as undefined', () => { + const noLang = resultRead() + delete (noLang as { lang?: string }).lang + expect(readCardModel(settled({ resultView: noLang }))?.lang).toBeUndefined() + }) + + it('returns null for a running read: the read intent is result-side only', () => { + // A read carries no content until execute returns, so the pending call is a + // generic card and there is no read card to draw yet. + expect(readCardModel(running())).toBeNull() + }) + + it('returns null for every non-read settled call: no view, generic view, unknown card', () => { + expect(readCardModel(settled({ resultView: null }))).toBeNull() + expect(readCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull() + // A card tag this UI version does not know arrives over the wire; the + // documented generic-card default takes it, not a crash. + const future = { card: 'chart' } as unknown as ToolResultView + expect(readCardModel(settled({ resultView: future }))).toBeNull() + }) +}) + +describe('GenericToolCard read body', () => { + const ownerProps = (block: RunningToolCall | ToolResultNode): GenericToolCardProps => ({ + callId: 'c1', toolName: 'web_fetch', block, openFile: vi.fn(), t, + }) + + it('renders the read card resident under the summary, capped tighter than the panel', () => { + expect(CHAT_READ_MAX_LINES).toBeLessThan(16) + // web_fetch lands on the read variant without its own keyed row, so the + // fallback card owns the resident read block. + const view = render() + expect(view.container.querySelector('[data-read]')).not.toBeNull() + expect(contentTexts(view.container)).toContain('export const a = 1') + // The gutter keeps the file's own line numbers. + expect(view.getByText('41')).toBeTruthy() + }) + + it('a non-read tool renders the bare row with no read card', () => { + const view = render() + expect(view.container.querySelector('[data-read]')).toBeNull() + }) + + it('a running read renders the summary row alone (no result view yet)', () => { + const view = render() + expect(view.container.querySelector('[data-read]')).toBeNull() + }) +}) + +describe('ReadRow keyed toolview', () => { + const list = () => createSnapshotStore({ + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd: '/w/app' } }, + current: SID, + phase: 'ready', + }) + + const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({ + callId: 'c1', toolName: 'read', block, openFile: vi.fn(), + sessionId: SID, useSessions: bindSnapshotSelector(list()), + } as unknown as ToolRowProps) + + it('renders the file path summary and the resident read card', () => { + const view = render() + expect(view.getByText('Read')).toBeTruthy() + // The path appears twice: the row summary link and the card's banner label. + expect(view.getAllByText('src/a.ts').length).toBe(2) + expect(view.container.querySelector('[data-read]')).not.toBeNull() + expect(contentTexts(view.container)).toContain('export const a = 1') + expect(view.getByText('显示 3 / 180 行')).toBeTruthy() + }) + + it('the path summary opens the file through the host', () => { + const openFile = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'src/a.ts' })) + // The row derives the file path from args; the chat view resolves it against + // the cwd before this callback opens it, so the arg path is what arrives. + expect(openFile).toHaveBeenCalledWith('src/a.ts') + }) + + it('a running read renders the summary row alone, and its state', () => { + const view = render() + expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('running') + expect(view.container.querySelector('[data-read]')).toBeNull() + }) + + it('an error read result shows the error state and no read card', () => { + const view = render() + expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('error') + expect(view.container.querySelector('[data-read]')).toBeNull() + }) + + it('an interrupted read shows the stopped state', () => { + const view = render() + expect(view.container.querySelector('[data-variant="read"]')?.getAttribute('data-state')).toBe('stopped') + }) + + it('registers under the read key of the keyed toolview slot', () => { + const registered: { name: unknown; key?: unknown }[] = [] + const ctx = { slots: { register: (options: { name: unknown; key?: unknown }) => { registered.push(options) } } } as unknown as Context + readToolview.apply(ctx) + expect(registered).toEqual([{ name: 'conversation.chat.toolview', key: 'read' }]) + expect(readToolview.inject).toContain('conversation') + }) +}) + +describe('DetailsPanel Output section (read)', () => { + function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) { + localStorage.clear() + const chat = createChatStore().create() + if (selection !== null) chat.actions.select(selection) + const sessions = createSnapshotStore(cwd === undefined + ? { ids: [], byId: {}, current: undefined, phase: 'ready' } + : { + ids: [SID], + byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } }, + current: SID, + phase: 'ready', + }) + const workspaces = createSnapshotStore({ + items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, + baselinesReady: true, recentWorkspaceId: undefined, + }) + return render( + snapshot, subscribe: () => () => {} })} + useSessions={bindSnapshotSelector(sessions)} + useWorkspaces={bindSnapshotSelector(workspaces)} + useInput={(() => { throw new Error('unused') })} + inputActions={{ setDraft: () => {}, submit: () => {} }} + useProjection={(() => undefined)} + useStore={bindSnapshotSelector(chat)} + actions={chat.actions} + closeDetails={vi.fn()} + />, + ) + } + + function snapshot(over: Partial = {}): ConversationSnapshot { + return { + sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(), + pending: [], queue: [], running: false, composerPhase: 'active', removed: false, + openState: 'open', openError: null, hasMore: false, loadingOlder: false, + promptError: null, blank: false, lastAgentError: null, ...over, + } + } + + const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'read' } + + it('renders the read card at full height, keeping the JSON Input section', () => { + const long = Array.from({ length: 20 }, (_, i) => ({ number: i + 1, text: `row-${i}` })) + const view = mount(snapshot({ + nodes: [settled({ resultView: resultRead({ lines: long, totalLines: 20 }) })], + }), target) + expect(view.getByText(/"file_path"/)).toBeTruthy() + expect(view.container.querySelector('[data-read]')).not.toBeNull() + // The panel takes the primitive's own default cap (16), not the row's. + expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy() + expect(contentTexts(view.container)).toContain('row-0') + }) + + it('a non-read result keeps the flattened pre form', () => { + const view = mount(snapshot({ + nodes: [settled({ + callView: null, resultView: null, + content: [{ type: 'text', text: 'plain result' }], + })], + }), target) + expect(view.container.querySelector('[data-read]')).toBeNull() + expect(view.getByText('输出').closest('section')?.querySelector('pre')?.textContent).toBe('plain result') + }) + + it('a running read keeps the 运行中… placeholder (no result view)', () => { + const view = mount(snapshot({ runningCalls: [running()] }), target) + expect(view.getByText('运行中…')).toBeTruthy() + expect(view.container.querySelector('[data-read]')).toBeNull() + }) +}) diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 0cd3fa6269..ac9bc641e9 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/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-models/README.md -README.md: 4edb34ccbe8f628c04e410a6dd2f002e247623f3 -README.zh.md: 68a1e0ee205d3ba764620bcfeba7c11a88ee8736 +README.md: 937b8e6bf9b41049f359d702eb3ac2dc11bf0767 +README.zh.md: 37d8642e8d6d52a2d95e86207649b7a6ce3e8246 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 4edb34ccbe..937b8e6bf9 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -Models settings plugin: the provider configuration page and official-DeepSeek first-run routing overlay. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. +Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. -The first-run overlay projects `deepseek-official` readiness from that same joined snapshot. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference suppresses the prompt, including a read-only launch-environment credential. Only a mounted adapter with a missing writable reference shows the action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the overlay never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability is skipped so onboarding cannot block the rest of the product; the Models page remains the diagnostic surface. +The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 68a1e0ee20..37d8642e8d 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -模型设置插件:提供方配置页和 DeepSeek 官方首次使用跳转浮层。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 +模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另加 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai);其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 -首次使用浮层从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此不会把同一提供方 ID 下没有相应声明的存活路由视为可通过配置修复。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,浮层就不再显示,其中包括来自启动环境且只读的凭据。只有适配器已挂载、引用可写但尚未配置时,浮层才显示一个操作按钮,用于打开「设置」的 Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,浮层绝不持有 secret。适配器缺失、路由未激活、联接失败、部署只读、设置能力不可用或凭据能力不可用时均跳过,以免首次使用引导阻塞产品的其他部分;Models 页仍是诊断界面。 +前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css index 577b8e0287..221239d7bb 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css @@ -1,7 +1,99 @@ -.dialog { - width: min(420px, 100%); +.page { + position: relative; + z-index: 1; + width: min(640px, calc(100vw - 64px)); + max-height: 100vh; + padding: clamp(104px, 18vh, 156px) 0 40px; + box-sizing: border-box; + overflow-y: auto; + color: var(--dsw-alias-label-primary); +} + +.brand { + display: flex; + align-items: center; + margin-bottom: 42px; + color: var(--dsw-alias-label-primary); +} + +.title { + margin: 0; + font-size: 28px; + line-height: 36px; + font-weight: 600; + letter-spacing: -0.02em; + outline: none; +} + +.description { + margin: 16px 0 0; + font-size: 16px; + line-height: 28px; + color: var(--dsw-alias-label-secondary); +} + +.actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin-top: 32px; } .primary { - width: 100%; + min-width: 132px; +} + +.brand, +.title, +.description, +.actions { + animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.title { animation-delay: 40ms; } +.description { animation-delay: 80ms; } +.actions { animation-delay: 120ms; } + +@keyframes credential-enter { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .brand, + .title, + .description, + .actions { + animation: none; + } +} + +@media (max-width: 560px) { + .page { + width: calc(100vw - 40px); + padding-top: 64px; + } + + .brand { + margin-bottom: 30px; + } + + .actions { + align-items: stretch; + flex-direction: column-reverse; + margin-top: 32px; + } + + .primary, + .later { + width: 100%; + } } diff --git a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx index b4bfcf02d0..7ee67484bc 100644 --- a/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx @@ -1,13 +1,13 @@ /** - * Official-DeepSeek first-run dialog. Readiness comes from the same + * Official-DeepSeek first-run step. Readiness comes from the same * provider/settings/credential join as the Models page; the prompt only * routes the user to that page's single credential editor. */ -import { useEffect, useState } from 'react' +import { useEffect, useRef } from 'react' import type { ReactNode } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { deepSeekReadiness } from './store.ts' @@ -37,29 +37,35 @@ function assertNever(_value: never): never { * Prompt a first-run user to open Models while the official adapter exists * and its effective credential is not configured. * @param props - settings-shell owner state and Models feature dependencies. - * @returns the controlled modal or null when onboarding needs no intervention. + * @returns the onboarding page or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { - const { active, openSection, controller, useSnapshot, t } = props + const { complete, openSection, controller, useSnapshot, t } = props const state = useSnapshot(snapshot => snapshot) const readiness = deepSeekReadiness(state) - const [dismissed, setDismissed] = useState(false) + const titleRef = useRef(null) useEffect(() => { - if (active && !dismissed && state.status === 'idle') void controller.load() - }, [active, controller, dismissed, state.status]) + if (state.status === 'idle') void controller.load() + }, [controller, state.status]) - const close = (): void => { - setDismissed(true) - } + useEffect(() => { + if ( + readiness.kind === 'adapter-absent' + || readiness.kind === 'configured' + || readiness.kind === 'unavailable' + ) complete() + }, [complete, readiness.kind]) + + useEffect(() => { + if (readiness.kind === 'credential-missing') titleRef.current?.focus() + }, [readiness.kind]) const openModels = (): void => { - close() + complete() openSection('models') } - if (!active || dismissed) return null - switch (readiness.kind) { case 'loading': case 'adapter-absent': @@ -74,23 +80,25 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): } return ( - +
+ +

+ {t('onboardingTitle')} +

+

{t('onboardingDescription')}

+
+ + - )} - /> +
+
) } diff --git a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx index 1d757d340a..07acc89fe4 100644 --- a/packages/client/ui-models/tests/onboarding-dialog.spec.tsx +++ b/packages/client/ui-models/tests/onboarding-dialog.spec.tsx @@ -24,8 +24,8 @@ function fail(message: string): RpcResponse { function harness(options: { provider?: boolean - providerActive?: boolean providerSettingsNs?: string + providerActive?: boolean settingsNamespace?: boolean apiKeyEnv?: string | null literal?: boolean @@ -40,9 +40,7 @@ function harness(options: { const face = { llm: { providers: () => { - if (options.providersReject === true) { - return Promise.reject(new Error('provider transport unavailable')) - } + if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable')) return Promise.resolve(ok({ providers: options.provider === false ? [] @@ -91,9 +89,11 @@ function harness(options: { } const controller = new ModelsSettingsStore(face as never) const openSection = vi.fn() + const complete = vi.fn() const unusedHook = (() => { throw new Error('unused standard hook') }) as never const props: DeepSeekOnboardingDialogProps = { - active: true, + stepId: 'deepseek-official', + complete, openSection, useSessions: unusedHook, useWorkspaces: unusedHook, @@ -101,36 +101,36 @@ function harness(options: { useSnapshot: bindSnapshotSelector(controller.store), t: key => en[key], } - return { controller, openSection, props, configure: () => { fileConfigured = true } } + return { controller, complete, openSection, props, configure: () => { fileConfigured = true } } } describe('DeepSeekOnboardingDialog', () => { it('loads on first entry and presents one accessible route to Models', async () => { const h = harness() render() - expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy() + expect(await screen.findByRole('region', { name: en.onboardingTitle })).toBeTruthy() expect(screen.getByText(en.onboardingDescription)).toBeTruthy() const action = screen.getByRole('button', { name: en.onboardingGoToSettings }) expect(action).toBeTruthy() - expect(document.activeElement).toBe(action) + expect(document.activeElement).toBe(screen.getByRole('heading', { name: en.onboardingTitle })) expect(screen.queryByRole('textbox')).toBeNull() }) it('opens the Models section and dismisses the prompt', async () => { const h = harness() render() - await screen.findByRole('dialog') + await screen.findByRole('region') fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings })) + expect(h.complete).toHaveBeenCalledOnce() expect(h.openSection).toHaveBeenCalledWith('models') - expect(screen.queryByRole('dialog', { name: en.onboardingTitle })).toBeNull() }) it('allows configure-later dismissal without opening settings', async () => { const h = harness() render() - await screen.findByRole('dialog') + await screen.findByRole('region') fireEvent.click(screen.getByRole('button', { name: en.onboardingLater })) - expect(screen.queryByRole('dialog')).toBeNull() + expect(h.complete).toHaveBeenCalledOnce() expect(h.openSection).not.toHaveBeenCalled() }) @@ -146,7 +146,8 @@ describe('DeepSeekOnboardingDialog', () => { ]) { const view = render() await act(async () => { await h.controller.load() }) - expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() + await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() }) expect(h.openSection).not.toHaveBeenCalled() view.unmount() } @@ -161,7 +162,8 @@ describe('DeepSeekOnboardingDialog', () => { ]) { const view = render() await act(async () => { await h.controller.load() }) - expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.queryByRole('region')).toBeNull() + await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() }) view.unmount() } }) @@ -169,18 +171,10 @@ describe('DeepSeekOnboardingDialog', () => { it('closes when an external credential invalidation refreshes the shared join', async () => { const h = harness() render() - await screen.findByRole('dialog') + await screen.findByRole('region') h.configure() await act(async () => { await h.controller.load() }) - await waitFor(() => { expect(screen.queryByRole('dialog')).toBeNull() }) - }) - - it('stays hidden while the onboarding owner is inactive', async () => { - const h = harness() - const view = render() - await act(async () => { await h.controller.load() }) - expect(screen.queryByRole('dialog')).toBeNull() - view.rerender() - expect(await screen.findByRole('dialog', { name: en.onboardingTitle })).toBeTruthy() + await waitFor(() => { expect(screen.queryByRole('region')).toBeNull() }) + expect(h.complete).toHaveBeenCalledOnce() }) }) diff --git a/packages/client/ui-primitives/src/ReadBlock.module.css b/packages/client/ui-primitives/src/ReadBlock.module.css new file mode 100644 index 0000000000..a18afe6152 --- /dev/null +++ b/packages/client/ui-primitives/src/ReadBlock.module.css @@ -0,0 +1,117 @@ +/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner row, + markdown code-block font) so a read card and a fenced code block read as one + family. Content keeps `white-space: pre` and scrolls horizontally rather than + folding, because a source line's indentation is part of what a reader is + reading. */ + +.block { + --dsl-read-radius: 12px; + --dsl-read-line-height: 22px; + /* Fixed-width gutter column for the line numbers, so the content edge stays + put down the whole window regardless of how wide the numbers grow. */ + --dsl-read-gutter: 48px; + + position: relative; + margin: 16px 0; + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-markdown-code-block); + border-radius: var(--dsl-read-radius); +} + +.banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 9px 14px; + background: var(--dsw-alias-markdown-code-block-banner); + border-top-left-radius: var(--dsl-read-radius); + border-top-right-radius: var(--dsl-read-radius); +} + +.label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + +.action { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 12px; +} + +.count { + color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); +} + +.lang { + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; +} + +.copyButton { + background-color: transparent; + border: none; + padding: 0; + margin: 0; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + font: var(--dsw-font-xs-13); +} + +.body { + padding: 12px 0; + font: var(--dsw-font-markdown-code-block); + overflow-x: auto; + overflow-y: hidden; +} + +/* One row per file line: a fixed gutter column, then the content. No wrapping — + a source line's leading whitespace is meaningful and scrolls sideways. */ +.line { + display: flex; + min-height: var(--dsl-read-line-height); + line-height: var(--dsl-read-line-height); + white-space: pre; +} + +.gutter { + flex: none; + width: var(--dsl-read-gutter); + padding-right: 14px; + text-align: right; + color: var(--dsw-alias-label-tertiary); + /* The gutter is chrome, not content: keep it out of a text selection so a + copy of the visible rows carries the source, not the line numbers. */ + user-select: none; +} + +.content { + color: var(--dsw-alias-label-primary); +} + +.expand { + display: block; + width: 100%; + padding: 0 0 0 var(--dsl-read-gutter); + border: none; + background-color: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; + font: inherit; + text-align: left; +} + +.expand:hover { + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-primitives/src/ReadBlock.tsx b/packages/client/ui-primitives/src/ReadBlock.tsx new file mode 100644 index 0000000000..a6a83d3668 --- /dev/null +++ b/packages/client/ui-primitives/src/ReadBlock.tsx @@ -0,0 +1,172 @@ +// ReadBlock: the file surface for a read tool result — a banner (label + +// language + a "showing N of M" note when the read is a window + a copy +// control) over line-numbered, syntax-highlighted source. Each row carries the +// file's OWN line number in a gutter, so a windowed read past an offset keeps +// its file numbering rather than re-counting from 1. Highlighting reuses the +// CodeBlock shiki path (highlight.ts) at the per-line granularity a gutter +// needs; an unknown or absent language renders plain monospace. Long content is +// height-capped with the same head/tail arithmetic TerminalBlock uses, so the +// two cards collapse a long body at the same place. Colors resolve through +// --shiki-*/--dsw-* tokens. + +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import clsx from 'clsx' +import { writeClipboard } from './clipboard.ts' +import { + grammarLoadCount, + highlightLines, + subscribeGrammarLoaded, + type HighlightSpan, +} from './markdown/highlight.ts' +import css from './ReadBlock.module.css' + +/** + * Content lines shown before the height cap collapses the middle. Matches + * TerminalBlock's default so a long read and a long command output cut at the + * same place in the same flow. + */ +export const DEFAULT_READ_MAX_LINES = 16 + +/** One line of the read window: its file line number and its text (no trailing newline). */ +export interface ReadBlockLine { + /** 1-based line number in the file (a window past an offset keeps the file's own numbering). */ + number: number + /** The line's text, already truncated to the read tool's per-line cap. */ + text: string +} + +export interface ReadBlockProps { + /** Banner label (the file path, or a tool-supplied replacement title); omitted draws no label. */ + label?: string | undefined + /** The returned window's lines, in file order, each keeping its file line number. */ + lines: readonly ReadBlockLine[] + /** Exact total line count in the file, for the "showing N of M" note when the read is a window. */ + totalLines: number + /** Grammar hint (a file-extension-derived language id); unknown or absent = plain monospace. */ + lang?: string | undefined + /** Height cap in content lines before the middle collapses (default {@link DEFAULT_READ_MAX_LINES}). */ + maxLines?: number | undefined + /** Extra class merged onto the wrapper (callers position; this component draws). */ + className?: string | undefined +} + +/** + * Render one line's highlighted runs. The css-variables theme colors every run, + * so each run is a styled span; a line with no highlighting at all takes the + * bare-text path in the caller instead (an unknown or absent language). + * @param spans - the line's styled runs. + * @returns the line's children. + */ +function renderSpans(spans: readonly HighlightSpan[]) { + return spans.map((span, index) => {span.text}) +} + +/** + * Render a read tool result as a line-numbered, optionally syntax-highlighted + * file view. + * @param props - see {@link ReadBlockProps}. + * @returns the read block element. + */ +export function ReadBlock({ + label, + lines, + totalLines, + lang, + maxLines = DEFAULT_READ_MAX_LINES, + className, +}: ReadBlockProps) { + // The raw text the copy control writes and the highlighter tokenizes: the + // window's lines joined by newlines, without the file numbers or any chrome. + // Highlighting the whole window in one call (not line by line) keeps grammar + // context across lines — a multi-line string or comment stays one construct. + const raw = useMemo(() => lines.map(line => line.text).join('\n'), [lines]) + // Re-render when a lazy grammar finishes loading, so a read card that showed + // plain text while its language's grammar imported picks up highlighting. The + // snapshot value is opaque; only its change across renders drives the memo. + const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount) + // Per-line highlighted runs aligned 1:1 with `lines`; undefined for an + // unknown/absent (or not-yet-loaded) language, when every line renders as + // bare text. + const highlighted = useMemo(() => highlightLines(raw, lang), [raw, lang, loaded]) + const [expanded, setExpanded] = useState(false) + const [copied, setCopied] = useState(false) + + const onCopy = useCallback(() => { + if (copied) return + // The window's raw text, never the rendered tree: the gutter numbers and the + // banner are chrome the file does not contain. + void writeClipboard(raw).then((ok) => { + if (!ok) return + setCopied(true) + window.setTimeout(() => { setCopied(false) }, 1000) + }) + }, [copied, raw]) + + const onToggle = useCallback(() => { setExpanded(value => !value) }, []) + + const hidden = lines.length - maxLines + const capped = hidden > 0 && !expanded + // Same split arithmetic as TerminalBlock's height cap, so a long read and a + // long command output slice their head and tail at the same place. + const headLines = Math.ceil(maxLines / 2) + const tailLines = maxLines - headLines + // A read is a window when its returned lines are fewer than the file's total; + // the note states that so a reader is not misled that the file ends here. + const windowed = lines.length < totalLines + + /** + * Render a slice of the line array as gutter-numbered rows. + * @param slice - the lines to draw, each with its aligned run array. + * @returns the row elements. + */ + const rows = (slice: readonly (readonly [ReadBlockLine, readonly HighlightSpan[] | undefined])[]) => + slice.map(([line, spans]) => ( +
+ {line.number} + {spans === undefined ? line.text : renderSpans(spans)} +
+ )) + + // Pair each line with its aligned run array up front, so head/tail slicing + // keeps the two in step without re-indexing. + const paired = lines.map((line, index): readonly [ReadBlockLine, readonly HighlightSpan[] | undefined] => + [line, highlighted?.[index]]) + + return ( +
+
+
{label ?? ''}
+
+ {windowed && ( + {`显示 ${lines.length} / ${totalLines} 行`} + )} + {lang ?? ''} + {/* Hide copy on an empty window, matching TerminalBlock's empty-output + guard: a successful read of an empty file returns lines: [] with + card:'read', so this branch is reachable, and copying then would + wipe the clipboard with an empty string. */} + {lines.length > 0 && ( + + )} +
+
+
+ {rows(capped ? paired.slice(0, headLines) : paired)} + {hidden > 0 && ( + + )} + {capped && rows(paired.slice(paired.length - tailLines))} +
+
+ ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 39b40bf680..684e7f4c0d 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -24,6 +24,8 @@ export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' +export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx' +export type { ReadBlockProps, ReadBlockLine } from './ReadBlock.tsx' export { DiffBlock, DEFAULT_DIFF_MAX_LINES } from './DiffBlock.tsx' export type { DiffBlockProps, DiffHunk } from './DiffBlock.tsx' export { WebBlock, DEFAULT_WEB_MAX_SOURCES } from './WebBlock.tsx' diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx index e161be4b9f..7f2fb1caf7 100644 --- a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx +++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx @@ -4,10 +4,10 @@ // plain fallback for everything else. Chrome (language banner + copy) matches // deepsuite `@deepseek/md` code blocks; token colors stay on `--shiki-*`. -import { useCallback, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { writeClipboard } from '../clipboard.ts' -import { highlightToHtml } from './highlight.ts' +import { grammarLoadCount, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts' import css from './CodeBlock.module.css' export interface CodeBlockProps { @@ -25,7 +25,11 @@ export interface CodeBlockProps { export function CodeBlock({ code, lang, className, copyLabel = '复制', copiedLabel = '复制成功' }: CodeBlockProps) { const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code - const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang]) + // Re-render when a lazy grammar finishes loading, so a fence that showed plain + // text while its language's grammar imported picks up highlighting. The + // snapshot value is opaque; only its change across renders drives the memo. + const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount) + const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang, loaded]) const rootRef = useRef(null) const [copied, setCopied] = useState(false) diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts index 1fa50f6d2f..d0727c833a 100644 --- a/packages/client/ui-primitives/src/markdown/highlight.ts +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -5,10 +5,17 @@ * theme package's token sheets as `--shiki-*` custom properties (light and * dark blocks), never here — the repo's tokens-only styling rule. * - * Grammars are the set the harness actually renders: TypeScript programs - * (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands, - * and JSON payloads. An unknown or absent language falls back to plain text - * (no highlighting, still monospace) — never an error. + * Only the three markdown-fence and `run_code` grammars (TypeScript, shell, + * JSON) load into the singleton at boot — the set every session renders. The + * read card's wider extension set (the file-extension language hints the read + * tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml, + * markup, …) is imported lazily and registered the first time such a language + * is requested, so a session that never opens a read card in one of those + * languages pays neither the ~1.6 MB of grammar modules nor their synchronous + * init. The first render of a lazy language falls back to plain text while its + * grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render + * with highlighting. An unknown or absent language falls back to plain text (no + * highlighting, still monospace) — never an error. */ import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core' @@ -17,12 +24,69 @@ import langTs from '@shikijs/langs/typescript' import langBash from '@shikijs/langs/shellscript' import langJson from '@shikijs/langs/json' import type { HighlighterCore } from 'shiki/core' +import type { CSSProperties } from 'react' + +/** A shiki grammar module's default export (a `LanguageRegistration[]`), taken + * from a boot grammar so no direct `@shikijs/types` dependency is needed. */ +type LangModule = { default: typeof langTs } /** - * Language ids (and aliases) the singleton registers; everything else renders + * Grammars the singleton loads at boot; each entry's own `name` is the id + * `codeToTokens`/`codeToHtml` resolve. The JS-family aliases (js/jsx/ts/tsx) + * resolve to the TypeScript grammar rather than a separate one: it tokenizes + * plain TS/JS exactly, and JSX/TSX approximately (shiki's TS grammar is not the + * dedicated TSX grammar, so JSX elements tokenize imperfectly) — an accepted + * trade to keep the boot set to one JS-family grammar. The read card's wider + * set loads lazily through {@link LAZY_GRAMMARS}. + */ +const LANGS = [langTs, langBash, langJson] + +/** + * The read card's extension grammars, each behind a dynamic import so its + * module stays out of the boot chunk until a read of that language renders. + * Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to. + * `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader + * hands the whole array to `loadLanguageSync`, which registers each entry + * (including embedded sub-grammars). The three boot grammars are absent — + * already loaded, so no alias value ever points at a missing entry here. + */ +const LAZY_GRAMMARS = new Map Promise>([ + ['python', () => import('@shikijs/langs/python')], + ['ruby', () => import('@shikijs/langs/ruby')], + ['go', () => import('@shikijs/langs/go')], + ['rust', () => import('@shikijs/langs/rust')], + ['java', () => import('@shikijs/langs/java')], + ['c', () => import('@shikijs/langs/c')], + ['cpp', () => import('@shikijs/langs/cpp')], + ['csharp', () => import('@shikijs/langs/csharp')], + ['kotlin', () => import('@shikijs/langs/kotlin')], + ['swift', () => import('@shikijs/langs/swift')], + ['php', () => import('@shikijs/langs/php')], + ['yaml', () => import('@shikijs/langs/yaml')], + ['toml', () => import('@shikijs/langs/toml')], + ['ini', () => import('@shikijs/langs/ini')], + ['markdown', () => import('@shikijs/langs/markdown')], + ['mdx', () => import('@shikijs/langs/mdx')], + ['html', () => import('@shikijs/langs/html')], + ['css', () => import('@shikijs/langs/css')], + ['scss', () => import('@shikijs/langs/scss')], + ['less', () => import('@shikijs/langs/less')], + ['sql', () => import('@shikijs/langs/sql')], + ['xml', () => import('@shikijs/langs/xml')], + ['lua', () => import('@shikijs/langs/lua')], +]) + +/** + * Language ids (and aliases) the highlighter accepts; everything else renders * plain. A Map, not an object: fence info strings are assistant-authored, so * a label like `constructor` or `__proto__` must miss instead of resolving an - * inherited property and crashing the renderer inside shiki. + * inherited property and crashing the renderer inside shiki. Keys cover both + * the markdown-fence aliases `CodeBlock` uses and the file-extension hint ids + * the read tool's `langFromPath` emits, so both callers resolve the same + * grammars. The JS family maps to the TypeScript grammar (see {@link LANGS} for + * the JSX/TSX approximation), unchanged from when this was the only + * non-shell/JSON grammar. A value not in {@link LANGS} names a + * {@link LAZY_GRAMMARS} entry loaded on first use. */ const LANG_ALIASES = new Map([ ['typescript', 'typescript'], @@ -30,6 +94,7 @@ const LANG_ALIASES = new Map([ ['tsx', 'typescript'], ['javascript', 'typescript'], ['js', 'typescript'], + ['jsx', 'typescript'], ['shellscript', 'shellscript'], ['bash', 'shellscript'], ['sh', 'shellscript'], @@ -37,6 +102,35 @@ const LANG_ALIASES = new Map([ ['zsh', 'shellscript'], ['json', 'json'], ['jsonc', 'json'], + ['py', 'python'], + ['python', 'python'], + ['rb', 'ruby'], + ['ruby', 'ruby'], + ['go', 'go'], + ['rs', 'rust'], + ['rust', 'rust'], + ['java', 'java'], + ['c', 'c'], + ['cpp', 'cpp'], + ['cs', 'csharp'], + ['csharp', 'csharp'], + ['kotlin', 'kotlin'], + ['swift', 'swift'], + ['php', 'php'], + ['yaml', 'yaml'], + ['yml', 'yaml'], + ['toml', 'toml'], + ['ini', 'ini'], + ['md', 'markdown'], + ['markdown', 'markdown'], + ['mdx', 'mdx'], + ['html', 'html'], + ['css', 'css'], + ['scss', 'scss'], + ['less', 'less'], + ['sql', 'sql'], + ['xml', 'xml'], + ['lua', 'lua'], ]) /** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ @@ -52,12 +146,68 @@ let singleton: HighlighterCore | undefined function highlighter(): HighlighterCore { singleton ??= createHighlighterCoreSync({ themes: [cssVariablesTheme], - langs: [langTs, langBash, langJson], + langs: LANGS, engine: createJavaScriptRegexEngine({ forgiving: true }), }) return singleton } +/** Grammar ids whose lazy import is in flight or done, so it is requested once. */ +const requested = new Set() +/** Subscribers re-rendered after a lazy grammar registers (React callers). */ +const listeners = new Set<() => void>() +/** Bumped on each lazy-grammar load; the `useSyncExternalStore` snapshot. */ +let loadCount = 0 + +/** + * Subscribe to lazy-grammar load completions; `listener` fires after a + * {@link LAZY_GRAMMARS} grammar finishes registering on the singleton, so a + * caller that rendered its plain fallback while the grammar loaded can + * re-highlight. Shaped as a `useSyncExternalStore` subscribe: pair it with + * {@link grammarLoadCount} as the snapshot. Returns an unsubscribe function. + * @param listener - invoked (no args) on each grammar-load completion. + * @returns a disposer that removes the listener. + */ +export function subscribeGrammarLoaded(listener: () => void): () => void { + listeners.add(listener) + return () => { listeners.delete(listener) } +} + +/** + * The lazy-grammar load counter — a value that changes on every load, so a + * `useSyncExternalStore` snapshot re-renders the subscriber when a grammar + * registers. Opaque: only its identity across renders matters. + * @returns the current load count. + */ +export function grammarLoadCount(): number { + return loadCount +} + +/** + * Ensure the grammar `resolved` names is registered. A boot grammar (not in + * {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready + * synchronously; a lazy grammar not yet loaded starts its import (once) and + * reports not-ready, so the caller renders plain until a + * {@link subscribeGrammarLoaded} listener fires. + * @param resolved - the grammar id an alias resolved to. + * @returns whether the grammar is registered and ready to tokenize now. + */ +function ensureGrammar(resolved: string): boolean { + const load = LAZY_GRAMMARS.get(resolved) + // A boot grammar (already registered) has no lazy loader; it is always ready. + if (load === undefined) return true + if (highlighter().getLoadedLanguages().includes(resolved)) return true + if (!requested.has(resolved)) { + requested.add(resolved) + void load().then((mod) => { + highlighter().loadLanguageSync(mod.default) + loadCount += 1 + for (const listener of listeners) listener() + }) + } + return false +} + // Engine + grammar construction costs a long task (~120-175ms); building it // during the first finalized fence's render would jank exactly when a stream // completes. Warm the singleton in a deferred task at module load (= plugin @@ -70,13 +220,59 @@ const warmupTimer = setTimeout(() => { highlighter() }, 0) /** * Highlight `code` into shiki's HTML (a single `
` tree)
  * when `lang` maps to a registered grammar; `undefined` means the caller
- * renders its plain fallback.
+ * renders its plain fallback. A lazy grammar not yet loaded returns `undefined`
+ * for this call and loads in the background; subscribe with
+ * {@link onGrammarLoaded} to re-highlight once it registers.
  * @param code - the source text.
  * @param lang - the language hint (a markdown fence info string or a fixed caller id).
- * @returns the highlighted HTML, or `undefined` for unknown languages.
+ * @returns the highlighted HTML, or `undefined` for unknown or not-yet-loaded languages.
  */
 export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
   const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
   if (resolved === undefined) return undefined
+  if (!ensureGrammar(resolved)) return undefined
   return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
 }
+
+/**
+ * One highlighted run of a line: the text and the inline style shiki assigned
+ * it. The css-variables theme colors every run through a `--shiki-*` custom
+ * property, so `style.color` is always present; it is held as a style object
+ * rather than a bare color so a run spreads onto a `` uniformly.
+ */
+export interface HighlightSpan {
+  text: string
+  style: CSSProperties
+}
+
+/**
+ * Tokenize `code` into per-line highlighted runs when `lang` maps to a
+ * registered grammar; `undefined` means the caller renders its plain fallback.
+ * A line-numbered view needs the token runs split per line (one gutter number
+ * per line), which the single-`
` {@link highlightToHtml} does not expose,
+ * so this returns shiki's own 2D line/token structure narrowed to what a run
+ * renders. Each run's color is a `--shiki-*` custom property, keeping token
+ * colors on the theme package's sheets exactly as the HTML path does; the
+ * css-variables theme carries no font-style bits, matching that path's
+ * color-only output. The trailing newline shiki appends as a final empty line
+ * is dropped so the run count matches the caller's own line array.
+ * @param code - the source text.
+ * @param lang - the language hint (a file-extension-derived language id).
+ * @returns one entry per source line (each an array of runs), or `undefined` for unknown or not-yet-loaded languages.
+ */
+export function highlightLines(code: string, lang: string | undefined): HighlightSpan[][] | undefined {
+  const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
+  if (resolved === undefined) return undefined
+  if (!ensureGrammar(resolved)) return undefined
+  const { tokens } = highlighter().codeToTokens(code, { lang: resolved, theme: 'css-variables' })
+  // shiki tokenizes `a\nb` into two lines; a trailing newline (`a\n`) adds a
+  // third, empty line the caller's own line array does not carry. Drop that
+  // one terminator line so the two structures stay in step. The explicit
+  // `last !== undefined` (over `tokens[...]?.length`) keeps a single branch for
+  // per-file coverage, matching TerminalBlock's terminator check.
+  const last = tokens[tokens.length - 1]
+  const lines = tokens.length > 1 && last !== undefined && last.length === 0
+    ? tokens.slice(0, -1)
+    : tokens
+  return lines.map(line => line.map(token => ({ text: token.content, style: { color: token.color } })))
+}
diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx
index 47b0ad24fb..2d56bc1a2d 100644
--- a/packages/client/ui-primitives/tests/code-block.spec.tsx
+++ b/packages/client/ui-primitives/tests/code-block.spec.tsx
@@ -31,6 +31,24 @@ describe('highlightToHtml', () => {
     expect(highlightToHtml('x', 'cobol')).toBeUndefined()
     expect(highlightToHtml('x', undefined)).toBeUndefined()
   })
+
+  // Every read-tool language hint whose grammar loads lazily (the boot set —
+  // ts/js/bash/sh/json — is covered above). Touching each one drives its own
+  // dynamic import thunk, so the whole LAZY_GRAMMARS table is exercised.
+  const LAZY_ALIASES = [
+    'py', 'rb', 'go', 'rs', 'java', 'c', 'cpp', 'cs', 'kotlin', 'swift', 'php',
+    'yaml', 'toml', 'ini', 'md', 'mdx', 'html', 'css', 'scss', 'less', 'sql',
+    'xml', 'lua',
+  ]
+
+  it('lazily loads every read-card grammar: plain first, highlighted after load', async () => {
+    // First touch returns the plain fallback (undefined) and starts the import.
+    for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toBeUndefined()
+    // Once every grammar has registered, the same call highlights.
+    await vi.waitFor(() => {
+      for (const alias of LAZY_ALIASES) expect(highlightToHtml('x', alias)).toContain('shiki')
+    })
+  })
 })
 
 describe('CodeBlock', () => {
diff --git a/packages/client/ui-primitives/tests/read-block.spec.tsx b/packages/client/ui-primitives/tests/read-block.spec.tsx
new file mode 100644
index 0000000000..fab9450932
--- /dev/null
+++ b/packages/client/ui-primitives/tests/read-block.spec.tsx
@@ -0,0 +1,241 @@
+// @vitest-environment jsdom
+// ReadBlock + the highlightLines token path: the banner (label, language, the
+// "showing N of M" note only when the read is a window, copy control), the
+// gutter-numbered rows keeping the file's own line numbers, the shiki per-line
+// highlighting resolved to css-variables token spans with an identical-geometry
+// plain fallback for an unknown/absent language, the head/tail height cap and
+// its expand control, and the copy control writing the raw window text on both
+// the accepted and refused clipboard paths.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { DEFAULT_READ_MAX_LINES, ReadBlock, type ReadBlockLine } from '../src/index.ts'
+import { grammarLoadCount, highlightLines, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
+
+afterEach(cleanup)
+
+beforeEach(() => {
+  vi.useRealTimers()
+})
+
+/** `count` lines starting at `first`, each with distinct text. */
+function lines(count: number, first = 1): ReadBlockLine[] {
+  return Array.from({ length: count }, (_value, index) => ({ number: first + index, text: `line ${first + index}` }))
+}
+
+/** The rendered rows as `` strings (CSS-module class prefix). */
+function rowTexts(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
+}
+
+/** The gutter numbers of the rendered rows, in order. */
+function gutters(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_gutter_"]')].map(cell => cell.textContent ?? '')
+}
+
+describe('highlightLines', () => {
+  it('tokenizes a registered grammar into per-line css-variables runs', () => {
+    const result = highlightLines('const x = 1\n// c', 'ts')
+    expect(result).not.toBeUndefined()
+    expect(result).toHaveLength(2)
+    // The keyword run carries a color style through a --shiki-* custom property.
+    const keyword = result![0]!.find(span => span.text === 'const')
+    expect(keyword?.style?.color).toContain('var(--shiki-')
+    // Whitespace between tokens is a run of its own; the comment is line two.
+    expect(result![0]!.map(span => span.text).join('')).toBe('const x = 1')
+    expect(result![1]!.map(span => span.text).join('')).toBe('// c')
+  })
+
+  it('colors every run through a --shiki-* custom property', () => {
+    // The css-variables theme colors even the whitespace run (as the foreground
+    // token), so every run is a styled span; the plain fallback is the whole
+    // unknown-language path, not a per-run one.
+    const result = highlightLines('const x = 1', 'ts')
+    for (const span of result!) for (const run of span) expect(run.style.color).toContain('var(--shiki-')
+  })
+
+  it('drops the trailing terminator line so the run count matches the source lines', () => {
+    // `a\n` tokenizes to two lines in shiki (the second empty); the caller's own
+    // line array has one entry, so the terminator line is dropped.
+    const result = highlightLines('const a = 1\n', 'ts')
+    expect(result).toHaveLength(1)
+  })
+
+  it('keeps a genuinely blank final line when the source ends in two newlines', () => {
+    const result = highlightLines('a\n\n', 'ts')
+    expect(result).toHaveLength(2)
+    expect(result![1]).toEqual([])
+  })
+
+  it('returns undefined for an unknown or absent language', () => {
+    expect(highlightLines('x', 'cobol')).toBeUndefined()
+    expect(highlightLines('x', undefined)).toBeUndefined()
+  })
+
+  it('loads a lazy grammar on first use: plain first, highlighted after it registers', async () => {
+    // A boot grammar (ts) is ready synchronously; a lazy grammar (python) is
+    // not, so the first call renders plain and imports the grammar, and a
+    // subscriber fires once it registers, after which the same call highlights.
+    let notified = 0
+    const stop = subscribeGrammarLoaded(() => { notified += 1 })
+    // First touch: grammar not loaded yet, so plain fallback while it imports.
+    expect(highlightLines('def f(): pass', 'py')).toBeUndefined()
+    // The import + loadLanguageSync resolve on a microtask; wait for the notify.
+    await vi.waitFor(() => { expect(notified).toBeGreaterThan(0) })
+    expect(grammarLoadCount()).toBeGreaterThan(0)
+    const result = highlightLines('def f(): pass', 'py')
+    expect(result).not.toBeUndefined()
+    // `def` is a python keyword and carries a --shiki-* color once highlighted.
+    const keyword = result!.flat().find(span => span.text === 'def')
+    expect(keyword?.style?.color).toContain('var(--shiki-')
+    stop()
+  })
+})
+
+describe('ReadBlock rows', () => {
+  it('renders one gutter-numbered row per line, keeping the file line numbers', () => {
+    const view = render()
+    expect(gutters(view.container)).toEqual(['41', '42', '43'])
+    expect(rowTexts(view.container)).toEqual(['41line 41', '42line 42', '43line 43'])
+  })
+
+  it('highlights the content for a known language into token spans', () => {
+    const view = render(
+      ,
+    )
+    const content = view.container.querySelector('[class^="_content_"]')
+    expect(content?.querySelectorAll('span[style]').length).toBeGreaterThan(1)
+    expect(content?.textContent).toBe('const a = 1')
+  })
+
+  it('renders the content as bare text with no span wrappers for an unknown language', () => {
+    const view = render(
+      ,
+    )
+    const content = view.container.querySelector('[class^="_content_"]')
+    expect(content?.querySelectorAll('span').length).toBe(0)
+    expect(content?.textContent).toBe('IDENT DIVISION.')
+  })
+
+  it('renders bare text when no language is given', () => {
+    const view = render()
+    const content = view.container.querySelector('[class^="_content_"]')
+    expect(content?.querySelectorAll('span').length).toBe(0)
+    expect(view.getByText('plain')).toBeTruthy()
+  })
+})
+
+describe('ReadBlock banner', () => {
+  it('shows the label, the language, and the count note when the read is a window', () => {
+    const view = render()
+    expect(view.getByText('src/a.ts')).toBeTruthy()
+    expect(view.getByText('ts')).toBeTruthy()
+    expect(view.getByText('显示 3 / 180 行')).toBeTruthy()
+  })
+
+  it('omits the count note when the window is the whole file', () => {
+    const view = render()
+    expect(view.queryByText(/显示/u)).toBeNull()
+  })
+
+  it('draws an empty label and empty language when neither is given', () => {
+    const view = render()
+    expect(view.container.querySelector('[class^="_label_"]')?.textContent).toBe('')
+    expect(view.container.querySelector('[class^="_lang_"]')?.textContent).toBe('')
+  })
+})
+
+describe('ReadBlock height cap', () => {
+  it('renders every line and no expand control under the cap', () => {
+    const view = render()
+    expect(rowTexts(view.container)).toHaveLength(4)
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
+  })
+
+  it('slices head and tail over the cap and expands on click', () => {
+    const view = render()
+    // maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
+    expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
+    const toggle = view.getByRole('button', { name: '展开其余 6 行' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(toggle.textContent).toBe('… 其余 6 行')
+
+    fireEvent.click(toggle)
+    expect(rowTexts(view.container)).toHaveLength(10)
+    const collapse = view.getByRole('button', { name: '收起内容' })
+    expect(collapse.getAttribute('aria-expanded')).toBe('true')
+    expect(collapse.textContent).toBe('收起')
+
+    fireEvent.click(collapse)
+    expect(gutters(view.container)).toEqual(['1', '2', '9', '10'])
+  })
+
+  it('renders the head slice alone when the cap leaves no tail', () => {
+    const view = render()
+    expect(gutters(view.container)).toEqual(['1'])
+    expect(view.getByRole('button', { name: '展开其余 4 行' })).toBeTruthy()
+  })
+
+  it('caps at the documented default when maxLines is absent', () => {
+    const view = render(
+      ,
+    )
+    expect(rowTexts(view.container)).toHaveLength(DEFAULT_READ_MAX_LINES)
+    expect(view.getByRole('button', { name: '展开其余 1 行' })).toBeTruthy()
+  })
+})
+
+describe('ReadBlock copy', () => {
+  it('copies the raw window text, joined by newlines, never the gutter numbers', async () => {
+    vi.useFakeTimers()
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    expect(writeText).toHaveBeenCalledWith('line 41\nline 42\nline 43')
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
+    // While the ok label is showing, further clicks are no-ops.
+    fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
+    expect(writeText).toHaveBeenCalledTimes(1)
+    await vi.advanceTimersByTimeAsync(1000)
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+  })
+
+  it('copies the whole window while the height cap hides its middle', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    expect(writeText).toHaveBeenCalledWith(lines(10).map(line => line.text).join('\n'))
+    expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
+  })
+
+  it('does not claim success when the host refuses the write', async () => {
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
+    })
+    render()
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
+  })
+
+  it('merges className onto the wrapper', () => {
+    const view = render()
+    expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
+  })
+
+  it('hides the copy control for an empty window so it cannot wipe the clipboard', () => {
+    // A successful read of an empty file settles to lines: [] with card:'read',
+    // so this branch is reachable; copying then would clear the clipboard.
+    const view = render()
+    expect(view.queryByRole('button', { name: '复制' })).toBeNull()
+  })
+})
diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml
index b1fd862af4..c92c2554ec 100644
--- a/packages/client/ui-settings-general/README.i18n.yaml
+++ b/packages/client/ui-settings-general/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-general/README.md
-README.md: 241678567c4dbc7411ab9e76f595f2f696cc02d6
-README.zh.md: da4568d109c20bf1860fb8841942d42078b9443a
+README.md: 3e191b501e69062b671df0f237f2128a4ad086d1
+README.zh.md: 44ba3eba8bfc756a7d68e43a3d34056349f7eaaa
diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md
index 241678567c..3e191b501e 100644
--- a/packages/client/ui-settings-general/README.md
+++ b/packages/client/ui-settings-general/README.md
@@ -2,7 +2,9 @@
 
 English | [中文](README.zh.md)
 
-Settings ownerless-copy plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, and the `settings` dictionaries. Feature-owned rows (Permission, Language, Appearance) and sections (Models) stay with their feature packages.
+Settings ownerless-copy and product-onboarding plugin: registers everything on the Settings surface that belongs to no single feature — the shell's trigger/header/close chrome content, the General section and its `settings.general.item` slot, the `settings` dictionaries, and the first ordered welcome step. Feature-owned rows (Permission, Language, Appearance), sections (Models), and conditional onboarding steps stay with their feature packages.
+
+`src/onboarding-copy.ts` is the single editable owner of the complete Chinese and English notice plus `WELCOME_NOTICE_VERSION`. The Host half registers `ui-onboarding` in the user-settings seam; the browser compares `welcomeNoticeVersion` for exact equality and writes the current value only after Continue succeeds. The path mutation is idempotent across tabs and preserves sibling settings, while `host/settings-changed` makes an externally acknowledged notice advance without a reload. A different version deliberately presents the notice again. The welcome page preserves every authored paragraph, gives the requested clause in the final paragraph the sole emphasis, initially focuses the title, and has no close, Escape, mask-click, or secondary path. None of its copy or acknowledgement enters a Session log or model request.
 
 ## Model Experience
 
diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md
index da4568d109..44ba3eba8b 100644
--- a/packages/client/ui-settings-general/README.zh.md
+++ b/packages/client/ui-settings-general/README.zh.md
@@ -2,7 +2,9 @@
 
 [English](README.md) | 中文
 
-设置界面无归属文案插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot,以及 `settings` 字典。归具体功能所有的行(「权限」、「语言」、「外观」)和分区(「模型」)仍由各自的功能包提供。
+设置界面无特定功能归属的文案与产品引导插件:在设置界面注册所有不属于单一功能的内容,包括外壳的触发器、标题栏与关闭控件内容,「通用」分区及其 `settings.general.item` slot、`settings` 字典,以及第一个有序欢迎步骤。归具体功能所有的行(「权限」、「语言」、「外观」)、分区(「模型」)和条件式首次使用引导步骤仍由各自的功能包提供。
+
+`src/onboarding-copy.ts` 是完整中英文通知文案和 `WELCOME_NOTICE_VERSION` 的唯一可编辑来源。宿主端在 user-settings seam 中注册 `ui-onboarding`;浏览器比较 `welcomeNoticeVersion` 是否精确相等,仅在「继续」操作成功后写入当前值。该路径变更在不同标签页间幂等,并会保留同级设置;`host/settings-changed` 则让页面在通知被外部确认后,无需重新加载即可推进。版本不同时,系统会有意重新显示通知。欢迎页保留原文的每个段落,仅强调最后一段中指定的句段,初始焦点落在标题上,并且没有关闭操作、Escape、点击遮罩或次要操作路径。其文案和确认状态均不会进入会话日志或模型请求。
 
 ## 模型体验
 
diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json
index a5f78c9256..8f78ce6acb 100644
--- a/packages/client/ui-settings-general/package.json
+++ b/packages/client/ui-settings-general/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-settings-general",
-  "description": "Settings ownerless-copy plugin: the General section, shell trigger/header chrome content, and settings dictionaries",
+  "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -26,7 +26,8 @@
     "inject": [
       "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-settings",
-      "@deepseek-ai/dsh-client-locale"
+      "@deepseek-ai/dsh-client-locale",
+      "@deepseek-ai/dsh-client-connection"
     ],
     "platform": "web"
   },
@@ -35,23 +36,31 @@
     "watch": "tsdown --watch"
   },
   "license": "BSD-3-Clause",
+  "dependencies": {
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "schemastery": "^3.18.0"
+  },
   "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-primitives": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-client-web-react": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "cordis": "^4.0.0-rc.7",
     "react": "^18.2.0"
   },
   "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-primitives": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-client-web-react": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "cordis": "^4.0.0-rc.7",
diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts
index 0a0d84ed80..8b3e969860 100644
--- a/packages/client/ui-settings-general/src/client/index.ts
+++ b/packages/client/ui-settings-general/src/client/index.ts
@@ -7,12 +7,18 @@
  */
 import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
 import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 // Type-only: pulls the shell's SlotMap merges (trigger/header/section/item).
 import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
 // Type-only: pulls ctx.locale and the 'settings.general.item' SlotMap merge.
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import { CloseLabel, HeaderContent, TriggerContent } from './chrome.tsx'
 import { GeneralSection } from './GeneralSection.tsx'
+import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx'
+import { WelcomeNotice } from './WelcomeNotice.tsx'
+import { refreshWelcomeIfLoaded, WelcomeNoticeStore } from './welcome-store.ts'
+import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts'
 import { en, zh, type SettingsKey } from './locales.ts'
 
 export type {
@@ -21,6 +27,8 @@ export type {
 export type {
   GeneralSectionComponentProps,
 } from './GeneralSection.tsx'
+export type { WelcomeNoticeInjected, WelcomeNoticeProps } from './WelcomeNotice.tsx'
+export type { WelcomeNoticeState } from './welcome-store.ts'
 export type { SettingsKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -38,7 +46,7 @@ const NS = 'settings'
  * ui-settings' apply, whose activation order relative to this one is NOT
  * constrained; registration goes through declaration-aware deferral.
  */
-export const inject = ['slots', 'locale']
+export const inject = ['slots', 'locale', 'connection']
 
 /**
  * Register the `settings` dictionaries, the chrome content, and the General
@@ -52,6 +60,25 @@ export function apply(ctx: ClientContext): void {
   // seat, and the nav label is a thunk the owner resolves per render — no
   // locale/change re-registration wiring.
   const t = ctx.locale.bind(NS)
+  const connection = ctx.get('connection') as ConnectionHandle
+  const welcomeController = new WelcomeNoticeStore(connection.api)
+  const useWelcomeSnapshot = bindSnapshotSelector(welcomeController.store)
+  const welcomeInjected = (): WelcomeNoticeInjected => ({
+    controller: welcomeController,
+    useSnapshot: useWelcomeSnapshot,
+  })
+
+  ctx.effect(() => {
+    const refresh = (ns?: string): void => {
+      if (ns !== undefined && ns !== WELCOME_NOTICE_SETTINGS_NAMESPACE) return
+      refreshWelcomeIfLoaded(welcomeController)
+    }
+    const disposers = [
+      ctx.on('settings/changed', refresh),
+      ctx.on('connection/reset', () => { refresh() }),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'ui-settings-general: welcome invalidations')
   ctx.effect(() => {
     const trigger = deferRegistration(ctx.slots, 'settings.trigger', TriggerContent, () =>
       ctx.slots.register({ name: 'settings.trigger', locale: NS }, TriggerContent))
@@ -68,11 +95,20 @@ export function apply(ctx: ClientContext): void {
         locale: NS,
         children: { 'settings.general.item': { kind: 'list', scope: 'root' } },
       }, GeneralSection))
+    const welcome = deferRegistration(ctx.slots, 'settings.onboarding', WelcomeNotice, () =>
+      ctx.slots.register({
+        name: 'settings.onboarding',
+        id: 'welcome-notice',
+        order: -100,
+        locale: NS,
+        inject: welcomeInjected,
+      }, WelcomeNotice))
     return () => {
       trigger.dispose()
       header.dispose()
       close.dispose()
       general.dispose()
+      welcome.dispose()
     }
-  }, 'ui-settings-general: chrome and section registrations')
+  }, 'ui-settings-general: chrome, section, and onboarding registrations')
 }
diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts
index b71fc683b9..e4359b3f10 100644
--- a/packages/client/ui-settings-general/src/client/locales.ts
+++ b/packages/client/ui-settings-general/src/client/locales.ts
@@ -1,4 +1,5 @@
-/** Shell chrome and General-nav dictionaries; feature rows own their copy. */
+/** Shell chrome, General-nav, and welcome-notice dictionaries; feature rows own their copy. */
+import { WELCOME_NOTICE_COPY } from '../onboarding-copy.ts'
 
 /** Simplified Chinese dictionary (the key-set source of truth). */
 export const zh = {
@@ -6,6 +7,14 @@ export const zh = {
   'title': '设置',
   'close': '关闭',
   'general.nav': '通用设置',
+  'welcome.title': WELCOME_NOTICE_COPY.zh.title,
+  'welcome.paragraph.0': WELCOME_NOTICE_COPY.zh.paragraphs[0],
+  'welcome.paragraph.1': WELCOME_NOTICE_COPY.zh.paragraphs[1],
+  'welcome.paragraph.2': WELCOME_NOTICE_COPY.zh.paragraphs[2],
+  'welcome.paragraph.3': WELCOME_NOTICE_COPY.zh.paragraphs[3],
+  'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.zh.feedbackEmphasis,
+  'welcome.continue': WELCOME_NOTICE_COPY.zh.continueLabel,
+  'welcome.error': '暂时无法保存确认状态,请重试。',
 } satisfies Record
 
 /** The settings namespace key union. */
@@ -17,4 +26,12 @@ export const en = {
   'title': 'Settings',
   'close': 'Close',
   'general.nav': 'General',
+  'welcome.title': WELCOME_NOTICE_COPY.en.title,
+  'welcome.paragraph.0': WELCOME_NOTICE_COPY.en.paragraphs[0],
+  'welcome.paragraph.1': WELCOME_NOTICE_COPY.en.paragraphs[1],
+  'welcome.paragraph.2': WELCOME_NOTICE_COPY.en.paragraphs[2],
+  'welcome.paragraph.3': WELCOME_NOTICE_COPY.en.paragraphs[3],
+  'welcome.feedbackEmphasis': WELCOME_NOTICE_COPY.en.feedbackEmphasis,
+  'welcome.continue': WELCOME_NOTICE_COPY.en.continueLabel,
+  'welcome.error': 'The acknowledgement could not be saved. Please try again.',
 } satisfies Record
diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts
index 94b9bdf674..18518c2835 100644
--- a/packages/client/ui-settings-general/src/index.ts
+++ b/packages/client/ui-settings-general/src/index.ts
@@ -1,4 +1,31 @@
 /** Host loader entry for the browser implementation exported from `./client`. */
 
-/** Host plugin body — no host-side behavior for the general settings plugin. */
-export function apply(): void {}
+import type { Context } from 'cordis'
+import z from 'schemastery'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE,
+} from './onboarding-copy.ts'
+
+export {
+  WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_COPY, WELCOME_NOTICE_SETTINGS_NAMESPACE,
+  WELCOME_NOTICE_VERSION,
+} from './onboarding-copy.ts'
+
+interface OnboardingSettings {
+  welcomeNoticeVersion?: string
+}
+
+const OnboardingSettingsSchema: z = z.object({
+  [WELCOME_NOTICE_ACK_FIELD]: z.string(),
+})
+
+/** Register the durable GUI-onboarding section when a settings provider exists. */
+export function apply(ctx: Context): void {
+  ctx.inject(['settings'], (settingsCtx) => {
+    settingsCtx.settings.register(
+      settingsNamespace(WELCOME_NOTICE_SETTINGS_NAMESPACE),
+      OnboardingSettingsSchema,
+    )
+  })
+}
diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts
index 29f762834d..d13ecc5cb8 100644
--- a/packages/client/ui-settings-general/src/invariant.ts
+++ b/packages/client/ui-settings-general/src/invariant.ts
@@ -15,10 +15,9 @@ export const name = 'client-ui-settings-general-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: a copy-owning registrant contributing chrome content
- * and the General section into shell-declared slots — it emits no cordis
- * events and owns no cross-plugin mutable relation; slot conflicts already
- * fail loud in the slot core at load time.
+ * No runtime invariant: the settings seam validates and publishes the durable
+ * welcome section, while slot conflicts fail loud in the slot core; this
+ * package owns no additional event/data relationship between those systems.
  */
 const install: InvariantInstaller = () => {}
 
diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts
index 5678821c20..81a56a9a4a 100644
--- a/packages/client/ui-settings-general/tests/apply.spec.ts
+++ b/packages/client/ui-settings-general/tests/apply.spec.ts
@@ -8,17 +8,21 @@ import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-general/client'
 import { CloseLabel, HeaderContent, TriggerContent } from '../src/client/chrome.tsx'
 import { GeneralSection } from '../src/client/GeneralSection.tsx'
+import { WelcomeNotice } from '../src/client/WelcomeNotice.tsx'
+import type { WelcomeNoticeInjected } from '../src/client/WelcomeNotice.tsx'
+import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../src/onboarding-copy.ts'
 
 // The service reads its initial locale from the browser; these specs assert
 // the shipped Chinese copy, so they state the browser they assume.
 usePinnedBrowserLanguages('zh-CN')
 
-/** The four seats this plugin fills (slot name → expected component). */
+/** The five seats this plugin fills (slot name → expected component). */
 const SEATS = [
   ['settings.trigger', TriggerContent],
   ['settings.header', HeaderContent],
   ['settings.close', CloseLabel],
   ['settings.section', GeneralSection],
+  ['settings.onboarding', WelcomeNotice],
 ] as const
 
 async function bench() {
@@ -26,7 +30,25 @@ async function bench() {
   await ctx.plugin(SlotsService).await()
   const locale = new LocaleService(ctx)
   ctx.provide('locale', locale)
-  return { ctx, slots: ctx.get('slots') as SlotsService, locale }
+  const settingsDescribe = vi.fn(() => Promise.resolve({
+    rpcId: 'settings-general' as never,
+    result: {
+      ok: true as const,
+      value: {
+        writable: true,
+        namespaces: [{
+          ns: WELCOME_NOTICE_SETTINGS_NAMESPACE,
+          schema: {},
+          value: {},
+          applies: 'live' as const,
+          secrets: [],
+          revision: 0,
+        }],
+      },
+    },
+  }))
+  ctx.provide('connection', { api: { settings: { describe: settingsDescribe } } } as never)
+  return { ctx, slots: ctx.get('slots') as SlotsService, locale, settingsDescribe }
 }
 
 /** Declare the shell's four child slots the way ui-settings' entry does. */
@@ -39,6 +61,7 @@ function declare(slots: SlotsService): () => void {
         'settings.header': { kind: 'single', scope: 'root' },
         'settings.close': { kind: 'single', scope: 'root' },
         'settings.section': { kind: 'list', scope: 'root' },
+        'settings.onboarding': { kind: 'list', scope: 'root' },
       },
     } as never,
     () => null,
@@ -51,10 +74,10 @@ function generalEntry(slots: SlotsService) {
 
 describe('ui-settings-general apply', () => {
   it('declares the services it uses', () => {
-    expect(inject).toEqual(['slots', 'locale'])
+    expect(inject).toEqual(['slots', 'locale', 'connection'])
   })
 
-  it('fills all four seats for declarations before or after apply', async () => {
+  it('fills all five seats for declarations before or after apply', async () => {
     const before = await bench()
     declare(before.slots)
     await before.ctx.plugin({ inject: [...inject], apply }).await()
@@ -67,6 +90,8 @@ describe('ui-settings-general apply', () => {
     expect(resolveSlotLabel(entry.options.label)).toBe('通用设置')
     expect(before.slots.spec('settings.general.item')).toEqual({ kind: 'list', scope: 'root' })
     expect(before.slots.entries('settings.general.item')).toEqual([])
+    const welcome = before.slots.entries('settings.onboarding')[0]!
+    expect(welcome.options).toMatchObject({ id: 'welcome-notice', order: -100 })
     // Copy rides the standard locale seat: every seat declares the namespace.
     for (const [name] of SEATS) {
       expect(before.slots.entries(name)[0]!.locale).toBe('settings')
@@ -118,6 +143,22 @@ describe('ui-settings-general apply', () => {
     expect(resolveSlotLabel(generalEntry(b.slots)!.options.label)).toBe('通用设置')
   })
 
+  it('refreshes loaded welcome state only for its settings namespace or a reconnect', async () => {
+    const b = await bench()
+    declare(b.slots)
+    await b.ctx.plugin({ inject: [...inject], apply }).await()
+    const entry = b.slots.entries('settings.onboarding')[0]!
+    const { controller } = (entry.inject as unknown as () => WelcomeNoticeInjected)()
+    await controller.load()
+    expect(b.settingsDescribe).toHaveBeenCalledOnce()
+    b.ctx.emit('settings/changed', 'unrelated')
+    expect(b.settingsDescribe).toHaveBeenCalledOnce()
+    b.ctx.emit('settings/changed', WELCOME_NOTICE_SETTINGS_NAMESPACE)
+    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(2) })
+    b.ctx.emit('connection/reset')
+    await vi.waitFor(() => { expect(b.settingsDescribe).toHaveBeenCalledTimes(3) })
+  })
+
   it('re-registers after an HMR collapse of the declaring chain (stale disposers must not block)', async () => {
     const b = await bench()
     const redeclare = declare(b.slots)
diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts
index 7b0527c0ff..59863a5794 100644
--- a/packages/client/ui-settings-general/tests/invariant.spec.ts
+++ b/packages/client/ui-settings-general/tests/invariant.spec.ts
@@ -9,10 +9,4 @@ describe('invariant companion', () => {
     await ctx.plugin(InvariantService, { enabled: true })
     await expect(ctx.plugin(GeneralInvariant).await()).resolves.toBeDefined()
   })
-
-  it('node-half apply is a no-op host placeholder', async () => {
-    const { apply } = await import('@deepseek-ai/dsh-client-ui-settings-general')
-    apply()
-    expect(true).toBe(true) // reaching here without throw is the contract
-  })
 })
diff --git a/packages/client/ui-settings-general/tsconfig.json b/packages/client/ui-settings-general/tsconfig.json
index 5ef01ba51c..5e37578f91 100644
--- a/packages/client/ui-settings-general/tsconfig.json
+++ b/packages/client/ui-settings-general/tsconfig.json
@@ -14,6 +14,9 @@
     {
       "path": "../ui-slots"
     },
+    {
+      "path": "../connection"
+    },
     {
       "path": "../ui-primitives"
     },
@@ -23,9 +26,15 @@
     {
       "path": "../ui-settings"
     },
+    {
+      "path": "../web-react"
+    },
     {
       "path": "../locale"
     },
+    {
+      "path": "../../settings/settings"
+    },
     {
       "path": "../../support/invariants"
     }
diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml
index 654235e895..668853e41f 100644
--- a/packages/client/ui-settings/README.i18n.yaml
+++ b/packages/client/ui-settings/README.i18n.yaml
@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
-README.md: eaa588489bbb6369a9fe073f0a9a37efb3af7d9f
-README.zh.md: 4330ea90e487270c8da31dace3d531d94f61b8de
+README.md: 14c78c83467313a6efa7033c31fb9c9b1cd94e0c
+README.zh.md: 8831842d03db4572f1dca5547b84c0d8a3be8f2d
diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md
index eaa588489b..14c78c8346 100644
--- a/packages/client/ui-settings/README.md
+++ b/packages/client/ui-settings/README.md
@@ -2,9 +2,9 @@
 
 English | [中文](README.zh.md)
 
-Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (feature-owned overlays on the empty Hero). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome and General; features own their sections, rows, and onboarding overlays). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
+Settings shell plugin: a pure composition face. It occupies `sidebar.settings` with the trigger chrome and modal settings panel, and declares the slots registrants fill: `settings.trigger` / `settings.header` / `settings.close` (chrome content), `settings.section` (one page per feature), and `settings.onboarding` (ordered feature-owned pages in a full-viewport stage). The shell ships no copy of its own — all text arrives from registrants (ui-settings-general owns chrome, General, and the product notice; features own their sections, rows, and conditional onboarding pages). Nav labels may be locale-following thunks, so the nav projection resolves them through `resolveSlotLabel` and re-renders on the section ledger bump or the locale revision (an optional `ctx.get('locale')` read; no hard locale dependency).
 
-The shell supplies onboarding registrants only two navigation facts: whether the session surface is the empty Hero and an `openSection(id)` callback that opens the panel on a registered section. Registrants own capability readiness, dismissal, copy, and mutations; the shell therefore does not become a second configuration fact source.
+The shell projects the onboarding ledger into ascending order and mounts exactly one page at a time in a body-level stage while marking the underlying app root inert. The active registrant receives its id, `complete()`, and an `openSection(id)` callback; completing or skipping transfers ownership to the next entry. Registrants own durable completion, capability readiness, copy, and mutations, so independently registered flows cannot stack and the shell does not become a second configuration fact source.
 
 ## Model Experience
 
diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md
index 4330ea90e4..8831842d03 100644
--- a/packages/client/ui-settings/README.zh.md
+++ b/packages/client/ui-settings/README.zh.md
@@ -2,9 +2,9 @@
 
 [English](README.md) | 中文
 
-设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、覆盖在空白 Hero 之上的浮层)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架和「通用」分区;各功能拥有各自的分区、行和首次使用浮层)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
+设置外壳插件:一个纯组合表层。它以触发控件和模态设置面板占用 `sidebar.settings`,并声明由注册方填充的 slot:`settings.trigger`/`settings.header`/`settings.close`(界面框架内容)、`settings.section`(每项功能一页)和 `settings.onboarding`(由各功能持有、显示在全视口展示层中的有序页面)。外壳不自带文案:所有文本都来自注册方(ui-settings-general 拥有界面框架、「通用」分区和产品声明;各功能拥有各自的分区、行和条件式首次使用引导页面)。导航 label 可以是跟随语言的 thunk,因此导航投影经 `resolveSlotLabel` 解析,并在分区账本更新或 locale revision 变化时重新渲染(`ctx.get('locale')` 可选读取,无硬 locale 依赖)。
 
-外壳只向首次使用注册方提供两个导航事实:当前会话界面是否为空白 Hero,以及一个 `openSection(id)` 回调;后者会打开设置面板并切换到已注册的指定分区。能力就绪状态、浮层关闭、文案和变更操作均由注册方持有,因此外壳不会成为第二个配置事实来源。
+外壳将首次使用引导记录按升序投影,在 body 层级的展示层中每次只挂载一个页面,同时将下层应用根节点标记为 `inert`。当前注册方会收到该条目的 id、`complete()` 和 `openSection(id)` 回调;完成或跳过当前页面后,所有权转交给下一项。持久化完成状态、能力就绪状态、文案和变更操作均由注册方持有,因此独立注册的流程无法堆叠,外壳也不会成为第二个配置事实来源。
 
 ## 模型体验
 
diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json
index 0a6fce95af..d4b872173f 100644
--- a/packages/client/ui-settings/package.json
+++ b/packages/client/ui-settings/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-ui-settings",
-  "description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and root-scoped onboarding overlays",
+  "description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -43,7 +43,8 @@
     "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "cordis": "^4.0.0-rc.7",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-client-locale": "workspace:^",
@@ -52,9 +53,11 @@
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/react-dom": "~18.3.0",
     "@types/react": "~18.3.1",
     "cordis": "^4.0.0-rc.7",
-    "react": "^18.2.0"
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
   },
   "files": [
     "lib/index.js",
diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css
index 04eaf14d2b..817ab38d9a 100644
--- a/packages/client/ui-settings/src/client/SettingsRoot.module.css
+++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css
@@ -209,3 +209,33 @@
   clip: rect(0 0 0 0);
   white-space: nowrap;
 }
+
+/* First-run stage: keep the product top bar visible, then let onboarding own
+   the complete workspace instead of presenting another settings modal. */
+.onboardingOverlay {
+  position: fixed;
+  inset: 0;
+  z-index: 1100;
+}
+
+/* Mask */
+.onboardingMask {
+  position: absolute;
+  left: 0px;
+  right: 0px;
+  top: 80px;
+  bottom: 0px;
+  background: rgba(0, 0, 0, 0.24);
+  /* Mask-blur */
+  backdrop-filter: blur(2px);
+}
+
+.onboardingStage {
+  position: absolute;
+  z-index: 1;
+  inset: 0;
+  display: flex;
+  justify-content: center;
+  overflow: hidden;
+  background: var(--dsw-alias-bg-layer-1);
+}
diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx
index cfa3ac6cef..3eefbd4ef1 100644
--- a/packages/client/ui-settings/src/client/SettingsRoot.tsx
+++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx
@@ -6,10 +6,11 @@
  * names resolve to that content (trigger: its own text; dialog:
  * aria-labelledby the title node; close: visually-hidden slot text). Modal
  * open state and the active section id are component-local viewing state;
- * the onboarding slot receives the sessions-derived empty-Hero fact and a
- * private callback that opens one registered section.
+ * the onboarding coordinator mounts exactly one ordered registrant while the
+ * sessions-derived empty-Hero fact is active.
  */
 import { useCallback, useEffect, useId, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
 import clsx from 'clsx'
 import { IconCloseOutline16, IconDataOutline16, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
@@ -95,9 +96,10 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
  * @returns the settings shell element tree.
  */
 export function SettingsRoot(props: SettingsRootComponentProps) {
-  const { wide, useSections, useSessions, renderSlot } = props
+  const { wide, useSections, useOnboardingSteps, useSessions, renderSlot } = props
   const [open, setOpen] = useState(false)
   const [activeId, setActiveId] = useState(undefined)
+  const [completedOnboarding, setCompletedOnboarding] = useState>(() => new Set())
   const close = useCallback(() => {
     setOpen(false)
     setActiveId(undefined)
@@ -111,9 +113,33 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
   // freshly localized text on locale change, and the trigger/header/close
   // seats re-render through their own outlets' subscriptions.
   const rows = useSections(s => s)
+  const onboardingSteps = useOnboardingSteps(s => s)
   const onboardingActive = useSessions(state =>
     state.phase === 'ready'
     && (state.current === undefined || state.byId[state.current]?.blank === true))
+  const onboardingStep = onboardingActive
+    ? onboardingSteps.find(step => !completedOnboarding.has(step.id))
+    : undefined
+
+  useEffect(() => {
+    if (onboardingActive) return
+    setCompletedOnboarding(new Set())
+  }, [onboardingActive])
+
+  const completeOnboardingStep = useCallback((id: string) => {
+    setCompletedOnboarding((previous) => {
+      if (previous.has(id)) return previous
+      return new Set([...previous, id])
+    })
+  }, [])
+
+  useEffect(() => {
+    if (onboardingStep === undefined) return
+    const appRoot = document.getElementById('root')
+    if (appRoot === null) return
+    appRoot.inert = true
+    return () => { appRoot.inert = false }
+  }, [onboardingStep])
 
   return (
     <>
@@ -135,7 +161,18 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
           onClose={close}
         />
       )}
-      {renderSlot('settings.onboarding', { active: onboardingActive, openSection })}
+      {onboardingStep !== undefined && createPortal((
+        
+ + ), document.body)} ) } diff --git a/packages/client/ui-settings/src/client/contract/slots.ts b/packages/client/ui-settings/src/client/contract/slots.ts index 37847832bf..4d5e48d80f 100644 --- a/packages/client/ui-settings/src/client/contract/slots.ts +++ b/packages/client/ui-settings/src/client/contract/slots.ts @@ -48,10 +48,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'settings.section': { kind: 'list'; scope: 'root'; owner: SettingsSectionOwnerProps } /** - * Root-scoped onboarding overlays contributed by settings features. The - * shell supplies whether the current navigation state is the empty Hero - * and a private callback that opens one settings section; registrants own - * readiness, copy, and dialog behavior. + * Root-scoped onboarding steps contributed by settings features. The + * shell mounts one ordered step at a time; the active registrant either + * completes itself or keeps ownership until the user completes its sole + * path. Registrants own readiness, copy, and dialog behavior. */ 'settings.onboarding': { kind: 'list'; scope: 'root'; owner: SettingsOnboardingOwnerProps } } @@ -79,10 +79,12 @@ export interface SettingsSectionOwnerProps { children?: never } -/** Owner share of a settings-backed onboarding overlay. */ +/** Owner share of the currently active settings-backed onboarding step. */ export interface SettingsOnboardingOwnerProps { - /** Whether the current UI is in its empty Hero/onboarding state. */ - active: boolean + /** Stable id of the step currently selected by the coordinator. */ + stepId: string + /** Complete or skip this step and transfer ownership to the next entry. */ + complete: () => void /** Open the settings panel directly on one registered section. */ openSection: (id: string) => void } @@ -94,6 +96,12 @@ export interface SettingsSectionRow { label: string } +/** One ordered onboarding step projected from a slot registration. */ +export interface SettingsOnboardingStep { + id: string + order: number +} + /** * Registrant-private injected share of the settings shell (assembled in * apply): the ledger's nav-row projection as a hooks-compartment source — @@ -103,6 +111,8 @@ export type SettingsRootInjected = { hooks: { /** settings.section ledger projected into ordered nav rows. */ sections: HostObservable + /** settings.onboarding ledger projected into coordinator order. */ + onboardingSteps: HostObservable } } diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts index 277e036f0d..18719bb042 100644 --- a/packages/client/ui-settings/src/client/index.ts +++ b/packages/client/ui-settings/src/client/index.ts @@ -2,10 +2,10 @@ * Settings shell plugin, browser half. A pure composition face: occupies the * sidebar-owned `sidebar.settings` hole with the trigger chrome + modal * panel, declares its chrome, section, and onboarding slots, and projects the - * section ledger into panel navigation. The shell ships no copy and reads no - * locale state — all text arrives from registrants (ui-settings-general owns - * the chrome and General content; features own their rows, sections, and - * onboarding overlays). Export discipline: packages/client/AGENTS.md. + * section ledger into panel navigation. The shell ships no copy; it reads the + * optional locale revision only to resolve registrant-owned nav-label thunks. + * ui-settings-general owns the chrome and General content; features own their + * rows, sections, and onboarding pages. Export discipline: packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // Type-only: the ctx.locale Context merge for the optional ctx.get('locale') @@ -13,12 +13,15 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' // copy of its own and takes no hard locale dependency). import type {} from '@deepseek-ai/dsh-client-locale/client' import { deferRegistration, resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts' +import type { + SettingsOnboardingStep, SettingsRootInjected, SettingsSectionRow, +} from './contract/slots.ts' import { SettingsRoot } from './SettingsRoot.tsx' export type { SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected, - SettingsOnboardingOwnerProps, SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps, + SettingsOnboardingOwnerProps, SettingsOnboardingStep, SettingsSectionOwnerProps, + SettingsSectionRow, SettingsTriggerOwnerProps, } from './contract/slots.ts' /** @@ -42,6 +45,8 @@ export function apply(ctx: ClientContext): void { let rowsVersion = -1 let rowsRevision = -1 let rows: readonly SettingsSectionRow[] = [] + let onboardingVersion = -1 + let onboardingSteps: readonly SettingsOnboardingStep[] = [] const localeRevision = (): number => ctx.get('locale')?.getSnapshot().revision ?? 0 const injected = (): SettingsRootInjected => ({ hooks: { @@ -72,6 +77,23 @@ export function apply(ctx: ClientContext): void { } }, }, + onboardingSteps: { + getSnapshot: () => { + const version = ctx.slots.getVersion('settings.onboarding') + if (version !== onboardingVersion) { + onboardingVersion = version + onboardingSteps = ctx.slots.entries('settings.onboarding') + .map(e => ({ + /* v8 ignore next -- list-slot registration requires id */ + id: e.options.id ?? '', + order: e.options.order ?? 0, + })) + .sort((a, b) => a.order - b.order) + } + return onboardingSteps + }, + subscribe: listener => ctx.slots.subscribe('settings.onboarding', listener), + }, }, }) ctx.effect(() => { diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index de50c88d87..48b63bc0a0 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -83,6 +83,29 @@ describe('ui-settings apply', () => { off() }) + it('projects onboarding entries into stable coordinator order', async () => { + const b = await bench() + declare(b.slots) + await b.ctx.plugin({ inject: [...inject], apply }).await() + const { onboardingSteps } = injectedOf(b.slots).hooks + b.slots.register({ name: 'settings.onboarding', id: 'credential', order: 0 } as never, () => null) + b.slots.register({ name: 'settings.onboarding', id: 'welcome', order: -100 } as never, () => null) + b.slots.register({ name: 'settings.onboarding', id: 'default-order' } as never, () => null) + const steps = onboardingSteps.getSnapshot() + expect(steps).toEqual([ + { id: 'welcome', order: -100 }, + { id: 'credential', order: 0 }, + { id: 'default-order', order: 0 }, + ]) + expect(onboardingSteps.getSnapshot()).toBe(steps) + const listener = vi.fn() + const off = onboardingSteps.subscribe(listener) + b.slots.register({ name: 'settings.onboarding', id: 'later', order: 10 } as never, () => null) + await Promise.resolve() + expect(listener).toHaveBeenCalledOnce() + off() + }) + it('re-registers after an HMR collapse re-declares the slot (stale disposer must not block)', async () => { const b = await bench() const redeclare = declare(b.slots) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index a7df311672..0ddb8f98d9 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -8,6 +8,7 @@ import { SettingsRoot } from '../src/client/SettingsRoot.tsx' afterEach(cleanup) type Row = { id: string; order: number; label: string } +type Step = { id: string; order: number } /** Slot-content stand-ins: the shell renders whatever the seats contribute. */ const SEAT_CONTENT: Record = { @@ -23,7 +24,11 @@ function mount({ { id: 'general', order: 0, label: 'General' }, { id: 'models', order: 10, label: 'Models' }, ], -}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[] } = {}) { + steps = [ + { id: 'welcome', order: -100 }, + { id: 'credential', order: 0 }, + ], +}: { wide?: boolean; onboardingActive?: boolean; rows?: Row[]; steps?: Step[] } = {}) { // Mutable row source standing in for the bound useSections hook; bump() // plays a ledger change through the same observable contract. let current = rows @@ -46,6 +51,7 @@ function mount({ useSessions, useWorkspaces: unusedHook, wide, + useOnboardingSteps: select => select(steps), useSections: (select) => { const [, force] = useState(0) useEffect(() => { @@ -164,20 +170,41 @@ describe('SettingsPanel navigation', () => { expect(screen.queryByTestId('section-general')).toBeNull() }) - it('hands Hero readiness and a direct section opener to onboarding registrants', () => { + it('mounts onboarding steps in order and transfers ownership only on completion', () => { const { renderSlot } = mount() - const onboardingCall = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding') - expect(onboardingCall?.[1]).toMatchObject({ active: true }) + const first = renderSlot.mock.calls.find(call => call[0] === 'settings.onboarding') + expect(first?.[1]).toMatchObject({ stepId: 'welcome' }) + expect(first?.[2]).toEqual({ only: 'welcome' }) act(() => { - (onboardingCall?.[1] as { openSection: (id: string) => void }).openSection('models') + (first?.[1] as { complete: () => void }).complete() + ;(first?.[1] as { complete: () => void }).complete() + }) + const onboardingCalls = renderSlot.mock.calls.filter(call => call[0] === 'settings.onboarding') + const second = onboardingCalls.at(-1) + expect(second?.[1]).toMatchObject({ stepId: 'credential' }) + expect(second?.[2]).toEqual({ only: 'credential' }) + + act(() => { + (second?.[1] as { openSection: (id: string) => void }).openSection('models') }) expect(screen.getByRole('dialog')).toBeTruthy() expect(screen.getByTestId('section-models')).toBeTruthy() cleanup() - const active = mount({ onboardingActive: false }).renderSlot.mock.calls - .find(call => call[0] === 'settings.onboarding') - expect(active?.[1]).toMatchObject({ active: false }) + const inactive = mount({ onboardingActive: false }).renderSlot.mock.calls + .filter(call => call[0] === 'settings.onboarding') + expect(inactive).toHaveLength(0) + }) + + it('makes the underlying application inert while onboarding owns the viewport', () => { + const appRoot = document.createElement('div') + appRoot.id = 'root' + document.body.append(appRoot) + const { view } = mount() + expect(appRoot.inert).toBe(true) + view.unmount() + expect(appRoot.inert).toBe(false) + appRoot.remove() }) it('falls back to the first row when the active entry unregisters', () => { diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index c7cb49d338..9c8ca695c7 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/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/compact/compact-basic/README.md -README.md: 775355f1ac1a7c79c16f66a5b2489d73df7b960d -README.zh.md: bfa139596b5ef61c23d29575bdea5534fa82b158 +README.md: b35e5dc110e908047338054337b309a77b7e0f68 +README.zh.md: 9c5f83d987b584d58ffacadce4e469bb6ba81aa2 diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 775355f1ac..b35e5dc110 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t - [decisions and their rationale, constraints, user preferences, open questions, data needed to continue] Rules: -- Preserve exact file paths, commands, error strings, identifiers, and function signatures. +- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments. - Capture user feedback and explicit instructions faithfully, especially corrections. - Do NOT mention this summarization request or that the context was compacted. - Output only the checkpoint text: do not call any tool or take any other action. diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index bfa139596b..9c5f83d987 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -136,7 +136,7 @@ Output EXACTLY the Markdown structure below: keep every section, in order. Use t - [decisions and their rationale, constraints, user preferences, open questions, data needed to continue] Rules: -- Preserve exact file paths, commands, error strings, identifiers, and function signatures. +- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments. - Capture user feedback and explicit instructions faithfully, especially corrections. - Do NOT mention this summarization request or that the context was compacted. - Output only the checkpoint text: do not call any tool or take any other action. diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 3202970c7d..ba3dad5592 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -58,7 +58,7 @@ const COMPACTION_INSTRUCTION = [ '- [decisions and their rationale, constraints, user preferences, open questions, data needed to continue]', '', 'Rules:', - '- Preserve exact file paths, commands, error strings, identifiers, and function signatures.', + '- Write concise English engineering prose. Preserve exact file paths, commands, error strings, identifiers, numeric values, function signatures, and syntax fragments.', '- Capture user feedback and explicit instructions faithfully, especially corrections.', '- Do NOT mention this summarization request or that the context was compacted.', '- Output only the checkpoint text: do not call any tool or take any other action.', diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 2b3cdecf17..264f8ba45f 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1224,7 +1224,8 @@ describe('default one-shot summarizer', () => { expect(messages[0]).toEqual(prefix) const last = messages.at(-1)?.content[0] const lastText = last?.type === 'text' ? last.text : '' - expect(lastText).toContain('Condense the conversation ABOVE') + expect(lastText).toContain('Write concise English engineering prose.') + expect(lastText).toContain('numeric values, function signatures, and syntax fragments.') expect(lastText).toContain('## Primary Request and Intent') }) diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 4e9c24f667..e6f98817c3 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -342,6 +342,11 @@ describe('context-overflow recovery across the real loop and compact-basic', () expect(adapter.conversationRequests).toHaveLength(2) expect(adapter.summaryRequests).toHaveLength(1) + const instruction = adapter.summaryRequests[0]!.messages.at(-1)?.content + .map(block => (block.type === 'text' ? block.text : '')) + .join('') ?? '' + expect(instruction).toContain('Write concise English engineering prose.') + expect(instruction).toContain('numeric values, function signatures, and syntax fragments.') expect(JSON.stringify(adapter.conversationRequests[0]!.messages)).toContain('OLD HISTORY SENTINEL') const retry = JSON.stringify(adapter.conversationRequests[1]!.messages) expect(retry).toContain('RECOVERY CHECKPOINT') diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 098e00325d..b888f33ed4 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2287,6 +2287,26 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ScopeKey', declaration: 'export type ScopeKey = object;', }, + { + name: 'SearchFileMatches', + declaration: 'export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n}', + }, + { + name: 'SearchLineMatch', + declaration: 'export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n}', + }, + { + name: 'SearchMatchesResultView', + declaration: 'export interface SearchMatchesResultView {\n card: \'search\';\n shape: \'matches\';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n}', + }, + { + name: 'SearchPathsResultView', + declaration: 'export interface SearchPathsResultView {\n card: \'search\';\n shape: \'paths\';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n}', + }, + { + name: 'SearchResultView', + declaration: 'export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;', + }, { name: 'SendOptions', declaration: 'export interface SendOptions {\n target: SendTarget;\n wakeup: boolean;\n}', @@ -2857,7 +2877,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolResultView', - declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;', + declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;', }, { name: 'ToolRunContext', diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 064f3f9e1f..d26f891f91 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: dcce455f9551318f3871e3df84c29789078fef7c -README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99 +README.md: 15fc5839a3b0e3fa2d20c5a9cc50577e9807ffda +README.zh.md: 8547ee4a796dcd93945dfa40373c14c10d7d0c8a diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index dcce455f95..15fc5839a3 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. -- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ card: 'search', shape, title?, truncated, total, … }` (a completed discovery search — grouped-by-file matches for `shape: 'matches'` (grep) or a flat path list for `shape: 'paths'` (glob), with `truncated`/`total` so a UI never presents a capped result as complete; the view carries no result text and a search has no `card: 'search'` call-time analogue), `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content). Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 63eaa2e0b6..8547ee4a79 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -108,7 +108,7 @@ ctx.tools.register(defineTool({ 工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 -- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }`、`{ card: 'search', shape, title?, truncated, total, … }`(已完成的发现型搜索——`shape: 'matches'`(grep)为按文件分组的匹配,`shape: 'paths'`(glob)为扁平路径列表,配 `truncated`/`total` 使 UI 永不把被截断的结果当作完整结果呈现;该视图不携带结果文本,且搜索没有 `card: 'search'` 的调用时对应视图)、`{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 UI 回退到原始结果内容)。 返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 60dafed720..f30dce6cd0 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -83,6 +83,11 @@ export type { GenericResultView, TerminalResultView, DiffResultView, + SearchResultView, + SearchMatchesResultView, + SearchPathsResultView, + SearchFileMatches, + SearchLineMatch, ReadResultView, WebResultView, WebSearchResultView, diff --git a/packages/core/tools/src/presentation.ts b/packages/core/tools/src/presentation.ts index b2b24554c4..5ca7bc5989 100644 --- a/packages/core/tools/src/presentation.ts +++ b/packages/core/tools/src/presentation.ts @@ -137,7 +137,7 @@ export interface ReadFileLine { * `ToolDefinition.presentResult`; omitting the method keeps the pending * title and renders the raw result content. */ -export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView +export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView /** * The default completed card: an optional replacement title and reformatted @@ -189,6 +189,83 @@ export interface DiffResultView { diffs: FileDiff[] } +/** One matched line inside a {@link SearchFileMatches} group: its 1-based line number and text. */ +export interface SearchLineMatch { + /** 1-based line number of the match within its file. */ + lineNumber: number + /** The matched line text, as the tool surfaced it (the per-line preview budget already applied). */ + line: string +} + +/** One file's grouped content matches for a {@link SearchMatchesResultView}, in first-seen file order. */ +export interface SearchFileMatches { + /** The file the matches belong to (the model-facing display path). */ + path: string + /** The file's matched lines, in output order. */ + matches: SearchLineMatch[] +} + +/** + * A completed content search (`grep`) rendered as a search card whose matches are + * grouped by file, so a capable UI can list each file as an expandable group of + * its matched lines. `shape: 'matches'` discriminates this variant from the path + * variant ({@link SearchPathsResultView}) within {@link SearchResultView}. The + * discriminant is `shape`, not `kind`, so it never collides with the + * {@link ToolCallKind} `kind` an icon-picking bridge reads off a call view. + */ +export interface SearchMatchesResultView { + card: 'search' + shape: 'matches' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** Matched lines grouped by file, in first-seen file order. */ + files: SearchFileMatches[] + /** + * Whether the tool capped the inline result: `files` carries only the retained + * matches, not every match the search found. A UI shows a capped indicator so it + * never presents a partial group as complete. + */ + truncated: boolean + /** Total matches the search found before capping (equals the retained count when not `truncated`). */ + total: number +} + +/** + * A completed path search (`glob`) rendered as a search card whose result is a flat + * path list. `shape: 'paths'` discriminates this variant from the grouped-matches + * variant ({@link SearchMatchesResultView}) within {@link SearchResultView}. + */ +export interface SearchPathsResultView { + card: 'search' + shape: 'paths' + /** Replacement title for the completed call. Omit to keep the pending-state title. */ + title?: string + /** The discovered paths, in the tool's result order (the retained page when `truncated`). */ + paths: string[] + /** + * Whether the tool capped the inline result: `paths` carries only the retained + * page, not every path the search found. A UI shows a capped indicator so it + * never presents a partial list as complete. + */ + truncated: boolean + /** Total paths the search found before capping (equals `paths.length` when not `truncated`). */ + total: number +} + +/** + * A completed search rendered as a search card, the result-time view a discovery + * tool (`grep`, `glob`) returns from `presentResult`. One `card: 'search'` view + * with two `shape`-discriminated variants: grouped-by-file content matches + * ({@link SearchMatchesResultView}) and a flat path list + * ({@link SearchPathsResultView}). Both carry a `truncated`/`total` signal so a UI + * never presents a capped result as complete. The view carries no result text: a + * UI without a search card falls back to the raw `tool/result` content. There is + * no call-time analogue: a search call stays a {@link GenericCallView} + * (`kind: 'search'`) because the pending state has no matches or paths to show — + * the structured shape exists only after `execute`. + */ +export type SearchResultView = SearchMatchesResultView | SearchPathsResultView + /** * A completed file read rendered as a line-numbered, optionally syntax-highlighted * code view by a capable UI. Set by a tool whose call reads file text (e.g. diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index d934e76d21..2670ab57c4 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -11,11 +11,12 @@ import type { Context } from 'cordis' import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { SpillRef } from '@deepseek-ai/dsh-spill' import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' import { runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import { globSearchMeta, searchViewFromMeta } from './presentation.ts' import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' @@ -43,6 +44,8 @@ export interface GlobToolCaps { sampleOverCapGlobResults: boolean /** Max paths retained inline; later paths go to the formatted spill file. */ maxResults: number + /** Max bytes of serialized `presentationMeta`; trailing paths drop past it. */ + maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ @@ -231,6 +234,24 @@ function renderGlobPaths(paths: string[], caps: GlobToolCaps, root: string, spil return formatGlobOutput(sampleAcrossTopLevel(paths, caps.maxResults, root), paths.length, spillRef) } +/** + * The inline page of paths a completed `glob` card shows, computed the SAME way + * {@link renderGlobPaths} computes its model-facing page so the card and the text + * agree on which paths survived the cap. A result within the cap is shown whole; + * an over-cap result is either the modification-time head or the top-level sample, + * matching the deployment's `sampleOverCapGlobResults`. + * + * @param paths - the complete discovered path list, in modification-time order. + * @param caps - the resolved glob caps (the inline cap and the sampling switch). + * @param root - the search root in the same display-path space as `paths`. + * @returns the inline page and whether the complete result was capped. + */ +function globCardPage(paths: string[], caps: GlobToolCaps, root: string): { items: string[]; truncated: boolean } { + if (paths.length <= caps.maxResults) return { items: paths, truncated: false } + if (!caps.sampleOverCapGlobResults) return { items: paths.slice(0, caps.maxResults), truncated: true } + return { items: sampleAcrossTopLevel(paths, caps.maxResults, root).items, truncated: true } +} + /** * Pending-call presentation: a search card titled by the pattern (and root). * @@ -242,6 +263,24 @@ export function presentGlobCall(args: { pattern: string; path?: string }): Gener return { card: 'generic', title: `Glob ${args.pattern}${where}`, kind: 'search', rawInput: args.pattern } } +/** + * Completed-call presentation: the search card projected from the result's + * `presentationMeta` (the discovered path list, with the truncation signal). A UI + * without a search card falls back to the raw `tool/result` content, so the view + * carries no result text of its own. Malformed or absent metadata (an obsolete or + * hand-edited replayed log) falls back to the generic card. + * + * @param _args - the raw tool arguments; unused, the view derives from the result. + * @param result - the final model-facing tool result carrying the projected metadata. + * @returns the search card view, or `undefined` for the generic fallback. + */ +export function presentGlobResult(_args: { pattern: string; path?: string }, result: ToolResult): SearchResultView | undefined { + if (result.isError) return undefined + const view = searchViewFromMeta(result.meta) + if (view === undefined || view.shape !== 'paths') return undefined + return view +} + /** * Register the `glob` tool and its system-prompt guidance. * @@ -289,6 +328,10 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { }, }, render: (_args, value) => [{ type: 'text', text: renderGlobPaths(value.paths, caps, value.root) }], + presentationMeta: (_args, value) => { + const page = globCardPage(value.paths, caps, value.root) + return globSearchMeta({ items: page.items, truncated: page.truncated, seen: value.paths.length }, caps.maxMetaBytes) + }, }, async execute(args, exec) { const input = parseGlobArgs(args) @@ -305,6 +348,7 @@ export function applyGlobTool(ctx: Context, caps: GlobToolCaps): void { return { root, paths: all } }, presentCall: presentGlobCall, + presentResult: presentGlobResult, }) ctx.tools.register(tool) diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index aa82749f3f..b7e67ea153 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -12,13 +12,14 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' -import type { GenericCallView } from '@deepseek-ai/dsh-tools' -import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' +import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { SpillRef } from '@deepseek-ai/dsh-spill' import type {} from '@deepseek-ai/dsh-bash' import type {} from '@deepseek-ai/dsh-system-prompt' -import { SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import type { GrepMatch } from './search-core.ts' +import { SearchError, previewLine, retainGrepMatches, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' +import { grepSearchMeta, searchViewFromMeta } from './presentation.ts' import { singleQuote } from './shell-quote.ts' import { acceptedSurfaceValue } from './surface.ts' @@ -41,6 +42,8 @@ export interface GrepToolCaps { maxMatches: number /** Max bytes retained per matched-line preview. */ maxLineBytes: number + /** Max bytes of serialized `presentationMeta`; trailing file groups drop past it. */ + maxMetaBytes: number /** Cap on the complete raw `rg` stdout the tool will parse. */ rawOutputMaxBytes: number /** Cooperative tool-call budget (ms) attached as `ToolDefinition.timeoutMs`. */ @@ -54,13 +57,6 @@ export interface GrepInput { include?: string } -/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */ -export interface GrepMatch { - path: string - lineNumber: number - line: string -} - /** * Reject an `include` that is not ONE positive glob filter: blank strings, * negated patterns (`!…`), and comma-separated lists. A comma inside a brace @@ -177,22 +173,6 @@ export function parseGrepMatches(stdout: string): GrepMatch[] { return matches } -/** - * Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and - * mark the cut. The cap is a per-line budget fact; the complete line stays in - * the searched file for `read`. - * - * @param line - the matched line text (trailing newline already stripped). - * @param maxBytes - the preview budget in bytes. - * @returns the preview, suffixed with ` (line truncated)` when bytes were cut. - */ -export function previewLine(line: string, maxBytes: number): string { - const retainer = new TextRetainer({ kind: 'head', maxBytes }) - retainer.push(line) - const kept = retainer.finish() - return kept.truncated ? `${kept.text} (line truncated)` : kept.text -} - /** `match` / `matches` for a count. */ function matchNoun(count: number): string { return count === 1 ? 'match' : 'matches' @@ -241,18 +221,10 @@ export function formatGrepOutput(retained: RetainedItems, spillRef: S return `${header}\n\n${body}\n\n(${recovery})` } -/** Apply the Native per-line preview budget without changing the canonical matches. */ -function previewGrepMatches(matches: GrepMatch[], maxLineBytes: number): GrepMatch[] { - return matches.map(match => ({ ...match, line: previewLine(match.line, maxLineBytes) })) -} - -/** Retain and format one canonical match list for the Native surface. */ -function renderGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number, spillRef?: SpillRef): string { - if (matches.length === 0) return 'No matches found' - const previewed = previewGrepMatches(matches, maxLineBytes) - const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) - for (const match of previewed) retainer.push(match) - return formatGrepOutput(retainer.finish(), spillRef) +/** Format one already-retained match list for the Native surface. */ +function formatRetainedGrep(retained: RetainedItems, spillRef?: SpillRef): string { + if (retained.seen === 0) return 'No matches found' + return formatGrepOutput(retained, spillRef) } /** @@ -268,6 +240,27 @@ export function presentGrepCall(args: { pattern: string; path?: string; include? return { card: 'generic', title: `Grep ${args.pattern}${where}${filter}`, kind: 'search', rawInput: args.pattern } } +/** + * Completed-call presentation: the search card projected from the result's + * `presentationMeta` (matches grouped by file, with the truncation signal). A UI + * without a search card falls back to the raw `tool/result` content, so the view + * carries no result text of its own. Malformed or absent metadata (an obsolete or + * hand-edited replayed log) falls back to the generic card. + * + * @param _args - the raw tool arguments; unused, the view derives from the result. + * @param result - the final model-facing tool result carrying the projected metadata. + * @returns the search card view, or `undefined` for the generic fallback. + */ +export function presentGrepResult( + _args: { pattern: string; path?: string; include?: string }, + result: ToolResult, +): SearchResultView | undefined { + if (result.isError) return undefined + const view = searchViewFromMeta(result.meta) + if (view === undefined || view.shape !== 'matches') return undefined + return view +} + /** * Register the `grep` tool and its system-prompt guidance. * @@ -315,8 +308,10 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { }, render: (_args, value) => [{ type: 'text', - text: renderGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), + text: formatRetainedGrep(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes)), }], + presentationMeta: (_args, value) => + grepSearchMeta(retainGrepMatches(value.matches, caps.maxMatches, caps.maxLineBytes), caps.maxMetaBytes), }, async execute(args, exec) { const input = parseGrepArgs(args) @@ -335,6 +330,7 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { return { matches: all } }, presentCall: presentGrepCall, + presentResult: presentGrepResult, }) ctx.tools.register(tool) @@ -344,17 +340,20 @@ export function applyGrepTool(ctx: Context, caps: GrepToolCaps): void { if (value === undefined) return decision const matches = value.matches if (matches.length <= caps.maxMatches) return decision + // The spill artifact holds the COMPLETE result: preview each line, but keep + // every match (no inline cap), so the recovery file is the full search. + const previewedAll = matches.map(match => ({ ...match, line: previewLine(match.line, caps.maxLineBytes) })) const spillRef = await trySaveFormattedResult( ctx, exec, 'grep-results.txt', - `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewGrepMatches(matches, caps.maxLineBytes))}`, + `Found ${matches.length} ${matchNoun(matches.length)}\n\n${formatGrepMatches(previewedAll)}`, ) return { kind: 'accept', content: [{ type: 'text', - text: renderGrepMatches(matches, caps.maxMatches, caps.maxLineBytes, spillRef), + text: formatRetainedGrep(retainGrepMatches(matches, caps.maxMatches, caps.maxLineBytes), spillRef), }], ...decision.additionalContexts !== undefined ? { additionalContexts: decision.additionalContexts } : {}, } diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index eea213a938..072865d568 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -31,9 +31,9 @@ import type { Context } from 'cordis' import z from 'schemastery' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' -import { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' +import { RAW_OUTPUT_MAX_BYTES, SEARCH_META_MAX_BYTES, SEARCH_TIMEOUT_MS } from './search-core.ts' -export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, sampleAcrossTopLevel } from './glob.ts' +export { GLOB_MAX_RESULTS, GLOB_VCS_EXCLUDES, applyGlobTool, buildGlobCommand, formatGlobOutput, parseGlobArgs, presentGlobCall, presentGlobResult, sampleAcrossTopLevel } from './glob.ts' export type { GlobInput, GlobSample, GlobToolCaps } from './glob.ts' export { GREP_MAX_LINE_BYTES, @@ -45,11 +45,20 @@ export { parseGrepArgs, parseGrepMatches, presentGrepCall, - previewLine, + presentGrepResult, } from './grep.ts' -export type { GrepInput, GrepMatch, GrepToolCaps } from './grep.ts' -export { RAW_OUTPUT_MAX_BYTES, SEARCH_TIMEOUT_MS, SearchError, runRipgrep, toWorkdirRelative, trySaveFormattedResult } from './search-core.ts' -export type { RipgrepRun, SearchErrorCode } from './search-core.ts' +export type { GrepInput, GrepToolCaps } from './grep.ts' +export { + RAW_OUTPUT_MAX_BYTES, + SEARCH_META_MAX_BYTES, + SEARCH_TIMEOUT_MS, + SearchError, + previewLine, + runRipgrep, + toWorkdirRelative, + trySaveFormattedResult, +} from './search-core.ts' +export type { GrepMatch, RipgrepRun, SearchErrorCode } from './search-core.ts' export { singleQuote } from './shell-quote.ts' /** Cordis plugin name used by loader diagnostics. */ @@ -68,6 +77,8 @@ export interface Config { grepMaxMatches?: number /** Max bytes retained for one matched-line preview (the cut preserves UTF-8 boundaries). */ grepMaxLineBytes?: number + /** Max bytes of one search's serialized `presentationMeta`; trailing groups/paths drop past it so the persisted card stays bounded. */ + searchMetaMaxBytes?: number /** Max complete raw `rg` stdout bytes a search will parse; larger raw output fails with `SEARCH_RAW_OUTPUT_OVERFLOW`. */ rawOutputMaxBytes?: number /** Cooperative tool-call timeout budget (ms) on both tools, enforced by `@deepseek-ai/dsh-timeout-policy` through `exec.signal`. */ @@ -79,6 +90,7 @@ export const Config: z = z.object({ globMaxResults: z.number().default(GLOB_MAX_RESULTS), grepMaxMatches: z.number().default(GREP_MAX_MATCHES), grepMaxLineBytes: z.number().default(GREP_MAX_LINE_BYTES), + searchMetaMaxBytes: z.number().default(SEARCH_META_MAX_BYTES), rawOutputMaxBytes: z.number().default(RAW_OUTPUT_MAX_BYTES), timeoutMs: z.number().default(SEARCH_TIMEOUT_MS), }) @@ -133,6 +145,7 @@ export async function apply(ctx: Context, config: Config): Promise { assertPositiveInteger('globMaxResults', resolved.globMaxResults) assertPositiveInteger('grepMaxMatches', resolved.grepMaxMatches) assertPositiveInteger('grepMaxLineBytes', resolved.grepMaxLineBytes) + assertPositiveInteger('searchMetaMaxBytes', resolved.searchMetaMaxBytes) assertPositiveInteger('rawOutputMaxBytes', resolved.rawOutputMaxBytes) assertPositiveInteger('timeoutMs', resolved.timeoutMs) if (!await ripgrepAvailable(ctx)) { @@ -142,12 +155,14 @@ export async function apply(ctx: Context, config: Config): Promise { applyGlobTool(ctx, { sampleOverCapGlobResults: resolved.sampleOverCapGlobResults, maxResults: resolved.globMaxResults, + maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, timeoutMs: resolved.timeoutMs, }) applyGrepTool(ctx, { maxMatches: resolved.grepMaxMatches, maxLineBytes: resolved.grepMaxLineBytes, + maxMetaBytes: resolved.searchMetaMaxBytes, rawOutputMaxBytes: resolved.rawOutputMaxBytes, timeoutMs: resolved.timeoutMs, }) diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 0eff077fea..402fc9d655 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -19,6 +19,8 @@ import { isAbsolute, relative, sep } from 'node:path' import type { Context } from 'cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' +import type { RetainedItems } from '@deepseek-ai/dsh-retention' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import type { ToolExecution } from '@deepseek-ai/dsh-tools' @@ -36,6 +38,18 @@ export const RAW_OUTPUT_MAX_BYTES = 20_000_000 */ export const SEARCH_TIMEOUT_MS = 30_000 +/** + * Default cap in bytes on one search's serialized `presentationMeta` (the + * `searchMetaMaxBytes` config). The inline match/path caps already bound the item + * COUNT, but retained matches of a broad search (many long lines) can still + * serialize to hundreds of kilobytes, and `meta` is persisted with the session + * log and re-sent on every request. A deployment's final output budget + * (`dsh-spill-policy`) only shrinks a result's `content`, never its `meta`, so the + * projection owns this cap. 64 KiB holds the full default-capped result of a + * typical search while bounding the pathological one. + */ +export const SEARCH_META_MAX_BYTES = 65_536 + /** * Stable, machine-routable codes for search failures. Package-owned (not * `FsErrorCode`) because these tools are bash-backed discovery, not `ctx.fs` @@ -212,6 +226,63 @@ export function toWorkdirRelative(path: string, workdir: string): string { return rel } +/** One parsed match: the file, the 1-based line number, and the (possibly previewed) line text. */ +export interface GrepMatch { + path: string + lineNumber: number + line: string +} + +/** + * Bound one matched-line preview to `maxBytes` (UTF-8 boundary preserved) and + * mark the cut. The cap is a per-line budget fact; the complete line stays in + * the searched file for `read`. + * + * @param line - the matched line text (trailing newline already stripped). + * @param maxBytes - the preview budget in bytes. + * @returns the preview, suffixed with ` (line truncated)` when bytes were cut. + */ +export function previewLine(line: string, maxBytes: number): string { + const retainer = new TextRetainer({ kind: 'head', maxBytes }) + retainer.push(line) + const kept = retainer.finish() + return kept.truncated ? `${kept.text} (line truncated)` : kept.text +} + +/** + * Apply the shared inline cap to a canonical `grep` match list: preview each + * retained line to `maxLineBytes` and keep the first `maxMatches`. The single + * retention pass both the model-facing render ({@link module:@deepseek-ai/dsh-tool-fs-search/grep} + * `formatGrepOutput`) and the search-card projection + * ({@link module:@deepseek-ai/dsh-tool-fs-search/presentation} `grepSearchMeta`) + * consume, so text and card never disagree about which matches survived. + * + * @param matches - every match the search parsed (the canonical value's matches). + * @param maxMatches - the inline match cap (the `grepMaxMatches` config). + * @param maxLineBytes - the per-matched-line preview budget in bytes. + * @returns the retention outcome over the previewed matches. + */ +export function retainGrepMatches(matches: GrepMatch[], maxMatches: number, maxLineBytes: number): RetainedItems { + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxMatches }) + for (const match of matches) retainer.push({ ...match, line: previewLine(match.line, maxLineBytes) }) + return retainer.finish() +} + +/** + * Apply the shared inline cap to a canonical `glob` path list: keep the first + * `maxResults`. The single retention pass both the model-facing render and the + * search-card projection consume. + * + * @param paths - every path the search discovered (the canonical value's paths). + * @param maxResults - the inline path cap (the `globMaxResults` config). + * @returns the retention outcome over the paths. + */ +export function retainGlobPaths(paths: string[], maxResults: number): RetainedItems { + const retainer = new ItemRetainer({ kind: 'head', maxItems: maxResults }) + for (const path of paths) retainer.push(path) + return retainer.finish() +} + /** * Best-effort save of one COMPLETE formatted search result through * `ctx.spillStore.saveText()` — the model-facing recovery path for a capped diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index a0d3cdf38c..389d8dbe05 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -27,7 +27,9 @@ import { formatGrepMatches, parseGrepMatches, presentGlobCall, + presentGlobResult, presentGrepCall, + presentGrepResult, previewLine, sampleAcrossTopLevel, toWorkdirRelative, @@ -987,6 +989,73 @@ describe('presentation', () => { expect(presentGrepCall({ pattern: 'todo' })).toMatchObject({ card: 'generic', title: 'Grep todo', kind: 'search' }) expect(presentGrepCall({ pattern: 'todo', path: 'src', include: '*.ts' }).title).toBe('Grep todo in src (*.ts)') }) + + it('grep projects a search card from a real execute, grouped by file with total and truncation', async () => { + const { ctx, bash } = await setup({ config: { grepMaxMatches: 2 } }) + bash.handler = () => runResult([ + matchLine('a.ts', 1, 'one'), + matchLine('a.ts', 2, 'two'), + matchLine('b.ts', 3, 'three'), + '', + ].join('\n')) + const result = await call(ctx, 'grep', { pattern: 'e' }, { agent: agent('/w') }) + if (result.isError) throw new Error('expected grep success') + // The presentationMeta projection rides the result meta (a surface call). + expect(result.meta).toEqual({ + shape: 'matches', + files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], + truncated: true, + total: 3, + }) + const view = presentGrepResult({ pattern: 'e' }, result) + expect(view).toEqual({ + card: 'search', + shape: 'matches', + files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'one' }, { lineNumber: 2, line: 'two' }] }], + truncated: true, + total: 3, + }) + }) + + it('glob projects a search card from a real execute, a flat path list with total and truncation', async () => { + const { ctx, bash } = await setup({ config: { globMaxResults: 2 } }) + bash.handler = () => runResult('a.ts\nb.ts\nc.ts\n') + const result = await call(ctx, 'glob', { pattern: '*.ts' }, { agent: agent('/w') }) + if (result.isError) throw new Error('expected glob success') + expect(result.meta).toEqual({ shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) + const view = presentGlobResult({ pattern: '*.ts' }, result) + expect(view).toEqual({ card: 'search', shape: 'paths', paths: ['a.ts', 'b.ts'], truncated: true, total: 3 }) + }) + + it('nested Code dispatch computes no meta, so presentResult falls back to the generic card', async () => { + const { ctx, bash } = await setup() + bash.handler = () => runResult(`${matchLine('a.ts', 1, 'one')}\n`) + const result = await call(ctx, 'grep', { pattern: 'o' }, { + agent: agent('/w'), + parent: Symbol('run_code') as ToolExecutionToken, + }) + if (result.isError) throw new Error('expected grep success') + expect(result.meta).toBeUndefined() + expect(presentGrepResult({ pattern: 'o' }, result)).toBeUndefined() + }) + + it('presentResult returns undefined for a failed result and for the other tool’s meta shape', () => { + const errorResult = { content: [{ type: 'text' as const, text: 'boom' }], isError: true } + expect(presentGrepResult({ pattern: 'x' }, errorResult)).toBeUndefined() + expect(presentGlobResult({ pattern: '*' }, errorResult)).toBeUndefined() + // A grep result carrying a paths-shaped meta (and vice versa) is not this + // tool's shape: each presenter narrows to its own shape and otherwise falls back. + const pathsResult = { content: [], isError: false, meta: { shape: 'paths', paths: ['a.ts'], truncated: false, total: 1 } } + const matchesResult = { content: [], isError: false, meta: { shape: 'matches', files: [], truncated: false, total: 0 } } + expect(presentGrepResult({ pattern: 'x' }, pathsResult)).toBeUndefined() + expect(presentGlobResult({ pattern: '*' }, matchesResult)).toBeUndefined() + }) + + it('presentResult falls back to the generic card on malformed replayed meta', () => { + const malformed = { content: [], isError: false, meta: { shape: 'matches', files: 'nope', truncated: false, total: 0 } } + expect(presentGrepResult({ pattern: 'x' }, malformed)).toBeUndefined() + expect(presentGlobResult({ pattern: '*' }, { content: [], isError: false, meta: 42 })).toBeUndefined() + }) }) describe('helpers', () => { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 1bd0f19d0b..ab277b7d79 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 8f08d90f8a91afc2ff022761d2d83de055df18a0 -README.zh.md: febeba0e601d75cbc69490d29135c206189efe29 +README.md: 94698d02f599e10b1bbe69e2f9b1b058eb3e1647 +README.zh.md: da63ff09d7ca52bdc10c8a8bce81be4c2a0abf0d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 8f08d90f8a..94698d02f5 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -34,7 +34,7 @@ Directory picking delegates to the composed `ctx.directoryPicker` backend ([the The `command.*` and `skill.*` domains expose the host command registry and skill catalog to clients. Every method addresses one session's agent by `sessionId` (a served session always has an Agent; `command.*` resumes cold sessions through the same path as `session.*`, while `skill.list` resolves the project root from the session header without touching the Agent registry). `skill.list` serves the browser's user-selected model-reference path, so it returns only skills that are both model-invocable and user-invocable; this domain has no direct skill-loading RPC. `command.execute` runs a slash-command line host-side with pure admission semantics: the response reports whether the line resolved to a handler plus the minted lifecycle `commandId` when it did (correlating the acknowledgment with the flow node), while the outcome rides the durably logged `command/run`/`command/done` lifecycle pair broadcast on the mux stream. Command handlers may legitimately outlast the 30-second transport health deadline, so `command.execute` carries only caller/connection cancellation; that signal cancels the running handler. `host/commands-changed` is the catalog invalidation frame: clients refetch `command.list` instead of diffing. -The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit Web-preference allowlist, currently only `permission`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. ## Carrier layer (`/client` + root) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index febeba0e60..da63ff09d7 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -34,7 +34,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和技能目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于浏览器中由用户选择的模型引用路径,因此仅返回模型和用户均可调用的 skill;该领域没有直接加载 skill 的 RPC。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是目录失效帧:客户端重新拉取 `command.list` 而不是做差分。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 Web 偏好 allowlist,目前仅包含 `permission`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 ## 载体层(`/client` + 根路径) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index b6365a4a52..d765ab70df 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -84,6 +84,9 @@ const COLD_SUMMARY_BATCH_SIZE = 16 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message']) +/** Product settings intentionally exposed beside model-provider namespaces. */ +const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding']) + /** Read live abort state across awaits without treating it as synchronously immutable. */ function isAborted(signal: AbortSignal): boolean { return signal.aborted @@ -1108,13 +1111,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro /** * The settings namespaces this proxy serves: configurable model providers - * plus the small explicit Web preference allowlist. The settings seam - * remains general; a future registration does not become remotely readable - * or writable by default. + * plus the small explicit Web preference and product-owned allowlists. The + * settings seam remains general; a future registration does not become + * remotely readable or writable by default. */ function exposedNamespaces(): Set { const exposed = modelProviderNamespaces() for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns) + for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns) return exposed } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 08d2dee2f3..c50ad95806 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -160,8 +160,8 @@ async function harness(options?: { await ctx.plugin(LlmService) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - // Model-provider namespaces and the explicit Web preference allowlist are - // the proxy's complete settings surface. + // Model-provider namespaces plus the explicit Web preference and product + // onboarding allowlists are the proxy's complete settings surface. if (options?.configurableProviders !== false) { ctx.llm.registerConfigurableProviders([ { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, @@ -226,8 +226,8 @@ describe('settings domain', () => { // The settings seam is general: any plugin may register a namespace for // its own configuration. The Web configuration plane remains opt-in, so a // future internal plugin cannot become remotely configurable just by - // registering; permission is the one non-model namespace intentionally - // admitted by this surface. + // registering; permission and the product onboarding namespace are the + // non-model namespaces intentionally admitted by this surface. const ctx = await harness() ctx.settings.register(NS, AdapterConfig) ctx.settings.register(settingsNamespace('some-other-plugin'), z.object({ secretPath: z.string() })) @@ -258,6 +258,21 @@ describe('settings domain', () => { expect(ctx.settings.describe().find(d => String(d.ns) === 'some-other-plugin')?.value).toEqual({}) }) + it('serves the product onboarding namespace without invalidating the model catalog', async () => { + const ctx = await harness() + ctx.settings.register(settingsNamespace('ui-onboarding'), z.object({ welcomeNoticeVersion: z.string() })) + const api = createApiProxy(ctx, DEFAULTS) + expect(expectOk(await api.settings.describe(request({}))).namespaces.map(view => view.ns)) + .toEqual(['ui-onboarding']) + const frames = await collectHost(api, ['host/settings-changed'], 1, async () => { + expectOk(await api.settings.mutate(request({ + ns: 'ui-onboarding', + ops: [{ op: 'set', path: ['welcomeNoticeVersion'], value: 'v1' }], + }))) + }) + expect(frames).toEqual([{ type: 'host/settings-changed', ns: 'ui-onboarding' }]) + }) + it('refuses even a model-provider namespace once its directory entry is gone', async () => { const ctx = await harness({ configurableProviders: false }) ctx.settings.register(NS, AdapterConfig) diff --git a/packages/ui/tui/src/chat/model-command.ts b/packages/ui/tui/src/chat/model-command.ts index 133a3d0d9f..c86b3b7e3d 100644 --- a/packages/ui/tui/src/chat/model-command.ts +++ b/packages/ui/tui/src/chat/model-command.ts @@ -8,7 +8,7 @@ */ import type { AgentLlmTarget, AgentLlmTargetRef } from '@deepseek-ai/dsh-agent' -import { errorChain, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { errorChain, LlmError, type ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { TuiOverlaySession } from '../extension/types.ts' import { displayText } from '../components/text.ts' import { @@ -37,6 +37,8 @@ export interface ModelController { resetContextResolution(): void /** Forget the tracked selector overlay (shutdown). */ clearOverlay(): void + /** Remove the adapter-registration listener (channel detach). */ + detach(): void } type ContextResolution = @@ -55,8 +57,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle let modelOverlay: TuiOverlaySession | undefined let modelCommands = Promise.resolve() + // A route whose adapter has not registered yet. Loader activation order is + // service-driven, so the TUI can mount before a configured adapter plugin + // activates; that transient NO_ADAPTER is not an error — the resolution + // waits for the next `llm/adapters-updated` commit instead of surfacing it. + let awaitingAdapter = false + const resolveContextWindow = (selected: AgentLlmTarget | undefined): void => { contextWindow = undefined + awaitingAdapter = false const resolution: Promise = selected === undefined ? Promise.resolve({ kind: 'resolved', contextWindow: undefined } as const) : ctx.llm.resolveModelInfo(selected.provider, selected.model).then( @@ -67,6 +76,10 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle void resolution.then((result) => { if (contextResolution !== resolution) return if (result.kind === 'error') { + if (selected !== undefined && result.error instanceof LlmError && result.error.code === 'NO_ADAPTER') { + awaitingAdapter = true + return + } deps.appendNotice(`Could not resolve model context: ${errorChain(result.error)}`, 'error') return } @@ -74,6 +87,15 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle deps.requestRender() }) } + // The wait cannot go stale against `target.current`: every target change + // re-enters resolveContextWindow, which clears it. A commit that still + // lacks the route parks the resolution again rather than erroring, so + // unrelated topology changes stay silent. The disposer rides the channel's + // detachListeners() through detach(), matching the sibling listeners. + const disposeAdapterListener = ctx.on('llm/adapters-updated', () => { + if (deps.isDisposed() || !awaitingAdapter) return + resolveContextWindow(target.current) + }) resolveContextWindow(target.current) const selectModel = ( @@ -187,5 +209,8 @@ export function createModelController(deps: ModelControllerDeps): ModelControlle clearOverlay(): void { modelOverlay = undefined }, + detach(): void { + disposeAdapterListener() + }, } } diff --git a/packages/ui/tui/src/components/transcript.ts b/packages/ui/tui/src/components/transcript.ts index c991e85c1f..c8ba061166 100644 --- a/packages/ui/tui/src/components/transcript.ts +++ b/packages/ui/tui/src/components/transcript.ts @@ -402,23 +402,26 @@ export class ToolCardComponent implements Component { const glyph = this.result === undefined ? '○' : '●' const rawBody = this.renderBody() const view = this.resultView ?? this.callView - // A generic card's own content, or a read card's `content` fallback (the + // A generic card's own content, a read card's `content` fallback (the // envelope-stripped file text — the TUI has no dedicated read rendering, so a - // read renders exactly as before the read card existed), or a web card's - // fallback to the raw result content (the `web` view carries no `content` - // copy), all render as one dim Markdown block below, so links/lists/headings - // keep the unified dim styling rather than reading as bare text. Terminal and - // diff cards own their body styling, so they are excluded (mirrors - // renderBody's post-terminal/diff fallback). + // read renders exactly as before the read card existed), or a search/web + // card's fallback to the raw result content (neither the `search` nor the + // `web` view carries a `content` copy), all render as one dim Markdown block + // below, so links/lists/headings keep the unified dim styling rather than + // reading as bare text. A search card thus stays byte-identical to the + // pre-search-card generic fallback. Terminal and diff cards own their body + // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback). const markdownContent = view.card === 'generic' || view.card === 'read' ? view.content ?? this.result?.content - : view.card === 'web' - // A web resultView is only assigned alongside this.result (the result - // handler sets both) and the pending callView is never a web card, so - // the optional-chain undefined side is unreachable here. - /* v8 ignore next */ + : view.card === 'search' ? this.result?.content - : undefined + : view.card === 'web' + // A web resultView is only assigned alongside this.result (the result + // handler sets both) and the pending callView is never a web card, so + // the optional-chain undefined side is unreachable here. + /* v8 ignore next */ + ? this.result?.content + : undefined const unknownXml = this.definition === undefined && markdownContent !== undefined ? renderUnknownXml( displayText(contentText(markdownContent)), @@ -535,11 +538,12 @@ export class ToolCardComponent implements Component { // rather than under the dim result-output color. return { prelude: [...hunks, footer], lines: [] } } - // A generic or read card carries its own envelope-stripped `content`; a `web` - // card carries no `content` copy and falls back to the raw result content - // here. (Mirrors the `markdownContent` selection in render(); a read card has - // no dedicated TUI rendering, so its `content` takes the same body path, - // keeping read output as it was before the read card existed.) + // A generic or read card carries its own envelope-stripped `content`; a + // search or web card carries no `content` copy and falls back to the raw + // result content here. (Mirrors the `markdownContent` selection in render(); + // a read card has no dedicated TUI rendering, so its `content` takes the same + // body path, keeping read output as it was before the read card existed, and + // a search card stays byte-identical to the pre-search-card fallback.) const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content const prelude: string[] = [] const lines: string[] = [] diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a1250bb5b3..e2eaf89986 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1563,6 +1563,7 @@ export function createTuiChat( disposeAgent() disposeSchemeListener() disposeTargetListeners() + modelController.detach() } // Sweep reveal of the whole banner: the header wipes in left-to-right over diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 339c63eef5..8231dc34d1 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -10,6 +10,7 @@ import AgentRegistry, { } from '@deepseek-ai/dsh-agent' import { createUserMessage, createToolResultMessage, + LlmError, ReasoningEffortId, type LlmCallConfig, type LlmModelReasoningInfo, @@ -3630,6 +3631,96 @@ describe('pi-tui chat lifecycle and transcript', () => { await dispose(reasoningFailed) }) + it('defers a NO_ADAPTER context resolution until the provider registers instead of surfacing an error', async () => { + // Loader activation order is service-driven: the TUI can mount before a + // configured adapter plugin activates, so the initial resolveModelInfo + // fails with NO_ADAPTER. That transient state must not print an error; + // the resolution retries on llm/adapters-updated. + const adapters = new Set() + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + contextTokens: 50_000, + catalog: { + providers: [], + models: [], + resolveModelInfo: () => adapters.has('openai-codex') + ? Promise.resolve({ context: { contextWindow: 100_000 } }) + : Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')), + }, + }) + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + + // A topology commit that still lacks the route parks the wait again. + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('% context') + expect(result.terminal.output).not.toContain('Could not resolve model context') + + adapters.add('openai-codex') + result.ctx.emit('llm/adapters-updated') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('% context') + }) + expect(result.terminal.output).not.toContain('Could not resolve model context') + + // A commit after satisfaction is a no-op for the resolved value. + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + await dispose(result) + }) + + it('stops listening for adapter registrations after channel detach', async () => { + // The listener disposer rides detachListeners() through the controller's + // detach(): after dispose, a registry commit must not re-enter resolution + // at all (the isDisposed() guard is a fallback, not the removal). + const calls: string[] = [] + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + catalog: { + providers: [], + models: [], + resolveModelInfo: (provider) => { + calls.push(provider) + return Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')) + }, + }, + }) + await tick() + const callsAtDetach = calls.length + await result.controller.dispose() + result.ctx.emit('llm/adapters-updated') + await tick() + expect(calls.length).toBe(callsAtDetach) + await result.ctx.fiber.dispose() + }) + + it('drops a deferred NO_ADAPTER resolution when the target moved before the adapter registered', async () => { + const result = await setup({ + agentOptions: { provider: 'openai-codex', model: 'gpt-x' }, + catalog: { + providers: [{ id: 'alpha', name: 'Alpha' }], + models: [{ provider: 'alpha', id: 'a1', name: 'Alpha One' }], + resolveModelInfo: provider => provider === 'alpha' + ? Promise.resolve({ context: { contextWindow: 64_000 } }) + : Promise.reject(new LlmError('no adapter registered for provider "openai-codex"', 'NO_ADAPTER')), + }, + }) + await tick() + // Switching the model re-resolves and clears the deferred wait, so the + // stale route's adapter arriving afterwards must be a no-op. + result.terminal.send('/model alpha/a1') + result.terminal.send('\r') + await vi.waitFor(() => { + expect(result.terminal.output).toContain('Model selected: alpha/a1') + }) + result.ctx.emit('llm/adapters-updated') + await tick() + expect(result.terminal.output).not.toContain('Could not resolve model context') + await dispose(result) + }) + it('does not render a model catalog that resolves after TUI disposal', async () => { const deferred = Promise.withResolvers() const result = await setup({ @@ -4387,6 +4478,20 @@ describe('tool cards and surface replay', () => { presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }), presentResult: () => ({ card: 'terminal', output: 'converted terminal' }), }, + // A search card carries no result text of its own; the TUI has no dedicated + // search arm and falls back to the raw result content, rendered as the same + // dim generic body a pre-search-card grep/glob result showed. + search: { + name: 'search', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Grep todo', kind: 'search' }), + presentResult: () => ({ + card: 'search', + shape: 'matches', + files: [{ path: 'a.ts', matches: [{ lineNumber: 1, line: 'todo one' }] }], + truncated: false, + total: 1, + }), + }, symbolic: { name: 'symbolic', description: '', parameters: {}, output: UNUSED_TOOL_OUTPUT, execute: async () => [], presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }), @@ -4424,6 +4529,7 @@ describe('tool cards and surface replay', () => { ['c12', 'symbolic', '{}'], ['c13', 'knownXml', '{}'], ['c16', 'webCard', '{}'], + ['c17', 'search', '{"pattern":"todo"}'], ] as const appendAssistant(result.session, [ { type: 'text', text: 'Calling tools' }, @@ -4525,6 +4631,14 @@ describe('tool cards and surface replay', () => { isError: false, }), }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: 'c17' as never, + content: [{ type: 'text', text: 'Found 1 match\n\na.ts\nLine 1: todo one' }], + isError: false, + }), + }, { surfaceOp: 'append' }) result.session.append('tool/result', { turn: 1, step: 1, @@ -4556,6 +4670,11 @@ describe('tool cards and surface replay', () => { expect(output).toContain('$ blank desc command') // A card whose title only repeats the name renders header-only (empty body). expect(output).toContain('Tool / emptyBody') + // A search result view carries no `content` of its own, so the card renders + // the raw model-facing result text through the same dim generic body — the + // TUI has no dedicated search arm. + expect(output).toContain('Tool / search') + expect(output).toContain('Line 1: todo one') // A diff card drops its title (the paths + change footer carry the meaning). // The first file's path is head-visible; the second file and the change // footer sit past this card's 4-line budget and appear only when expanded. diff --git a/packages/web/tool-web/README.i18n.yaml b/packages/web/tool-web/README.i18n.yaml index 4a853a1fa8..590b319209 100644 --- a/packages/web/tool-web/README.i18n.yaml +++ b/packages/web/tool-web/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/web/tool-web/README.md -README.md: 7bee0d2d30fbbcf582fd7b60eb5d9130b6bdf888 -README.zh.md: 3d708839c9ffbdd89df08678fd6997fc6c45ee07 +README.md: 12f5c806db66b2109888c1ec642d117f3432d0df +README.zh.md: cfbf47219f160af706912ca53f85cff535c381ec diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index 7bee0d2d30..12f5c806db 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The model-facing web tool suite — `web_search` and `web_fetch` — over the [web capability seam](../web/README.md) (`ctx.web`). It owns model-facing concerns only: tool names, JSON schemas, snake_case argument names, prompt sections, the result-count bound, result formatting, HTML→markdown presentation, and the UI presentation projection — `presentCall`, `presentResult` (a `card: 'web'` result card discriminated by `kind: 'search' | 'fetch'`), and the `output.presentationMeta` that carries the structured search sources or the fetch summary the lossy render text cannot (see the [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md)). All web access goes through `ctx.web`; this package never imports a concrete provider. Neither tool exposes a model-facing timeout — each tool's cooperative tool-call budget is declared here via config (`fetchTimeoutMs`/`searchTimeoutMs`, attached as `ToolDefinition.timeoutMs`) and enforced by [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md) (a `tools/execute` wrapper); each tool just forwards `exec.signal` to the seam. -Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). +Each tool is registered independently; a product that wants only one disables the other via config (`{ search: false }` / `{ fetch: false }`). Search guidance mentions `web_fetch` only when fetch is also config-enabled; a search-only composition instead tells the model to use returned snippets and cite their URLs. ## Tools @@ -47,14 +47,20 @@ The tool never calls a provider's `available()` and never enumerates providers #### What the model sees -Search and fetch contribute the web-search and web-fetch guidance below. A scoped tool restriction does not remove these independently registered sections. +Search and fetch contribute the web-search and web-fetch guidance below. Search chooses its fetch-enabled or search-only text from config at registration time. A scoped tool restriction does not remove these independently registered sections. -##### Web search guidance +##### Web search guidance with fetch enabled ```markdown Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. ``` +##### Web search-only guidance + +```markdown +Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. +``` + ##### Web fetch guidance ```markdown @@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### Token effect -Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. +Fixed guidance cost per request for each config-enabled tool, even when a restriction hides its schema. Toggling fetch changes the search guidance as well as registering or removing the fetch section. #### KV Cache effect -Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it. +Prefix-stable while enabled tools, scope, and guidance text are unchanged. Config enablement—including toggling fetch's search-guidance branch—or plugin lifecycle may invalidate reuse from the first changed prompt section; scoped schema restrictions do not remove it. ### Tool schemas diff --git a/packages/web/tool-web/README.zh.md b/packages/web/tool-web/README.zh.md index 3d708839c9..cfbf47219f 100644 --- a/packages/web/tool-web/README.zh.md +++ b/packages/web/tool-web/README.zh.md @@ -4,7 +4,7 @@ 面向模型的 web 工具套件 `web_search` 与 `web_fetch`,构建于 [web 能力 seam](../web/README.md)(`ctx.web`)之上。它只负责面向模型的事项:工具名称、JSON Schema、snake_case 参数名称、提示词区段、结果数量上限、结果格式、HTML→markdown 呈现,以及 UI 呈现投影——`presentCall`、`presentResult`(以 `kind: 'search' | 'fetch'` 区分的 `card: 'web'` 结果卡片),以及承载有损渲染文本无法携带的结构化搜索来源或抓取摘要的 `output.presentationMeta`(见 [web-result-card Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card.md))。所有 web 访问都通过 `ctx.web`;该包(package)绝不导入具体提供方。两个工具都不公开面向模型的超时:每个工具的协作式工具调用超时预算通过配置在此声明(`fetchTimeoutMs`/`searchTimeoutMs`,附加为 `ToolDefinition.timeoutMs`),由 [`@deepseek-ai/dsh-timeout-policy`](../../timeout/timeout-policy/README.md)(`tools/execute` 包装层)强制执行;每个工具只把 `exec.signal` 转发给 seam。 -每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。 +每个工具独立注册;只需要其中一个工具的产品可以通过配置禁用另一个(`{ search: false }`/`{ fetch: false }`)。仅当抓取也通过配置启用时,搜索指引才会提及 `web_fetch`;仅启用搜索的组合则会要求模型使用返回的 snippet 并引用其 URL。 ## 工具 @@ -47,14 +47,20 @@ #### 模型看到的内容 -搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。scope 工具限制不会移除这些独立注册的区段。 +搜索与抓取分别贡献以下 web-search 和 web-fetch 指引。搜索会在注册时根据配置选用启用抓取或仅搜索的文本。scope 工具限制不会移除这些独立注册的区段。 -##### Web 搜索指引 +##### 启用抓取时的 Web 搜索指引 ```markdown Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links. ``` +##### 仅搜索时的 Web 搜索指引 + +```markdown +Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links. +``` + ##### Web 抓取指引 ```markdown @@ -63,11 +69,11 @@ Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL (for ex #### Token 影响 -每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。 +每个通过配置启用的工具都会为每次请求增加固定的指引 token 开销,即使限制隐藏了其 schema。切换抓取状态不仅会注册或移除抓取区段,也会更改搜索指引。 #### KV Cache 影响 -只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。 +只要启用工具、scope 与指引文本不变,前缀就保持稳定。配置启用状态(包括因切换抓取状态而改变搜索指引分支)或插件生命周期可能使从第一个变化的提示词区段起的复用失效;scope schema 限制不会移除该区段。 ### 工具 schema diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index 397e2bf7bb..f8f32c568a 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -84,6 +84,8 @@ export function apply(ctx: Context, config: Config): void { assertPositiveInteger('fetchTimeoutMs', resolved.fetchTimeoutMs) assertPositiveInteger('searchTimeoutMs', resolved.searchTimeoutMs) assertPositiveInteger('fetchMaxOutputChars', resolved.fetchMaxOutputChars) - if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs) + if (resolved.search) { + applyWebSearchTool(ctx, resolved.searchMaxResults, resolved.searchTimeoutMs, resolved.fetch) + } if (resolved.fetch) applyWebFetchTool(ctx, resolved.fetchTimeoutMs, resolved.fetchMaxOutputChars) } diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 20979e4035..35c97dfda2 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -204,12 +204,21 @@ export function presentSearchResult(args: { query: string }, result: ToolResult) * request's `maxResults`. * @param timeoutMs - the cooperative tool-call budget (ms) attached as the tool's * `ToolDefinition.timeoutMs` for `@deepseek-ai/dsh-timeout-policy` to enforce. + * @param fetchEnabled - whether the same composition exposes `web_fetch`, which + * controls whether search guidance may recommend that follow-up tool. */ -export function applyWebSearchTool(ctx: Context, maxResults: number, timeoutMs: number): void { +export function applyWebSearchTool( + ctx: Context, + maxResults: number, + timeoutMs: number, + fetchEnabled: boolean, +): void { ctx.systemPrompt.section({ name: 'tool:web_search', order: 110, - text: 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.', + text: fetchEnabled + ? 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.' + : 'Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.', }) ctx.tools.register(defineTool({ diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index fa07c4ba56..de9bf79284 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -483,8 +483,17 @@ describe('tool-web registration', () => { const { fiber, ctx } = await mountTools() const prompt = await ctx.systemPrompt.assemble() const text = prompt.sections.map(s => s.text).join('\n') - expect(text).toContain('web_search') - expect(text).toContain('web_fetch') + expect(text).toContain('Use the web_search tool to discover current information on the web. It returns an optional answer plus a list of source URLs. Follow up with web_fetch when you need the full content of a specific result, and cite the relevant URLs as markdown links.') + expect(text).toContain('Use the web_fetch tool to retrieve the content of a specific HTTP(S) URL') + await fiber.dispose() + }) + + it('does not advertise web_fetch in search-only prompt guidance', async () => { + const { fiber, ctx } = await mountTools({ config: { search: true, fetch: false } }) + const prompt = await ctx.systemPrompt.assemble() + const text = prompt.sections.map(s => s.text).join('\n') + expect(text).toContain('Use the returned source snippets when available') + expect(text).not.toContain('web_fetch') await fiber.dispose() }) }) diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 83129cba21..edc7b5d18b 100644 --- a/packages/web/web-search-deepseek/README.i18n.yaml +++ b/packages/web/web-search-deepseek/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/web/web-search-deepseek/README.md -README.md: 54eb7561b9d81a9e2da565e3870abe094dbe984d -README.zh.md: 8862b8a9c1ba69d247942bb6e41289214826c682 +README.md: 9046934de209ed0787efa50332e5be16bfdf55c6 +README.zh.md: 94e01daba69cecd2f5c3c6680979ee5fd66d7cdd diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 54eb7561b9..9046934de2 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) A [DeepSeek](https://deepseek.com)-backed `WebSearchProvider` for the harness [web capability seam](../web/README.md) (`ctx.web`). It calls DeepSeek's **Anthropic-compatible Messages API** (`POST {baseURL}/messages`) with the native `web_search_20250305` server tool enabled, and maps the structured `web_search_tool_result` blocks DeepSeek returns into the seam's normalized `WebSearchResult`. -This is an **implementation** package: it registers a provider into `ctx.web`, it does not own the key and it does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. +This is an **implementation** package: it registers a provider into `ctx.web`, resolves its credential for each search through the optional `ctx.credentials` seam, records the auxiliary request in the initiating Agent session when one exists, and does not register a model-facing tool. Like `@deepseek-ai/dsh-llm-deepseek`, it is a function/namespace plugin (`inject: ['web']`). The Anthropic wire shape is a provider-private detail — it does **not** make this provider depend on `ctx.llm`. ## How it differs from a dedicated search endpoint @@ -12,13 +12,14 @@ Exa and Perplexity expose dedicated search endpoints; DeepSeek does not. Instead **Strict mode**: if the response carries no `web_search_tool_result` block (native search did not trigger), the provider throws `WebError` `WEB_PROVIDER_ERROR` rather than degrading to prose-scraping — honest and debuggable. -It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. +It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not** `$DEEPSEEK_BASE_URL`: the search endpoint is the Anthropic-compatible base (`https://api.deepseek.com/anthropic/v1`), distinct from the chat-completions base (`https://api.deepseek.com`) the LLM adapter uses. A mounted credentials service is authoritative; without one, the provider falls back to the launching process environment. The reference is resolved for each search, so a key stored or rotated by the Web Models page reaches the next call without a restart. ## Config | Key | Default | Meaning | |---|---|---| -| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API key. Empty/absent makes the provider unavailable. Sent as both `x-api-key` and `Authorization: Bearer` (official vs Anthropic-compatible proxy). | +| `apiKey` | omitted | Literal DeepSeek API key. Prefer `apiKeyEnv` so no secret enters configuration; a non-empty literal wins. | +| `apiKeyEnv` | `DEEPSEEK_API_KEY` | Credential reference resolved for each search through `ctx.credentials`, or from the process environment when that seam is absent. A missing value fails the call as `WEB_PROVIDER_CREDENTIAL_MISSING`. | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic-compatible endpoint base; `/messages` is appended. Use a separate env var such as `$DEEPSEEK_SEARCH_BASE_URL` when overriding it; do not reuse `$DEEPSEEK_BASE_URL`, which belongs to the chat-completions LLM adapter. An unparseable value makes the provider unavailable. | | `model` | `deepseek-v4-flash` | Anthropic-format model name. | | `apiVersion` | `2023-06-01` | `anthropic-version` header value. | @@ -29,7 +30,7 @@ It reuses `$DEEPSEEK_API_KEY` (no new secret) but **not** `$DEEPSEEK_BASE_URL`: - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL ``` @@ -41,6 +42,10 @@ Results are deduplicated by URL because one request may surface the same page ac Provider failures become `WEB_PROVIDER_ERROR`; caller cancellation becomes `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`. +## Request logging + +Immediately before dispatch, a search running under an initiating Agent appends the log-only `web/deepseek-search-llm-request` session event. It contains the resolved endpoint, API version, and exact secret-free JSON body sent to DeepSeek; headers and credentials are excluded. Credential failures and cancellations before dispatch create no event, while later HTTP or response failures leave the attempted request durable. Direct programmatic provider calls outside an Agent have no initiating session to log. + ## Model Experience ### Auxiliary DeepSeek search request @@ -61,7 +66,7 @@ Independent of the conversation request cache. The auxiliary instruction and nat #### What the model sees -Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures are `DeepSeek search aborted`, `DeepSeek search request failed: `, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. +Through [`dsh-tool-web`](../tool-web/README.md), the conversation model sees deduplicated URLs, titles, dates, and citation snippets from structured search blocks; provider prose is not trusted as an answer. This provider's exact failures include the actionable missing-credential message, `DeepSeek search credential resolution failed: `, `DeepSeek search aborted`, `DeepSeek search request failed: `, `DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search`, and `DeepSeek returned an unprocessable response body: `; HTTP failures preserve the provider message. The consumer owns the error wrapper. #### Token effect @@ -74,6 +79,6 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **One search costs a full Messages model turn** — latency plus generated tokens, with up to `maxUses` server-side searches; DeepSeek exposes no dedicated retrieval endpoint. +- **Dynamic credential availability resolves inside the operation** — the synchronous `available()` contract can establish that a resolver exists but cannot query an asynchronous credential store. A selected keyless provider therefore fails the search with `WEB_PROVIDER_CREDENTIAL_MISSING`; the stable `web_search` schema remains registered. Caller cancellation races this preflight locally, but cannot force an arbitrary credential backend itself to stop work. - **Over-returned sources still cost tokens** — with no result-count knob on the wire, `maxResults` is enforced only post-hoc by seam truncation. - **Uncited results carry no `snippet`** — a source gains one only when a `text` block citation (`cited_text`) matches its URL. -- **Abort classification is error-shape-based** — only a `DOMException` named `AbortError` maps to `WEB_ABORTED`; an abort carrying a custom reason (e.g. `dsh-timeout`'s `TimeoutReason`) surfaces as `WEB_PROVIDER_ERROR`. diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 8862b8a9c1..94e01daba6 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -4,7 +4,7 @@ 由 [DeepSeek](https://deepseek.com) 支持的 `WebSearchProvider`,用于 harness [web 能力 seam](../web/README.md)(`ctx.web`)。它调用 DeepSeek 的 **Anthropic 兼容 Messages API**(`POST {baseURL}/messages`),启用原生 `web_search_20250305` 服务器工具,并把 DeepSeek 返回的结构化 `web_search_tool_result` 块映射为 seam 规范化的 `WebSearchResult`。 -这是一个**实现**包(package):它向 `ctx.web` 注册提供方,不拥有该键,也不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 +这是一个**实现**包(package):它向 `ctx.web` 注册提供方,通过可选的 `ctx.credentials` seam 为每次搜索解析凭据,若存在发起请求的 agent(智能体)会话,还会在其中记录该辅助请求,且不注册面向模型的工具。与 `@deepseek-ai/dsh-llm-deepseek` 一样,它是函数/命名空间插件(`inject: ['web']`)。Anthropic 协议格式(wire format)是提供方私有细节,并**不**使该提供方依赖 `ctx.llm`。 ## 与专用搜索端点的区别 @@ -12,13 +12,14 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 **严格模式**:如果响应不含 `web_search_tool_result` 块(未触发原生搜索),提供方会抛出 `WebError` `WEB_PROVIDER_ERROR`,而非降级为文本抓取;这种行为诚实且可诊断。 -它复用 `$DEEPSEEK_API_KEY`(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。 +它复用 `DEEPSEEK_API_KEY` 凭据引用(不增加密钥),但**不会**复用 `$DEEPSEEK_BASE_URL`:搜索端点使用 Anthropic 兼容基址(`https://api.deepseek.com/anthropic/v1`),不同于大语言模型(LLM)适配器使用的 chat-completions 基址(`https://api.deepseek.com`)。已挂载的凭据服务具有权威性;没有该服务时,提供方会回退到启动进程的环境变量。每次搜索都会解析该引用,因此在 Web 的 Models 页中存储或轮换的密钥无需重启,即可用于下一次调用。 ## 配置 | 配置键 | 默认值 | 含义 | |---|---|---| -| `apiKey` | `$DEEPSEEK_API_KEY` | DeepSeek API 密钥。为空或缺失时提供方不可用。同时通过 `x-api-key` 和 `Authorization: Bearer` 发送(分别用于官方接口与 Anthropic 兼容代理)。 | +| `apiKey` | 未设置 | DeepSeek API 密钥字面值。优先使用 `apiKeyEnv`,避免密钥进入配置;非空字面值优先。 | +| `apiKeyEnv` | `DEEPSEEK_API_KEY` | 每次搜索都会通过 `ctx.credentials` 解析该凭据引用;没有该 seam 时则从进程环境解析。值缺失时,调用以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。 | | `baseURL` | `https://api.deepseek.com/anthropic/v1` | Anthropic 兼容端点基址;追加 `/messages`。覆盖时使用 `$DEEPSEEK_SEARCH_BASE_URL` 等独立环境变量;禁止复用属于 chat-completions LLM 适配器的 `$DEEPSEEK_BASE_URL`。无法解析时提供方不可用。 | | `model` | `deepseek-v4-flash` | Anthropic 格式模型名称。 | | `apiVersion` | `2023-06-01` | `anthropic-version` 标头值。 | @@ -29,7 +30,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' config: - apiKey: !!js process.env.DEEPSEEK_API_KEY + apiKeyEnv: DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_SEARCH_BASE_URL ``` @@ -41,6 +42,10 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案 提供方失败变为 `WEB_PROVIDER_ERROR`;调用方取消变为 `WEB_ABORTED`。HTTP 重定向会在接触 `Location` 目标前被拒绝,并以 `WEB_PROVIDER_ERROR` 呈现。 +## 请求日志 + +由 agent 发起的搜索会在发出请求前一刻,向相应会话追加仅用于日志的 `web/deepseek-search-llm-request` 会话事件。其中包含已解析端点、API 版本,以及发送给 DeepSeek 且不含密钥的精确 JSON 请求体;不包含标头和凭据。发出请求前发生凭据处理失败或取消时不会创建事件;发出请求后才发生 HTTP 或响应失败时,本次请求尝试仍保留持久记录。在 agent 之外通过程序直接调用提供方时,没有发起会话可供记录。 + ## 模型体验 ### 辅助 DeepSeek 搜索请求 @@ -61,7 +66,7 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案 #### 模型看到的内容 -通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息为 `DeepSeek search aborted`、`DeepSeek search request failed: `、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装属于消费方。 +通过 [`dsh-tool-web`](../tool-web/README.md),会话模型会看到结构化搜索块中去重后的 URL、标题、日期与引用 snippet;提供方文本不会作为答案受到信任。该提供方的具体错误消息包括带有处理指引的凭据缺失消息、`DeepSeek search credential resolution failed: `、`DeepSeek search aborted`、`DeepSeek search request failed: `、`DeepSeek returned no web_search_tool_result blocks; the request may not have triggered native web search` 和 `DeepSeek returned an unprocessable response body: `;HTTP 失败保留提供方消息。错误包装属于消费方。 #### Token 影响 @@ -74,6 +79,6 @@ DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案 ## 已知限制与暂缓事项 - **一次搜索需要完整的 Messages 模型轮次**:会产生延迟与生成 token,并且最多执行 `maxUses` 次服务器侧搜索;DeepSeek 不公开专用检索端点。 +- **动态凭据的可用性在操作内部解析**:同步的 `available()` 契约可以确认解析器存在,但无法查询异步凭据存储。因此,选中的无密钥提供方会使搜索以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败;稳定的 `web_search` schema 仍保持注册。调用方取消在本地与该预检存在竞态,但无法强制任意凭据后端自行停止工作。 - **超量返回的源仍消耗 token**:协议没有结果数量旋钮,`maxResults` 只能由 seam 在事后截断。 - **未引用的结果没有 `snippet`**:只有 `text` 块中的引用(`cited_text`)匹配其 URL 时,源才会获得 snippet。 -- **中止分类基于错误结构**:只有 `DOMException` 且名为 `AbortError` 时才映射为 `WEB_ABORTED`;携带自定义原因的中止(例如 `dsh-timeout` 的 `TimeoutReason`)会呈现为 `WEB_PROVIDER_ERROR`。 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index b38f552957..e1dbf720f6 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -27,7 +27,10 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -35,7 +38,11 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-credentials-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index a24bb3f95f..8569f0e944 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -7,6 +7,9 @@ import type { Context } from 'cordis' import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-agent' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' import { DeepSeekSearchProvider, @@ -26,7 +29,7 @@ export { DEEPSEEK_DEFAULT_MODEL, DEEPSEEK_PROVIDER_ID, } from './provider.ts' -export type { DeepSeekSearchProviderOptions } from './provider.ts' +export type { DeepSeekSearchLlmRequest, DeepSeekSearchProviderOptions } from './provider.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'web-search-deepseek' @@ -34,10 +37,14 @@ export const name = 'web-search-deepseek' /** The web seam this provider registers into. */ export const inject = ['web'] +const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY' + /** Plugin config (all optional — `apply` fills env-var and constant defaults). */ export interface Config { - /** DeepSeek API key. Falls back to `$DEEPSEEK_API_KEY`. Empty → unavailable. */ + /** Literal DeepSeek API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ apiKey?: string + /** Credential reference resolved for each search; defaults to `DEEPSEEK_API_KEY`. */ + apiKeyEnv?: string /** Anthropic-compatible endpoint base; `/messages` is appended. */ baseURL?: string /** Anthropic-format model name. Defaults to `deepseek-v4-flash`. */ @@ -51,7 +58,8 @@ export interface Config { } export const Config: z = z.object({ - apiKey: z.string(), + apiKey: z.string().role('secret'), + apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), baseURL: z.string(), model: z.string(), apiVersion: z.string(), @@ -63,12 +71,29 @@ export const Config: z = z.object({ export function apply(ctx: Context, config: Config): void { const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES + const apiKeyEnv = credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV) + const literalApiKey = config.apiKey !== undefined && config.apiKey.length > 0 + ? config.apiKey + : undefined ctx.web.registerSearchProvider(new DeepSeekSearchProvider({ - apiKey: config.apiKey ?? process.env.DEEPSEEK_API_KEY ?? '', + ...literalApiKey === undefined ? {} : { apiKey: literalApiKey }, + resolveApiKey: async () => { + const credentials = ctx.get('credentials') + if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value + const ambient = process.env[apiKeyEnv] + return ambient !== undefined && ambient.length > 0 ? ambient : undefined + }, + apiKeyEnv, baseURL: config.baseURL ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, maxTokens, maxUses, + recordRequest: (request) => { + ctx.get('agents')?.currentInitiator()?.session.append( + 'web/deepseek-search-llm-request', + request, + ) + }, })) } diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index 8b79315f7d..d1f707f2bc 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -15,8 +15,9 @@ export const name = 'web-search-deepseek-invariant' export const inject = ['invariants'] /** - * No runtime invariant: this package exposes no independent event sequence or mutable data relation - * beyond contracts enforced at its owning seam. + * No runtime invariant: the package emits a pre-dispatch log event but owns no + * later authoritative dispatch event to relate it to. Exact envelope equality + * is pinned at the provider boundary instead. */ const install: InvariantInstaller = () => {} diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 871c911deb..aef898ed60 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -13,6 +13,8 @@ import type { WebSearchResult, WebSearchSource, } from '@deepseek-ai/dsh-web' +import type { CredentialRef } from '@deepseek-ai/dsh-credentials' +import type {} from '@deepseek-ai/dsh-session' import type { AnthropicError, AnthropicResponse, @@ -47,10 +49,49 @@ export const DEEPSEEK_DEFAULT_MAX_USES = 5 /** Attribution header sent on every request. Bump with the package version. */ const USER_AGENT = 'deepseek-harness/0.0.1' -/** Resolved provider options (the plugin's `apply` supplies env-var and constant defaults). */ +/** + * Exact secret-free DeepSeek Messages request recorded immediately before one + * auxiliary search dispatch. + */ +export interface DeepSeekSearchLlmRequest { + /** Fully resolved Messages endpoint. */ + readonly endpoint: string + /** `anthropic-version` header value. */ + readonly apiVersion: string + /** Exact JSON body sent to the provider. */ + readonly body: { + readonly model: string + readonly max_tokens: number + readonly messages: readonly [{ + readonly role: 'user' + readonly content: readonly [{ + readonly type: 'text' + readonly text: string + }] + }] + readonly tools: readonly [{ + readonly type: 'web_search_20250305' + readonly name: 'web_search' + readonly max_uses: number + }] + } +} + +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + /** Secret-free auxiliary DeepSeek search request recorded before dispatch. */ + 'web/deepseek-search-llm-request': DeepSeekSearchLlmRequest + } +} + +/** Resolved provider options (the plugin's `apply` supplies credential and constant defaults). */ export interface DeepSeekSearchProviderOptions { - /** DeepSeek API key. Empty/absent makes the provider unavailable. */ - apiKey: string + /** Literal DeepSeek API key; when present it wins over {@link resolveApiKey}. */ + apiKey?: string + /** Resolve the current DeepSeek API key for one search operation. */ + resolveApiKey?: () => Promise + /** Credential reference named by missing-credential diagnostics. */ + apiKeyEnv?: CredentialRef /** Endpoint base; `/messages` is appended. */ baseURL: string /** Anthropic-format model name. */ @@ -61,6 +102,11 @@ export interface DeepSeekSearchProviderOptions { maxTokens: number /** Maximum `web_search` server-tool uses per request. */ maxUses: number + /** + * Record the exact secret-free request immediately before dispatch. A throw + * prevents dispatch so model-visible auxiliary input cannot escape logging. + */ + recordRequest?: (request: DeepSeekSearchLlmRequest) => void } /** @@ -134,41 +180,51 @@ export class DeepSeekSearchProvider implements WebSearchProvider { constructor(private readonly options: DeepSeekSearchProviderOptions) {} available(): boolean { - return this.options.apiKey.length > 0 + return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined) && URL.canParse(this.options.baseURL) && isPositiveInteger(this.options.maxTokens) && isPositiveInteger(this.options.maxUses) } async search(request: WebSearchRequest, signal?: AbortSignal): Promise { + const apiKey = await this.apiKey(signal) + throwIfSearchAborted(signal) + const endpoint = `${this.options.baseURL}/messages` + const body: DeepSeekSearchLlmRequest['body'] = { + model: this.options.model, + max_tokens: this.options.maxTokens, + messages: [{ + role: 'user', + content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }], + }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], + } + this.options.recordRequest?.({ + endpoint, + apiVersion: this.options.apiVersion, + body, + }) + throwIfSearchAborted(signal) let response: Response try { - response = await fetch(`${this.options.baseURL}/messages`, { + response = await fetch(endpoint, { method: 'POST', redirect: 'error', headers: { // Official DeepSeek expects `x-api-key`; an Anthropic-compatible proxy // may expect `Authorization: Bearer` — send both so either resolves. - 'x-api-key': this.options.apiKey, - 'authorization': `Bearer ${this.options.apiKey}`, + 'x-api-key': apiKey, + 'authorization': `Bearer ${apiKey}`, 'anthropic-version': this.options.apiVersion, 'content-type': 'application/json', 'accept': 'application/json', 'user-agent': USER_AGENT, }, - body: JSON.stringify({ - model: this.options.model, - max_tokens: this.options.maxTokens, - messages: [{ - role: 'user', - content: [{ type: 'text', text: `Perform a web search for the query: ${request.query}` }], - }], - tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: this.options.maxUses }], - }), + body: JSON.stringify(body), ...signal !== undefined ? { signal } : {}, }) } catch (error: unknown) { - if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) throw new WebError(`DeepSeek search request failed: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } @@ -183,7 +239,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { // An abort fired mid-body must surface as WEB_ABORTED, not be swallowed // into a generic HTTP-error message — cancellation is not a provider // error (the seam's cancellation contract). - if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) // Otherwise: the HTTP status is already captured in `message` above; a // malformed/non-JSON error body (normal for gateway 5xx/429s) can only // cost a richer provider message, never the real error. @@ -195,11 +251,72 @@ export class DeepSeekSearchProvider implements WebSearchProvider { const payload = await response.json() as AnthropicResponse return mapAnthropicResponse(payload) } catch (error: unknown) { - if (isAbortError(error)) throw new WebError('DeepSeek search aborted', 'WEB_ABORTED', { cause: error }) + if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) if (error instanceof WebError) throw error throw new WebError(`DeepSeek returned an unprocessable response body: ${String(error)}`, 'WEB_PROVIDER_ERROR', { cause: error }) } } + + /** Resolve one operation's credential without retaining it on the provider. */ + private async apiKey(signal?: AbortSignal): Promise { + throwIfSearchAborted(signal) + if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey + let resolved: string | undefined + try { + resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal) + } catch (error: unknown) { + if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) + throw new WebError( + `DeepSeek search credential resolution failed: ${String(error)}`, + 'WEB_PROVIDER_ERROR', + { cause: error }, + ) + } + if (resolved !== undefined && resolved.length > 0) return resolved + const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' + throw new WebError( + `DeepSeek search has no API key for "${ref}"; store it through the credentials service` + + ' (the web Models page writes it), export it in the launching environment, or set a literal' + + ' "apiKey" in the web-search-deepseek config', + 'WEB_PROVIDER_CREDENTIAL_MISSING', + ) + } +} + +/** + * Race a same-process asynchronous preflight against caller cancellation. The + * attached settlement handlers keep observing an uncooperative operation after + * abort so a later rejection cannot become unhandled. + */ +function abortable(operation: Promise, signal?: AbortSignal): Promise { + if (signal === undefined) return operation + if (signal.aborted) return Promise.reject(searchAborted(signal)) + return new Promise((resolve, reject) => { + const onAbort = (): void => { reject(searchAborted(signal)) } + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(new Error(String(error).replace(/^Error: /u, ''), { cause: error })) + }, + ) + }) +} + +/** Throw the provider's stable cancellation error when the caller already aborted. */ +function throwIfSearchAborted(signal?: AbortSignal): void { + if (signal?.aborted === true) throw searchAborted(signal) +} + +/** Build the provider's stable cancellation error while retaining the caller's reason. */ +function searchAborted(signal?: AbortSignal, fallback?: unknown): WebError { + return new WebError('DeepSeek search aborted', 'WEB_ABORTED', { + cause: signal?.aborted === true ? signal.reason : fallback, + }) } /** True for a fetch/`AbortSignal` abort, surfaced as `WEB_ABORTED`. */ diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 5a9972cfd6..7990a96a9b 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import { credentialRef } from '@deepseek-ai/dsh-credentials' +import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import WebService from '@deepseek-ai/dsh-web' import { DeepSeekSearchProvider, @@ -156,10 +161,11 @@ describe('DeepSeekSearchProvider availability', () => { }) describe('DeepSeekSearchProvider request mapping', () => { - it('posts an Anthropic Messages request enabling the web_search server tool', async () => { + it('records and posts the same Anthropic Messages request with the web_search server tool', async () => { const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + const recordRequest = vi.fn() vi.stubGlobal('fetch', fetchMock) - await new DeepSeekSearchProvider(options).search({ query: 'hello' }) + await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.deepseek.test/anthropic/v1/messages') expect(init).toMatchObject({ method: 'POST', redirect: 'error' }) @@ -167,12 +173,20 @@ describe('DeepSeekSearchProvider request mapping', () => { expect(headers['x-api-key']).toBe('ds-key') expect(headers['authorization']).toBe('Bearer ds-key') expect(headers['anthropic-version']).toBe('2023-06-01') - expect(JSON.parse(init.body as string)).toEqual({ + const body = { model: 'deepseek-chat', max_tokens: 4096, messages: [{ role: 'user', content: [{ type: 'text', text: 'Perform a web search for the query: hello' }] }], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }], + } + expect(JSON.parse(init.body as string)).toEqual(body) + expect(recordRequest).toHaveBeenCalledOnce() + expect(recordRequest).toHaveBeenCalledWith({ + endpoint: url, + apiVersion: '2023-06-01', + body, }) + expect(recordRequest.mock.invocationCallOrder[0]).toBeLessThan(fetchMock.mock.invocationCallOrder[0] ?? 0) }) it('forwards the abort signal', async () => { @@ -186,6 +200,91 @@ describe('DeepSeekSearchProvider request mapping', () => { }) describe('DeepSeekSearchProvider error handling', () => { + it('does not start credential resolution or dispatch for a pre-aborted call', async () => { + const resolveApiKey = vi.fn(async () => 'late-key') + const recordRequest = vi.fn() + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + controller.abort(new Error('caller stopped')) + await expect(new DeepSeekSearchProvider({ + ...options, + apiKey: '', + resolveApiKey, + recordRequest, + }).search({ query: 'q' }, controller.signal)) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + expect(resolveApiKey).not.toHaveBeenCalled() + expect(recordRequest).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('aborts while an uncooperative credential resolver remains pending', async () => { + const resolveApiKey = vi.fn(() => new Promise(() => {})) + const recordRequest = vi.fn() + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + const search = new DeepSeekSearchProvider({ + ...options, + apiKey: '', + resolveApiKey, + recordRequest, + }).search({ query: 'q' }, controller.signal) + controller.abort(new Error('deadline')) + await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + expect(resolveApiKey).toHaveBeenCalledOnce() + expect(recordRequest).not.toHaveBeenCalled() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('resolves credentials under an active cancellation signal', async () => { + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const controller = new AbortController() + await expect(new DeepSeekSearchProvider({ + ...options, + apiKey: '', + resolveApiKey: async () => 'resolved-key', + }).search({ query: 'q' }, controller.signal)).resolves.toMatchObject({ truncated: false }) + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + expect((init.headers as Record)['x-api-key']).toBe('resolved-key') + }) + + it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => { + const controller = new AbortController() + await expect(new DeepSeekSearchProvider({ + ...options, + apiKey: '', + resolveApiKey: () => Promise.reject(new Error('credential backend failed')), + }).search({ query: 'q' }, controller.signal)) + .rejects.toThrow(expect.objectContaining({ + code: 'WEB_PROVIDER_ERROR', + message: 'DeepSeek search credential resolution failed: Error: credential backend failed', + })) + }) + + it('uses the default credential reference when no resolver is configured', async () => { + await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' })) + .rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"') + }) + + it('observes cancellation triggered synchronously by credential resolution', async () => { + const controller = new AbortController() + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + await expect(new DeepSeekSearchProvider({ + ...options, + apiKey: '', + resolveApiKey: () => { + controller.abort(new Error('resolver cancelled caller')) + return Promise.resolve('unused-key') + }, + }).search({ query: 'q' }, controller.signal)) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('maps an HTTP error to WEB_PROVIDER_ERROR with the provider message', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: { message: 'rate limited' } }, { status: 429 }))) await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) @@ -216,6 +315,17 @@ describe('DeepSeekSearchProvider error handling', () => { .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) + it('maps a custom abort reason to WEB_ABORTED', async () => { + const controller = new AbortController() + vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { reject(new Error('custom abort reason')) }, { once: true }) + }))) + const search = new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal) + controller.abort(new Error('timeout reason')) + await expect(search).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) + }) + it('maps an unparseable success body to WEB_PROVIDER_ERROR', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('not json', { status: 200 }))) await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) @@ -323,28 +433,66 @@ describe('web-search-deepseek plugin registration', () => { vi.stubGlobal('fetch', fetchMock) const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) - const fiber = await ctx.plugin(deepseekPlugin, {}) + deepseekPlugin.apply(ctx, {}) await ctx.web.search({ query: 'q' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages') expect((init.headers as Record)['x-api-key']).toBe('env-key') expect(JSON.parse(init.body as string)).toMatchObject({ model: 'deepseek-v4-flash' }) - await fiber.dispose() + await ctx.fiber.dispose() } finally { if (prev === undefined) delete process.env.DEEPSEEK_API_KEY else process.env.DEEPSEEK_API_KEY = prev } }) - it('is unavailable when neither config nor env supplies a key', async () => { + it('resolves the credential for each search so a stored or rotated key needs no restart', async () => { + const previous = process.env.DEEPSEEK_API_KEY + delete process.env.DEEPSEEK_API_KEY + const dir = await mkdtemp(join(tmpdir(), 'dsh-web-search-credentials-')) + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + const ctx = new Context() + try { + await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) + await ctx.plugin(CredentialsLocal, { path: join(dir, '.env'), watch: false }) + await ctx.plugin(deepseekPlugin, { baseURL: 'https://api.deepseek.test/anthropic/v1' }) + + await expect(ctx.web.search({ query: 'missing' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' })) + + const ref = credentialRef('DEEPSEEK_API_KEY') + await ctx.credentials.set(ref, 'stored-key') + await ctx.web.search({ query: 'stored' }) + await ctx.credentials.set(ref, 'rotated-key') + await ctx.web.search({ query: 'rotated' }) + + const headers = fetchMock.mock.calls.map(([, init]) => (init as RequestInit).headers as Record) + expect(headers.map(value => value['x-api-key'])).toEqual(['stored-key', 'rotated-key']) + } finally { + await ctx.fiber.dispose() + await rm(dir, { recursive: true, force: true }) + if (previous === undefined) delete process.env.DEEPSEEK_API_KEY + else process.env.DEEPSEEK_API_KEY = previous + } + }) + + it('reports an actionable credential error when neither config nor env supplies a key', async () => { const prev = process.env.DEEPSEEK_API_KEY delete process.env.DEEPSEEK_API_KEY try { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) await ctx.plugin(deepseekPlugin, {}) - await expect(ctx.web.search({ query: 'q' })) - .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) + let caught: unknown + try { + await ctx.web.search({ query: 'q' }) + } catch (error: unknown) { + caught = error + } + expect(caught).toMatchObject({ code: 'WEB_PROVIDER_CREDENTIAL_MISSING' }) + if (!(caught instanceof Error)) throw new Error('search did not throw an Error') + expect(caught.message).toMatch(/store it through the credentials service.*Models page/s) } finally { if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev } diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index e9610ea5c9..76c411d089 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -20,6 +20,15 @@ { "path": "../web" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../credentials/credentials" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 823fbdbf3e..9abd5abc1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -411,6 +411,9 @@ importers: '@deepseek-ai/dsh-tool-todo': specifier: workspace:^ version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web '@deepseek-ai/dsh-tool-workflow': specifier: workspace:^ version: link:../../packages/workflow/tool-workflow @@ -426,6 +429,12 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../packages/web/web + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../packages/web/web-search-deepseek '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../../packages/workflow/workflow-workerthread @@ -1632,15 +1641,31 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 + '@types/react-dom': + specifier: ~18.3.0 + version: 18.3.7(@types/react@18.3.31) cordis: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-settings-general: + dependencies: + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -1659,6 +1684,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: workspace:^ + version: link:../web-react '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5794,9 +5822,21 @@ importers: specifier: ^3.18.0 version: 3.18.0 devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-credentials': + specifier: workspace:^ + version: link:../../credentials/credentials + '@deepseek-ai/dsh-credentials-local': + specifier: workspace:^ + version: link:../../credentials/credentials-local '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index 3892a0e5f3..185acf2db5 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -217,20 +217,35 @@ describe('docsPages locale routes', () => { expect(english?.section).toBe('Cordis Core API') } }) + + it('includes persistence event headings in both locale outlines', () => { + const pages = docsPages.filter(page => page.source === 'docs/persistence-catalog.md') + expect(pages).toHaveLength(2) + expect(pages.map(page => page.outline)).toEqual(['deep', 'deep']) + }) }) describe('addProjectionFrontmatter', () => { it('adds frontmatter to an ordinary Markdown page', () => { - expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe( + expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe( '---\neditSource: "docs/guide.md"\n---\n\n# Guide\n', ) }) it('extends existing VitePress frontmatter', () => { - expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe( + expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe( '---\neditSource: "docs/index.md"\nlayout: home\n---\n', ) }) + + it('adds the page-specific outline depth from the publication manifest', () => { + expect(addProjectionFrontmatter('# Catalog\n', { + source: 'docs/catalog.md', + outline: [2, 4], + })).toBe( + '---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n', + ) + }) }) describe('projectedPageContent', () => { diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 8d68aac7a6..592bbcfdee 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -259,13 +259,16 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions) * Record the canonical edit target in VitePress frontmatter. * * @param markdown Projected Markdown content. - * @param sourcePath Repository-relative canonical source path. - * @returns Markdown with an `editSource` frontmatter field. + * @param page Publication manifest entry for the content. + * @returns Markdown with projection-owned frontmatter fields. */ -export function addProjectionFrontmatter(markdown: string, sourcePath: string): string { - const field = `editSource: ${JSON.stringify(sourcePath)}` - if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`) - return `---\n${field}\n---\n\n${markdown}` +export function addProjectionFrontmatter(markdown: string, page: Pick): string { + const fields = [ + `editSource: ${JSON.stringify(page.source)}`, + ...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]), + ].join('\n') + if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`) + return `---\n${fields}\n---\n\n${markdown}` } /** @@ -317,6 +320,6 @@ export function projectDocs(): void { repoRoot: root, repositoryRef, }) - writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source)) + writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page)) } } diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 1cc80d9286..ecdfab081b 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,11 +8,11 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nThank you for making time to try DeepSeek Harness.\n\nThis version is still in internal testing. Some features remain unfinished, and parts of the experience may feel rough.\n\n“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.\n\nWe 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.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe 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.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [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.\n\n## Why DeepSeek Harness\n\nBuilt-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.\n\n- **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.\n- **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).\n- **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).\n- **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).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nThank you for taking the time to try DeepSeek Harness.\n\nThis version is still in internal testing. Its functionality still needs improvement, and the experience may feel a little rough.\n\n“As one cuts and files, as one chisels and polishes.” A product grows through real encounters and candid feedback. Problems you discover in real use may prompt us to reconsider—or even overturn—our existing designs.\n\nWe especially want to hear about failures, confusion, and friction. If you have any feedback or suggestions, please leave us a message in our WeCom group. Every piece of feedback helps us refine it.\n\n## Install\n\nInstall `dsh` with one command:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.\n\nThe installer keeps every checkout under `~/.dsh/source`: the master clone at `~/.dsh/source/master` and each install's staging checkout as a git worktree `~/.dsh/source/staging-`. The stable symlink `~/.dsh/source/current` points at the active staging worktree, and `dsh` in `~/.local/bin` links to `current/bin/dsh`, so an upgrade repoints one symlink and the `dsh` on PATH never moves. Re-running the command adds a fresh staging worktree from an updated master and repoints `current` at it. See [`scripts/install.sh`](scripts/install.sh) for alternate install locations and other options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, build the active checkout after installation and after each update, then start the Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe 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.\n\n### TUI\n\nStart the full-screen terminal interface:\n\n```sh\ndsh\n```\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [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.\n\n## Why DeepSeek Harness\n\nBuilt-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.\n\n- **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.\n- **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).\n- **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).\n- **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).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,有些功能仍待完善,有些体验难免粗粝。\n\n“如切如磋,如琢如磨。”产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中暴露的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果它未能帮到您,甚至反而为工作平添了麻烦,请在企业微信群中留言,将使用感受告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(编程智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\n感谢您愿意拨冗试用 DeepSeek Harness。\n\n目前的版本仍处于内部测试阶段,功能仍待完善,体验难免有些粗糙。\n\n“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。\n\n我们尤其希望听见那些失败、困惑与不顺手的时刻——如果您有任何反馈与建议,请在企业微信群中留言告诉我们。每一条反馈,都会帮助我们把它打磨得更好。\n\n## 安装\n\n使用一条命令安装 `dsh`:\n\n```sh\ncurl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。\n\n安装器会把所有检出都放在 `~/.dsh/source` 下:master 克隆位于 `~/.dsh/source/master`,每次安装的 staging 检出是一个 git worktree `~/.dsh/source/staging-<时间戳>`。稳定符号链接 `~/.dsh/source/current` 指向当前生效的 staging worktree,`~/.local/bin` 中的 `dsh` 链接到 `current/bin/dsh`,因此升级只需重指一个符号链接,PATH 上的 `dsh` 从不移动。再次运行该命令会基于更新后的 master 新增一个 staging worktree,并把 `current` 重指到它。其他安装位置和选项见 [`scripts/install.sh`](scripts/install.sh)。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI。安装完成后以及每次更新后,请先构建当前生效的检出,再启动 Web UI:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n完整构建会生成库与客户端 bundle,以及前端 dist。上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### TUI\n\n启动全屏终端界面:\n\n```sh\ndsh\n```\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh -p \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。TUI 与 Web UI 均包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均可组合为 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/cordis/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n```sh\npnpm install\npnpm run test:coverage\n```\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n" }, { "role": "user", diff --git a/tsconfig.host.json b/tsconfig.host.json index e773e6105e..8c2a9d1905 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", + "apps/web/tests/web-search-round.e2e.ts", "apps/web/tests/message-actions.e2e.ts", "apps/web/tests/queue-actions.e2e.ts", "apps/web/tests/skill-invocation-policy.e2e.ts", diff --git a/website/docs.ts b/website/docs.ts index a640d01e18..8d42ae3209 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -37,6 +37,8 @@ export interface DocsPage { section: string /** Stable order within the section. */ order: number + /** Heading levels included in this page's VitePress outline. */ + outline?: number | readonly [number, number] | 'deep' | false /** Additional repository paths that resolve to this page. */ sourceAliases?: string[] } @@ -49,6 +51,7 @@ interface MirroredPage { sidebar: Record section: Record order: number + outline?: DocsPage['outline'] sourceAliases?: string[] | Partial> } @@ -79,6 +82,7 @@ function mirroredPages(pages: MirroredPage[]): DocsPage[] { sidebar: page.sidebar[locale], section: page.section[locale], order: page.order, + ...(page.outline === undefined ? {} : { outline: page.outline }), ...(aliases === undefined ? {} : { sourceAliases: aliases }), } })) @@ -288,8 +292,8 @@ const reference = mirroredPages([ ['docs/tool-catalog.md', 'reference/tool-catalog.md', 'Tool Schema', 'Tool schemas'], ['docs/cordis-catalog/services.md', 'reference/cordis-catalog/services.md', '服务', 'Services'], ['docs/cordis-catalog/events.md', 'reference/cordis-catalog/events.md', '事件', 'Events'], - ['docs/persistence-catalog.md', 'reference/persistence-catalog.md', '持久化事件', 'Persistence events'], - ] as const).map(([source, route, rootLabel, enLabel], order): MirroredPage => ({ + ['docs/persistence-catalog.md', 'reference/persistence-catalog.md', '持久化事件', 'Persistence events', 'deep'], + ] as const).map(([source, route, rootLabel, enLabel, outline], order): MirroredPage => ({ source, route, contentLocale: 'en-US', @@ -297,6 +301,7 @@ const reference = mirroredPages([ sidebar: { root: 'zh-reference', en: 'en-reference' }, section: { root: '生成参考', en: 'Generated reference' }, order, + ...(outline === undefined ? {} : { outline }), })), ...([ ['context.md', 'Context', 'Context'],