From ddc1ec2bd22dab11f73d58c01c7bf8497dcfafdd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 16:10:33 +0800 Subject: [PATCH 01/19] feat(bash): resolve executor config through the bash settings namespace The capability's namespace is owned by the seam because it names the capability, not an implementation: a host composes exactly one provider of ctx.bash, so both executor families register the same namespace with their own schema and composition entry without ever colliding, and a settings document carried between platforms keeps resolving on both. Both executors read their config through a source thunk, so a stored change reaches the next command. The constructor checks the schema cannot express become the section validator, refusing a bad value at the write instead of at the next command. pwsh re-resolves its executable only when the declared path changed, so an unrelated settings change never re-probes the filesystem. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 4 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- docs/subsystems/bash.i18n.yaml | 4 +- docs/subsystems/bash.md | 2 +- docs/subsystems/bash.zh.md | 2 +- packages/bash/bash-local/README.i18n.yaml | 4 +- packages/bash/bash-local/README.md | 1 + packages/bash/bash-local/README.zh.md | 1 + packages/bash/bash-local/package.json | 2 + packages/bash/bash-local/src/index.ts | 51 ++++++-- .../bash/bash-local/tests/settings.spec.ts | 115 ++++++++++++++++++ packages/bash/bash-local/tsconfig.json | 3 + packages/bash/bash/README.i18n.yaml | 4 +- packages/bash/bash/README.md | 2 + packages/bash/bash/README.zh.md | 2 + packages/bash/bash/package.json | 6 +- packages/bash/bash/src/index.ts | 12 ++ packages/bash/bash/tsconfig.json | 3 + packages/bash/pwsh-local/README.i18n.yaml | 4 +- packages/bash/pwsh-local/README.md | 3 +- packages/bash/pwsh-local/README.zh.md | 3 +- packages/bash/pwsh-local/package.json | 2 + packages/bash/pwsh-local/src/index.ts | 73 ++++++++--- .../bash/pwsh-local/tests/settings.spec.ts | 108 ++++++++++++++++ packages/bash/pwsh-local/tsconfig.json | 3 + pnpm-lock.yaml | 9 ++ 29 files changed, 396 insertions(+), 51 deletions(-) create mode 100644 packages/bash/bash-local/tests/settings.spec.ts create mode 100644 packages/bash/pwsh-local/tests/settings.spec.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index a1aa65a008..9536e421ba 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 234e07eafdc4a01681ccff4b7ac8290905cd677b +config-catalog.md: 14610ca9c06d5c4beb6ccb74795e5859952c58a4 config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 234e07eafd..14610ca9c0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -315,7 +315,7 @@ export interface Config { } ``` -Source: [`packages/bash/bash-local/src/index.ts:40`](../packages/bash/bash-local/src/index.ts) +Source: [`packages/bash/bash-local/src/index.ts:41`](../packages/bash/bash-local/src/index.ts) ## `@deepseek-ai/dsh-bash-sandbox` @@ -1237,7 +1237,7 @@ export interface Config { } ``` -Source: [`packages/bash/pwsh-local/src/index.ts:54`](../packages/bash/pwsh-local/src/index.ts) +Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts) ## `@deepseek-ai/dsh-pwsh-sandbox` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index ed5df0a29b..84e060f8a8 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e1c52b700a45e24c78a505bb522f0b3259415187 -module-graph.zh.md: 26306588773d715a88b901edba5391abff19211d +module-graph.md: 65741283b5a48817bd01c42fe7982fff6c89511a +module-graph.zh.md: 00ea2925c6636a65e224c899ee264a4e87d4a2cd diff --git a/docs/module-graph.md b/docs/module-graph.md index e1c52b700a..65741283b5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -548,6 +548,7 @@ flowchart TD pkg_goal --> pkg_type_meta pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox + pkg_bash --> pkg_settings pkg_bash --> pkg_subprocess pkg_fs --> pkg_brand pkg_fs --> pkg_invariants @@ -655,10 +656,12 @@ flowchart TD pkg_goal_session --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout pkg_pwsh_local --> pkg_bash pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs @@ -1325,7 +1328,7 @@ flowchart TD | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`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) | @@ -1351,8 +1354,8 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 2630658877..00ea2925c6 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -550,6 +550,7 @@ flowchart TD pkg_goal --> pkg_type_meta pkg_bash --> pkg_invariants pkg_bash --> pkg_sandbox + pkg_bash --> pkg_settings pkg_bash --> pkg_subprocess pkg_fs --> pkg_brand pkg_fs --> pkg_invariants @@ -657,10 +658,12 @@ flowchart TD pkg_goal_session --> pkg_session pkg_bash_local --> pkg_bash pkg_bash_local --> pkg_invariants + pkg_bash_local --> pkg_settings pkg_bash_local --> pkg_subprocess pkg_bash_local --> pkg_timeout pkg_pwsh_local --> pkg_bash pkg_pwsh_local --> pkg_invariants + pkg_pwsh_local --> pkg_settings pkg_pwsh_local --> pkg_subprocess pkg_pwsh_local --> pkg_timeout pkg_fs_local --> pkg_fs @@ -1327,7 +1330,7 @@ flowchart TD | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | -| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`subprocess`](../packages/subprocess/subprocess) | +| [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`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) | @@ -1353,8 +1356,8 @@ flowchart TD | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`pwsh-local`](../packages/bash/pwsh-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`fs-local`](../packages/fs/fs-local) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`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) | diff --git a/docs/subsystems/bash.i18n.yaml b/docs/subsystems/bash.i18n.yaml index dbfc52bf6e..cbc775c187 100644 --- a/docs/subsystems/bash.i18n.yaml +++ b/docs/subsystems/bash.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/subsystems/bash.md -bash.md: d2797c1e3ff73fe8ecb5a053ed4a1d13b13298dd -bash.zh.md: b38a4f332978e94d2945f5fa49b38e8d7df8c9ca +bash.md: 16899d78916c84fbb6f9501b9a278c9749fd144f +bash.zh.md: 7beae15f9ae46038ce15243ad249533115a938d4 diff --git a/docs/subsystems/bash.md b/docs/subsystems/bash.md index d2797c1e3f..16899d7891 100644 --- a/docs/subsystems/bash.md +++ b/docs/subsystems/bash.md @@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashProcess ``` -Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts) diff --git a/docs/subsystems/bash.zh.md b/docs/subsystems/bash.zh.md index b38a4f3329..7beae15f9a 100644 --- a/docs/subsystems/bash.zh.md +++ b/docs/subsystems/bash.zh.md @@ -266,7 +266,7 @@ abstract run(spec: BashExecSpec): Promise abstract start(spec: BashExecSpec): BashProcess ``` -Source: [`packages/bash/bash/src/index.ts:53`](../../packages/bash/bash/src/index.ts) +Source: [`packages/bash/bash/src/index.ts:65`](../../packages/bash/bash/src/index.ts) diff --git a/packages/bash/bash-local/README.i18n.yaml b/packages/bash/bash-local/README.i18n.yaml index aa5f35a125..4332858874 100644 --- a/packages/bash/bash-local/README.i18n.yaml +++ b/packages/bash/bash-local/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/bash/bash-local/README.md -README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52 -README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094 +README.md: b011db6478db8fa4dbdfef4812355d0ed2c1eba8 +README.zh.md: 1ddaf7c283aad40d14764388b5ec1e8ebbdf6d16 diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index cb8e7f0ae7..b011db6478 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -23,6 +23,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i ## Behavior - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files. +- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section; without a provider, or after one detaches, the composition entry is what runs. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). - **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md). diff --git a/packages/bash/bash-local/README.zh.md b/packages/bash/bash-local/README.zh.md index 20af9c1899..1ddaf7c283 100644 --- a/packages/bash/bash-local/README.zh.md +++ b/packages/bash/bash-local/README.zh.md @@ -23,6 +23,7 @@ ## 行为 - **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。 +- **组装条目是一层,而不是最终值**:当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段;没有提供方、或提供方脱离之后,运行的就是组装条目。 - **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。 - **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。 diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index c3c3a5c3e6..ebf368e1d7 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -37,6 +38,7 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 3d3ca833bc..8c9473f664 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -11,9 +11,10 @@ import { Context } from 'cordis' import z from 'schemastery' -import { BashExecutor } from '@deepseek-ai/dsh-bash' +import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { installSettingsSection } from '@deepseek-ai/dsh-settings' import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' /** @@ -71,6 +72,26 @@ function assertPositiveFinite(name: string, value: number): void { } } +/** + * Reject a resolved section this executor could not run with. The schema + * expresses neither "positive and finite" nor the timer bound `graceMs` has to + * fit, so a stored value is refused where it is written instead of failing at + * the next command. + * @param config - the resolved section, schema-valid by construction. + * @throws Error naming the field that cannot be used. + */ +export function assertServiceableBashConfig(config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite('timeoutMs', resolved.timeoutMs) + assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) + assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes) + assertPositiveFinite('graceMs', resolved.graceMs) + if (resolved.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** * Local bash executor over `ctx.subprocess`. Bounded output, spill files, and * process-group SIGTERM→SIGKILL escalation are the subprocess service's @@ -90,21 +111,29 @@ export class LocalBashExecutor extends BashExecutor { graceMs: z.number().default(DEFAULT_GRACE_MS), }) + /** The currently authoritative config: the settings section, or the composition entry. */ + private source: () => ResolvedConfig + /** Validated config (schemastery applied the defaults before construction). */ - readonly config: ResolvedConfig + get config(): ResolvedConfig { + return this.source() + } constructor(ctx: Context, config: Config) { super(ctx) // Schemastery fills these fields before construction; the type does not encode that step. - this.config = config as ResolvedConfig - assertPositiveFinite('timeoutMs', this.config.timeoutMs) - assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) - assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) - assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) - assertPositiveFinite('graceMs', this.config.graceMs) - if (this.config.graceMs > MAX_TIMER_DELAY_MS) { - throw new Error(`bash-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) - } + const entry = config as ResolvedConfig + assertServiceableBashConfig(entry) + this.source = () => entry + installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, LocalBashExecutor.Config, entry, { + validate: assertServiceableBashConfig, + setSource: (current) => { + this.source = current as () => ResolvedConfig + }, + // Every field is read through the getter at each command, so nothing + // derived from the source needs rebuilding when the document changes. + onChange: () => {}, + }) } /** diff --git a/packages/bash/bash-local/tests/settings.spec.ts b/packages/bash/bash-local/tests/settings.spec.ts new file mode 100644 index 0000000000..36fd8bdca5 --- /dev/null +++ b/packages/bash/bash-local/tests/settings.spec.ts @@ -0,0 +1,115 @@ +/** The `bash` settings section layered over the executor's composition entry. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +async function boot(config: ConstructorParameters[1] = {}): Promise<{ + ctx: Context + settingsFiber: Fiber + executorFiber: Fiber + bash: LocalBashExecutor +}> { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + const settingsFiber = ctx.plugin(MemorySettings) + await settingsFiber.await() + const executorFiber = ctx.plugin(LocalBashExecutor, { timeoutMs: 60_000, ...config }) + await executorFiber.await() + return { ctx, settingsFiber, executorFiber, bash: ctx.bash as LocalBashExecutor } +} + +describe('bash settings section', () => { + it('resolves the user layer over the composition entry', async () => { + const bench = await boot() + expect(bench.bash.config.timeoutMs).toBe(60_000) + + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) + + expect(bench.bash.config.timeoutMs).toBe(5_000) + await bench.ctx.fiber.dispose() + }) + + it('refuses a stored value the constructor would have rejected', async () => { + const bench = await boot() + + await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 })) + .rejects.toThrow(/positive finite/) + + expect(bench.bash.config.timeoutMs).toBe(60_000) + await bench.ctx.fiber.dispose() + }) + + it('refuses a grace period longer than a timer can carry', async () => { + const bench = await boot() + + await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { graceMs: Number.MAX_SAFE_INTEGER })) + .rejects.toThrow(/graceMs must be no greater than/) + + await bench.ctx.fiber.dispose() + }) + + it('serves the stored section to every later read', async () => { + const bench = await boot() + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { maxOutputBytes: 1_024, cwd: '/tmp' }) + + const spec = bench.bash.resolve({ command: 'true' }) + + expect(spec.stdoutMaxBytes).toBe(1_024) + expect(spec.workdir).toBe('/tmp') + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the settings provider detaches', async () => { + const bench = await boot() + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) + expect(bench.bash.config.timeoutMs).toBe(5_000) + + await bench.settingsFiber.dispose() + + expect(bench.bash.config.timeoutMs).toBe(60_000) + await bench.ctx.fiber.dispose() + }) + + it('keeps the composition entry when no settings provider is mounted', async () => { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + await ctx.plugin(LocalBashExecutor, { timeoutMs: 1_234 }) + + expect((ctx.bash as LocalBashExecutor).config.timeoutMs).toBe(1_234) + await ctx.fiber.dispose() + }) + + it('releases the namespace when the executor unloads', async () => { + const bench = await boot() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash') + + await bench.executorFiber.dispose() + + expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash') + await bench.ctx.fiber.dispose() + }) +}) diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 53ccc94926..80015bf539 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/bash/README.i18n.yaml b/packages/bash/bash/README.i18n.yaml index d5cb0cb0e5..739811a642 100644 --- a/packages/bash/bash/README.i18n.yaml +++ b/packages/bash/bash/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/bash/bash/README.md -README.md: 23b0acd096bb835ef57563337c91e5cf63b58677 -README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a +README.md: a3debe2e511a188f0578a4f9c65f8714ae67a159 +README.zh.md: 0f338d77372b5c62e822a263235e3b6b73f9ceb9 diff --git a/packages/bash/bash/README.md b/packages/bash/bash/README.md index 23b0acd096..a3debe2e51 100644 --- a/packages/bash/bash/README.md +++ b/packages/bash/bash/README.md @@ -27,6 +27,8 @@ The split is a standard capability seam ([capability-seams Agent Note](../../../ Implementations subclass `BashExecutor` and implement the abstract methods. Disposal must kill every running process and await its exit. +`BASH_SETTINGS_NAMESPACE` (`bash`) is exported here rather than by a provider because it names the capability, not an implementation. A host composes exactly one provider of `ctx.bash` — the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration — so every provider can register this one namespace with its own schema and composition entry without two of them ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both. + ## Vocabulary `BashExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `BashExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing. diff --git a/packages/bash/bash/README.zh.md b/packages/bash/bash/README.zh.md index 14ba749a00..0f338d7737 100644 --- a/packages/bash/bash/README.zh.md +++ b/packages/bash/bash/README.zh.md @@ -27,6 +27,8 @@ 实现会继承 `BashExecutor` 并实现抽象方法。dispose(资源释放)必须终止每个运行中的进程并等待其退出。 +`BASH_SETTINGS_NAMESPACE`(`bash`)由此处导出而非由某个提供方导出,因为它命名的是能力而不是实现。一个宿主只组装一个 `ctx.bash` 提供方——win32 层会把 POSIX 行换成 pwsh 行,同时挂载两者会因服务重复注册而在加载期失败——所以每个提供方都能用自己的 schema 与组装条目注册这同一个命名空间,两者永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析。 + ## 词汇 `BashExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `BashExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。 diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 71e9ed8d9f..ce6ba54ef9 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -26,14 +26,16 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", + "@deepseek-ai/dsh-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.7" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "cordis": "^4.0.0-rc.7" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 73f3d7b519..539ce12983 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -6,9 +6,21 @@ */ import { Context, Service } from 'cordis' +import { settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts' +/** + * Settings namespace of this capability, owned here rather than by either + * executor family because it names the capability, not an implementation: a + * host composes exactly one provider of `ctx.bash` (the win32 layer swaps the + * POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate + * service registration), so the providers share one namespace without ever + * registering it twice, and a settings document carried between platforms + * keeps resolving on both. + */ +export const BASH_SETTINGS_NAMESPACE = settingsNamespace('bash') + export { DSH_ENV_PREFIX } from './types.ts' export type { BashExecRequest, diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 3f611c80e0..dac354f18a 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../sandbox/sandbox" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/packages/bash/pwsh-local/README.i18n.yaml b/packages/bash/pwsh-local/README.i18n.yaml index 1d26227716..46929dd0f2 100644 --- a/packages/bash/pwsh-local/README.i18n.yaml +++ b/packages/bash/pwsh-local/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/bash/pwsh-local/README.md -README.md: eb3365b009e3595230e5fb0f616079bd73c55840 -README.zh.md: d79201c756a26bbc343e2b284a803b0cf9aee69b +README.md: 76f3071dc1049e2ea5929d5990ee0cb526ef702e +README.zh.md: e773e0e83e81ffa311bd555b7a75433ba22dfd22 diff --git a/packages/bash/pwsh-local/README.md b/packages/bash/pwsh-local/README.md index eb3365b009..76f3071dc1 100644 --- a/packages/bash/pwsh-local/README.md +++ b/packages/bash/pwsh-local/README.md @@ -28,8 +28,9 @@ The package root exports the default and named `PwshLocalExecutor` plugin, its ` The Windows counterpart of `dsh-bash-local`, deliberately mirroring its semantics call-for-call: - **Spawn per call, no shell state** — every call is a fresh non-interactive `pwsh -Command` (deterministic; no profile files). The `-NoLogo -NoProfile -NonInteractive` flags disable startup banners, profile loading, and prompts that would garble tool output. +- **The composition entry is a layer, not the last word** — when a settings provider is composed, this executor registers the capability's [`bash` namespace](../bash/README.md) with the entry above as its base, so a user section in `settings.yaml` layers over it and the next command runs with the new budgets. The namespace is shared with the POSIX family because a host composes exactly one provider of `ctx.bash`; a document written on either platform keeps resolving on the other. Values the schema cannot judge (positive and finite, the `graceMs` timer bound) are refused at the write, leaving the running executor on its last good section. - **UTF-8 output pinned** — every command runs with `[Console]::OutputEncoding` and `$OutputEncoding` set to UTF-8 first, so the Windows PowerShell 5.1 fallback (or any host whose console code page is not UTF-8) cannot garble non-ASCII output: the subprocess collector decodes bytes as UTF-8. Input encoding is left at the host default; pwsh 7 defaults to UTF-8 and is unaffected. -- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)` and happens once at construction. +- **Executable resolution** — `resolvePwshPath` prefers an explicit `pwshPath`, then on Windows probes PowerShell 7's install location, every PATH entry (Microsoft Store installs; surrounding quotes stripped), and Windows PowerShell 5.1 as a legacy last resort, checking `existsSync` on each; elsewhere it falls back to a bare `pwsh` resolved through PATH. Resolution is a pure function of `(configured, env, platform)`; it runs at construction and again only when a stored `pwshPath` differs from the one the current executable was resolved from, so an unrelated settings change never re-probes the filesystem. - **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Tree termination (taskkill on Windows, process-group signals on POSIX), the post-exit pipe-drain grace, tail-keep truncation, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`. - **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-terminated command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)). Windows reports forced termination as exit 1 without a signal, so signal-stamped facts (`signal`, `killed` status) are POSIX-only there; the timeout/abort classification is platform-independent. - **Model-friendly terminal env** — `NO_COLOR=1 PAGER=cat GIT_PAGER=cat` (no `TERM=dumb`: that is a POSIX concept; `NO_COLOR` is honored by modern PowerShell renderers) merged as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. diff --git a/packages/bash/pwsh-local/README.zh.md b/packages/bash/pwsh-local/README.zh.md index d79201c756..e773e0e83e 100644 --- a/packages/bash/pwsh-local/README.zh.md +++ b/packages/bash/pwsh-local/README.zh.md @@ -28,8 +28,9 @@ 作为 `dsh-bash-local` 的 Windows 对应物,逐调用地镜像其语义: - **每次调用新建进程,无 shell 状态**——每次调用都是全新的非交互 `pwsh -Command`(确定性;不加载 profile 文件)。`-NoLogo -NoProfile -NonInteractive` 关闭启动横幅、profile 加载与会干扰工具输出的提示符。 +- **组装条目是一层,而不是最终值**——当组装中存在 settings 提供方时,本执行器以上面的条目为 base 注册该能力的 [`bash` 命名空间](../bash/README.md),因此 `settings.yaml` 中的用户段会叠加其上,下一条命令即按新预算运行。该命名空间与 POSIX 家族共用,因为一个宿主只组装一个 `ctx.bash` 提供方;在任一平台写下的文档在另一平台仍能解析。schema 无法判定的值(正有限、`graceMs` 的定时器上界)会在写入时被拒绝,运行中的执行器保持它最后一份可用的段。 - **UTF-8 输出固定**——每条命令都先以 UTF-8 设置 `[Console]::OutputEncoding` 与 `$OutputEncoding`,因此 Windows PowerShell 5.1 兜底(或任何控制台代码页非 UTF-8 的主机)不会破坏非 ASCII 输出:subprocess collector 以 UTF-8 解码字节。输入编码保持宿主默认;pwsh 7 默认为 UTF-8,不受影响。 -- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数,在构造时执行一次。 +- **可执行文件解析**——`resolvePwshPath` 优先显式 `pwshPath`,然后在 Windows 上依次探测 PowerShell 7 安装位置、每个 PATH 条目(Microsoft Store 安装;剥离两端引号)以及作为遗留兜底的 Windows PowerShell 5.1,逐一检查 `existsSync`;其他平台回退为通过 PATH 解析的裸 `pwsh`。解析是 `(configured, env, platform)` 的纯函数;它在构造时执行,此后仅当存储的 `pwshPath` 与当前可执行文件所依据的值不同才再次执行,因此无关的设置变更绝不会重新探测文件系统。 - **受管进程组之上的配置预算**——`resolve()` 从配置填充 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务提供显式字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程树终止(Windows 用 taskkill,POSIX 用进程组信号)、退出后管道排空宽限、保尾截断与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为单个受信调用方提高 stdout 捕获预算;stderr 与后台运行仍使用 `maxOutputBytes`。 - **超时与取消分类**——`run()` 通过一个 deadline 融合按配置上限截取的超时与调用方信号;只有执行器自身超时报告 `timedOut`,上游取消报告 `aborted`,自我终止的命令两者都不报告(见 [timeout 库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。Windows 将强制终止报告为退出码 1 且无信号,因此带信号标记的事实(`signal`、`killed` 状态)在那里仅限 POSIX;超时/取消分类与平台无关。 - **面向模型的终端环境**——`NO_COLOR=1 PAGER=cat GIT_PAGER=cat`(没有 `TERM=dumb`:那是 POSIX 概念;现代 PowerShell 渲染器遵循 `NO_COLOR`),作为普通 env 在服务的凭据清理与 `DSH_*` 通道规则之下合并;显式调用方条目仍然优先。 diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index f65d524904..487932d3cf 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -27,6 +27,7 @@ "peerDependencies": { "@deepseek-ai/dsh-bash": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -37,6 +38,7 @@ "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 5983500772..3b872b0c34 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -15,9 +15,10 @@ import { Context } from 'cordis' import z from 'schemastery' -import { BashExecutor } from '@deepseek-ai/dsh-bash' +import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' +import { installSettingsSection } from '@deepseek-ai/dsh-settings' import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' import { resolvePwshPath } from './resolve.ts' @@ -96,6 +97,26 @@ function assertPositiveFinite(name: string, value: number): void { } } +/** + * Reject a resolved section this executor could not run with. The schema + * expresses neither "positive and finite" nor the timer bound `graceMs` has to + * fit, so a stored value is refused where it is written instead of failing at + * the next command. + * @param config - the resolved section, schema-valid by construction. + * @throws Error naming the field that cannot be used. + */ +export function assertServiceablePwshConfig(config: Config): void { + const resolved = config as ResolvedConfig + assertPositiveFinite('timeoutMs', resolved.timeoutMs) + assertPositiveFinite('maxTimeoutMs', resolved.maxTimeoutMs) + assertPositiveFinite('maxOutputBytes', resolved.maxOutputBytes) + assertPositiveFinite('maxSpillBytes', resolved.maxSpillBytes) + assertPositiveFinite('graceMs', resolved.graceMs) + if (resolved.graceMs > MAX_TIMER_DELAY_MS) { + throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) + } +} + /** * Local PowerShell executor over `ctx.subprocess`. Bounded output, spill * files, and process-tree termination are the subprocess service's mechanics; @@ -114,25 +135,47 @@ export class PwshLocalExecutor extends BashExecutor { pwshPath: z.string(), }) - /** Validated config (schemastery applied the defaults before construction). */ - readonly config: ResolvedConfig + /** The currently authoritative config: the settings section, or the composition entry. */ + private source: () => ResolvedConfig - /** The pwsh executable resolved once at construction. */ - readonly pwshPath: string + /** The declared executable the current {@link pwshPath} was resolved from. */ + private declaredPwshPath: string | undefined + + /** The pwsh executable resolved from the current config. */ + private resolvedPwshPath: string + + /** Validated config (schemastery applied the defaults before construction). */ + get config(): ResolvedConfig { + return this.source() + } + + /** The pwsh executable every command runs through. */ + get pwshPath(): string { + return this.resolvedPwshPath + } constructor(ctx: Context, config: Config) { super(ctx) // Schemastery fills these fields before construction; the type does not encode that step. - this.config = config as ResolvedConfig - assertPositiveFinite('timeoutMs', this.config.timeoutMs) - assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) - assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) - assertPositiveFinite('maxSpillBytes', this.config.maxSpillBytes) - assertPositiveFinite('graceMs', this.config.graceMs) - if (this.config.graceMs > MAX_TIMER_DELAY_MS) { - throw new Error(`pwsh-local: graceMs must be no greater than ${MAX_TIMER_DELAY_MS}`) - } - this.pwshPath = resolvePwshPath(this.config.pwshPath) + const entry = config as ResolvedConfig + assertServiceablePwshConfig(entry) + this.source = () => entry + this.declaredPwshPath = entry.pwshPath + this.resolvedPwshPath = resolvePwshPath(entry.pwshPath) + installSettingsSection(ctx, BASH_SETTINGS_NAMESPACE, PwshLocalExecutor.Config, entry, { + validate: assertServiceablePwshConfig, + setSource: (current) => { + this.source = current as () => ResolvedConfig + }, + // Probing the filesystem is the one fact derived from the source: every + // other field is read through the getter at each command. + onChange: () => { + const declared = this.source().pwshPath + if (declared === this.declaredPwshPath) return + this.declaredPwshPath = declared + this.resolvedPwshPath = resolvePwshPath(declared) + }, + }) } /** diff --git a/packages/bash/pwsh-local/tests/settings.spec.ts b/packages/bash/pwsh-local/tests/settings.spec.ts new file mode 100644 index 0000000000..7c5a9ed2ae --- /dev/null +++ b/packages/bash/pwsh-local/tests/settings.spec.ts @@ -0,0 +1,108 @@ +/** The shared `bash` settings section as the pwsh executor family resolves it. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import { BASH_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-bash' +import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' +import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +async function boot(config: ConstructorParameters[1] = {}): Promise<{ + ctx: Context + settingsFiber: Fiber + executorFiber: Fiber + pwsh: PwshLocalExecutor +}> { + const ctx = new Context() + await ctx.plugin(LocalSubprocessService) + const settingsFiber = ctx.plugin(MemorySettings) + await settingsFiber.await() + const executorFiber = ctx.plugin(PwshLocalExecutor, { timeoutMs: 60_000, ...config }) + await executorFiber.await() + return { ctx, settingsFiber, executorFiber, pwsh: ctx.bash as PwshLocalExecutor } +} + +describe('pwsh executor over the bash settings section', () => { + it('resolves the user layer over the composition entry', async () => { + const bench = await boot() + expect(bench.pwsh.config.timeoutMs).toBe(60_000) + + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) + + expect(bench.pwsh.config.timeoutMs).toBe(5_000) + await bench.ctx.fiber.dispose() + }) + + it('refuses a stored value the constructor would have rejected', async () => { + const bench = await boot() + + await expect(bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 0 })) + .rejects.toThrow(/pwsh-local: timeoutMs must be a positive finite number/) + + expect(bench.pwsh.config.timeoutMs).toBe(60_000) + await bench.ctx.fiber.dispose() + }) + + it('re-resolves the executable when the stored path changes', async () => { + const bench = await boot({ pwshPath: '/opt/first/pwsh' }) + expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh') + + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { pwshPath: '/opt/second/pwsh' }) + + expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh') + await bench.ctx.fiber.dispose() + }) + + it('keeps the resolved executable when an unrelated field changes', async () => { + const bench = await boot({ pwshPath: '/opt/first/pwsh' }) + const before = bench.pwsh.pwshPath + + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000 }) + + expect(bench.pwsh.pwshPath).toBe(before) + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the settings provider detaches', async () => { + const bench = await boot({ pwshPath: '/opt/first/pwsh' }) + await bench.ctx.settings.update(BASH_SETTINGS_NAMESPACE, { timeoutMs: 5_000, pwshPath: '/opt/second/pwsh' }) + expect(bench.pwsh.config.timeoutMs).toBe(5_000) + expect(bench.pwsh.pwshPath).toBe('/opt/second/pwsh') + + await bench.settingsFiber.dispose() + + expect(bench.pwsh.config.timeoutMs).toBe(60_000) + expect(bench.pwsh.pwshPath).toBe('/opt/first/pwsh') + await bench.ctx.fiber.dispose() + }) + + it('releases the namespace when the executor unloads', async () => { + const bench = await boot() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('bash') + + await bench.executorFiber.dispose() + + expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('bash') + await bench.ctx.fiber.dispose() + }) +}) diff --git a/packages/bash/pwsh-local/tsconfig.json b/packages/bash/pwsh-local/tsconfig.json index 53ccc94926..80015bf539 100644 --- a/packages/bash/pwsh-local/tsconfig.json +++ b/packages/bash/pwsh-local/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../subprocess/subprocess" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1fa4fb925..4600aca05c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -861,6 +861,9 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess @@ -911,6 +914,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess @@ -966,6 +972,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess From fdf9bddbf85258ece924dc761c7ab212d1c6e9a0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 18:00:03 +0800 Subject: [PATCH 02/19] feat(agent-loop): make the parallel tool-call cap a user setting The section is a strict subset of the plugin config: `agents` is consumed once when the service starts, so a stored change there could only look like it had an effect. The cap resolves through a getter over the settings source, which the scheduler destructures at the start of each tool group, so a committed change bounds the next group without disturbing the one in flight. `resolveMaxParallelToolCalls` becomes the section validator, refusing a value at the write instead of at that group. The deferred-resume effect-shape assertion now allows the one plugin effect the optional settings wiring adds at the fiber's own level; a resumed agent joining it there is still the regression it pins. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/event-producer-consumer.i18n.yaml | 2 +- docs/event-producer-consumer.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 4 +- docs/subsystems/core.zh.md | 4 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/package.json | 2 + packages/core/agent-loop/src/index.ts | 41 ++++++- .../tests/config-session-id.spec.ts | 5 +- .../core/agent-loop/tests/settings.spec.ts | 106 ++++++++++++++++++ packages/core/agent-loop/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 19 files changed, 178 insertions(+), 20 deletions(-) create mode 100644 packages/core/agent-loop/tests/settings.spec.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9536e421ba..d7f4b74b77 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 14610ca9c06d5c4beb6ccb74795e5859952c58a4 +config-catalog.md: b588a80d6ee773fde52d9c3050b9d76438810d02 config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14610ca9c0..b588a80d6e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -122,7 +122,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) -Source: [`packages/core/agent-loop/src/index.ts:236`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:255`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-presets` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index b8d32e778c..a0b7d947e6 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: 622df039a42bccb1ecf8dcaf11a1c68ea111dec1 +event-producer-consumer.md: b9f8fbaf92e1d3da99bd233496c717d66a0f55e6 event-producer-consumer.zh.md: 976f41d7798e9182b60366d77e546d79c4a66a13 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 622df039a4..b9f8fbaf92 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:183`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 84e060f8a8..c38702ce4b 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 65741283b5a48817bd01c42fe7982fff6c89511a -module-graph.zh.md: 00ea2925c6636a65e224c899ee264a4e87d4a2cd +module-graph.md: 949f8813f1d8f49e5a433049eb2f61c7235bf571 +module-graph.zh.md: 70085ab7301cf3ec81494189aea065c9a487b04c diff --git a/docs/module-graph.md b/docs/module-graph.md index 65741283b5..949f8813f1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -759,6 +759,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_agent_tool_mode --> pkg_invariants @@ -1375,7 +1376,7 @@ flowchart TD | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 00ea2925c6..70085ab730 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -761,6 +761,7 @@ flowchart TD pkg_agent_loop --> pkg_scope pkg_agent_loop --> pkg_session pkg_agent_loop --> pkg_session_persistence + pkg_agent_loop --> pkg_settings pkg_agent_loop --> pkg_system_prompt pkg_agent_loop --> pkg_tools pkg_agent_tool_mode --> pkg_invariants @@ -1377,7 +1378,7 @@ flowchart TD | [`session-title-llm`](../packages/session/session-title-llm) | `session` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`timeout`](../packages/util/timeout) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`token-meter`](../packages/llm/token-meter) | `llm` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0b2b87a954..3df05a5871 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: af27484160769156836f377e5b3aba2521280005 -core.zh.md: 12935f4d881f371cfe2c3c5bed85ef88f57ec71a +core.md: a900192f4cee2b53bbf4d2f6d00d9586d87d438a +core.zh.md: 17ad5f03b34b8acff37689c38ed4323096774c0a diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index af27484160..a900192f4c 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -375,7 +375,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -1002,5 +1002,5 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:183`](../../packages/core/agent-loop/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 12935f4d88..17ad5f03b3 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -383,7 +383,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise @@ -1010,5 +1010,5 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(payload: { sessionId: SessionId; error: unknown }): void ``` -Source: [`packages/core/agent-loop/src/index.ts:182`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:183`](../../packages/core/agent-loop/src/index.ts) diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 60ceaa7572..12bdb09626 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/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/agent-loop/README.md -README.md: 6092363fae2853d6c5d92aaf8cd01e41e18e0b52 -README.zh.md: b65b5334d735a1e0b51fa517ce41c0c953f87cf7 +README.md: b16ba8186254468a335f6d55ce550de86a3436ef +README.zh.md: ad8fc9478eb45620cee303eb7295b6b3fe8b0ed3 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6092363fae..b16ba81862 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -49,7 +49,7 @@ interface Config { } ``` -Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. +Configured agents start automatically. A model call requires both `provider` and `model`; `agent/request` may supply a missing pair before dispatch. An optional positive `maxTokens` seeds each conversation request's output cap and is logged in its request header. `maxParallelToolCalls` bounds every agent's rolling pool for parallel-safe calls and defaults to `10`; it is also the whole of the `agent-loop` Settings section, so a user layer over this entry caps the next tool group without a restart, and a value that is not a positive integer is refused at the write rather than at that group. `agents` is deliberately absent from that section — it is consumed once when the service starts, so a stored change could only look like it had an effect. `cwd` applies only to fresh sessions, while `resumeSessionId` retains persisted metadata. Configured agents use the deployment persona, and programmatic setup can shadow it per agent. This plugin supplies the per-agent `provider`, `model`, and `cwd` prompt variables; harness identity and deployment persona belong to `dsh-system-prompt`. ### Internal concrete driver diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index b65b5334d7..ad8fc9478e 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -49,7 +49,7 @@ interface Config { } ``` -通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 +通过配置创建的 agent 会自动启动。模型调用同时需要 `provider` 和 `model`;`agent/request` 可以在分发前补齐缺失的这一对值。可选的正数 `maxTokens` 会为每次对话请求提供初始输出上限,并记录在请求 header 中。`maxParallelToolCalls` 限制每个 agent 针对并行安全调用使用的滚动池,默认值为 `10`;它同时也是 `agent-loop` Settings 段的全部内容,因此叠加在该条目之上的用户层无需重启即可限制下一组工具调用,而非正整数的值会在写入时被拒绝,而不是到那一组时才失败。`agents` 刻意不在该段中——它在服务启动时被消费一次,所以存储的改动只会看起来生效。`cwd` 仅应用于全新会话,而 `resumeSessionId` 保留持久化元数据。通过配置创建的 agent 使用部署 persona;编程式 setup 可以按 agent 遮蔽它。该插件为每个 agent 提供 `provider`、`model` 和 `cwd` 提示词变量;harness 身份与部署 persona 属于 `dsh-system-prompt`。 ### 包内部具体驱动器 diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 68c30d9e55..35c5be0145 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" @@ -45,6 +46,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index e7c840e296..62fa565843 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -20,6 +20,7 @@ import type { SessionStartSource, } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { SessionId, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -232,6 +233,24 @@ function applyLauncherIdentities( }) } +/** Settings namespace carrying the tool-call parallelism a user owns. */ +export const AGENT_LOOP_SETTINGS_NAMESPACE = settingsNamespace('agent-loop') + +/** + * The agent-loop fields a user owns. Deliberately a strict subset of + * {@link Config}: `agents` is a boot-time composition array consumed once when + * the service starts, so a stored change could only look like it had an effect. + */ +export interface AgentLoopSettings { + /** Maximum parallel-safe calls in flight per agent step. */ + maxParallelToolCalls: number +} + +/** Schema of the agent-loop settings section. */ +export const AGENT_LOOP_SETTINGS_SCHEMA: z = z.object({ + maxParallelToolCalls: z.number().step(1).min(1).default(DEFAULT_MAX_PARALLEL_TOOL_CALLS), +}) + /** Agent-loop plugin configuration. */ export interface Config { /** @@ -299,11 +318,31 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') + const entry: AgentLoopSettings = { + maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), + } + let source: () => AgentLoopSettings = () => entry this.config = { ...config, agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)), - maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), + // Read through on every scheduler decision: `tool-calls.ts` destructures + // this at the start of each group, so a committed change caps the next + // group without disturbing the one in flight. + get maxParallelToolCalls() { + return source().maxParallelToolCalls + }, } + installSettingsSection(ctx, AGENT_LOOP_SETTINGS_NAMESPACE, AGENT_LOOP_SETTINGS_SCHEMA, entry, { + // The schema admits any integer above zero; `resolveMaxParallelToolCalls` + // owns the whole rule, so refusing here keeps the running scheduler on + // its last good cap instead of failing at the next tool group. + validate: value => void resolveMaxParallelToolCalls(value.maxParallelToolCalls), + setSource: (current) => { + source = current + }, + // Nothing is derived from the cap: the getter above is the only reader. + onChange: () => {}, + }) validateConfiguredAgents(this.config.agents) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 74608e5f1a..1ad8ac1043 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -344,7 +344,10 @@ describe('config-driven session id', () => { const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)') expect(resumeEffect?.children.map(child => child.label)).toEqual(['ctx.plugin()']) - expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toEqual([]) + // Exactly one plugin effect sits at the fiber's own level — the optional + // settings wiring, whose `ctx.inject` cordis labels like any other plugin. + // A resumed agent joining it there is the regression this pins. + expect(loopFiber.getEffects().filter(effect => effect.label === 'ctx.plugin()')).toHaveLength(1) await loopFiber.dispose() }) diff --git a/packages/core/agent-loop/tests/settings.spec.ts b/packages/core/agent-loop/tests/settings.spec.ts new file mode 100644 index 0000000000..f9f52be226 --- /dev/null +++ b/packages/core/agent-loop/tests/settings.spec.ts @@ -0,0 +1,106 @@ +/** The `agent-loop` settings section layered over the composition entry. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import LlmService from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import AgentLoop, { AGENT_LOOP_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-loop' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; loopFiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const settingsFiber = ctx.plugin(MemorySettings) + await settingsFiber.await() + const loopFiber = ctx.plugin(AgentLoop, { agents: [], maxParallelToolCalls: 4 }) + await loopFiber.await() + return { ctx, settingsFiber, loopFiber } +} + +describe('agent-loop settings section', () => { + it('layers the stored parallel cap over the composition entry', async () => { + const bench = await boot() + expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(4) + + await bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 1 }) + + expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(1) + await bench.ctx.fiber.dispose() + }) + + it('refuses a non-positive cap at the write', async () => { + const bench = await boot() + + await expect(bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 0 })) + .rejects.toThrow() + + expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(4) + await bench.ctx.fiber.dispose() + }) + + it('never offers the composed agents array to the settings document', async () => { + const bench = await boot() + + const descriptor = bench.ctx.settings.describe().find(row => String(row.ns) === 'agent-loop') + + expect(Object.keys(descriptor?.value as object)).toEqual(['maxParallelToolCalls']) + await bench.ctx.fiber.dispose() + }) + + it('keeps serving the composed agents array to its own consumers', async () => { + const bench = await boot() + + await bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 2 }) + + expect(bench.ctx.agentLoop.config.agents).toEqual([]) + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the settings provider detaches', async () => { + const bench = await boot() + await bench.ctx.settings.update(AGENT_LOOP_SETTINGS_NAMESPACE, { maxParallelToolCalls: 1 }) + expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(1) + + await bench.settingsFiber.dispose() + + expect(bench.ctx.agentLoop.config.maxParallelToolCalls).toBe(4) + await bench.ctx.fiber.dispose() + }) + + it('releases the namespace when the service unloads', async () => { + const bench = await boot() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('agent-loop') + + await bench.loopFiber.dispose() + + expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('agent-loop') + await bench.ctx.fiber.dispose() + }) +}) diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index c3504e4eb4..724f0c6741 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../core/scope" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4600aca05c..fe6f610dff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3301,6 +3301,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../system-prompt From f736e6f58473eb388c3c598c63d2f4340291f21b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 18:06:27 +0800 Subject: [PATCH 03/19] feat(web-search-deepseek): resolve provider options from the settings section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider now takes a thunk rather than a value: it projects the authoritative section per search, so a stored endpoint, model, or key reference reaches the next call without re-registering the provider — which would make the seam's provider selection observable as a flicker. apiKey already carries role('secret'), so the section is safe to describe: the literal never rides a response in any layer and a configuration surface learns only that a key is set. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 2 + packages/web/web-search-deepseek/README.zh.md | 2 + packages/web/web-search-deepseek/package.json | 2 + packages/web/web-search-deepseek/src/index.ts | 40 ++++-- .../web/web-search-deepseek/src/provider.ts | 13 +- .../web-search-deepseek/tests/deepseek.e2e.ts | 8 +- .../tests/deepseek.spec.ts | 58 ++++---- .../tests/redirect.spec.ts | 8 +- .../tests/settings.spec.ts | 124 ++++++++++++++++++ .../web/web-search-deepseek/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 17 files changed, 236 insertions(+), 45 deletions(-) create mode 100644 packages/web/web-search-deepseek/tests/settings.spec.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index d7f4b74b77..bd321c33a0 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: b588a80d6ee773fde52d9c3050b9d76438810d02 +config-catalog.md: 36053fd205923e207224a01f3f948c1977004c1c config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b588a80d6e..36053fd205 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2595,7 +2595,7 @@ export interface Config { } ``` -Source: [`packages/web/web-search-deepseek/src/index.ts:44`](../packages/web/web-search-deepseek/src/index.ts) +Source: [`packages/web/web-search-deepseek/src/index.ts:46`](../packages/web/web-search-deepseek/src/index.ts) ## `@deepseek-ai/dsh-web-search-exa` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c38702ce4b..c9b096618a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 949f8813f1d8f49e5a433049eb2f61c7235bf571 -module-graph.zh.md: 70085ab7301cf3ec81494189aea065c9a487b04c +module-graph.md: a9d4def424c875860781b4b7d46aea2e9af903dd +module-graph.zh.md: 759ded070a2991689ee18ca679cfa57213abc49d diff --git a/docs/module-graph.md b/docs/module-graph.md index 949f8813f1..a9d4def424 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -559,6 +559,7 @@ flowchart TD pkg_web_search_deepseek --> pkg_environment pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill @@ -1331,7 +1332,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 70085ab730..759ded070a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -561,6 +561,7 @@ flowchart TD pkg_web_search_deepseek --> pkg_environment pkg_web_search_deepseek --> pkg_invariants pkg_web_search_deepseek --> pkg_session + pkg_web_search_deepseek --> pkg_settings pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill @@ -1333,7 +1334,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`type-meta`](../packages/typert/type-meta) | | [`bash`](../packages/bash/bash) | `bash` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | -| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | +| [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 41b7516362..09e251b340 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: fb9e528633954b8eb9fd0fec8d19e24381a63f12 -README.zh.md: 4e761aa6ef1a67ce17fbc1cabf595af7f24e15e6 +README.md: 3e8b631b5775793e6e6e542c810edce9d92a0d59 +README.zh.md: 9bd59000c2a0054265444aa90fc46ff5f34fd607 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index fb9e528633..3e8b631b57 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -34,6 +34,8 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* baseURL: https://gateway.internal/anthropic/v1 ``` +The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only that a key is set. + ## Mapping DeepSeek returns no provider-generated answer surface this provider trusts as `content`, so `content` is omitted. `sources[]` comes from `web_search_result` items inside `web_search_tool_result` blocks: `url` ← `url`, `title` ← `title`, and `publishedAt` ← `page_age`. Snippets live separately as URL-keyed `cited_text` entries in a text block's `citations[]`; the provider joins them, leaving `snippet` absent when no excerpt exists. diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 4e761aa6ef..9bd59000c2 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -34,6 +34,8 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 baseURL: https://gateway.internal/anthropic/v1 ``` +上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道密钥是否已设置。 + ## 映射 DeepSeek 不返回该提供方可作为 `content` 信任的提供方生成答案表层,因此省略 `content`。`sources[]` 来自 `web_search_result` 配置项,这些配置项位于 `web_search_tool_result` 块内:`url` ← `url`、`title` ← `title`、`publishedAt` ← `page_age`。`cited_text` 配置项按 URL 标识,单独位于文本块的 `citations[]` 中;提供方会将其作为 snippet 连接,没有摘录时省略 `snippet`。 diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 0ca52390de..2d7ad6749d 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-settings": "^0.0.1", "@deepseek-ai/dsh-web": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -43,6 +44,7 @@ "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "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 5e55e12457..50d9c69e22 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -9,6 +9,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' +import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' import { environmentOf } from '@deepseek-ai/dsh-environment' import type {} from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-web' @@ -20,6 +21,7 @@ import { DEEPSEEK_DEFAULT_MAX_USES, DEEPSEEK_DEFAULT_MODEL, } from './provider.ts' +import type { DeepSeekSearchProviderOptions } from './provider.ts' export { DeepSeekSearchProvider, @@ -76,15 +78,23 @@ export const Config: z = z.object({ */ const SEARCH_BASE_URL_ENV = 'DEEPSEEK_SEARCH_BASE_URL' -/** Register the DeepSeek search provider with `ctx.web`. */ -export function apply(ctx: Context, config: Config): void { - const maxTokens = config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS - const maxUses = config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES +/** Settings namespace carrying this provider's endpoint, model, and key reference. */ +export const WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE = settingsNamespace('web-search-deepseek') + +/** + * Project one resolved section into the options the provider serves its next + * search with. Environment fallbacks stay here rather than in the provider: + * every value it reads is already fully defaulted. + * @param ctx - plugin context supplying the credential and environment planes. + * @param config - the currently authoritative section. + * @returns options for one search. + */ +function resolveOptions(ctx: Context, config: Config): DeepSeekSearchProviderOptions { 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({ + return { ...literalApiKey === undefined ? {} : { apiKey: literalApiKey }, resolveApiKey: async () => { const credentials = ctx.get('credentials') @@ -99,13 +109,27 @@ export function apply(ctx: Context, config: Config): void { ?? DEEPSEEK_DEFAULT_BASE_URL, model: config.model ?? DEEPSEEK_DEFAULT_MODEL, apiVersion: config.apiVersion ?? DEEPSEEK_DEFAULT_API_VERSION, - maxTokens, - maxUses, + maxTokens: config.maxTokens ?? DEEPSEEK_DEFAULT_MAX_TOKENS, + maxUses: config.maxUses ?? DEEPSEEK_DEFAULT_MAX_USES, recordRequest: (request) => { ctx.get('agents')?.currentInitiator()?.session.append( 'web/deepseek-search-llm-request', request, ) }, - })) + } +} + +/** Register the DeepSeek search provider with `ctx.web`. */ +export function apply(ctx: Context, config: Config): void { + let current: () => Config = () => config + installSettingsSection(ctx, WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, Config, config, { + setSource: (source) => { + current = source + }, + // The registration carries no resolved value: the provider projects the + // section per search, so a committed change needs no re-registration. + onChange: () => {}, + }) + ctx.web.registerSearchProvider(new DeepSeekSearchProvider(() => resolveOptions(ctx, current()))) } diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index f88941fa12..4b37f26bee 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -177,7 +177,18 @@ export function mapAnthropicResponse(response: AnthropicResponse): WebSearchResu export class DeepSeekSearchProvider implements WebSearchProvider { readonly id = DEEPSEEK_PROVIDER_ID - constructor(private readonly options: DeepSeekSearchProviderOptions) {} + /** + * @param resolveOptions - the options for the NEXT operation. A thunk rather + * than a value because the plugin's settings section can change between + * searches, and re-registering the provider to carry a new endpoint would + * make the seam's selection observable to the user as a flicker. + */ + constructor(private readonly resolveOptions: () => DeepSeekSearchProviderOptions) {} + + /** Options resolved per read, so a committed settings change reaches the next search. */ + private get options(): DeepSeekSearchProviderOptions { + return this.resolveOptions() + } available(): boolean { return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined) diff --git a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts index e86be695b2..543b602786 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.e2e.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.e2e.ts @@ -8,6 +8,12 @@ import { DEEPSEEK_DEFAULT_MODEL, } from '@deepseek-ai/dsh-web-search-deepseek' +/** Construct the provider over a fixed options value; production passes a live thunk. */ +import type { DeepSeekSearchProviderOptions } from '@deepseek-ai/dsh-web-search-deepseek' + +const searchProvider = (options: DeepSeekSearchProviderOptions): DeepSeekSearchProvider => + new DeepSeekSearchProvider(() => options) + /** * Disabled real-API probe for the DeepSeek search provider. The live endpoint * can complete without structured source blocks, so this is not a reliable @@ -18,7 +24,7 @@ const maybe = apiKey !== undefined && apiKey.length > 0 ? describe : describe.sk maybe('DeepSeekSearchProvider real API', () => { it.skip('returns citeable sources for a live query via native web_search', async () => { - const provider = new DeepSeekSearchProvider({ + const provider = searchProvider({ apiKey: apiKey!, baseURL: process.env.DEEPSEEK_SEARCH_BASE_URL ?? DEEPSEEK_DEFAULT_BASE_URL, model: process.env.DEEPSEEK_SEARCH_MODEL ?? DEEPSEEK_DEFAULT_MODEL, diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 23c2d2c237..5fc529e985 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -15,6 +15,12 @@ import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek' import { citationSnippets, mapAnthropicResponse } from '../src/provider.ts' import type { AnthropicResponse } from '@deepseek-ai/dsh-web-search-deepseek/src/types.ts' +/** Construct the provider over a fixed options value; production passes a live thunk. */ +import type { DeepSeekSearchProviderOptions } from '@deepseek-ai/dsh-web-search-deepseek' + +const searchProvider = (options: DeepSeekSearchProviderOptions): DeepSeekSearchProvider => + new DeepSeekSearchProvider(() => options) + const options = { apiKey: 'ds-key', baseURL: 'https://api.deepseek.test/anthropic/v1', @@ -142,21 +148,21 @@ describe('mapAnthropicResponse', () => { describe('DeepSeekSearchProvider availability', () => { it('is unavailable without a key', () => { - expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).available()).toBe(false) + expect(searchProvider({ ...options, apiKey: '' }).available()).toBe(false) }) it('is available with a key', () => { - expect(new DeepSeekSearchProvider(options).available()).toBe(true) + expect(searchProvider(options).available()).toBe(true) }) it('is misconfigured when the base URL is unparseable', () => { - expect(new DeepSeekSearchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) + expect(searchProvider({ ...options, baseURL: 'not a url' }).available()).toBe(false) }) it('is misconfigured when request limits are not positive integers', () => { - expect(new DeepSeekSearchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) - expect(new DeepSeekSearchProvider({ ...options, maxUses: 0 }).available()).toBe(false) - expect(new DeepSeekSearchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false) + expect(searchProvider({ ...options, maxTokens: 0 }).available()).toBe(false) + expect(searchProvider({ ...options, maxUses: 0 }).available()).toBe(false) + expect(searchProvider({ ...options, maxUses: 1.5 }).available()).toBe(false) }) }) @@ -165,7 +171,7 @@ describe('DeepSeekSearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) const recordRequest = vi.fn() vi.stubGlobal('fetch', fetchMock) - await new DeepSeekSearchProvider({ ...options, recordRequest }).search({ query: 'hello' }) + await searchProvider({ ...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' }) @@ -193,7 +199,7 @@ describe('DeepSeekSearchProvider request mapping', () => { const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() - await new DeepSeekSearchProvider(options).search({ query: 'q' }, controller.signal) + await searchProvider(options).search({ query: 'q' }, controller.signal) const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(init.signal).toBe(controller.signal) }) @@ -207,7 +213,7 @@ describe('DeepSeekSearchProvider error handling', () => { vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() controller.abort(new Error('caller stopped')) - await expect(new DeepSeekSearchProvider({ + await expect(searchProvider({ ...options, apiKey: '', resolveApiKey, @@ -225,7 +231,7 @@ describe('DeepSeekSearchProvider error handling', () => { const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() - const search = new DeepSeekSearchProvider({ + const search = searchProvider({ ...options, apiKey: '', resolveApiKey, @@ -242,7 +248,7 @@ describe('DeepSeekSearchProvider error handling', () => { const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) vi.stubGlobal('fetch', fetchMock) const controller = new AbortController() - await expect(new DeepSeekSearchProvider({ + await expect(searchProvider({ ...options, apiKey: '', resolveApiKey: async () => 'resolved-key', @@ -253,7 +259,7 @@ describe('DeepSeekSearchProvider error handling', () => { it('maps a credential resolver rejection under an active signal to WEB_PROVIDER_ERROR', async () => { const controller = new AbortController() - await expect(new DeepSeekSearchProvider({ + await expect(searchProvider({ ...options, apiKey: '', resolveApiKey: () => Promise.reject(new Error('credential backend failed')), @@ -265,7 +271,7 @@ describe('DeepSeekSearchProvider error handling', () => { }) it('uses the default credential reference when no resolver is configured', async () => { - await expect(new DeepSeekSearchProvider({ ...options, apiKey: '' }).search({ query: 'q' })) + await expect(searchProvider({ ...options, apiKey: '' }).search({ query: 'q' })) .rejects.toThrow('DeepSeek search has no API key for "DEEPSEEK_API_KEY"') }) @@ -273,7 +279,7 @@ describe('DeepSeekSearchProvider error handling', () => { const controller = new AbortController() const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) - await expect(new DeepSeekSearchProvider({ + await expect(searchProvider({ ...options, apiKey: '', resolveApiKey: () => { @@ -287,31 +293,31 @@ describe('DeepSeekSearchProvider error handling', () => { 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' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR', message: 'rate limited' })) }) it('handles a string-form error body', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ error: 'bad request' }, { status: 400 }))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ message: 'bad request' })) }) it('keeps a status-line message when the error body is not JSON', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response('upstream error', { status: 503 }))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 503)' })) }) it('keeps the status-line message when the JSON error body carries no detail', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({}, { status: 500 }))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ message: 'DeepSeek API error (HTTP 500)' })) }) it('maps an abort to WEB_ABORTED', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new DOMException('aborted', 'AbortError')))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) @@ -321,46 +327,46 @@ describe('DeepSeekSearchProvider error handling', () => { 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) + const search = searchProvider(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' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) it('maps a well-formed body of the wrong shape to WEB_PROVIDER_ERROR, not a raw TypeError', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: {} }, { status: 200 }))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) it('surfaces an abort during success-body parse as WEB_ABORTED', async () => { const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: true, status: 200 } vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) it('surfaces an abort during error-body parse as WEB_ABORTED', async () => { const body = { json: () => Promise.reject(new DOMException('aborted', 'AbortError')), ok: false, status: 500 } vi.stubGlobal('fetch', vi.fn(async () => body as unknown as Response)) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' })) }) it('maps a network failure to WEB_PROVIDER_ERROR', async () => { vi.stubGlobal('fetch', vi.fn(() => Promise.reject(new TypeError('connection refused')))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) it('strict mode flows through search(): a prose-only response throws WEB_PROVIDER_ERROR', async () => { vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ content: [{ type: 'text', text: 'no search happened' }] }))) - await expect(new DeepSeekSearchProvider(options).search({ query: 'q' })) + await expect(searchProvider(options).search({ query: 'q' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' })) }) }) diff --git a/packages/web/web-search-deepseek/tests/redirect.spec.ts b/packages/web/web-search-deepseek/tests/redirect.spec.ts index 100d60f390..e42446e6ec 100644 --- a/packages/web/web-search-deepseek/tests/redirect.spec.ts +++ b/packages/web/web-search-deepseek/tests/redirect.spec.ts @@ -8,6 +8,12 @@ import { createServer, type IncomingMessage, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { DeepSeekSearchProvider } from '@deepseek-ai/dsh-web-search-deepseek' +/** Construct the provider over a fixed options value; production passes a live thunk. */ +import type { DeepSeekSearchProviderOptions } from '@deepseek-ai/dsh-web-search-deepseek' + +const searchProvider = (options: DeepSeekSearchProviderOptions): DeepSeekSearchProvider => + new DeepSeekSearchProvider(() => options) + const TEST_API_KEY = 'redirect-test-key' const TEST_QUERY = 'private redirect query' const targetRequests: ReceivedRequest[] = [] @@ -46,7 +52,7 @@ afterAll(async () => { describe('DeepSeekSearchProvider redirect policy', () => { it.each([301, 302, 303, 307, 308])('rejects HTTP %i before contacting Location', async (status) => { targetRequests.length = 0 - const provider = new DeepSeekSearchProvider({ + const provider = searchProvider({ apiKey: TEST_API_KEY, baseURL: `${redirectOrigin}/${status}`, model: 'deepseek-chat', diff --git a/packages/web/web-search-deepseek/tests/settings.spec.ts b/packages/web/web-search-deepseek/tests/settings.spec.ts new file mode 100644 index 0000000000..943fa21cc3 --- /dev/null +++ b/packages/web/web-search-deepseek/tests/settings.spec.ts @@ -0,0 +1,124 @@ +/** The `web-search-deepseek` settings section layered over the composition entry. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Fiber } from 'cordis' +import { Settings } from '@deepseek-ai/dsh-settings' +import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' +import WebService from '@deepseek-ai/dsh-web' +import * as deepseekPlugin from '@deepseek-ai/dsh-web-search-deepseek' +import { WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-web-search-deepseek' + +/** The smallest real provider: one in-memory document, always writable. */ +class MemorySettings extends Settings { + doc: Record = {} + + get writable(): boolean { + return true + } + + protected load(): Promise> { + return Promise.resolve(structuredClone(this.doc)) + } + + protected persist(ns: SettingsNamespace, section: Record): Promise { + this.doc = { ...this.doc, [ns]: structuredClone(section) } + return Promise.resolve() + } +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) +} + +/** The smallest Anthropic-shaped answer the provider accepts — enough to observe the request. */ +const ONE_RESULT = { + content: [ + { type: 'text', text: 'ok' }, + { + type: 'web_search_tool_result', + content: [{ type: 'web_search_result', url: 'https://a.test', title: 'A' }], + }, + ], +} + +async function boot(): Promise<{ ctx: Context; settingsFiber: Fiber; pluginFiber: Fiber }> { + const ctx = new Context() + await ctx.plugin(WebService, {}) + const settingsFiber = ctx.plugin(MemorySettings) + await settingsFiber.await() + const pluginFiber = ctx.plugin(deepseekPlugin, { apiKey: 'ds-key', baseURL: 'https://search.entry.test/v1' }) + await pluginFiber.await() + return { ctx, settingsFiber, pluginFiber } +} + +afterEach(() => { + vi.restoreAllMocks() +}) + +/** + * Run one search and answer the endpoint it reached. A fresh `Response` per + * call because a body can only be read once, and the call history is cleared + * because repeated `spyOn` returns the same spy. + * @param ctx - context whose `ctx.web` serves the search. + * @returns the URL the provider fetched. + */ +async function searchOnce(ctx: Context): Promise { + const fetchSpy = vi.spyOn(globalThis, 'fetch') + .mockImplementation(() => Promise.resolve(jsonResponse(ONE_RESULT))) + fetchSpy.mockClear() + await ctx.web.search({ query: 'anything' }) + return String((fetchSpy.mock.calls.at(-1)?.[0] as URL | string | undefined) ?? '') +} + +describe('web-search-deepseek settings section', () => { + it('serves a stored endpoint to the next search without re-registering the provider', async () => { + const bench = await boot() + expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test/v1') + + await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { + baseURL: 'https://search.stored.test/v1', + }) + + expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test/v1') + await bench.ctx.fiber.dispose() + }) + + it('keeps the literal key out of every described layer', async () => { + const bench = await boot() + await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { apiKey: 'ds-stored-secret' }) + + const [descriptor] = bench.ctx.settings.describe({ redactSecrets: true }) + .filter(row => String(row.ns) === 'web-search-deepseek') + + expect(JSON.stringify(descriptor)).not.toContain('ds-stored-secret') + expect(descriptor?.secrets).toEqual([{ path: ['apiKey'], set: true }]) + await bench.ctx.fiber.dispose() + }) + + it('falls back to the composition entry when the settings provider detaches', async () => { + const bench = await boot() + await bench.ctx.settings.update(WEB_SEARCH_DEEPSEEK_SETTINGS_NAMESPACE, { + baseURL: 'https://search.stored.test/v1', + }) + expect(await searchOnce(bench.ctx)).toContain('https://search.stored.test/v1') + + await bench.settingsFiber.dispose() + + expect(await searchOnce(bench.ctx)).toContain('https://search.entry.test/v1') + await bench.ctx.fiber.dispose() + }) + + it('releases the namespace when the plugin unloads', async () => { + const bench = await boot() + expect(bench.ctx.settings.describe().map(row => String(row.ns))).toContain('web-search-deepseek') + + await bench.pluginFiber.dispose() + + expect(bench.ctx.settings.describe().map(row => String(row.ns))).not.toContain('web-search-deepseek') + await bench.ctx.fiber.dispose() + }) +}) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index b3d8e2ade6..28dbbc3b33 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../credentials/credentials" }, + { + "path": "../../settings/settings" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe6f610dff..142fceb77d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7324,6 +7324,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web From bd0563bff687279b5fe59d80b56fd6540c524ba7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 18:09:09 +0800 Subject: [PATCH 04/19] feat(apiproxy): expose the shell, agent-loop and web-search sections to the browser Registration alone still never crosses the boundary: the three host-plane sections the plugin configuration page edits join the explicit allowlist, and the assertion that pins the served set is what catches a namespace silently dropping out of the page. --- packages/host/apiproxy/README.i18n.yaml | 4 +-- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 15 +++++++++-- .../apiproxy/tests/api-proxy-config.spec.ts | 25 +++++++++++++++++++ 5 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 60fc24a978..e544eb0e38 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: 75763f8826dc0161ce7ec85aaf5806b184efa918 -README.zh.md: bbbdee3441f8bfd443a69d8e28e8ffe1eae90cc9 +README.md: ca1503b7596fd948c5bd110f6122bb1fd9beec3b +README.zh.md: 04644fcb1852691ff619cd52a8f264f4c1213d11 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 75763f8826..ca1503b759 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -52,7 +52,7 @@ The `agentPreset.list` domain exposes the deployment's preset roster so a browse 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 composer's menu: it returns every user-invocable skill with its `modelInvocable` flag, so menus can mark user-only (`disable-model-invocation`) entries whose only invocation path is the slash gesture. Listing is the skill domain's only RPC — invocation itself is an ordinary `session.prompt` whose whitespace-bounded `/name` tokens `dsh-tool-skill` recognizes at the pre-step boundary and answers with injected `` context, so every entry point (Web, TUI, and ACP) shares one deterministic path—including for hand-typed text—with no dedicated invocation wire. `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 registry-wide catalog invalidation frame: clients refetch `command.list` instead of diffing. `host/session-preset-changed` is its per-session counterpart, framed off the logged `agent-preset/selected` commit: recomposing a blank session's agent re-parents its scope without registering anything, so both catalogs that session's composition decides (`command.list`, `skill.list`) go stale with no registry change to announce it. -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 preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, 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, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `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. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. 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 `locale`, `permission`, `ui-conversation`, `ui-theme`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`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 preferences `locale`, `permission`, `ui-conversation`, and `ui-theme`, the host-plane plugin sections `agent-loop`, `bash`, and `web-search-deepseek` that the plugin configuration page edits, 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, the section's `revision`, and the boolean `hasDocument` capability flag. The browser receives no Host path: pathless `settings.openDocument` asks the provider to materialize its document and then hands the Host-resolved result to the native opener, so no browser payload can select any filesystem target. `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. `llm.discoverModels` interrogates a provider endpoint the page is still drafting: `settingsNs` selects the adapter family that knows how to read the listing, and the endpoint, protocol, and key come from the form rather than from storage. It writes nothing — the reply is candidates, and only a later `settings.mutate` decides what a route serves — so its `apiKey` is the third payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`. The host never stores or returns it; like the other two it does ride the client's outgoing envelope, which `subscribeEnvelopes()` observers can see, and redacting that tap is a configuration-plane-wide change rather than this method's to make alone. Every refusal (an unserved namespace, a protocol with no readable listing, an unreachable endpoint, a rejected credential) folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. 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 `locale`, `permission`, `ui-conversation`, `ui-theme`, `agent-loop`, `bash`, `web-search-deepseek`, or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads and native actions included (`settings.describe`/`openDocument`/`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 bbbdee3441..04644fcb18 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -52,7 +52,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `command.*` 与 `skill.*` 领域向客户端暴露宿主命令注册表和 skill(技能)目录。每个方法都通过 `sessionId` 寻址一个会话的 Agent(被服务的会话必有 Agent;`command.*` 经由与 `session.*` 相同的路径恢复冷会话,而 `skill.list` 从会话头解析项目根目录,不触碰 Agent 注册表)。`skill.list` 服务于 composer 的菜单:它返回每一个用户可调用的 skill 及其 `modelInvocable` 标志,让菜单能够标出仅限用户(`disable-model-invocation`)的条目——斜杠手势是这类条目唯一的调用路径。列表是 skill 领域唯一的 RPC——调用本身就是一次普通的 `session.prompt`,`dsh-tool-skill` 会在 pre-step 边界识别其中以空白为界的 `/name` token,并以注入的 `` 上下文作答,因此所有入口(Web、TUI 与 ACP(Agent Client Protocol))共享同一条确定性路径,手动键入的文本也走该路径,且没有专设的调用协议。`command.execute` 在宿主侧运行一条斜杠命令行,语义为纯准入:响应报告该行是否解析到处理器,并在解析到时回带铸造的生命周期 `commandId`(将本次确认与流节点关联);结局经由持久落账并在 mux 流广播的 `command/run`/`command/done` 生命周期事件对承载。命令处理器运行超过 30 秒的传输健康时限仍属正常,因此 `command.execute` 仅携带调用方/连接取消信号;该信号可取消正在运行的处理器。`host/commands-changed` 是注册表级目录失效帧:客户端重新拉取 `command.list` 而不是做差分。`host/session-preset-changed` 是它按会话粒度的对应物,由落账的 `agent-preset/selected` 提交点成帧:重组空会话的 agent 只是重新挂接其 scope,不产生任何注册,因此该会话组成所决定的两份目录(`command.list`、`skill.list`)都会失效,却没有任何注册表变化来宣告它。 -`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `locale`、`permission`、`ui-conversation` 与 `ui-theme`,插件配置页所编辑的宿主平面插件分节 `agent-loop`、`bash` 与 `web-search-deepseek`,以及产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表、该分节的 `revision`,以及布尔型 `hasDocument` 能力标志。浏览器不会收到 Host 路径:无路径参数的 `settings.openDocument` 会请求提供方准备文档,再把由 Host 解析出的结果交给原生打开器,因此任何浏览器载荷都无法选择任意文件系统目标。`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` 则是与会话无关的目录。`llm.discoverModels` 询问页面尚在起草的提供方端点:`settingsNs` 选出懂得读取该列表的适配器家族,端点、协议与密钥则来自表单而非存储。它什么都不写——回复是候选,只有随后的 `settings.mutate` 才决定路由服务什么——因此其 `apiKey` 是 secret 可以搭乘的第三个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。host 从不存储或回传它;与另两者一样,它确实会搭乘客户端的出站信封,`subscribeEnvelopes()` 的观察者能看到——为该 tap 做脱敏是整个配置面的改动,而非本方法一家的事。每一种拒绝(无人服务的 namespace、没有可读列表的协议、不可达端点、被拒凭据)都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`locale`、`permission`、`ui-conversation`、`ui-theme`、`agent-loop`、`bash`、`web-search-deepseek` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取与原生操作:`settings.describe`/`openDocument`/`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 d0457ddeb5..5633472f48 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -92,8 +92,19 @@ import { canOpenNativePath, openNativePath, openNativeTextFile } from './native- /** Page size when history is called without maxMessages. */ const DEFAULT_MAX_MESSAGES = 50 -/** Non-model settings namespaces intentionally served to the Web client. */ -const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const +/** + * Non-model settings namespaces intentionally served to the Web client. The + * plugin-owned entries (`agent-loop`, `bash`, `web-search-deepseek`) are the + * host-plane sections the plugin configuration page edits; a namespace absent + * here answers `settings-not-exposed` even when its owner registered it, so + * adding a section to that page is a decision made here rather than by the + * registering plugin. Moving that declaration to `settings.register()`, so a + * plugin can expose its own configuration without a change in this package, + * is deferred work. + */ +const WEB_SETTINGS_NAMESPACES = [ + 'agent-loop', 'bash', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek', +] as const /** Provider work budget: at most 100 calls and 2,000 inspected hits. */ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100 diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index bb266b8a7a..2b3ef5027e 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -328,11 +328,21 @@ describe('settings domain', () => { ctx.settings.register(settingsNamespace('ui-conversation'), z.object({ busyEnter: z.union(['queue', 'steer']).default('queue'), })) + ctx.settings.register(settingsNamespace('bash'), z.object({ + timeoutMs: z.number().default(120_000), + })) + ctx.settings.register(settingsNamespace('agent-loop'), z.object({ + maxParallelToolCalls: z.number().default(10), + })) + ctx.settings.register(settingsNamespace('web-search-deepseek'), z.object({ + baseURL: z.string(), + })) const api = createApiProxy(ctx, DEFAULTS) const value = expectOk(await api.settings.describe(request({}))) expect(value.namespaces.map(view => view.ns)).toEqual([ 'llm-deepseek', 'permission', 'ui-theme', 'locale', 'ui-conversation', + 'bash', 'agent-loop', 'web-search-deepseek', ]) const permission = expectOk(await api.settings.mutate(request({ ns: 'permission', @@ -354,6 +364,21 @@ describe('settings domain', () => { ops: [{ op: 'set', path: ['busyEnter'], value: 'steer' }], }))) expect(conversation.value).toEqual({ busyEnter: 'steer' }) + const bash = expectOk(await api.settings.mutate(request({ + ns: 'bash', + ops: [{ op: 'set', path: ['timeoutMs'], value: 5_000 }], + }))) + expect(bash.value).toEqual({ timeoutMs: 5_000 }) + const agentLoop = expectOk(await api.settings.mutate(request({ + ns: 'agent-loop', + ops: [{ op: 'set', path: ['maxParallelToolCalls'], value: 2 }], + }))) + expect(agentLoop.value).toEqual({ maxParallelToolCalls: 2 }) + const webSearch = expectOk(await api.settings.mutate(request({ + ns: 'web-search-deepseek', + ops: [{ op: 'set', path: ['baseURL'], value: 'https://search.test/v1' }], + }))) + expect(webSearch.value).toEqual({ baseURL: 'https://search.test/v1' }) for (const response of [ await api.settings.update(request({ ns: 'some-other-plugin', patch: { secretPath: '/etc/shadow' } })), From 8a3c5daad7b89947b1f13afd74dec3267e89badc Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 18:27:01 +0800 Subject: [PATCH 05/19] feat(client-runtime): carry the layered view and a field reset through the settings scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A form needs two things the snapshot did not carry. The `user` layer tells it which fields the user overrode — presence, not value equality, because an override equal to the composition default is still an override — and `base` is what a cleared field reverts to. `unset` is that clear, sharing `set`'s queue, revision fence, and rejected-write recovery through one write path. --- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/settings-scope.ts | 40 ++++++++++- .../runtime/tests/settings-scope.spec.ts | 71 +++++++++++++++++++ .../client/test-runtime/src/settings-scope.ts | 8 ++- .../test-runtime/tests/runtime.spec.tsx | 30 ++++++++ 7 files changed, 150 insertions(+), 7 deletions(-) diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 7dc91083c9..8ab833eaf5 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/runtime/README.md -README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412 -README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8 +README.md: b605fd2adc13ab6a1a4b727d116fc4e8b9973b10 +README.zh.md: efd86b9d5deb21bdec298d305c1fef0e687ce6f4 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1ec6cc38ae..b605fd2adc 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. -`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. +`bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, the composition `base` and raw `user` layers, revision, writability, host/memory mode), serializes `set` and `unset` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. A field is overridden when it is PRESENT in `user` — an override equal to the composition default is still an override, which comparing values could not see — and `unset` is how a form clears one back to `base`. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. ## Slot declaration injection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6602152790..efd86b9d5d 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -4,7 +4,7 @@ 客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 -`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 +`bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、组装 `base` 层与原始 `user` 层、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 与 `unset` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。字段是否被覆盖,取决于它是否**出现**在 `user` 中——与组装默认值相同的覆盖仍然是覆盖,比较值是看不出来的——而 `unset` 就是表单把某个字段清回 `base` 的方式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 ## Slot 声明注入 diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts index 91b6c7ec3a..e441371359 100644 --- a/packages/client/runtime/src/client/settings-scope.ts +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - ConnectionHandle, IApiClient, SettingsNamespaceView, + ConnectionHandle, IApiClient, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-client-connection/client' import { rehydrateSchema, validateDraft } from '@deepseek-ai/dsh-client-schema-form' import { createSnapshotStore, type SnapshotStore } from './contract/store.ts' @@ -17,6 +17,17 @@ export interface SettingsScopeSnapshot { status: 'loading' | 'ready' | 'unavailable' /** Last accepted schema-resolved section; undefined before the first acceptance. */ value: T | undefined + /** + * Composition layer the Host resolved {@link value} over, when the owning + * plugin declared one. What a field reverts to once cleared. + */ + base: unknown + /** + * Raw user layer as stored, when one exists. A field's PRESENCE here is what + * marks it overridden — an override whose value equals the composition + * default is still an override, and comparing values could not see it. + */ + user: unknown /** Namespace revision fencing the next write; undefined before the first Host view. */ revision: number | undefined /** Whether the Host document accepts writes; memory mode never does. */ @@ -60,6 +71,13 @@ export interface SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise + /** + * Queue one field clear, so the field re-inherits the composition layer. + * Shares {@link set}'s ordering, revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @returns settlement after the clear and any latest-write recovery read. + */ + unset(field: string): Promise } type SettingsFace = Pick @@ -90,6 +108,8 @@ export class SettingsScopeController implements SettingsScope { this.store = createSnapshotStore>({ status: persistence === 'host' ? 'loading' : 'unavailable', value: undefined, + base: undefined, + user: undefined, revision: undefined, writable: false, mode: persistence, @@ -127,6 +147,20 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { + return this.write({ op: 'set', path: [field], value }) + } + + /** + * Queue one field clear; see {@link SettingsScope.unset} for the ordering, + * revision, and recovery contract. + * @param field - scalar field inside the namespace section. + * @returns settlement after the clear and any latest-write recovery read. + */ + unset(field: string): Promise { + return this.write({ op: 'unset', path: [field] }) + } + + private write(op: SettingsPathOpView): Promise { this.readGeneration += 1 const generation = ++this.writeGeneration return this.enqueue(async () => { @@ -135,7 +169,7 @@ export class SettingsScopeController implements SettingsScope { try { response = await this.api.settings.mutate({ ns: this.spec.namespace, - ops: [{ op: 'set', path: [field], value }], + ops: [op], ...(revision === undefined ? {} : { expectedRevision: revision }), }) } catch (_settingsWriteFailure) { @@ -200,6 +234,8 @@ export class SettingsScopeController implements SettingsScope { const decoded = publish ? this.decode(view) : undefined this.store.update((draft) => { draft.revision = view.revision + draft.base = view.base + draft.user = view.user if (writable !== undefined) draft.writable = writable if (decoded === undefined) return draft.status = 'ready' diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts index db980bf6d1..168ed7e784 100644 --- a/packages/client/runtime/tests/settings-scope.spec.ts +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -293,6 +293,77 @@ describe('SettingsScopeController', () => { expect(describeCall).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() }) + + it('carries the composition base and the user layer into the snapshot', async () => { + const layered: SettingsNamespaceView = { + ...view({ preference: 'dark' }, 3), + base: { preference: 'system' }, + user: { preference: 'dark' }, + } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [layered] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + + await scope.load() + + expect(scope.getSnapshot()).toMatchObject({ + value: { preference: 'dark' }, + base: { preference: 'system' }, + user: { preference: 'dark' }, + }) + }) + + it('reports an inherited field as absent from the user layer', async () => { + const inherited: SettingsNamespaceView = { ...view({ preference: 'system' }, 1), base: { preference: 'system' } } + const describeCall = vi.fn() + .mockResolvedValueOnce(ok({ writable: true, hasDocument: true, namespaces: [inherited] })) + const scope = new SettingsScopeController( + { settings: { describe: describeCall } } as never, + { namespace: 'ui-test' }, + ) + + await scope.load() + + expect(scope.getSnapshot().user).toBeUndefined() + }) + + it('clears one field through an unset op fenced by the held revision', async () => { + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'system' }, 4))) + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + + await scope.unset('preference') + + expect(mutate).toHaveBeenCalledWith({ + ns: 'ui-test', + ops: [{ op: 'unset', path: ['preference'] }], + expectedRevision: 3, + }) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'system' }, revision: 4 }) + }) + + it('recovers the Host state when the latest clear is refused', async () => { + const mutate = vi.fn().mockResolvedValueOnce(rejected()) + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'dark' }, 3)) + .mockResolvedValueOnce(described({ preference: 'light' }, 5)) + const scope = new SettingsScopeController( + { settings: { describe: describeCall, mutate } } as never, + { namespace: 'ui-test' }, + ) + await scope.load() + + await scope.unset('preference') + + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'light' }, revision: 5 }) + }) }) describe('bindSettingsScope', () => { diff --git a/packages/client/test-runtime/src/settings-scope.ts b/packages/client/test-runtime/src/settings-scope.ts index c901221018..377ed84210 100644 --- a/packages/client/test-runtime/src/settings-scope.ts +++ b/packages/client/test-runtime/src/settings-scope.ts @@ -8,6 +8,8 @@ export interface StubSettingsScope { scope: SettingsScope /** Spy behind `scope.set`; resolves immediately. */ set: ReturnType + /** Spy behind `scope.unset`; resolves immediately. */ + unset: ReturnType /** @returns how many listeners are currently subscribed (disposal assertions). */ listenerCount(): number /** @@ -25,10 +27,12 @@ export interface StubSettingsScope { */ export function stubSettingsScope(): StubSettingsScope { let snapshot: SettingsScopeSnapshot = { - status: 'loading', value: undefined, revision: undefined, writable: false, mode: 'host', + status: 'loading', value: undefined, base: undefined, user: undefined, + revision: undefined, writable: false, mode: 'host', } const listeners = new Set<() => void>() const set = vi.fn(() => Promise.resolve()) + const unset = vi.fn(() => Promise.resolve()) return { scope: { getSnapshot: () => snapshot, @@ -37,8 +41,10 @@ export function stubSettingsScope(): StubSettingsScope { return () => { listeners.delete(listener) } }, set, + unset, }, set, + unset, listenerCount: () => listeners.size, publish: (next) => { snapshot = { ...snapshot, ...next } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 05827a63a1..e991bb4dc5 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -7,6 +7,7 @@ * stack — this suite is the fixture the migrated feature specs rely on. */ import { afterEach, describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '../src/settings-scope.ts' import { cleanup } from '@testing-library/react' import { defineStore } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' @@ -635,3 +636,32 @@ describe('single-slot mounting edge arms', () => { await runtime.dispose() }) }) + +describe('stubbed settings scope', () => { + it('records both write kinds and publishes a Host acceptance to its listeners', async () => { + const host = stubSettingsScope<{ preference: string }>() + let notified = 0 + const stop = host.scope.subscribe(() => { notified += 1 }) + expect(host.listenerCount()).toBe(1) + expect(host.scope.getSnapshot()).toMatchObject({ + status: 'loading', base: undefined, user: undefined, + }) + + await host.scope.set('preference', 'dark') + await host.scope.unset('preference') + host.publish({ + status: 'ready', + value: { preference: 'system' }, + base: { preference: 'system' }, + revision: 2, + writable: true, + }) + + expect(host.set).toHaveBeenCalledWith('preference', 'dark') + expect(host.unset).toHaveBeenCalledWith('preference') + expect(notified).toBe(1) + expect(host.scope.getSnapshot()).toMatchObject({ status: 'ready', revision: 2, writable: true }) + stop() + expect(host.listenerCount()).toBe(0) + }) +}) From f8555b5561624858ed5a183f473d345aec7e859a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:14:59 +0800 Subject: [PATCH 06/19] feat(client): configure host-plane plugins from a settings section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section knows no namespace: it declares `settings.plugin.item` and renders whatever cards were registered into it, so a plugin that ships a browser half owns its card and its controls. The three cards here cover the host-plane sections this deployment exposes. A field shows its effective value and, when the raw user layer carries it, an override badge and a reset that clears it back to the composition layer. Controls commit on blur and Enter rather than per keystroke, which would burn namespace revisions and race its own reads. The search key is the one value that never rides a response: the card reports only whether one is configured and writes it through the credentials domain, addressed by the reference the section names. A card renders nothing while its namespace is unavailable — a deployment that does not compose the owning plugin should show no trace of it rather than a disabled card the user cannot act on. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 1 + docs/config-catalog.zh.md | 1 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 10 + docs/module-graph.zh.md | 10 + packages/bundle/web-app/cordis.patch.yml | 5 + packages/bundle/web-app/package.json | 4 +- packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + .../client/ui-plugin-config/README.i18n.yaml | 6 + packages/client/ui-plugin-config/README.md | 33 ++ packages/client/ui-plugin-config/README.zh.md | 33 ++ packages/client/ui-plugin-config/package.json | 71 ++++ .../src/client/AgentLoopCard.tsx | 63 ++++ .../ui-plugin-config/src/client/BashCard.tsx | 79 +++++ .../src/client/PluginCard.module.css | 35 ++ .../src/client/PluginCard.tsx | 61 ++++ .../src/client/PluginConfigSection.module.css | 36 ++ .../src/client/PluginConfigSection.tsx | 49 +++ .../src/client/WebSearchCard.tsx | 98 ++++++ .../src/client/agent-loop-store.ts | 60 ++++ .../ui-plugin-config/src/client/bash-store.ts | 71 ++++ .../ui-plugin-config/src/client/card-store.ts | 84 +++++ .../src/client/fields.module.css | 90 +++++ .../ui-plugin-config/src/client/fields.tsx | 193 ++++++++++ .../ui-plugin-config/src/client/index.ts | 91 +++++ .../ui-plugin-config/src/client/locales.ts | 80 +++++ .../src/client/slot-contract.ts | 24 ++ .../src/client/web-search-store.ts | 144 ++++++++ .../ui-plugin-config/src/css-modules.d.ts | 4 + packages/client/ui-plugin-config/src/index.ts | 11 + .../client/ui-plugin-config/src/invariant.ts | 31 ++ .../ui-plugin-config/tests/apply.spec.ts | 100 ++++++ .../ui-plugin-config/tests/fields.spec.tsx | 330 ++++++++++++++++++ .../ui-plugin-config/tests/invariant.spec.ts | 25 ++ .../ui-plugin-config/tests/section.spec.tsx | 223 ++++++++++++ .../ui-plugin-config/tests/stores.spec.ts | 193 ++++++++++ .../client/ui-plugin-config/tsconfig.json | 42 +++ .../client/ui-plugin-config/tsdown.config.ts | 3 + pnpm-lock.yaml | 42 +++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 45 files changed, 2445 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-plugin-config/README.i18n.yaml create mode 100644 packages/client/ui-plugin-config/README.md create mode 100644 packages/client/ui-plugin-config/README.zh.md create mode 100644 packages/client/ui-plugin-config/package.json create mode 100644 packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx create mode 100644 packages/client/ui-plugin-config/src/client/BashCard.tsx create mode 100644 packages/client/ui-plugin-config/src/client/PluginCard.module.css create mode 100644 packages/client/ui-plugin-config/src/client/PluginCard.tsx create mode 100644 packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css create mode 100644 packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx create mode 100644 packages/client/ui-plugin-config/src/client/WebSearchCard.tsx create mode 100644 packages/client/ui-plugin-config/src/client/agent-loop-store.ts create mode 100644 packages/client/ui-plugin-config/src/client/bash-store.ts create mode 100644 packages/client/ui-plugin-config/src/client/card-store.ts create mode 100644 packages/client/ui-plugin-config/src/client/fields.module.css create mode 100644 packages/client/ui-plugin-config/src/client/fields.tsx create mode 100644 packages/client/ui-plugin-config/src/client/index.ts create mode 100644 packages/client/ui-plugin-config/src/client/locales.ts create mode 100644 packages/client/ui-plugin-config/src/client/slot-contract.ts create mode 100644 packages/client/ui-plugin-config/src/client/web-search-store.ts create mode 100644 packages/client/ui-plugin-config/src/css-modules.d.ts create mode 100644 packages/client/ui-plugin-config/src/index.ts create mode 100644 packages/client/ui-plugin-config/src/invariant.ts create mode 100644 packages/client/ui-plugin-config/tests/apply.spec.ts create mode 100644 packages/client/ui-plugin-config/tests/fields.spec.tsx create mode 100644 packages/client/ui-plugin-config/tests/invariant.spec.ts create mode 100644 packages/client/ui-plugin-config/tests/section.spec.tsx create mode 100644 packages/client/ui-plugin-config/tests/stores.spec.ts create mode 100644 packages/client/ui-plugin-config/tsconfig.json create mode 100644 packages/client/ui-plugin-config/tsdown.config.ts diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index bd321c33a0..632534c060 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 36053fd205923e207224a01f3f948c1977004c1c -config-catalog.zh.md: d092947d31cfe4b24cae5d0ee8570dda39d7a287 +config-catalog.md: a5269c1c5bbf45cdd6e13a9b4c9e6cb57dab1db9 +config-catalog.zh.md: 7cff05d3f741b40689db7ece92b0739e114a5206 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 36053fd205..a5269c1c5b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2717,6 +2717,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-models` ([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission` ([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugin-config` ([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question` ([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d092947d31..7cff05d3f7 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2718,6 +2718,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-models`([`packages/client/ui-models/src/index.ts`](../packages/client/ui-models/src/index.ts)) - `@deepseek-ai/dsh-client-ui-permission`([`packages/client/ui-permission/src/index.ts`](../packages/client/ui-permission/src/index.ts)) - `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-plugin-config`([`packages/client/ui-plugin-config/src/index.ts`](../packages/client/ui-plugin-config/src/index.ts)) - `@deepseek-ai/dsh-client-ui-question`([`packages/client/ui-question/src/index.ts`](../packages/client/ui-question/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts)) - `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c9b096618a..5cdbfcaed7 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a9d4def424c875860781b4b7d46aea2e9af903dd -module-graph.zh.md: 759ded070a2991689ee18ca679cfa57213abc49d +module-graph.md: e6908727593755d52353e5119f6d57919f3d83f4 +module-graph.zh.md: bd7fe1e7212019168554367e505aca99eba97d67 diff --git a/docs/module-graph.md b/docs/module-graph.md index a9d4def424..e690872759 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -157,6 +157,7 @@ flowchart TD pkg_client_ui_models["client-ui-models"] pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] + pkg_client_ui_plugin_config["client-ui-plugin-config"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -432,6 +433,14 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_plugin_config --> pkg_client_connection + pkg_client_ui_plugin_config --> pkg_client_locale + pkg_client_ui_plugin_config --> pkg_client_runtime + pkg_client_ui_plugin_config --> pkg_client_ui_primitives + pkg_client_ui_plugin_config --> pkg_client_ui_settings + pkg_client_ui_plugin_config --> pkg_client_ui_slots + pkg_client_ui_plugin_config --> pkg_client_web_react + pkg_client_ui_plugin_config --> pkg_invariants pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_connection @@ -1303,6 +1312,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `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-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-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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 759ded070a..bd7fe1e721 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -159,6 +159,7 @@ flowchart TD pkg_client_ui_models["client-ui-models"] pkg_client_ui_permission["client-ui-permission"] pkg_client_ui_plan["client-ui-plan"] + pkg_client_ui_plugin_config["client-ui-plugin-config"] pkg_client_ui_primitives["client-ui-primitives"] pkg_client_ui_question["client-ui-question"] pkg_client_ui_settings["client-ui-settings"] @@ -434,6 +435,14 @@ flowchart TD pkg_skill --> pkg_scope pkg_web --> pkg_invariants pkg_web --> pkg_llm + pkg_client_ui_plugin_config --> pkg_client_connection + pkg_client_ui_plugin_config --> pkg_client_locale + pkg_client_ui_plugin_config --> pkg_client_runtime + pkg_client_ui_plugin_config --> pkg_client_ui_primitives + pkg_client_ui_plugin_config --> pkg_client_ui_settings + pkg_client_ui_plugin_config --> pkg_client_ui_slots + pkg_client_ui_plugin_config --> pkg_client_web_react + pkg_client_ui_plugin_config --> pkg_invariants pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_connection @@ -1305,6 +1314,7 @@ flowchart TD | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-plugin-config`](../packages/client/ui-plugin-config) | `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-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-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) | diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..f00f757fe8 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -188,6 +188,11 @@ - id: ui-agent-preset name: '@deepseek-ai/dsh-client-ui-agent-preset' + # Plugin configuration: the host-plane sections a user owns, as expandable + # cards. A namespace this deployment does not expose renders nothing. + - id: ui-plugin-config + name: '@deepseek-ai/dsh-client-ui-plugin-config' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 4f8b8d4318..ab42f7b747 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -33,12 +33,11 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", @@ -50,6 +49,7 @@ "@deepseek-ai/dsh-client-ui-models": "workspace:^", "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-plugin-config": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 816f8737e7..2b52f97ce2 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd -README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 +README.md: 2518285cea1dfd022a3d656bd4b7a7f2bd77a08e +README.zh.md: 962326f055866119370ff9ad845fed5103928541 diff --git a/packages/client/README.md b/packages/client/README.md index 567e10f74a..2518285cea 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -32,6 +32,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | The Plugins settings section: host-plane plugin configuration as expandable cards. | | [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. | | [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | | [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ad6a9fb199..962326f055 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -32,6 +32,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | +| [`ui-plugin-config/`](ui-plugin-config/README.md) | 插件设置分区:把宿主平面的插件配置呈现为可展开卡片。 | | [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 | | [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 | | [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml new file mode 100644 index 0000000000..d9d198f4dc --- /dev/null +++ b/packages/client/ui-plugin-config/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-plugin-config/README.md +README.md: e830589b5e279fb0bdda23591502989bebc2a336 +README.zh.md: e614d7b6858f8cbf2a38cb7397b5d8f93445ab6c diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md new file mode 100644 index 0000000000..e830589b5e --- /dev/null +++ b/packages/client/ui-plugin-config/README.md @@ -0,0 +1,33 @@ +# dsh-client-ui-plugin-config + +English | [中文](README.zh.md) + +The **Plugins** settings section: one expandable card per Host plugin whose configuration a user owns. A card shows the plugin's name and what it governs; expanding it in place reveals hand-written controls bound to that plugin's settings namespace, each field marking whether the user overrode it and offering a reset back to the value the deployment composed. + +## What appears here + +A card renders only when its namespace is both registered by a live Host plugin and served to the browser. A deployment that does not compose the owning plugin — or serves the namespace to no client — renders nothing for it rather than an empty or disabled card, so the section reflects what this deployment actually runs. + +The first batch covers the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). + +## Extension point + +The section declares `settings.plugin.item`, a root list slot. A plugin that ships a browser half registers its own card into that slot and owns its controls; this package neither enumerates namespaces nor renders a form it was not given. Ordering follows the slot's `order`. + +## Writes + +Every control writes one field through the client settings scope, which fences each write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control reports only whether one is configured and writes through the credentials domain rather than the settings section. + +## Model Experience + +None, as the section renders a browser configuration UI; the values it writes reach a model only through the plugins that own them, each documenting that effect itself. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job. +- **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`. +- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the card's fields differ by platform and a deployment composing neither shows no card. diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md new file mode 100644 index 0000000000..e614d7b685 --- /dev/null +++ b/packages/client/ui-plugin-config/README.zh.md @@ -0,0 +1,33 @@ +# dsh-client-ui-plugin-config + +[English](README.md) | 中文 + +**插件**设置分区:每个配置由用户拥有的 Host 插件占一张可展开卡片。卡片展示插件名称及其管辖范围;就地展开后是绑定到该插件 settings 命名空间的手写控件,每个字段标注用户是否覆盖过它,并提供重置回部署组装值的入口。 + +## 这里会出现什么 + +只有当某个命名空间既被存活的 Host 插件注册、又被服务给浏览器时,它的卡片才会渲染。未组装该插件的部署——或未向任何客户端服务该命名空间的部署——不会渲染空卡片或禁用卡片,而是什么都不渲染,因此这一分区反映的是该部署实际运行的东西。 + +第一批覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 + +## 扩展点 + +本分区声明了根级列表 slot `settings.plugin.item`。带浏览器半侧的插件把自己的卡片注册进该 slot 并拥有其控件;本包既不枚举命名空间,也不渲染未被交给它的表单。排序遵循 slot 的 `order`。 + +## 写入 + +每个控件都通过客户端 settings scope 写入单个字段,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件只报告是否已配置,并经由 credentials 领域而非 settings 分节写入。 + +## 模型体验 + +无。该分区渲染浏览器配置 UI;它写入的值只通过拥有这些值的插件到达模型,而这些效应各由其拥有方的包记录。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。 +- **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。 +- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以该卡片的字段随平台不同,而两者都不组装的部署不会显示这张卡片。 diff --git a/packages/client/ui-plugin-config/package.json b/packages/client/ui-plugin-config/package.json new file mode 100644 index 0000000000..f3a9c04e62 --- /dev/null +++ b/packages/client/ui-plugin-config/package.json @@ -0,0 +1,71 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-plugin-config", + "description": "Plugin configuration section: host-plane plugin settings as expandable cards", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "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", + "react": "^18.2.0" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx new file mode 100644 index 0000000000..99790467dc --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx @@ -0,0 +1,63 @@ +/** The agent-loop plugin's card: how many tool calls may run at once. */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NumberField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { AgentLoopCardState } from './agent-loop-store.ts' +import type {} from './slot-contract.ts' + +/** Registration-side business face for the agent-loop card. */ +export interface AgentLoopCardInjected { + hooks: { + /** Card snapshot bound by the renderer as useAgentLoopCard. */ + agentLoopCard: SnapshotStore + } + /** Write the parallel tool-call cap. */ + setMaxParallelToolCalls: (next: number) => void + /** Clear the cap so it re-inherits the composition layer. */ + resetMaxParallelToolCalls: () => void +} + +/** Props the renderer binds for the agent-loop card. */ +export type AgentLoopCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.pluginConfig'> + & InjectFace + +/** + * Render the agent-loop card. + * @param props - locale copy, the card snapshot, and its write actions. + * @returns the card. + */ +export function AgentLoopCard(props: AgentLoopCardProps) { + const { t } = props + const state = props.useAgentLoopCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const disabled = !state.writable + return ( + { setOpen(!open) }} + readOnly={disabled} + readOnlyLabel={t('readOnly')} + > + + + ) +} diff --git a/packages/client/ui-plugin-config/src/client/BashCard.tsx b/packages/client/ui-plugin-config/src/client/BashCard.tsx new file mode 100644 index 0000000000..7291ed18e8 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/BashCard.tsx @@ -0,0 +1,79 @@ +/** The shell plugin's card: the limits every command the agent runs is bound by. */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NumberField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { BashCardState } from './bash-store.ts' +import type {} from './slot-contract.ts' + +/** Registration-side business face for the shell card. */ +export interface BashCardInjected { + hooks: { + /** Card snapshot bound by the renderer as useBashCard. */ + bashCard: SnapshotStore + } + /** Write the foreground command timeout. */ + setTimeoutMs: (next: number) => void + /** Clear the timeout so it re-inherits the composition layer. */ + resetTimeoutMs: () => void + /** Write the per-stream output cap. */ + setMaxOutputBytes: (next: number) => void + /** Clear the output cap so it re-inherits the composition layer. */ + resetMaxOutputBytes: () => void +} + +/** Props the renderer binds for the shell card. */ +export type BashCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.pluginConfig'> + & InjectFace + +/** + * Render the shell card. + * @param props - locale copy, the card snapshot, and its write actions. + * @returns the card. + */ +export function BashCard(props: BashCardProps) { + const { t } = props + const state = props.useBashCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const disabled = !state.writable + return ( + { setOpen(!open) }} + readOnly={disabled} + readOnlyLabel={t('readOnly')} + > + + + + ) +} diff --git a/packages/client/ui-plugin-config/src/client/PluginCard.module.css b/packages/client/ui-plugin-config/src/client/PluginCard.module.css new file mode 100644 index 0000000000..6a63962926 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginCard.module.css @@ -0,0 +1,35 @@ +/* Plugin card: one expandable row per plugin, its body holding the controls. */ + +.card { + list-style: none; + border-bottom: 1px solid var(--dsw-alias-border-l2); +} + +.row { + padding: 16px 0; +} + +.title { + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.description { + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.body { + padding: 0 0 8px 24px; +} + +.readOnly { + margin: 0 0 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-plugin-config/src/client/PluginCard.tsx b/packages/client/ui-plugin-config/src/client/PluginCard.tsx new file mode 100644 index 0000000000..469444571b --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginCard.tsx @@ -0,0 +1,61 @@ +/** + * One plugin's card: an expandable row whose body is that plugin's controls. + * A card renders nothing while its namespace is unavailable — a deployment + * that does not compose the owning plugin should show no trace of it, rather + * than an empty or disabled card the user cannot act on. + */ + +import type { ReactNode } from 'react' +import { DisclosureRow } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './PluginCard.module.css' + +/** Card chrome shared by every plugin section. */ +export interface PluginCardProps { + /** Plugin name shown on the row. */ + title: string + /** One line describing what this plugin's settings govern. */ + description: string + /** False while the namespace is not served to this client. */ + available: boolean + /** Whether the card body is showing. */ + open: boolean + /** Toggle the card body. */ + onToggle: () => void + /** Copy shown when the settings document refuses writes. */ + readOnlyLabel?: string | undefined + /** True when the Host document is read-only. */ + readOnly: boolean + /** The plugin's controls. */ + children: ReactNode +} + +/** + * Render one plugin card. + * @param props - card chrome, disclosure state, and the plugin's controls. + * @returns the card, or nothing when the namespace is unavailable. + */ +export function PluginCard(props: PluginCardProps) { + if (!props.available) return null + return ( +
  • + {props.description}} + > +
    + {props.readOnly && props.readOnlyLabel !== undefined + ?

    {props.readOnlyLabel}

    + : null} + {props.children} +
    +
    +
  • + ) +} diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css new file mode 100644 index 0000000000..6f2af513e9 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -0,0 +1,36 @@ +/* Plugin configuration section: heading, intro, and the card list. */ + +.section { + display: flex; + flex-direction: column; +} + +.heading { + margin: 0; + font-size: 16px; + font-weight: 500; + line-height: 24px; + color: var(--dsw-alias-label-primary); +} + +.intro { + margin: 8px 0 16px; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} + +.cards { + margin: 0; + padding: 0; + list-style: none; +} + +.empty { + margin: 0; + padding: 16px 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx new file mode 100644 index 0000000000..68de45eff3 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.tsx @@ -0,0 +1,49 @@ +/** + * Plugin configuration section: the shell around the per-plugin cards. It + * enumerates nothing itself — cards arrive through the `settings.plugin.item` + * slot it declares, so a plugin that ships a browser half owns its own card + * and this section never learns what a namespace means. + */ + +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from './slot-contract.ts' +import type { PluginConfigKey } from './locales.ts' +import css from './PluginConfigSection.module.css' + +/** Registration-side business face for the section. */ +export interface PluginConfigSectionInjected { + /** How many cards the slot ledger currently holds; zero renders the empty line. */ + cardCount: number +} + +/** Props the renderer binds for the section. */ +export type PluginConfigSectionProps = + PropsRuntime<'settings.section'> + & PropsLocale<'settings.pluginConfig'> + & PropsRenderSlots<'settings.plugin.item'> + & InjectFace + +/** + * Render the plugin configuration section. + * @param props - runtime slot rendering, locale copy, and the card count. + * @returns the section. + */ +export function PluginConfigSection(props: PluginConfigSectionProps) { + const { t, renderSlot, cardCount } = props + return ( +
    +

    {t('title')}

    +

    {t('intro')}

    + {cardCount === 0 + ?

    {t('empty')}

    + :
      {renderSlot('settings.plugin.item', {})}
    } +
    + ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Plugin configuration section and card copy. */ + 'settings.pluginConfig': PluginConfigKey + } +} diff --git a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx new file mode 100644 index 0000000000..72c11fa545 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx @@ -0,0 +1,98 @@ +/** + * The web-search provider's card: its endpoint, its per-request search budget, + * and the key — which is written through the credentials domain, never into + * the settings section, so the literal never rides a response. + */ + +import { useState } from 'react' +import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { NumberField, SecretField, TextField } from './fields.tsx' +import { PluginCard } from './PluginCard.tsx' +import type { WebSearchCardState } from './web-search-store.ts' +import type {} from './slot-contract.ts' + +/** Registration-side business face for the web-search card. */ +export interface WebSearchCardInjected { + hooks: { + /** Card snapshot bound by the renderer as useWebSearchCard. */ + webSearchCard: SnapshotStore + } + /** Write the provider endpoint; the empty string clears it. */ + setBaseUrl: (next: string) => void + /** Clear the endpoint so it re-inherits the composition layer. */ + resetBaseUrl: () => void + /** Write the per-request search budget. */ + setMaxUses: (next: number) => void + /** Clear the budget so it re-inherits the composition layer. */ + resetMaxUses: () => void + /** Write the credential the section references. */ + setApiKey: (next: string) => void +} + +/** Props the renderer binds for the web-search card. */ +export type WebSearchCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.pluginConfig'> + & InjectFace + +/** + * Render the web-search card. + * @param props - locale copy, the card snapshot, and its write actions. + * @returns the card. + */ +export function WebSearchCard(props: WebSearchCardProps) { + const { t } = props + const state = props.useWebSearchCard(snapshot => snapshot) + const [open, setOpen] = useState(false) + const disabled = !state.writable + return ( + { setOpen(!open) }} + readOnly={disabled} + readOnlyLabel={t('readOnly')} + > + + + + + ) +} diff --git a/packages/client/ui-plugin-config/src/client/agent-loop-store.ts b/packages/client/ui-plugin-config/src/client/agent-loop-store.ts new file mode 100644 index 0000000000..1f4dae6c1a --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/agent-loop-store.ts @@ -0,0 +1,60 @@ +/** The agent-loop card's state and writes over the `agent-loop` settings namespace. */ + +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' + +/** + * Namespace of the agent loop's user-owned settings. Spelled here rather than + * imported: a client package must not depend on a Host package. + */ +export const AGENT_LOOP_NS = 'agent-loop' + +/** + * The agent-loop fields this card edits. The Host section carries only this + * field — the composed `agents` array is deliberately not part of it. + */ +export interface AgentLoopSettings { + /** Upper bound on parallel-safe tool calls in flight per step. */ + maxParallelToolCalls?: number +} + +/** What the agent-loop card renders. */ +export interface AgentLoopCardState extends CardShell { + /** Parallel tool-call cap. */ + maxParallelToolCalls: CardField +} + +/** The registration-side face the agent-loop card's slot entry injects. */ +export interface AgentLoopCardFace { + hooks: { + /** Card snapshot bound by the renderer as useAgentLoopCard. */ + agentLoopCard: SnapshotStore + } + /** Write the parallel tool-call cap. */ + setMaxParallelToolCalls: (next: number) => void + /** Clear the cap so it re-inherits the composition layer. */ + resetMaxParallelToolCalls: () => void +} + +/** Bridges the `agent-loop` scope onto the card's state and writes. */ +export class AgentLoopCardController extends CardController { + /** @param scope - the bound settings scope for the `agent-loop` namespace. */ + constructor(scope: SettingsScope) { + super(scope, snapshot => ({ + ...shellOf(snapshot), + maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', 0), + })) + } + + /** + * Build the face the card's slot registration injects. + * @returns the card's snapshot and its write actions. + */ + inject(): AgentLoopCardFace { + return { + hooks: { agentLoopCard: this.store }, + setMaxParallelToolCalls: (next: number) => { void this.scope.set('maxParallelToolCalls', next) }, + resetMaxParallelToolCalls: () => { void this.scope.unset('maxParallelToolCalls') }, + } + } +} diff --git a/packages/client/ui-plugin-config/src/client/bash-store.ts b/packages/client/ui-plugin-config/src/client/bash-store.ts new file mode 100644 index 0000000000..91114669c1 --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/bash-store.ts @@ -0,0 +1,71 @@ +/** The shell card's state and writes over the `bash` settings namespace. */ + +import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' + +/** + * Namespace of the shell capability. Spelled here rather than imported: a + * client package must not depend on a Host package, and the executor families + * that own it spell the same value. + */ +export const BASH_NS = 'bash' + +/** The shell fields this card edits — a subset of the served schema by design. */ +export interface BashSettings { + /** Foreground command timeout in milliseconds. */ + timeoutMs?: number + /** Per-stream in-memory output cap in bytes. */ + maxOutputBytes?: number +} + +/** What the shell card renders. */ +export interface BashCardState extends CardShell { + /** Command timeout in milliseconds. */ + timeoutMs: CardField + /** Per-stream output cap in bytes. */ + maxOutputBytes: CardField +} + +/** The registration-side face the shell card's slot entry injects. */ +export interface BashCardFace { + hooks: { + /** Card snapshot bound by the renderer as useBashCard. */ + bashCard: SnapshotStore + } + /** Write the foreground command timeout. */ + setTimeoutMs: (next: number) => void + /** Clear the timeout so it re-inherits the composition layer. */ + resetTimeoutMs: () => void + /** Write the per-stream output cap. */ + setMaxOutputBytes: (next: number) => void + /** Clear the output cap so it re-inherits the composition layer. */ + resetMaxOutputBytes: () => void +} + +/** Bridges the `bash` scope onto the shell card's state and writes. */ +export class BashCardController extends CardController { + /** @param scope - the bound settings scope for the `bash` namespace. */ + constructor(scope: SettingsScope) { + super(scope, snapshot => ({ + ...shellOf(snapshot), + // The fallbacks only show before the Host serves a section; every served + // section is already schema-defaulted by the owning executor. + timeoutMs: fieldOf(snapshot, 'timeoutMs', 0), + maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', 0), + })) + } + + /** + * Build the face the card's slot registration injects. + * @returns the card's snapshot and its write actions. + */ + inject(): BashCardFace { + return { + hooks: { bashCard: this.store }, + setTimeoutMs: (next: number) => { void this.scope.set('timeoutMs', next) }, + resetTimeoutMs: () => { void this.scope.unset('timeoutMs') }, + setMaxOutputBytes: (next: number) => { void this.scope.set('maxOutputBytes', next) }, + resetMaxOutputBytes: () => { void this.scope.unset('maxOutputBytes') }, + } + } +} diff --git a/packages/client/ui-plugin-config/src/client/card-store.ts b/packages/client/ui-plugin-config/src/client/card-store.ts new file mode 100644 index 0000000000..ea18e79a9d --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/card-store.ts @@ -0,0 +1,84 @@ +/** + * Shared projection from one settings scope onto a card's fields. + * + * A card shows the effective value of each field and whether the user set it. + * Both come from the scope snapshot: `value` is what the plugin resolves, and + * the presence of a key in the raw `user` layer is what makes it overridden — + * an override equal to the composition default is still an override, and + * comparing values could not tell them apart. + */ + +import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' + +/** One field as a card renders it. */ +export interface CardField { + /** Effective value: the user layer over the composition layer over the schema default. */ + value: V + /** Whether the raw user layer carries this field. */ + overridden: boolean +} + +/** State every plugin card shares. */ +export interface CardShell { + /** False while the namespace is not served to this client; the card renders nothing. */ + available: boolean + /** Whether the Host document accepts writes. */ + writable: boolean +} + +/** + * Read one field out of a scope snapshot. + * @param snapshot - the scope snapshot to project. + * @param field - the section field to read. + * @param fallback - value shown before the Host serves a section. + * @returns the field as a card renders it. + */ +export function fieldOf( + snapshot: SettingsScopeSnapshot, + field: string, + fallback: V, +): CardField { + const section = snapshot.value as Record | undefined + const user = snapshot.user as Record | undefined + const value = section?.[field] + return { + value: value === undefined ? fallback : value as V, + overridden: user !== undefined && Object.hasOwn(user, field), + } +} + +/** + * Project the shell every card shares. + * @param snapshot - the scope snapshot to project. + * @returns availability and writability. + */ +export function shellOf(snapshot: SettingsScopeSnapshot): CardShell { + return { available: snapshot.status === 'ready', writable: snapshot.writable } +} + +/** + * Keep a snapshot store synchronized with one settings scope. + * + * The store exists because slot components read through a snapshot selector, + * while the scope publishes its own snapshot; this bridges the two and gives + * each card a state shaped for rendering rather than for the wire. + */ +export class CardController { + /** Snapshot the card's component reads through its bound selector. */ + readonly store: SnapshotStore + + /** + * @param scope - the bound settings scope for this card's namespace. + * @param project - build the card state from a scope snapshot. + */ + constructor( + protected readonly scope: SettingsScope, + private readonly project: (snapshot: SettingsScopeSnapshot) => S, + ) { + this.store = createSnapshotStore(project(scope.getSnapshot())) + scope.subscribe(() => { + this.store.set(this.project(this.scope.getSnapshot())) + }) + } +} diff --git a/packages/client/ui-plugin-config/src/client/fields.module.css b/packages/client/ui-plugin-config/src/client/fields.module.css new file mode 100644 index 0000000000..d48e1e37ee --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/fields.module.css @@ -0,0 +1,90 @@ +/* Plugin configuration fields: label, control, override badge, and hint. */ + +.field { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 0; +} + +.head { + display: flex; + align-items: center; + gap: 8px; +} + +.label { + flex: 1; + min-width: 0; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.badges { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.badge { + padding: 0 8px; + border-radius: 10px; + background: var(--dsw-alias-bg-module-platform); + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); +} + +.badgeMuted { + padding: 0 8px; + border-radius: 10px; + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +.reset { + border: none; + background: none; + padding: 0; + font: inherit; + font-size: 12px; + line-height: 20px; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.reset:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); +} + +.reset:disabled { + cursor: default; +} + +.input { + height: 36px; + padding: 0 12px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + background: var(--dsw-alias-bg-module-platform); + font: inherit; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.input:disabled { + color: var(--dsw-alias-label-tertiary); + cursor: default; +} + +.hint { + margin: 0; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-plugin-config/src/client/fields.tsx b/packages/client/ui-plugin-config/src/client/fields.tsx new file mode 100644 index 0000000000..407fbb264a --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/fields.tsx @@ -0,0 +1,193 @@ +/** + * Hand-written controls for the plugin configuration forms. Each renders one + * field's label, its current effective value, whether the user overrode it, + * and — when overridden — the reset that clears it back to the composition + * layer. Commits happen on blur and on Enter rather than per keystroke: a + * write per keystroke would burn namespace revisions and race its own reads. + */ + +import { useState, type KeyboardEvent } from 'react' +import css from './fields.module.css' + +/** What every field control needs regardless of its value type. */ +export interface FieldProps { + /** Stable id associating the label with its control. */ + id: string + /** Visible label. */ + label: string + /** One-line explanation rendered under the control. */ + hint: string + /** True when the raw user layer carries this field. */ + overridden: boolean + /** Copy for the overridden badge. */ + overriddenLabel: string + /** Copy for the reset control. */ + resetLabel: string + /** Disables every control (read-only document, or an unavailable namespace). */ + disabled: boolean + /** Clear the field so it re-inherits the composition layer. */ + onReset: () => void +} + +/** Label, badge, and reset chrome shared by every control. */ +function FieldFrame(props: FieldProps & { children: React.ReactNode }) { + return ( +
    +
    + + {props.overridden + ? ( + + {props.overriddenLabel} + + + ) + : null} +
    + {props.children} +

    {props.hint}

    +
    + ) +} + +/** + * Keep a draft seeded from the authoritative value, re-seeding whenever that + * value changes underneath (a Host acceptance, or a reset). + * @param value - the current authoritative text. + * @returns the draft and its setter. + */ +function useDraft(value: string): [string, (next: string) => void] { + const [draft, setDraft] = useState(value) + const [seed, setSeed] = useState(value) + if (seed !== value) { + setSeed(value) + setDraft(value) + } + return [draft, setDraft] +} + +/** A whole-number field committed on blur or Enter. */ +export function NumberField(props: FieldProps & { + /** Current effective value. */ + value: number + /** Commit a parsed value; a draft that is not a finite number is discarded. */ + onCommit: (next: number) => void +}) { + const [draft, setDraft] = useDraft(String(props.value)) + const commit = () => { + const parsed = Number(draft) + if (draft.trim() === '' || !Number.isFinite(parsed)) { + setDraft(String(props.value)) + return + } + if (parsed === props.value) return + props.onCommit(parsed) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + return ( + + { setDraft(event.target.value) }} + onBlur={commit} + onKeyDown={onKeyDown} + /> + + ) +} + +/** A free-text field committed on blur or Enter; an empty draft clears the field. */ +export function TextField(props: FieldProps & { + /** Current effective value; the empty string when the field is unset. */ + value: string + /** Placeholder shown while the draft is empty. */ + placeholder?: string + /** Commit the trimmed draft. */ + onCommit: (next: string) => void +}) { + const [draft, setDraft] = useDraft(props.value) + const commit = () => { + const next = draft.trim() + if (next === props.value) return + props.onCommit(next) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + return ( + + { setDraft(event.target.value) }} + onBlur={commit} + onKeyDown={onKeyDown} + /> + + ) +} + +/** + * A write-only credential field. The value never rides a response, so the + * control reports only whether one is configured, and an empty draft commits + * nothing — leaving the field blank keeps the stored key rather than clearing it. + */ +export function SecretField(props: Omit & { + /** Whether the Host reports a configured credential for this reference. */ + configured: boolean + /** Copy describing the configured state. */ + stateLabel: string + /** Commit a non-empty draft. */ + onCommit: (next: string) => void +}) { + const [draft, setDraft] = useState('') + const commit = () => { + const next = draft.trim() + if (next === '') return + setDraft('') + props.onCommit(next) + } + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Enter') event.currentTarget.blur() + } + return ( +
    +
    + + + {props.stateLabel} + +
    + { setDraft(event.target.value) }} + onBlur={commit} + onKeyDown={onKeyDown} + /> +

    {props.hint}

    +
    + ) +} diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts new file mode 100644 index 0000000000..098485ebee --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -0,0 +1,91 @@ +/** + * Plugin configuration surface, browser half — one settings section holding + * an expandable card per Host plugin whose configuration a user owns. + * + * The section owns no knowledge of any namespace: it declares the + * `settings.plugin.item` slot and renders whatever cards were registered into + * it, so a plugin that ships a browser half contributes its own card and its + * own controls. The three cards this package registers are the host-plane + * sections the deployment already exposes; each binds its namespace through + * the client settings scope, which keeps them unaware of one another. + */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' +// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry). +import type {} from '@deepseek-ai/dsh-client-ui-settings/client' +import { bindSettingsScope, type ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentLoopCard } from './AgentLoopCard.tsx' +import { BashCard } from './BashCard.tsx' +import { PluginConfigSection } from './PluginConfigSection.tsx' +import { WebSearchCard } from './WebSearchCard.tsx' +import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-store.ts' +import { BASH_NS, BashCardController } from './bash-store.ts' +import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-store.ts' +import { en, zh } from './locales.ts' + +export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' +export type { PluginCardProps } from './PluginCard.tsx' +export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' +export { NumberField, SecretField, TextField, type FieldProps } from './fields.tsx' +export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts' +export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts' +export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts' + +/** Dictionary namespace owned by this plugin. */ +const NS = 'settings.pluginConfig' + +/** Required services (cordis fiber inject). */ +export const inject = ['slots', 'locale', 'connection'] + +/** + * Mount the plugin configuration section and the cards this package ships. + * @param ctx - the browser plugin context. + */ +export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle + const t = ctx.locale.bind(NS) + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plugin-config: section dictionaries') + + const bash = new BashCardController(bindSettingsScope(ctx, { namespace: BASH_NS })) + const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS })) + const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api) + + // The section renders the empty line rather than an empty list when no card + // is registered; the ledger is read at render time so a card arriving later + // (or leaving with its plugin) is reflected without the section subscribing. + ctx.slots.inject('settings.section', () => ctx.slots.register({ + name: 'settings.section', + id: 'plugins', + order: 30, + label: () => t('nav'), + locale: NS, + inject: () => ({ cardCount: ctx.slots.entries('settings.plugin.item').length }), + children: { 'settings.plugin.item': { kind: 'list', scope: 'root' } }, + }, PluginConfigSection)) + + ctx.slots.inject('settings.plugin.item', function* () { + yield ctx.slots.register({ + name: 'settings.plugin.item', + id: 'bash', + order: 0, + locale: NS, + inject: () => bash.inject(), + }, BashCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + id: 'agent-loop', + order: 10, + locale: NS, + inject: () => agentLoop.inject(), + }, AgentLoopCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + id: 'web-search', + order: 20, + locale: NS, + inject: () => webSearch.inject(), + }, WebSearchCard) + }) +} diff --git a/packages/client/ui-plugin-config/src/client/locales.ts b/packages/client/ui-plugin-config/src/client/locales.ts new file mode 100644 index 0000000000..6a3804236c --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/locales.ts @@ -0,0 +1,80 @@ +/** Locale bundles for the plugin configuration section and its plugin cards. */ + +/** Locale keys these surfaces render. */ +export type PluginConfigKey = + | 'nav' | 'title' | 'intro' | 'empty' + | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse' + | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' + | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint' + | 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint' + | 'webSearchTitle' | 'webSearchDescription' + | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' + | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' + +/** English copy. */ +export const en: Record = { + nav: 'Plugins', + title: 'Plugin configuration', + intro: + 'Settings owned by the plugins this deployment composes. A value you set here layers over the ' + + 'composition default and applies to the next use.', + empty: 'This deployment exposes no plugin settings.', + overridden: 'Overridden', + reset: 'Reset to default', + readOnly: 'This deployment stores settings read-only.', + expand: 'Show settings', + collapse: 'Hide settings', + bashTitle: 'Shell', + bashDescription: 'Limits every command the agent runs.', + bashTimeoutMs: 'Command timeout (ms)', + bashTimeoutMsHint: 'How long one command may run before it is terminated.', + bashMaxOutputBytes: 'Output cap per stream (bytes)', + bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.', + agentLoopTitle: 'Agent loop', + agentLoopDescription: 'How the agent dispatches tool calls.', + agentLoopMaxParallel: 'Parallel tool calls', + agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.', + webSearchTitle: 'Web search', + webSearchDescription: 'The DeepSeek search provider.', + webSearchApiKey: 'API key', + webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.', + webSearchApiKeySet: 'A key is configured.', + webSearchApiKeyUnset: 'No key is configured; search is unavailable until one is.', + webSearchBaseUrl: 'Endpoint', + webSearchBaseUrlHint: 'Leave blank to use the provider default.', + webSearchMaxUses: 'Max searches per request', + webSearchMaxUsesHint: 'How many times one request may search before it must answer.', +} + +/** Simplified Chinese copy. */ +export const zh: Record = { + nav: '插件', + title: '插件配置', + intro: '本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。', + empty: '本部署没有开放任何插件设置。', + overridden: '已覆盖', + reset: '恢复默认', + readOnly: '本部署的设置为只读。', + expand: '展开设置', + collapse: '收起设置', + bashTitle: '终端', + bashDescription: '限制 agent 运行的每一条命令。', + bashTimeoutMs: '命令超时(毫秒)', + bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。', + bashMaxOutputBytes: '单流输出上限(字节)', + bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。', + agentLoopTitle: 'Agent 循环', + agentLoopDescription: 'Agent 如何派发工具调用。', + agentLoopMaxParallel: '并行工具调用数', + agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。', + webSearchTitle: '网页搜索', + webSearchDescription: 'DeepSeek 搜索提供方。', + webSearchApiKey: 'API Key', + webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。', + webSearchApiKeySet: '已配置密钥。', + webSearchApiKeyUnset: '未配置密钥;配置之前搜索不可用。', + webSearchBaseUrl: '接口地址', + webSearchBaseUrlHint: '留空则使用提供方默认地址。', + webSearchMaxUses: '单次请求最多搜索次数', + webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', +} diff --git a/packages/client/ui-plugin-config/src/client/slot-contract.ts b/packages/client/ui-plugin-config/src/client/slot-contract.ts new file mode 100644 index 0000000000..02b00ea35b --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/slot-contract.ts @@ -0,0 +1,24 @@ +/** + * The `settings.plugin.item` slot type — one plugin's card inside the plugin + * configuration section. Options: `id` (card key), `order` (card position). + * A card draws its own internals; the section only stacks them and reports + * how many there are. + * + * TYPE HOME RATIONALE: unlike `settings.general.item`, whose registrants span + * packages that cannot reference its declarer, every current registrant of + * this slot ships in this package, and a plugin registering its own card + * already depends on this package for the card chrome. The type therefore + * lives with the section that declares it at runtime. + */ +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** One plugin's card inside the plugin configuration section (see module JSDoc). */ + 'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps } + } +} + +/** Owner share of a plugin card (the section supplies nothing). */ +export interface SettingsPluginItemOwnerProps { + /** Marker field: card owner props are intentionally empty. */ + children?: never +} diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts new file mode 100644 index 0000000000..c0242eed8b --- /dev/null +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -0,0 +1,144 @@ +/** + * The web-search card's state and writes over the `web-search-deepseek` + * settings namespace. + * + * The key is the one field that does not live in the section: its literal + * never rides a response, so the card learns only whether one is configured + * and writes it through the credentials domain, addressed by the reference + * the section names. + */ + +import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' +import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' + +/** + * Namespace of the DeepSeek search provider. Spelled here rather than + * imported: a client package must not depend on a Host package. + */ +export const WEB_SEARCH_NS = 'web-search-deepseek' + +/** Credential reference the provider resolves when the section names none. */ +const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' + +/** The search-provider fields this card edits. */ +export interface WebSearchSettings { + /** Credential reference naming the environment key. */ + apiKeyEnv?: string + /** Provider endpoint; blank inherits the provider default. */ + baseURL?: string + /** Maximum searches served within one request. */ + maxUses?: number +} + +/** What the web-search card renders. */ +export interface WebSearchCardState extends CardShell { + /** Provider endpoint. */ + baseURL: CardField + /** Searches allowed per request. */ + maxUses: CardField + /** Credential reference the key is written under. */ + apiKeyRef: string + /** Whether the Host reports a credential configured for that reference. */ + apiKeyConfigured: boolean +} + +/** The registration-side face the web-search card's slot entry injects. */ +export interface WebSearchCardFace { + hooks: { + /** Card snapshot bound by the renderer as useWebSearchCard. */ + webSearchCard: SnapshotStore + } + /** Write the provider endpoint; the empty string clears it. */ + setBaseUrl: (next: string) => void + /** Clear the endpoint so it re-inherits the composition layer. */ + resetBaseUrl: () => void + /** Write the per-request search budget. */ + setMaxUses: (next: number) => void + /** Clear the budget so it re-inherits the composition layer. */ + resetMaxUses: () => void + /** Write the credential the section references. */ + setApiKey: (next: string) => void +} + +/** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */ +export class WebSearchCardController extends CardController { + private readonly credential: { configured: boolean } + + /** + * @param scope - the bound settings scope for the `web-search-deepseek` namespace. + * @param api - wire face used for the credential the section references. + */ + constructor(scope: SettingsScope, private readonly api: Pick) { + // Held in its own object because the projection runs during `super()`, + // before `this` exists, and must still see the latest credential state: + // that state comes from its own domain, so a settings change must not + // silently reset it to unknown. + const credential = { configured: false } + super(scope, snapshot => ({ + ...shellOf(snapshot), + baseURL: fieldOf(snapshot, 'baseURL', ''), + maxUses: fieldOf(snapshot, 'maxUses', 0), + apiKeyRef: refOf(snapshot), + apiKeyConfigured: credential.configured, + })) + this.credential = credential + scope.subscribe(() => { void this.readCredential() }) + void this.readCredential() + } + + /** Ask the credentials domain whether the referenced key exists. */ + private async readCredential(): Promise { + const ref = refOf(this.scope.getSnapshot()) + let response: Awaited> + try { + response = await this.api.credentials.describe({ refs: [ref] }) + } catch (_credentialReadFailure) { + // The card stays usable without this: the key control simply reports the + // last state it knew, and a write still reaches the Host. + return + } + if (!response.result.ok) return + const next = response.result.value.credentials[ref]?.configured ?? false + if (next === this.credential.configured) return + this.credential.configured = next + this.store.set({ ...this.store.getSnapshot(), apiKeyConfigured: next }) + } + + /** + * Build the face the card's slot registration injects. + * @returns the card's snapshot and its write actions. + */ + inject(): WebSearchCardFace { + return { + hooks: { webSearchCard: this.store }, + setBaseUrl: (next: string) => { void this.scope.set('baseURL', next) }, + resetBaseUrl: () => { void this.scope.unset('baseURL') }, + setMaxUses: (next: number) => { void this.scope.set('maxUses', next) }, + resetMaxUses: () => { void this.scope.unset('maxUses') }, + setApiKey: (next: string) => { void this.writeKey(next) }, + } + } + + private async writeKey(value: string): Promise { + const ref = refOf(this.scope.getSnapshot()) + try { + await this.api.credentials.set({ ref, value }) + } catch (_credentialWriteFailure) { + // Refusals surface through the re-read below: the Host is the only + // authority on whether the key now exists. + } + await this.readCredential() + } +} + +/** + * The credential reference the section names, or the provider's default. + * @param snapshot - the current scope snapshot. + * @returns the reference to address. + */ +function refOf(snapshot: SettingsScopeSnapshot): string { + const section = snapshot.value + const declared = section?.apiKeyEnv + return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF +} diff --git a/packages/client/ui-plugin-config/src/css-modules.d.ts b/packages/client/ui-plugin-config/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-plugin-config/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-plugin-config/src/index.ts b/packages/client/ui-plugin-config/src/index.ts new file mode 100644 index 0000000000..96ac4efa2e --- /dev/null +++ b/packages/client/ui-plugin-config/src/index.ts @@ -0,0 +1,11 @@ +/** + * Plugin configuration surface, node half. The empty apply exists so the + * plugin appears in the host cordis.yml / Loader; the browser half ships the + * settings section through exports["./client"], discovered from the + * package.json dshClient declaration. Every section this page edits is owned + * by the Host plugin that registered it, so this package registers no + * namespace of its own. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-plugin-config/src/invariant.ts b/packages/client/ui-plugin-config/src/invariant.ts new file mode 100644 index 0000000000..33989fa16a --- /dev/null +++ b/packages/client/ui-plugin-config/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-plugin-config`. + * @module @deepseek-ai/dsh-client-ui-plugin-config/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plugin-config' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-plugin-config-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this is a browser-side settings surface whose node half owns no event + * stream or mutable runtime data; the layering, write refusals, and exposure boundary are Host + * contracts covered by the owning plugins and the api-proxy. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-plugin-config/tests/apply.spec.ts b/packages/client/ui-plugin-config/tests/apply.spec.ts new file mode 100644 index 0000000000..86bb21606a --- /dev/null +++ b/packages/client/ui-plugin-config/tests/apply.spec.ts @@ -0,0 +1,100 @@ +/** What the browser half registers, and that it all leaves with the fiber. */ + +import { Context } from 'cordis' +import { describe, expect, it, vi } from 'vitest' +import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' +import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-plugin-config/client' + +// 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') + +async function bench() { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + const locale = new LocaleService(ctx) + ctx.provide('locale', locale) + ctx.provide('connection', { + isLoopback: true, + api: { + settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) }, + credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) }, + }, + } as never) + return { ctx, slots: ctx.get('slots') as SlotsService } +} + +function declareRoot(slots: SlotsService): () => void { + return slots.register({ + name: 'root', + children: { 'settings.section': { kind: 'list', scope: 'root' } }, + } as never, () => null) +} + +describe('ui-plugin-config apply', () => { + it('declares the services it uses', () => { + expect(inject).toEqual(['slots', 'locale', 'connection']) + }) + + it('registers the section and declares the per-plugin card slot', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + const section = slots.entries('settings.section')[0]! + expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) + // The nav label is a locale-following thunk; owners resolve it at read time. + expect(resolveSlotLabel(section.options.label)).toBe('插件') + expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) + }) + + it('registers one card per host-plane section it ships, in a stable order', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + + await ctx.plugin({ inject: [...inject], apply }).await() + + expect(slots.entries('settings.plugin.item').map(entry => entry.options.id)) + .toEqual(['bash', 'agent-loop', 'web-search']) + }) + + it('injects a live card count and one business face per card', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + + const section = slots.entries('settings.section')[0]! + expect((section as { inject?: () => unknown }).inject?.()).toEqual({ cardCount: 3 }) + for (const entry of slots.entries('settings.plugin.item')) { + const face = (entry as { inject?: () => unknown }).inject?.() as { hooks: Record } + // Each card injects exactly one snapshot store plus its own actions. + expect(Object.keys(face.hooks)).toHaveLength(1) + } + }) + + it('registers into a declaration that arrives after apply', async () => { + const { ctx, slots } = await bench() + await ctx.plugin({ inject: [...inject], apply }).await() + + declareRoot(slots) + + await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) + }) + + it('collapses every contribution on teardown', async () => { + const { ctx, slots } = await bench() + declareRoot(slots) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(slots.entries('settings.plugin.item')).toHaveLength(3) + + await fiber.dispose() + + expect(slots.entries('settings.section')).toHaveLength(0) + expect(slots.spec('settings.plugin.item')).toBeUndefined() + }) +}) diff --git a/packages/client/ui-plugin-config/tests/fields.spec.tsx b/packages/client/ui-plugin-config/tests/fields.spec.tsx new file mode 100644 index 0000000000..d444ae14a8 --- /dev/null +++ b/packages/client/ui-plugin-config/tests/fields.spec.tsx @@ -0,0 +1,330 @@ +// @vitest-environment jsdom +/** + * Field-control behavior: when a draft becomes a write, what a bad draft does + * instead, and how an overridden field offers its reset. + */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { NumberField, SecretField, TextField } from '../src/client/fields.tsx' + +afterEach(cleanup) + +const frame = { + id: 'field', + label: 'Command timeout', + hint: 'How long one command may run.', + overriddenLabel: 'Overridden', + resetLabel: 'Reset to default', + disabled: false, +} + +describe('NumberField', () => { + it('commits a changed draft on blur', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '9000' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith(9_000) + }) + + it('commits on Enter through the blur the key triggers', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '1234' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith(1_234) + }) + + it('restores the last good value instead of committing a draft that is not a number', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: 'soon' } }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + expect(input).toHaveProperty('value', '60000') + }) + + it('writes nothing when the draft settles on the value already shown', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '60000' } }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + }) + + it('offers the reset only while the field is overridden', () => { + const onReset = vi.fn() + const { rerender } = render( + , + ) + expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull() + + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Reset to default' })) + + expect(screen.getByText('Overridden')).toBeTruthy() + expect(onReset).toHaveBeenCalledOnce() + }) + + it('re-seeds the draft when the authoritative value changes underneath', () => { + const { rerender } = render( + , + ) + expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000') + + rerender( + , + ) + + expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000') + }) + + it('ignores a keystroke that is not Enter', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + fireEvent.change(input, { target: { value: '9000' } }) + fireEvent.keyDown(input, { key: 'Escape' }) + + expect(onCommit).not.toHaveBeenCalled() + }) + + it('suppresses every interaction while disabled', () => { + const onCommit = vi.fn() + const onReset = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + + expect(input).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true) + expect(onCommit).not.toHaveBeenCalled() + expect(onReset).not.toHaveBeenCalled() + }) +}) + +describe('TextField', () => { + it('commits the trimmed draft', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + + fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('https://search.test/v1') + }) + + it('commits an emptied draft, which clears the field', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + + fireEvent.change(input, { target: { value: '' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('') + }) + + it('renders its placeholder and commits on Enter', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com') + + fireEvent.change(input, { target: { value: 'https://other.test' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('https://other.test') + }) + + it('ignores a keystroke that is not Enter and writes nothing unchanged', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Endpoint') + + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + }) +}) + +describe('SecretField', () => { + it('commits a non-empty draft and clears the control after writing', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('API key') + + fireEvent.change(input, { target: { value: ' ds-secret ' } }) + fireEvent.blur(input) + + expect(onCommit).toHaveBeenCalledWith('ds-secret') + expect(input).toHaveProperty('value', '') + }) + + it('keeps the stored key when the draft is left blank', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('API key') + + fireEvent.change(input, { target: { value: ' ' } }) + fireEvent.blur(input) + + expect(onCommit).not.toHaveBeenCalled() + expect(screen.getByText('A key is configured.')).toBeTruthy() + }) + + it('ignores a keystroke that is not Enter', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('API key') + + fireEvent.change(input, { target: { value: 'ds-secret' } }) + fireEvent.keyDown(input, { key: 'Tab' }) + + expect(onCommit).not.toHaveBeenCalled() + }) + + it('never renders the value it writes', () => { + render( + , + ) + + expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password') + }) + + it('commits on Enter and stays disabled when the document is read-only', () => { + const onCommit = vi.fn() + const { rerender } = render( + , + ) + const input = screen.getByLabelText('API key') + fireEvent.change(input, { target: { value: 'ds-secret' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + fireEvent.blur(input) + expect(onCommit).toHaveBeenCalledWith('ds-secret') + + rerender( + , + ) + + expect(screen.getByLabelText('API key')).toHaveProperty('disabled', true) + }) +}) diff --git a/packages/client/ui-plugin-config/tests/invariant.spec.ts b/packages/client/ui-plugin-config/tests/invariant.spec.ts new file mode 100644 index 0000000000..04ad79c04c --- /dev/null +++ b/packages/client/ui-plugin-config/tests/invariant.spec.ts @@ -0,0 +1,25 @@ +/** The package's node half: an empty host body and an explained empty invariant companion. */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as PluginConfigInvariant from '@deepseek-ai/dsh-client-ui-plugin-config/invariant' + +describe('invariant companion', () => { + it('reserves package ownership with an empty installer', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + + await expect(ctx.plugin(PluginConfigInvariant).await()).resolves.toBeDefined() + }) + + it('has an empty node half', async () => { + const { apply } = await import('@deepseek-ai/dsh-client-ui-plugin-config') + + // The host body exists only so the plugin appears in the host cordis.yml; + // every surface this package ships lives in the browser half. + apply() + + expect(typeof apply).toBe('function') + }) +}) diff --git a/packages/client/ui-plugin-config/tests/section.spec.tsx b/packages/client/ui-plugin-config/tests/section.spec.tsx new file mode 100644 index 0000000000..891fa071f8 --- /dev/null +++ b/packages/client/ui-plugin-config/tests/section.spec.tsx @@ -0,0 +1,223 @@ +// @vitest-environment jsdom +/** + * What the section and its cards show: the empty line when no plugin + * contributed one, a card that renders nothing while its namespace is + * unavailable, and the read-only notice a locked document produces. + */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { AgentLoopCard } from '../src/client/AgentLoopCard.tsx' +import type { AgentLoopCardProps } from '../src/client/AgentLoopCard.tsx' +import { BashCard } from '../src/client/BashCard.tsx' +import type { BashCardProps } from '../src/client/BashCard.tsx' +import { PluginConfigSection } from '../src/client/PluginConfigSection.tsx' +import type { PluginConfigSectionProps } from '../src/client/PluginConfigSection.tsx' +import { WebSearchCard } from '../src/client/WebSearchCard.tsx' +import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' +import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' +import type { BashCardState } from '../src/client/bash-store.ts' +import type { WebSearchCardState } from '../src/client/web-search-store.ts' +import { en } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = (key: keyof typeof en) => en[key] + +function renderSection(cardCount: number, cards = 'cards') { + const props = { + t, + cardCount, + renderSlot: () =>
  • {cards}
  • , + } as unknown as PluginConfigSectionProps + render() +} + +function renderBash(state: Partial = {}) { + const store = createSnapshotStore({ + available: true, + writable: true, + timeoutMs: { value: 60_000, overridden: false }, + maxOutputBytes: { value: 64_000, overridden: false }, + ...state, + }) + const actions = { + setTimeoutMs: vi.fn(), + resetTimeoutMs: vi.fn(), + setMaxOutputBytes: vi.fn(), + resetMaxOutputBytes: vi.fn(), + } + const props = { + ...actions, + t, + useBashCard: bindSnapshotSelector(store), + } as unknown as BashCardProps + render() + return actions +} + +describe('PluginConfigSection', () => { + it('says so when no plugin contributed a card', () => { + renderSection(0) + + expect(screen.getByText(en.empty)).toBeTruthy() + expect(screen.queryByText('cards')).toBeNull() + }) + + it('renders the card list once a plugin contributed one', () => { + renderSection(1) + + expect(screen.getByText('cards')).toBeTruthy() + expect(screen.queryByText(en.empty)).toBeNull() + }) + + it('leads with its own heading and intro', () => { + renderSection(1) + + expect(screen.getByRole('heading', { name: en.title })).toBeTruthy() + expect(screen.getByText(en.intro)).toBeTruthy() + }) +}) + +describe('BashCard', () => { + it('renders nothing while its namespace is unavailable', () => { + const { container } = render(
    ) + renderBash({ available: false }) + + expect(container.textContent).toBe('') + expect(screen.queryByText(en.bashTitle)).toBeNull() + }) + + it('shows the plugin and reveals its fields only once expanded', () => { + renderBash() + expect(screen.getByText(en.bashTitle)).toBeTruthy() + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy() + }) + + it('commits an edited field through its action', () => { + const actions = renderBash() + fireEvent.click(screen.getByText(en.bashTitle)) + + const input = screen.getByLabelText(en.bashTimeoutMs) + fireEvent.change(input, { target: { value: '9000' } }) + fireEvent.blur(input) + + expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000) + }) + + it('offers the reset for an overridden field only', () => { + const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } }) + fireEvent.click(screen.getByText(en.bashTitle)) + + // One badge and one reset: the output cap is still inherited. + expect(screen.getAllByText(en.overridden)).toHaveLength(1) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.resetTimeoutMs).toHaveBeenCalledOnce() + }) + + it('says the document is read-only and disables its controls', () => { + renderBash({ writable: false }) + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) + expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true) + }) +}) + +describe('AgentLoopCard', () => { + it('edits the only field it owns', () => { + const store = createSnapshotStore({ + available: true, + writable: true, + maxParallelToolCalls: { value: 10, overridden: false }, + }) + const setMaxParallelToolCalls = vi.fn() + const props = { + t, + useAgentLoopCard: bindSnapshotSelector(store), + setMaxParallelToolCalls, + resetMaxParallelToolCalls: vi.fn(), + } as unknown as AgentLoopCardProps + render() + + fireEvent.click(screen.getByText(en.agentLoopTitle)) + const input = screen.getByLabelText(en.agentLoopMaxParallel) + fireEvent.change(input, { target: { value: '2' } }) + fireEvent.blur(input) + + expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2) + }) +}) + +describe('WebSearchCard', () => { + function renderWebSearch(state: Partial = {}) { + const store = createSnapshotStore({ + available: true, + writable: true, + baseURL: { value: '', overridden: false }, + maxUses: { value: 5, overridden: false }, + apiKeyRef: 'DEEPSEEK_API_KEY', + apiKeyConfigured: false, + ...state, + }) + const actions = { + setBaseUrl: vi.fn(), + resetBaseUrl: vi.fn(), + setMaxUses: vi.fn(), + resetMaxUses: vi.fn(), + setApiKey: vi.fn(), + } + const props = { + ...actions, + t, + useWebSearchCard: bindSnapshotSelector(store), + } as unknown as WebSearchCardProps + render() + return actions + } + + it('reports whether a key is configured without ever showing one', () => { + renderWebSearch({ apiKeyConfigured: true }) + fireEvent.click(screen.getByText(en.webSearchTitle)) + + expect(screen.getByText(en.webSearchApiKeySet)).toBeTruthy() + expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('type', 'password') + }) + + it('keeps the key control usable while the settings document is read-only', () => { + const actions = renderWebSearch({ writable: false }) + fireEvent.click(screen.getByText(en.webSearchTitle)) + + const key = screen.getByLabelText(en.webSearchApiKey) + expect(key).toHaveProperty('disabled', false) + expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true) + + fireEvent.change(key, { target: { value: 'ds-secret' } }) + fireEvent.blur(key) + + expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret') + }) + + it('commits the endpoint and the search budget', () => { + const actions = renderWebSearch() + fireEvent.click(screen.getByText(en.webSearchTitle)) + + const endpoint = screen.getByLabelText(en.webSearchBaseUrl) + fireEvent.change(endpoint, { target: { value: 'https://search.test/v1' } }) + fireEvent.blur(endpoint) + const budget = screen.getByLabelText(en.webSearchMaxUses) + fireEvent.change(budget, { target: { value: '3' } }) + fireEvent.blur(budget) + + expect(actions.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1') + expect(actions.setMaxUses).toHaveBeenCalledWith(3) + }) +}) diff --git a/packages/client/ui-plugin-config/tests/stores.spec.ts b/packages/client/ui-plugin-config/tests/stores.spec.ts new file mode 100644 index 0000000000..4797d3c262 --- /dev/null +++ b/packages/client/ui-plugin-config/tests/stores.spec.ts @@ -0,0 +1,193 @@ +/** + * Card controllers: how a scope snapshot becomes card state, and which wire + * call each action reaches. + */ + +import { describe, expect, it, vi } from 'vitest' +import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts' +import { BashCardController, type BashSettings } from '../src/client/bash-store.ts' +import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts' + +function credentialsApi(configured: boolean) { + const describe = vi.fn(() => Promise.resolve({ + rpcId: 'c-1' as never, + result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured, writable: true } } } }, + })) + const set = vi.fn(() => Promise.resolve({ rpcId: 'c-2' as never, result: { ok: true as const, value: {} } })) + return { api: { credentials: { describe, set } } as never, describe, set } +} + +describe('BashCardController', () => { + it('publishes the effective value and marks only user-layer fields overridden', () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + + host.publish({ + status: 'ready', + writable: true, + revision: 3, + value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, + base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, + user: { timeoutMs: 5_000 }, + }) + + expect(controller.store.getSnapshot()).toMatchObject({ + available: true, + writable: true, + timeoutMs: { value: 5_000, overridden: true }, + maxOutputBytes: { value: 64_000, overridden: false }, + }) + }) + + it('treats an override equal to the composition default as an override', () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 60_000 }, + base: { timeoutMs: 60_000 }, + user: { timeoutMs: 60_000 }, + }) + + expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true }) + }) + + it('routes each action to its field write', async () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 } }) + const actions = controller.inject() + + actions.setTimeoutMs(9_000) + actions.resetTimeoutMs() + actions.setMaxOutputBytes(1_024) + actions.resetMaxOutputBytes() + await Promise.resolve() + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) + expect(host.unset.mock.calls).toEqual([['timeoutMs'], ['maxOutputBytes']]) + }) + + it('stays unavailable while the namespace is not served', () => { + const host = stubSettingsScope() + const controller = new BashCardController(host.scope) + + host.publish({ status: 'unavailable' }) + + expect(controller.store.getSnapshot().available).toBe(false) + }) +}) + +describe('AgentLoopCardController', () => { + it('publishes the cap and routes its two actions', async () => { + const host = stubSettingsScope() + const controller = new AgentLoopCardController(host.scope) + host.publish({ + status: 'ready', + writable: true, + value: { maxParallelToolCalls: 2 }, + base: { maxParallelToolCalls: 10 }, + user: { maxParallelToolCalls: 2 }, + }) + expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true }) + + const actions = controller.inject() + actions.setMaxParallelToolCalls(4) + actions.resetMaxParallelToolCalls() + await Promise.resolve() + + expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) + expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls') + }) + + it('reports a read-only document so the card can disable its controls', () => { + const host = stubSettingsScope() + const controller = new AgentLoopCardController(host.scope) + + host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) + + expect(controller.store.getSnapshot().writable).toBe(false) + }) +}) + +describe('WebSearchCardController', () => { + it('reads the credential state for the reference the section names', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.api) + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } }) + await vi.waitFor(() => { + expect(controller.store.getSnapshot().apiKeyConfigured).toBe(true) + }) + + expect(controller.store.getSnapshot()).toMatchObject({ + baseURL: { value: 'https://search.test/v1', overridden: false }, + apiKeyRef: 'DEEPSEEK_API_KEY', + }) + }) + + it('writes the key through the credentials domain, never the settings section', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {} }) + + controller.inject().setApiKey('ds-secret') + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' }) + expect(host.set).not.toHaveBeenCalledWith('apiKey', expect.anything()) + }) + + it('addresses the reference the section declares rather than the default', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' } }) + + controller.inject().setApiKey('ds-secret') + await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) + + expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' }) + }) + + it('keeps the card usable when the credential read fails', async () => { + const host = stubSettingsScope() + const describe = vi.fn(() => Promise.reject(new Error('offline'))) + const controller = new WebSearchCardController( + host.scope, + { credentials: { describe, set: vi.fn() } } as never, + ) + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } }) + + expect(controller.store.getSnapshot()).toMatchObject({ + available: true, + apiKeyConfigured: false, + baseURL: { value: 'https://search.test/v1' }, + }) + }) + + it('routes the endpoint and budget actions to their field writes', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {} }) + const actions = controller.inject() + + actions.setBaseUrl('https://other.test') + actions.resetBaseUrl() + actions.setMaxUses(3) + actions.resetMaxUses() + await Promise.resolve() + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) + expect(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']]) + }) +}) diff --git a/packages/client/ui-plugin-config/tsconfig.json b/packages/client/ui-plugin-config/tsconfig.json new file mode 100644 index 0000000000..584069fb86 --- /dev/null +++ b/packages/client/ui-plugin-config/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../connection" + }, + { + "path": "../locale" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../runtime" + }, + { + "path": "../test-runtime" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-settings" + }, + { + "path": "../ui-slots" + }, + { + "path": "../web-react" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-plugin-config/tsdown.config.ts b/packages/client/ui-plugin-config/tsdown.config.ts new file mode 100644 index 0000000000..5cda1844ab --- /dev/null +++ b/packages/client/ui-plugin-config/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-plugin-config', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 142fceb77d..4eb7296d5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1508,6 +1508,9 @@ importers: '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../client/ui-plan + '@deepseek-ai/dsh-client-ui-plugin-config': + specifier: workspace:^ + version: link:../../client/ui-plugin-config '@deepseek-ai/dsh-client-ui-question': specifier: workspace:^ version: link:../../client/ui-question @@ -2287,6 +2290,45 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-plugin-config: + devDependencies: + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../connection + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-settings': + specifier: workspace:^ + version: link:../ui-settings + '@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 + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/client/ui-primitives: dependencies: '@shikijs/langs': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1b39447215..b7acca27fa 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -77,6 +77,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, 'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' }, 'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' }, + 'packages/client/ui-plugin-config': { kind: 'none', reason: 'Browser-side settings surface; registers no model surface.' }, 'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' }, 'packages/client/ui-question': { kind: 'indirect', reason: 'The package mounts dsh-tool-ask-user; that tool owns the model-visible schema and answer rendering.' }, 'packages/client/ui-trajectory': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 0523b378d9..ee1a7e4e9d 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -177,6 +177,7 @@ "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-plugin-config": ["./packages/client/ui-plugin-config/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 632f6a84a7..2deaabd877 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -71,6 +71,7 @@ { "path": "./packages/client/ui-agent-preset" }, { "path": "./packages/client/ui-permission" }, { "path": "./packages/client/ui-plan" }, + { "path": "./packages/client/ui-plugin-config" }, { "path": "./packages/client/ui-question" }, { "path": "./packages/client/ui-trajectory" }, { "path": "./packages/client/ui-theme" }, From 114af09aaec860a8a5a176713afb7776a84e3bac Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 19:42:43 +0800 Subject: [PATCH 07/19] test(web): cover the plugin configuration section end to end Three scenarios over the real wire: the cards this deployment's exposed namespaces produce, one field edited down to the settings document with the override badge that layering produces, and the reset that clears it back to the composed default. Every other settings golden gains the same nav entry and nothing else. --- apps/web/tests/plugin-config.e2e.ts | 130 ++++++++++++++++++ .../created.expected.md | 3 + .../damaged.expected.md | 3 + .../section.expected.md | 3 + .../models-settings/configured.expected.md | 3 + .../models-settings/declared.expected.md | 3 + .../models-settings/empty.expected.md | 3 + .../models.expected.md | 3 + .../plugin-config/section.expected.md | 34 +++++ .../settings-chrome/dialog.expected.md | 3 + apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 12 files changed, 190 insertions(+) create mode 100644 apps/web/tests/plugin-config.e2e.ts create mode 100644 apps/web/tests/snapshots/plugin-config/section.expected.md diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts new file mode 100644 index 0000000000..b0c95c969a --- /dev/null +++ b/apps/web/tests/plugin-config.e2e.ts @@ -0,0 +1,130 @@ +// Web e2e scenario: the Plugins settings section — the cards a deployment's +// exposed host-plane namespaces produce, one field edited through the real +// wire down to `$DSH_HOME/settings.yaml`, and the override badge and reset +// that layering produces. Zero model calls: everything is client state plus +// the settings document on a blank frame, so there is no fixture and a stray +// stream would fail loud on the open llm seam. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { join } from 'node:path' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plugin-config', import.meta.url)) +const SECTION_EXPECTED = join(SNAPSHOT_DIR, 'section.expected.md') +const MODE = webSnapshotMode() + +describe('web e2e: plugin configuration section', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + // Chinese browser: the section asserts the localized copy the client + // derives from it, as the rest of the settings surface does. + page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Open the settings dialog on the Plugins section. The scenarios share one + * page so the settings document accumulates across them, so this leaves any + * dialog a previous scenario opened closed first — its mask would otherwise + * swallow the trigger click. + */ + async function openPlugins() { + if (await page.getByRole('dialog', { name: '设置' }).count() > 0) { + await page.keyboard.press('Escape') + await expect.poll(() => page.getByRole('dialog', { name: '设置' }).count(), { timeout: 5_000 }).toBe(0) + } + await page.getByRole('button', { name: '设置', exact: true }).click() + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: '插件' }).click() + await expect + .poll(() => dialog.getByRole('button', { name: '插件' }).getAttribute('aria-current'), { timeout: 5_000 }) + .toBe('true') + return dialog + } + + /** The settings document as the Host has written it so far. */ + async function settingsDocument(): Promise { + return readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8').catch(() => '') + } + + it('shows one card per exposed host-plane namespace', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) + const dialog = await openPlugins() + + // Every card the shipped web composition exposes: the shell executor, the + // agent loop, and the DeepSeek search provider. + await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) + expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) + // Collapsed: a card's fields appear only once it is expanded. + expect(await dialog.getByLabel('命令超时(毫秒)').count()).toBe(0) + + const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('writes an edited field to the settings document and marks it overridden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) + const dialog = await openPlugins() + await dialog.getByText('终端', { exact: true }).click() + + const timeout = dialog.getByLabel('命令超时(毫秒)') + await timeout.waitFor({ timeout: 10_000 }) + // The composed default this deployment ships, before any user layer. + expect(await timeout.inputValue()).toBe('60000') + await timeout.fill('12000') + await timeout.blur() + + await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) + .toBe(true) + // Presence in the user layer is what the badge reports, and the reset is + // offered only for a field that has one. + await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) + expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('clears the field back to the composed default on reset', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-reset')) + const dialog = await openPlugins() + await dialog.getByText('终端', { exact: true }).click() + const timeout = dialog.getByLabel('命令超时(毫秒)') + await timeout.waitFor({ timeout: 10_000 }) + expect(await timeout.inputValue()).toBe('12000') + + await dialog.getByRole('button', { name: '恢复默认' }).click() + + await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) + .toBe(false) + await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000') + expect(await dialog.getByText('已覆盖').count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['section.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index e5cefe28ef..aece772fe4 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index 8269dc2993..7620e679c8 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index ac5d6f6736..dade3d7b84 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 02746c7f76..3b57d5ac6f 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index 857bfaf13e..dd3f03776f 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 03169f8e72..16cc93581b 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index e50c347966..b5f25aaaa6 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md new file mode 100644 index 0000000000..54ef09851f --- /dev/null +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -0,0 +1,34 @@ +- dialog "设置": + - navigation: + - text: 设置 + - button "通用设置": + - img + - text: 通用设置 + - button "模型": + - img + - text: 模型 + - button "Agent 预设": + - img + - text: Agent 预设 + - button "插件": + - img + - text: 插件 + - button "打开配置文件" + - button "关闭": + - img + - text: 关闭 + - heading "插件配置" [level=2] + - paragraph: 本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。 + - list: + - listitem: + - button "终端 限制 agent 运行的每一条命令。": + - img + - text: 终端 限制 agent 运行的每一条命令。 + - listitem: + - button "Agent 循环 Agent 如何派发工具调用。": + - img + - text: Agent 循环 Agent 如何派发工具调用。 + - listitem: + - button "网页搜索 DeepSeek 搜索提供方。": + - img + - text: 网页搜索 DeepSeek 搜索提供方。 diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index cf87f5acbd..3ac0245395 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件": + - img + - text: 插件 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index da25f0cc59..8106350a9e 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -35,6 +35,7 @@ "tests/trajectory-virtualization.e2e.ts", "tests/lifecycle-chrome.e2e.ts", "tests/details-session-lifecycle.e2e.ts", + "tests/plugin-config.e2e.ts", "tests/settings-chrome.e2e.ts", "tests/models-settings.e2e.ts", "tests/default-model.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..1817ccab26 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -24,6 +24,7 @@ "apps/web/tests/trajectory-virtualization.e2e.ts", "apps/web/tests/lifecycle-chrome.e2e.ts", "apps/web/tests/details-session-lifecycle.e2e.ts", + "apps/web/tests/plugin-config.e2e.ts", "apps/web/tests/settings-chrome.e2e.ts", "apps/web/tests/models-settings.e2e.ts", "apps/web/tests/onboarding-deepseek-config.e2e.ts", From 1b473be886a0287a8cdcc1e220a51f458522227e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 20:06:18 +0800 Subject: [PATCH 08/19] docs(agent-notes): record the web plugin configuration decision Also folds the duplication the three controls had grown: the draft-and-commit input both editable fields render is now one component, and the two sibling executors' identical import surface is marked as the deliberate mirror their READMEs already describe. --- ...6-08-10-web-plugin-configuration.i18n.yaml | 6 + .../2026-08-10-web-plugin-configuration.md | 43 +++++++ .../2026-08-10-web-plugin-configuration.zh.md | 43 +++++++ docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- packages/bash/pwsh-local/src/index.ts | 3 + .../ui-plugin-config/src/client/fields.tsx | 116 ++++++++++-------- 7 files changed, 164 insertions(+), 51 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml new file mode 100644 index 0000000000..a96f5e2d93 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +2026-08-10-web-plugin-configuration.md: 146dac0684a783b170614c3320485dd8f2127a66 +2026-08-10-web-plugin-configuration.zh.md: b0a1b1b91fc5a1bc0e3162c3e72d28ea057d44da diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md new file mode 100644 index 0000000000..146dac0684 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -0,0 +1,43 @@ +# Agent Note: Plugin configuration in the web settings page + +Status: implemented + +English | [中文](2026-08-10-web-plugin-configuration.zh.md) + +## Problem + +Everything a plugin can be configured with lived in `cordis.yml`. A user who wanted a longer shell timeout, a different search endpoint, or fewer parallel tool calls had to find the composition file, know its shape, and restart — while the Models page had shown for months that a settings namespace can be edited from the browser and take effect immediately. + +The seam that made the Models page possible was already general: any plugin may register a namespace, and `settings.describe` serves its schema, its layers, and its revision. What was missing was on the two ends. No plugin outside the LLM adapters and the permission service had registered one, and there was no surface for a namespace that is not a model provider. + +## Decision + +Three host-plane plugins register their own settings namespace, and one browser-side section renders whatever the deployment exposes. + +**Layering, unchanged.** A section resolves as schema defaults → the plugin's composition entry → the user layer. Each plugin passes its `cordis.yml` entry as the `base` and reads its config through a source thunk, so a stored change reaches the next use and a detaching settings provider leaves the composition entry running. Constraints the schema cannot express — positive and finite, the timer bound on `graceMs`, the parallel cap being a positive integer — become the section validator, so a bad value is refused at the write instead of at the next command. + +**The shell namespace names the capability, not an implementation.** `BASH_SETTINGS_NAMESPACE` is exported by `@deepseek-ai/dsh-bash` because a host composes exactly one provider of `ctx.bash`: the win32 layer swaps the POSIX rows for the pwsh ones, and mounting both fails loud on a duplicate service registration. Both families therefore register the same namespace with their own schema and entry without ever colliding, and a `settings.yaml` carried between platforms keeps resolving on both — schemastery objects preserve keys the active schema does not declare. + +**A section is a subset when the plugin config is bigger than what a user owns.** `agent-loop` exposes only `maxParallelToolCalls`; its `agents` array is consumed once when the service starts, so a stored change there could only look like it had an effect. + +**The provider projects, rather than captures.** `web-search-deepseek` hands its provider a thunk instead of an options value, so an endpoint or model change reaches the next search without re-registering the provider — which would make the web seam's provider selection observable to the user as a flicker. + +**Exposure stays a Host allowlist.** The three namespaces join `WEB_SETTINGS_NAMESPACES`; registration alone still never crosses the transport, and a namespace absent from that list answers `settings-not-exposed` exactly as an unregistered one does. + +**The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it. + +## Alternatives considered + +- **A registration-time exposure declaration replacing the allowlist.** The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in `packages/host/apiproxy`. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim. +- **A generic schema-driven form renderer.** Declined again for the reason recorded in the web-config-plane note: field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package. +- **Editing preset-mounted plugins from this page.** Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in `agent.cordis.yml` and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design. +- **One namespace per executor package instead of the capability-named `bash`.** Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows. +- **Writing the search key into the settings section.** Declined because the literal would then have to ride a `describe` response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names. + +## Consequences + +A user edits the shell's command timeout and output cap, the agent loop's parallel tool-call cap, and the search provider's key, endpoint, and per-request budget from the settings page, with each field marking whether they set it and offering a reset. + +Two costs are real. Adding a fourth plugin still requires an entry in the apiproxy allowlist, so the page's reach is a Host decision rather than a plugin's. And the plugins the web deployment moved into the agent plane — the file tools, the skills, compaction, the todo tool — appear nowhere here, which is most of what a user might expect to find; their configuration remains the preset editor's. + +The bash and pwsh executors now expose `config` as a getter over a source thunk rather than a readonly field. Every read site was already per-call, so nothing else changed, but a subclass that captured `this.config` at construction would silently pin the composition entry. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md new file mode 100644 index 0000000000..b0a1b1b91f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Plugin configuration in the web settings page + +Status: implemented + +[English](2026-08-10-web-plugin-configuration.md) | 中文 + +## 问题 + +插件的一切可配置项都只存在于 `cordis.yml`。想要更长的 shell 超时、不同的搜索端点或更少的并行工具调用,用户必须找到组装文件、了解它的形状,然后重启——而 Models 页几个月来一直在证明:settings 命名空间可以在浏览器里编辑并立即生效。 + +支撑 Models 页的那条 seam 本就是通用的:任何插件都可以注册命名空间,`settings.describe` 会提供它的 schema、分层与 revision。缺的是两端。除 LLM 适配器与权限服务外,没有插件注册过命名空间;而对于非模型提供方的命名空间,也没有任何表层。 + +## 决策 + +三个宿主平面插件各自注册 settings 命名空间,一个浏览器侧分区渲染该部署所暴露的一切。 + +**分层不变。** 一个分节按 schema 默认值 → 插件的组装条目 → 用户层解析。每个插件把自己的 `cordis.yml` 条目作为 `base` 传入,并通过 source thunk 读取配置,因此存储的变更会作用于下一次使用,而脱离的 settings 提供方会让组装条目继续运行。schema 无法表达的约束——正有限、`graceMs` 的定时器上界、并行上限必须是正整数——成为分节的校验器,因此错误的值在写入时被拒绝,而不是到下一条命令时才失败。 + +**shell 命名空间命名的是能力,而非某个实现。** `BASH_SETTINGS_NAMESPACE` 由 `@deepseek-ai/dsh-bash` 导出,因为一个宿主只组装一个 `ctx.bash` 提供方:win32 层会把 POSIX 行换成 pwsh 行,而同时挂载两者会因服务重复注册在加载期失败。因此两个家族都能用自己的 schema 与条目注册同一个命名空间而永不相撞;在平台间携带的 `settings.yaml` 也能在两边继续解析——schemastery 对象会保留当前 schema 未声明的键。 + +**当插件配置大于用户所拥有的部分时,分节就是一个子集。** `agent-loop` 只暴露 `maxParallelToolCalls`;它的 `agents` 数组在服务启动时被消费一次,所以存储在那里的变更只会看起来生效。 + +**提供方按次投影,而不是固化。** `web-search-deepseek` 交给提供方的是一个 thunk 而非 options 值,因此端点或模型的变更无需重新注册提供方即可作用于下一次搜索——重新注册会让 web seam 的提供方选择以闪断的形式被用户看到。 + +**暴露仍是 Host 的白名单。** 这三个命名空间加入 `WEB_SETTINGS_NAMESPACES`;仅有注册依然不会跨越传输边界,而不在该名单中的命名空间会与未注册的命名空间得到完全相同的 `settings-not-exposed`。 + +**该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。 + +## 备选方案 + +- **用注册期的暴露声明取代白名单。** 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 `packages/host/apiproxy` 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。 +- **通用 schema 驱动的表单渲染器。** 再次否决,理由与 web-config-plane 笔记所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。 +- **在本页编辑 preset 挂载的插件。** 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。 +- **按执行器包各取一个命名空间,而非按能力命名的 `bash`。** 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。 +- **把搜索密钥写进 settings 分节。** 否决,因为那样字面值就必须搭乘 `describe` 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。 + +## 影响 + +用户可以在设置页编辑 shell 的命令超时与输出上限、agent 循环的并行工具调用上限,以及搜索提供方的密钥、端点与单次请求预算,每个字段都标注是否由自己设定,并提供重置。 + +有两项真实代价。加入第四个插件仍需要在 apiproxy 白名单里添一条,因此本页的覆盖面是 Host 的决定而非插件的决定。而 web 部署移入 agent 平面的那些插件——文件工具、技能、压缩、todo 工具——在这里一个都不出现,而它们恰恰是用户最可能期待找到的;它们的配置仍归 preset 编辑器。 + +bash 与 pwsh 执行器现在把 `config` 暴露为 source thunk 之上的 getter,而不再是 readonly 字段。所有读取点本就是按次读取,因此别无变化;但若某个子类在构造期捕获 `this.config`,就会悄然把组装条目钉死。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index add34f496c..d8e449a752 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 14b6676865e536a15371e26f8f696e7beacfb033 +config-catalog.md: 1e31ddd9ca3fa9fdf6cbcf2969b516fbfde39e49 config-catalog.zh.md: 93e02dc1691dc830e9aacea598e1e5e9774b9c6e diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14b6676865..1e31ddd9ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1235,7 +1235,7 @@ export interface Config { } ``` -Source: [`packages/bash/pwsh-local/src/index.ts:55`](../packages/bash/pwsh-local/src/index.ts) +Source: [`packages/bash/pwsh-local/src/index.ts:58`](../packages/bash/pwsh-local/src/index.ts) ## `@deepseek-ai/dsh-pwsh-sandbox` diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 3b872b0c34..0956e0fe97 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -13,6 +13,8 @@ * @module @deepseek-ai/dsh-pwsh-local */ +/* jscpd:ignore-start -- this executor mirrors dsh-bash-local call-for-call by + design (see this package's README), so the two import the same seam surface */ import { Context } from 'cordis' import z from 'schemastery' import { BASH_SETTINGS_NAMESPACE, BashExecutor } from '@deepseek-ai/dsh-bash' @@ -20,6 +22,7 @@ import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashR import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { installSettingsSection } from '@deepseek-ai/dsh-settings' import { clampTimeout, deadline, MAX_TIMER_DELAY_MS, timeoutOf } from '@deepseek-ai/dsh-timeout' +/* jscpd:ignore-end */ import { resolvePwshPath } from './resolve.ts' /* jscpd:ignore-start -- deliberate call-for-call mirror of dsh-bash-local (Agent Note: pwsh-tool-and-executor). */ diff --git a/packages/client/ui-plugin-config/src/client/fields.tsx b/packages/client/ui-plugin-config/src/client/fields.tsx index 407fbb264a..fa447b5f5b 100644 --- a/packages/client/ui-plugin-config/src/client/fields.tsx +++ b/packages/client/ui-plugin-config/src/client/fields.tsx @@ -73,6 +73,46 @@ function useDraft(value: string): [string, (next: string) => void] { return [draft, setDraft] } +/** Blur the input so its own blur handler is the single commit path. */ +function commitOnEnter(event: KeyboardEvent): void { + if (event.key === 'Enter') event.currentTarget.blur() +} + +/** + * The text input both editable fields render: a draft seeded from the + * authoritative text, committed on blur and on Enter. + */ +function DraftInput(props: { + /** Stable id associating the label with this control. */ + id: string + /** Authoritative text the draft re-seeds from. */ + value: string + /** Disables editing. */ + disabled: boolean + /** Placeholder shown while the draft is empty. */ + placeholder?: string | undefined + /** Hints a numeric keypad without narrowing the value type. */ + numeric?: boolean | undefined + /** Settle the draft; the returned text replaces it (a rejected draft restores the value). */ + onSettle: (draft: string, restore: (text: string) => void) => void +}) { + const [draft, setDraft] = useDraft(props.value) + return ( + { setDraft(event.target.value) }} + onBlur={() => { props.onSettle(draft, setDraft) }} + onKeyDown={commitOnEnter} + /> + ) +} + /** A whole-number field committed on blur or Enter. */ export function NumberField(props: FieldProps & { /** Current effective value. */ @@ -80,31 +120,22 @@ export function NumberField(props: FieldProps & { /** Commit a parsed value; a draft that is not a finite number is discarded. */ onCommit: (next: number) => void }) { - const [draft, setDraft] = useDraft(String(props.value)) - const commit = () => { - const parsed = Number(draft) - if (draft.trim() === '' || !Number.isFinite(parsed)) { - setDraft(String(props.value)) - return - } - if (parsed === props.value) return - props.onCommit(parsed) - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') event.currentTarget.blur() - } return ( - { setDraft(event.target.value) }} - onBlur={commit} - onKeyDown={onKeyDown} + numeric + onSettle={(draft, restore) => { + const parsed = Number(draft) + if (draft.trim() === '' || !Number.isFinite(parsed)) { + restore(String(props.value)) + return + } + if (parsed === props.value) return + props.onCommit(parsed) + }} /> ) @@ -119,27 +150,18 @@ export function TextField(props: FieldProps & { /** Commit the trimmed draft. */ onCommit: (next: string) => void }) { - const [draft, setDraft] = useDraft(props.value) - const commit = () => { - const next = draft.trim() - if (next === props.value) return - props.onCommit(next) - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') event.currentTarget.blur() - } return ( - { setDraft(event.target.value) }} - onBlur={commit} - onKeyDown={onKeyDown} + placeholder={props.placeholder} + onSettle={(draft) => { + const next = draft.trim() + if (next === props.value) return + props.onCommit(next) + }} /> ) @@ -159,15 +181,6 @@ export function SecretField(props: Omit & onCommit: (next: string) => void }) { const [draft, setDraft] = useState('') - const commit = () => { - const next = draft.trim() - if (next === '') return - setDraft('') - props.onCommit(next) - } - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Enter') event.currentTarget.blur() - } return (
    @@ -184,8 +197,13 @@ export function SecretField(props: Omit & value={draft} disabled={props.disabled} onChange={(event) => { setDraft(event.target.value) }} - onBlur={commit} - onKeyDown={onKeyDown} + onBlur={() => { + const next = draft.trim() + if (next === '') return + setDraft('') + props.onCommit(next) + }} + onKeyDown={commitOnEnter} />

    {props.hint}

    From dae6cad0658c91784df81071c28706d01ac96df2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 21:29:45 +0800 Subject: [PATCH 09/19] fix(web): show the plugin settings a user actually gets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the page got wrong. The web-search provider's defaults lived only at their use site, so the served section carried no value for them and the card fell back to a zero it invented — a number the schema itself rejects. Declaring them on the schema makes the settings service the one authority: `maxUses` now reads 5 because that is what the Host resolves, not because the page guessed. `baseURL` keeps its code-side default, which exists so `$DEEPSEEK_SEARCH_BASE_URL` can win. A field the Host serves no value for now renders empty rather than as zero. The cards were rows in a settings page of cards: name and description ran together on one line because the shared disclosure row lays them side by side. Each card now draws its own header, stacking the two, and the section follows the idiom the Agent Preset page established. --- .../created.expected.md | 4 +- .../damaged.expected.md | 4 +- .../section.expected.md | 4 +- .../models-settings/configured.expected.md | 4 +- .../models-settings/declared.expected.md | 4 +- .../models-settings/empty.expected.md | 4 +- .../models.expected.md | 4 +- .../plugin-config/section.expected.md | 18 ++-- .../settings-chrome/dialog.expected.md | 4 +- packages/client/ui-plugin-config/package.json | 5 +- .../src/client/AgentLoopCard.tsx | 10 +-- .../ui-plugin-config/src/client/BashCard.tsx | 10 +-- .../src/client/PluginCard.module.css | 79 ++++++++++++++---- .../src/client/PluginCard.tsx | 83 +++++++++++-------- .../src/client/PluginConfigSection.module.css | 24 +++--- .../src/client/WebSearchCard.tsx | 10 +-- .../src/client/agent-loop-store.ts | 4 +- .../ui-plugin-config/src/client/bash-store.ts | 8 +- .../src/client/fields.module.css | 47 +++++++---- .../ui-plugin-config/src/client/fields.tsx | 11 ++- .../ui-plugin-config/src/client/locales.ts | 10 +-- .../src/client/web-search-store.ts | 4 +- .../ui-plugin-config/tests/apply.spec.ts | 2 +- .../ui-plugin-config/tests/fields.spec.tsx | 16 ++++ .../ui-settings/src/client/SettingsRoot.tsx | 4 +- packages/web/web-search-deepseek/src/index.ts | 11 ++- pnpm-lock.yaml | 4 + 27 files changed, 241 insertions(+), 151 deletions(-) diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index aece772fe4..b26cba28f4 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index 7620e679c8..14a6337736 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index dade3d7b84..7c35b40786 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 3b57d5ac6f..e4fb6e13e8 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/declared.expected.md b/apps/web/tests/snapshots/models-settings/declared.expected.md index dd3f03776f..b126a5025b 100644 --- a/apps/web/tests/snapshots/models-settings/declared.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 16cc93581b..5a1dba54ee 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index b5f25aaaa6..2624f4db70 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/apps/web/tests/snapshots/plugin-config/section.expected.md b/apps/web/tests/snapshots/plugin-config/section.expected.md index 54ef09851f..7d10d05cd1 100644 --- a/apps/web/tests/snapshots/plugin-config/section.expected.md +++ b/apps/web/tests/snapshots/plugin-config/section.expected.md @@ -10,25 +10,25 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img - text: 关闭 - heading "插件配置" [level=2] - - paragraph: 本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。 + - paragraph: 配置本部署已安装的插件。 - list: - listitem: - - button "终端 限制 agent 运行的每一条命令。": - - img + - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 - - listitem: - - button "Agent 循环 Agent 如何派发工具调用。": - img + - listitem: + - 'button "展开设置: Agent 循环"': - text: Agent 循环 Agent 如何派发工具调用。 - - listitem: - - button "网页搜索 DeepSeek 搜索提供方。": - img + - listitem: + - 'button "展开设置: 网页搜索"': - text: 网页搜索 DeepSeek 搜索提供方。 + - img diff --git a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md index 3ac0245395..914293aee3 100644 --- a/apps/web/tests/snapshots/settings-chrome/dialog.expected.md +++ b/apps/web/tests/snapshots/settings-chrome/dialog.expected.md @@ -10,9 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 - - button "插件": + - button "插件配置": - img - - text: 插件 + - text: 插件配置 - button "打开配置文件" - button "关闭": - img diff --git a/packages/client/ui-plugin-config/package.json b/packages/client/ui-plugin-config/package.json index f3a9c04e62..61cec64110 100644 --- a/packages/client/ui-plugin-config/package.json +++ b/packages/client/ui-plugin-config/package.json @@ -67,5 +67,8 @@ "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dependencies": { + "clsx": "^2.0.0" + } } diff --git a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx index 99790467dc..1e47453eff 100644 --- a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx @@ -1,6 +1,5 @@ /** The agent-loop plugin's card: how many tool calls may run at once. */ -import { useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { NumberField } from './fields.tsx' @@ -34,17 +33,14 @@ export type AgentLoopCardProps = export function AgentLoopCard(props: AgentLoopCardProps) { const { t } = props const state = props.useAgentLoopCard(snapshot => snapshot) - const [open, setOpen] = useState(false) const disabled = !state.writable return ( { setOpen(!open) }} readOnly={disabled} - readOnlyLabel={t('readOnly')} > snapshot) - const [open, setOpen] = useState(false) const disabled = !state.writable return ( { setOpen(!open) }} readOnly={disabled} - readOnlyLabel={t('readOnly')} > string + /** Locale key of the plugin's name. */ + titleKey: PluginConfigKey + /** Locale key of the line describing what this plugin's settings govern. */ + descriptionKey: PluginConfigKey /** False while the namespace is not served to this client. */ available: boolean - /** Whether the card body is showing. */ - open: boolean - /** Toggle the card body. */ - onToggle: () => void - /** Copy shown when the settings document refuses writes. */ - readOnlyLabel?: string | undefined - /** True when the Host document is read-only. */ + /** True when the Host document is read-only, which disables the fields. */ readOnly: boolean /** The plugin's controls. */ children: ReactNode @@ -31,31 +37,36 @@ export interface PluginCardProps { /** * Render one plugin card. - * @param props - card chrome, disclosure state, and the plugin's controls. + * @param props - the plugin's copy keys, its availability, and its controls. * @returns the card, or nothing when the namespace is unavailable. */ export function PluginCard(props: PluginCardProps) { + const [open, setOpen] = useState(false) if (!props.available) return null + const title = props.t(props.titleKey) return ( -
  • - {props.description}} +
  • + + {open + ? ( +
    + {props.readOnly ?

    {props.t('readOnly')}

    : null} + {props.children} +
    + ) + : null}
  • ) } diff --git a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css index 6f2af513e9..45c9a78f00 100644 --- a/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css +++ b/packages/client/ui-plugin-config/src/client/PluginConfigSection.module.css @@ -3,34 +3,34 @@ .section { display: flex; flex-direction: column; + gap: 12px; + max-width: 720px; + color: var(--dsw-alias-label-primary); } .heading { margin: 0; - font-size: 16px; - font-weight: 500; - line-height: 24px; - color: var(--dsw-alias-label-primary); + font-size: 18px; + font-weight: 600; } .intro { - margin: 8px 0 16px; - font-size: 12px; - font-weight: 400; - line-height: 18px; + margin: 0; + font-size: 13px; color: var(--dsw-alias-label-tertiary); } .cards { + list-style: none; margin: 0; padding: 0; - list-style: none; + display: flex; + flex-direction: column; + gap: 10px; } .empty { margin: 0; - padding: 16px 0; - font-size: 14px; - line-height: 22px; + font-size: 13px; color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx index 72c11fa545..702a7a6627 100644 --- a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx +++ b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx @@ -4,7 +4,6 @@ * the settings section, so the literal never rides a response. */ -import { useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { NumberField, SecretField, TextField } from './fields.tsx' @@ -44,17 +43,14 @@ export type WebSearchCardProps = export function WebSearchCard(props: WebSearchCardProps) { const { t } = props const state = props.useWebSearchCard(snapshot => snapshot) - const [open, setOpen] = useState(false) const disabled = !state.writable return ( { setOpen(!open) }} readOnly={disabled} - readOnlyLabel={t('readOnly')} > + maxParallelToolCalls: CardField } /** The registration-side face the agent-loop card's slot entry injects. */ @@ -42,7 +42,7 @@ export class AgentLoopCardController extends CardController) { super(scope, snapshot => ({ ...shellOf(snapshot), - maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', 0), + maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', undefined), })) } diff --git a/packages/client/ui-plugin-config/src/client/bash-store.ts b/packages/client/ui-plugin-config/src/client/bash-store.ts index 91114669c1..837a6fab04 100644 --- a/packages/client/ui-plugin-config/src/client/bash-store.ts +++ b/packages/client/ui-plugin-config/src/client/bash-store.ts @@ -21,9 +21,9 @@ export interface BashSettings { /** What the shell card renders. */ export interface BashCardState extends CardShell { /** Command timeout in milliseconds. */ - timeoutMs: CardField + timeoutMs: CardField /** Per-stream output cap in bytes. */ - maxOutputBytes: CardField + maxOutputBytes: CardField } /** The registration-side face the shell card's slot entry injects. */ @@ -50,8 +50,8 @@ export class BashCardController extends CardController void }) { @@ -124,13 +127,13 @@ export function NumberField(props: FieldProps & { { const parsed = Number(draft) if (draft.trim() === '' || !Number.isFinite(parsed)) { - restore(String(props.value)) + restore(props.value === undefined ? '' : String(props.value)) return } if (parsed === props.value) return diff --git a/packages/client/ui-plugin-config/src/client/locales.ts b/packages/client/ui-plugin-config/src/client/locales.ts index 6a3804236c..7babcc1bdd 100644 --- a/packages/client/ui-plugin-config/src/client/locales.ts +++ b/packages/client/ui-plugin-config/src/client/locales.ts @@ -13,11 +13,9 @@ export type PluginConfigKey = /** English copy. */ export const en: Record = { - nav: 'Plugins', + nav: 'Plugin config', title: 'Plugin configuration', - intro: - 'Settings owned by the plugins this deployment composes. A value you set here layers over the ' - + 'composition default and applies to the next use.', + intro: 'Configure the plugins this deployment installed.', empty: 'This deployment exposes no plugin settings.', overridden: 'Overridden', reset: 'Reset to default', @@ -48,9 +46,9 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - nav: '插件', + nav: '插件配置', title: '插件配置', - intro: '本部署所组装插件自己拥有的设置。你在这里设的值会覆盖组装默认值,并在下一次使用时生效。', + intro: '配置本部署已安装的插件。', empty: '本部署没有开放任何插件设置。', overridden: '已覆盖', reset: '恢复默认', diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts index c0242eed8b..2def74cb79 100644 --- a/packages/client/ui-plugin-config/src/client/web-search-store.ts +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -36,7 +36,7 @@ export interface WebSearchCardState extends CardShell { /** Provider endpoint. */ baseURL: CardField /** Searches allowed per request. */ - maxUses: CardField + maxUses: CardField /** Credential reference the key is written under. */ apiKeyRef: string /** Whether the Host reports a credential configured for that reference. */ @@ -78,7 +78,7 @@ export class WebSearchCardController extends CardController ({ ...shellOf(snapshot), baseURL: fieldOf(snapshot, 'baseURL', ''), - maxUses: fieldOf(snapshot, 'maxUses', 0), + maxUses: fieldOf(snapshot, 'maxUses', undefined), apiKeyRef: refOf(snapshot), apiKeyConfigured: credential.configured, })) diff --git a/packages/client/ui-plugin-config/tests/apply.spec.ts b/packages/client/ui-plugin-config/tests/apply.spec.ts index 86bb21606a..233f0cd337 100644 --- a/packages/client/ui-plugin-config/tests/apply.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.spec.ts @@ -48,7 +48,7 @@ describe('ui-plugin-config apply', () => { const section = slots.entries('settings.section')[0]! expect(section.options).toMatchObject({ id: 'plugins', order: 30 }) // The nav label is a locale-following thunk; owners resolve it at read time. - expect(resolveSlotLabel(section.options.label)).toBe('插件') + expect(resolveSlotLabel(section.options.label)).toBe('插件配置') expect(slots.spec('settings.plugin.item')).toMatchObject({ kind: 'list', scope: 'root' }) }) diff --git a/packages/client/ui-plugin-config/tests/fields.spec.tsx b/packages/client/ui-plugin-config/tests/fields.spec.tsx index d444ae14a8..0248fee10d 100644 --- a/packages/client/ui-plugin-config/tests/fields.spec.tsx +++ b/packages/client/ui-plugin-config/tests/fields.spec.tsx @@ -116,6 +116,22 @@ describe('NumberField', () => { expect(onCommit).not.toHaveBeenCalled() }) + it('renders an absent value as empty rather than as a number nobody chose', () => { + const onCommit = vi.fn() + render( + , + ) + const input = screen.getByLabelText('Command timeout') + expect(input).toHaveProperty('value', '') + + // A draft typed and then cleared restores the same emptiness, not a zero. + fireEvent.change(input, { target: { value: 'abc' } }) + fireEvent.blur(input) + + expect(input).toHaveProperty('value', '') + expect(onCommit).not.toHaveBeenCalled() + }) + it('suppresses every interaction while disabled', () => { const onCommit = vi.fn() const onReset = vi.fn() diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 54e0e0dbb7..23f5421ff3 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -14,7 +14,8 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { - IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, + IconCloseOutline16, IconDataOutline16, IconPersonalizationOutline16, + IconSettingsOutline16, IconThinkOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' @@ -23,6 +24,7 @@ import css from './SettingsRoot.module.css' function navIcon(id: string) { if (id === 'models') return if (id === 'agent-presets') return + if (id === 'plugins') return return } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 50d9c69e22..bb3a6efe0a 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -63,11 +63,14 @@ export interface Config { export const Config: z = z.object({ apiKey: z.string().role('secret'), apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV), + // Declared here rather than only at the use site: a configuration surface + // renders the resolved section, so a default the schema does not carry reads + // there as no value at all. baseURL: z.string(), - model: z.string(), - apiVersion: z.string(), - maxTokens: z.number().step(1).min(1), - maxUses: z.number().step(1).min(1), + model: z.string().default(DEEPSEEK_DEFAULT_MODEL), + apiVersion: z.string().default(DEEPSEEK_DEFAULT_API_VERSION), + maxTokens: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_TOKENS), + maxUses: z.number().step(1).min(1).default(DEEPSEEK_DEFAULT_MAX_USES), }) /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c1950a855..50b497608c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2291,6 +2291,10 @@ importers: version: 18.3.1 packages/client/ui-plugin-config: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-connection': specifier: workspace:^ From 5d3f392cd34422b6c19d8706bd23160fd828d4d6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 22:51:12 +0800 Subject: [PATCH 10/19] refactor(client-ui-plugin-config): stage card edits behind an explicit save Controls committed on blur, which turned leaving a field into a durable, revision-fenced document write the user could neither preview nor undo, and silently discarded a draft the field did not accept. A card's form now owns the staged text every control renders, and Save is the only point where drafts become writes. Reset stages the composed default the same way; an invalid draft blocks the save with its reason instead of being dropped; Discard drops the drafts; a collapsed card marks that it holds some. The Host stays the only authority on whether a value was accepted, so the save reads the section back and keeps the drafts of a save that did not land. --- ...6-08-10-web-plugin-configuration.i18n.yaml | 4 +- .../2026-08-10-web-plugin-configuration.md | 4 + .../2026-08-10-web-plugin-configuration.zh.md | 4 + apps/web/tests/plugin-config.e2e.ts | 50 +- .../client/ui-plugin-config/README.i18n.yaml | 4 +- packages/client/ui-plugin-config/README.md | 6 +- packages/client/ui-plugin-config/README.zh.md | 6 +- .../src/client/AgentLoopCard.tsx | 32 +- .../ui-plugin-config/src/client/BashCard.tsx | 42 +- .../src/client/PluginCard.module.css | 71 +++ .../src/client/PluginCard.tsx | 44 +- .../src/client/WebSearchCard.tsx | 48 +- .../src/client/agent-loop-store.ts | 37 +- .../ui-plugin-config/src/client/bash-store.ts | 52 +- .../ui-plugin-config/src/client/card-store.ts | 361 +++++++++++-- .../src/client/fields.module.css | 12 + .../ui-plugin-config/src/client/fields.tsx | 189 ++----- .../ui-plugin-config/src/client/index.ts | 6 +- .../ui-plugin-config/src/client/locales.ts | 13 + .../src/client/web-search-store.ts | 112 ++-- .../ui-plugin-config/tests/fields.spec.tsx | 340 +++--------- .../ui-plugin-config/tests/section.spec.tsx | 217 +++++--- .../ui-plugin-config/tests/stores.spec.ts | 485 +++++++++++++++--- 23 files changed, 1352 insertions(+), 787 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index a96f5e2d93..94d2e8fc8e 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: 146dac0684a783b170614c3320485dd8f2127a66 -2026-08-10-web-plugin-configuration.zh.md: b0a1b1b91fc5a1bc0e3162c3e72d28ea057d44da +2026-08-10-web-plugin-configuration.md: cfc7108d3f241ba91c102e32f58b0b4fc2966f0c +2026-08-10-web-plugin-configuration.zh.md: cb6c5e0903a2f3034ff365fc50dd78e4fb655a06 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index 146dac0684..cfc7108d3f 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -26,6 +26,8 @@ Three host-plane plugins register their own settings namespace, and one browser- **The section knows no namespace.** `dsh-client-ui-plugin-config` declares a `settings.plugin.item` slot and renders the cards registered into it, so a plugin that ships a browser half owns its card and its controls. Each card binds its namespace through the client settings scope, which gained the two things a form needs: the raw `user` layer, whose key PRESENCE is what marks a field overridden, and `unset`, which clears one field back to the composition layer. A card renders nothing while its namespace is unavailable, so a deployment that does not compose the owning plugin shows no trace of it. +**A card stages its edits and writes them on save.** Controls hold no draft of their own: the card's form owns the staged text, every control renders it, and only **Save** turns it into document mutations. A settings write is durable and revision-fenced, so a control that committed as it settled spent a revision on a value the user had not decided to store and could not preview; the reset stages the composed default the same way. Because the Host's validators own the constraints no schema can express, the form reads the section back after writing and reports a save that did not land instead of predicting the outcome, keeping those drafts for the user to correct. The credential control is staged with the rest even though it writes through the credentials domain, so one save covers everything the card shows. + ## Alternatives considered - **A registration-time exposure declaration replacing the allowlist.** The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in `packages/host/apiproxy`. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim. @@ -33,6 +35,8 @@ Three host-plane plugins register their own settings namespace, and one browser- - **Editing preset-mounted plugins from this page.** Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in `agent.cordis.yml` and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design. - **One namespace per executor package instead of the capability-named `bash`.** Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows. - **Writing the search key into the settings section.** Declined because the literal would then have to ride a `describe` response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names. +- **Committing each control as it settles, with no save.** Built first, and replaced: blur is not a decision. It spent a namespace revision per control, gave the user nothing to preview or undo before the write, and left an invalid draft silently discarded — a value the Host's validator refuses simply snapped back with no reason given. One save per card makes the write a gesture the user performs. +- **Validating the fields in the browser to keep the save honest.** Declined: the constraints live in the owning plugin's section validator, and restating them here would make two homes for one rule that could disagree per release. The card checks only what its own control can decide — that a numeric draft is a number — and lets the Host answer for the rest, which is why the save reads the section back. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index b0a1b1b91f..cb6c5e0903 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -26,6 +26,8 @@ Status: implemented **该分区不认识任何命名空间。** `dsh-client-ui-plugin-config` 声明 `settings.plugin.item` slot 并渲染注册进来的卡片,因此带浏览器半侧的插件拥有自己的卡片与控件。每张卡片通过客户端 settings scope 绑定其命名空间,而该 scope 补上了表单所需的两样东西:原始 `user` 层——键的**存在**才标记字段被覆盖——以及把单个字段清回组装层的 `unset`。命名空间不可用时卡片什么都不渲染,因此未组装该插件的部署不会显示它的任何痕迹。 +**卡片暂存修改,保存时才写入。** 控件不持有自己的草稿:暂存文本归卡片的表单所有,所有控件渲染的都是它,只有**保存**才把它变成文档变更。settings 写入是持久且带 revision 栅栏的,因此「失焦即提交」的控件会为用户尚未决定存储、也无从预览的值花掉一个 revision;重置同样只是暂存组装默认值。schema 表达不了的约束归 Host 的校验器所有,所以表单在写入后回读分节、报告没有落盘的保存,而不是自行预测结果,并保留这些草稿供用户修改。密钥控件虽然经由 credentials 领域写入,也和其余字段一起暂存,因此一次保存覆盖卡片上的全部内容。 + ## 备选方案 - **用注册期的暴露声明取代白名单。** 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 `packages/host/apiproxy` 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。 @@ -33,6 +35,8 @@ Status: implemented - **在本页编辑 preset 挂载的插件。** 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。 - **按执行器包各取一个命名空间,而非按能力命名的 `bash`。** 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。 - **把搜索密钥写进 settings 分节。** 否决,因为那样字面值就必须搭乘 `describe` 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。 +- **每个控件失焦即提交,不设保存。** 最初就是这么做的,后被替换:失焦不是决定。它每个控件花掉一个命名空间 revision,写入前不给用户任何预览或撤销的余地,还会把无效草稿悄悄丢弃——被 Host 校验器拒绝的值只是弹回原样,不给任何理由。每张卡片一个保存,才让写入成为用户执行的动作。 +- **在浏览器端校验字段,好让保存诚实。** 否决:这些约束住在拥有方插件的分节校验器里,在这里重述一遍就会让同一条规则有两个家,且可能随版本各说各话。卡片只判断自己的控件能判断的事——数字草稿是不是数字——其余交给 Host 回答,这正是保存要回读分节的原因。 ## 影响 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index b0c95c969a..bdad2083aa 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -85,7 +85,7 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('writes an edited field to the settings document and marks it overridden', async () => { + it('stages an edit and writes it only when saved', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) const dialog = await openPlugins() await dialog.getByText('终端', { exact: true }).click() @@ -97,12 +97,52 @@ describe('web e2e: plugin configuration section', () => { await timeout.fill('12000') await timeout.blur() + // Nothing crosses the wire until the user saves: leaving the control is + // not a decision to store the value. + expect(await settingsDocument()).not.toContain('timeoutMs') + const save = dialog.getByRole('button', { name: '保存', exact: true }) + await expect.poll(() => save.isEnabled(), { timeout: 5_000 }).toBe(true) + await save.click() + await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) .toBe(true) // Presence in the user layer is what the badge reports, and the reset is // offered only for a field that has one. await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) expect(await dialog.getByRole('button', { name: '恢复默认' }).count()).toBe(1) + // A settled form offers no save to repeat. + await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('drops a staged edit on discard without touching the document', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-discard')) + const dialog = await openPlugins() + await dialog.getByText('终端', { exact: true }).click() + const timeout = dialog.getByLabel('命令超时(毫秒)') + await timeout.waitFor({ timeout: 10_000 }) + + await timeout.fill('7000') + await dialog.getByRole('button', { name: '放弃修改' }).click() + + await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('12000') + expect(await settingsDocument()).toContain('timeoutMs: 12000') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('refuses to save a draft that is not a number', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-invalid')) + const dialog = await openPlugins() + await dialog.getByText('终端', { exact: true }).click() + const timeout = dialog.getByLabel('命令超时(毫秒)') + await timeout.waitFor({ timeout: 10_000 }) + + await timeout.fill('soon') + + const save = dialog.getByRole('button', { name: '保存', exact: true }) + await expect.poll(() => save.isDisabled(), { timeout: 5_000 }).toBe(true) + expect(await dialog.getByText('请填数字;留空表示使用默认值。').count()).toBe(1) + await dialog.getByRole('button', { name: '放弃修改' }).click() expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -114,11 +154,17 @@ describe('web e2e: plugin configuration section', () => { await timeout.waitFor({ timeout: 10_000 }) expect(await timeout.inputValue()).toBe('12000') + // The reset stages the composed default; the document still carries the + // override until the save lands. await dialog.getByRole('button', { name: '恢复默认' }).click() + await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000') + expect(await settingsDocument()).toContain('timeoutMs: 12000') + + await dialog.getByRole('button', { name: '保存', exact: true }).click() await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) .toBe(false) - await expect.poll(() => timeout.inputValue(), { timeout: 5_000 }).toBe('60000') + expect(await timeout.inputValue()).toBe('60000') expect(await dialog.getByText('已覆盖').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index d9d198f4dc..3e8e6143a9 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/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-plugin-config/README.md -README.md: e830589b5e279fb0bdda23591502989bebc2a336 -README.zh.md: e614d7b6858f8cbf2a38cb7397b5d8f93445ab6c +README.md: 51d86e8fbfd19d3d37f68650163180751fb27061 +README.zh.md: 48a68900a20aaaabcc9763b5aa61bc23cf3d16d8 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index e830589b5e..51d86e8fbf 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -16,7 +16,11 @@ The section declares `settings.plugin.item`, a root list slot. A plugin that shi ## Writes -Every control writes one field through the client settings scope, which fences each write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control reports only whether one is configured and writes through the credentials domain rather than the settings section. +A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. + +Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. + +A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key. ## Model Experience diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index e614d7b685..48a68900a2 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -16,7 +16,11 @@ ## 写入 -每个控件都通过客户端 settings scope 写入单个字段,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件只报告是否已配置,并经由 credentials 领域而非 settings 分节写入。 +卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。 + +保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 + +字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。 ## 模型体验 diff --git a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx index 1e47453eff..5231b73b64 100644 --- a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx @@ -1,22 +1,19 @@ -/** The agent-loop plugin's card: how many tool calls may run at once. */ +/** The agent loop's card: how many tool calls one step may run at once. */ import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { NumberField } from './fields.tsx' +import { ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' +import type { CardActions } from './card-store.ts' import type { AgentLoopCardState } from './agent-loop-store.ts' import type {} from './slot-contract.ts' /** Registration-side business face for the agent-loop card. */ -export interface AgentLoopCardInjected { +export interface AgentLoopCardInjected extends CardActions { hooks: { /** Card snapshot bound by the renderer as useAgentLoopCard. */ agentLoopCard: SnapshotStore } - /** Write the parallel tool-call cap. */ - setMaxParallelToolCalls: (next: number) => void - /** Clear the cap so it re-inherits the composition layer. */ - resetMaxParallelToolCalls: () => void } /** Props the renderer binds for the agent-loop card. */ @@ -27,32 +24,33 @@ export type AgentLoopCardProps = /** * Render the agent-loop card. - * @param props - locale copy, the card snapshot, and its write actions. + * @param props - locale copy, the card snapshot, and its form actions. * @returns the card. */ export function AgentLoopCard(props: AgentLoopCardProps) { const { t } = props const state = props.useAgentLoopCard(snapshot => snapshot) - const disabled = !state.writable return ( - { props.edit('maxParallelToolCalls', text) }} + onReset={() => { props.resetField('maxParallelToolCalls') }} /> ) diff --git a/packages/client/ui-plugin-config/src/client/BashCard.tsx b/packages/client/ui-plugin-config/src/client/BashCard.tsx index c31ec80525..ade767fa56 100644 --- a/packages/client/ui-plugin-config/src/client/BashCard.tsx +++ b/packages/client/ui-plugin-config/src/client/BashCard.tsx @@ -2,25 +2,18 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { NumberField } from './fields.tsx' +import { ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' +import type { CardActions } from './card-store.ts' import type { BashCardState } from './bash-store.ts' import type {} from './slot-contract.ts' /** Registration-side business face for the shell card. */ -export interface BashCardInjected { +export interface BashCardInjected extends CardActions { hooks: { /** Card snapshot bound by the renderer as useBashCard. */ bashCard: SnapshotStore } - /** Write the foreground command timeout. */ - setTimeoutMs: (next: number) => void - /** Clear the timeout so it re-inherits the composition layer. */ - resetTimeoutMs: () => void - /** Write the per-stream output cap. */ - setMaxOutputBytes: (next: number) => void - /** Clear the output cap so it re-inherits the composition layer. */ - resetMaxOutputBytes: () => void } /** Props the renderer binds for the shell card. */ @@ -31,7 +24,7 @@ export type BashCardProps = /** * Render the shell card. - * @param props - locale copy, the card snapshot, and its write actions. + * @param props - locale copy, the card snapshot, and its form actions. * @returns the card. */ export function BashCard(props: BashCardProps) { @@ -43,32 +36,35 @@ export function BashCard(props: BashCardProps) { t={t} titleKey="bashTitle" descriptionKey="bashDescription" - available={state.available} - readOnly={disabled} + state={state} + onSave={props.save} + onDiscard={props.discard} > - { props.edit('timeoutMs', text) }} + onReset={() => { props.resetField('timeoutMs') }} /> - { props.edit('maxOutputBytes', text) }} + onReset={() => { props.resetField('maxOutputBytes') }} /> ) diff --git a/packages/client/ui-plugin-config/src/client/PluginCard.module.css b/packages/client/ui-plugin-config/src/client/PluginCard.module.css index 9374f71317..091273cd5b 100644 --- a/packages/client/ui-plugin-config/src/client/PluginCard.module.css +++ b/packages/client/ui-plugin-config/src/client/PluginCard.module.css @@ -84,3 +84,74 @@ line-height: 1.5; color: var(--dsw-alias-label-tertiary); } + +/* Carried on the header so a collapsed card still says it holds edits. */ +.pending { + flex: none; + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 17px; + font-weight: 500; + white-space: nowrap; + background: var(--dsw-alias-bg-module-platform); + color: var(--dsw-alias-label-secondary); +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 12px 0 4px; + border-top: 1px solid var(--dsw-alias-border-l2); +} + +.failed { + flex: 1; + min-width: 0; + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-error); +} + +.discard, +.save { + appearance: none; + border: 1px solid transparent; + border-radius: 8px; + padding: 5px 14px; + font: inherit; + font-size: 13px; + line-height: 1.5; + cursor: pointer; +} + +.discard { + border-color: var(--dsw-alias-border-l2); + background: none; + color: var(--dsw-alias-label-secondary); +} + +.discard:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + border-color: var(--dsw-alias-label-dimmed); +} + +.save { + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.discard:disabled, +.save:disabled { + opacity: 0.4; + cursor: default; +} + +.discard:focus-visible, +.save:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} diff --git a/packages/client/ui-plugin-config/src/client/PluginCard.tsx b/packages/client/ui-plugin-config/src/client/PluginCard.tsx index eeb13662b1..459fd627c1 100644 --- a/packages/client/ui-plugin-config/src/client/PluginCard.tsx +++ b/packages/client/ui-plugin-config/src/client/PluginCard.tsx @@ -1,12 +1,13 @@ /** * One plugin's card: a header naming the plugin and what its settings govern, - * disclosing that plugin's controls in place. + * disclosing that plugin's controls in place, with the save that writes them. * * The header is its own button rather than a shared disclosure row because a * card stacks its name over its description, while that row lays the two side * by side — the layout, not the behavior, is what differs. Disclosure is * card-local state: which card a user has open is a reading gesture, not - * something the Host or the section has any stake in. + * something the Host or the section has any stake in. Staged edits outlive + * collapsing, so the header marks a card holding unsaved edits. * * A card renders nothing while its namespace is unavailable: a deployment that * does not compose the owning plugin should show no trace of it, rather than a @@ -16,6 +17,7 @@ import { useState, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { CardShell } from './card-store.ts' import type { PluginConfigKey } from './locales.ts' import css from './PluginCard.module.css' @@ -27,23 +29,27 @@ export interface PluginCardProps { titleKey: PluginConfigKey /** Locale key of the line describing what this plugin's settings govern. */ descriptionKey: PluginConfigKey - /** False while the namespace is not served to this client. */ - available: boolean - /** True when the Host document is read-only, which disables the fields. */ - readOnly: boolean + /** The card's form state: availability, writability, and what a save would do. */ + state: CardShell + /** Write every staged edit. */ + onSave: () => void + /** Drop every staged edit. */ + onDiscard: () => void /** The plugin's controls. */ children: ReactNode } /** * Render one plugin card. - * @param props - the plugin's copy keys, its availability, and its controls. + * @param props - the plugin's copy keys, its form state, and its controls. * @returns the card, or nothing when the namespace is unavailable. */ export function PluginCard(props: PluginCardProps) { const [open, setOpen] = useState(false) - if (!props.available) return null + const { state } = props + if (!state.available) return null const title = props.t(props.titleKey) + const blocked = !state.dirty || state.invalid || state.saving return (
  • {open ? (
    - {props.readOnly ?

    {props.t('readOnly')}

    : null} + {!state.writable ?

    {props.t('readOnly')}

    : null} {props.children} +
    + {state.failed ?

    {props.t('saveFailed')}

    : null} + + +
    ) : null} diff --git a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx index 702a7a6627..302bbf09e5 100644 --- a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx +++ b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx @@ -6,27 +6,18 @@ import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { NumberField, SecretField, TextField } from './fields.tsx' +import { SecretField, ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' +import type { CardActions } from './card-store.ts' import type { WebSearchCardState } from './web-search-store.ts' import type {} from './slot-contract.ts' /** Registration-side business face for the web-search card. */ -export interface WebSearchCardInjected { +export interface WebSearchCardInjected extends CardActions { hooks: { /** Card snapshot bound by the renderer as useWebSearchCard. */ webSearchCard: SnapshotStore } - /** Write the provider endpoint; the empty string clears it. */ - setBaseUrl: (next: string) => void - /** Clear the endpoint so it re-inherits the composition layer. */ - resetBaseUrl: () => void - /** Write the per-request search budget. */ - setMaxUses: (next: number) => void - /** Clear the budget so it re-inherits the composition layer. */ - resetMaxUses: () => void - /** Write the credential the section references. */ - setApiKey: (next: string) => void } /** Props the renderer binds for the web-search card. */ @@ -37,7 +28,7 @@ export type WebSearchCardProps = /** * Render the web-search card. - * @param props - locale copy, the card snapshot, and its write actions. + * @param props - locale copy, the card snapshot, and its form actions. * @returns the card. */ export function WebSearchCard(props: WebSearchCardProps) { @@ -49,45 +40,46 @@ export function WebSearchCard(props: WebSearchCardProps) { t={t} titleKey="webSearchTitle" descriptionKey="webSearchDescription" - available={state.available} - readOnly={disabled} + state={state} + onSave={props.save} + onDiscard={props.discard} > { props.edit('apiKey', text) }} /> - { props.edit('baseURL', text) }} + onReset={() => { props.resetField('baseURL') }} /> - { props.edit('maxUses', text) }} + onReset={() => { props.resetField('maxUses') }} /> ) diff --git a/packages/client/ui-plugin-config/src/client/agent-loop-store.ts b/packages/client/ui-plugin-config/src/client/agent-loop-store.ts index fd497840c8..2bed0d1def 100644 --- a/packages/client/ui-plugin-config/src/client/agent-loop-store.ts +++ b/packages/client/ui-plugin-config/src/client/agent-loop-store.ts @@ -1,7 +1,7 @@ -/** The agent-loop card's state and writes over the `agent-loop` settings namespace. */ +/** The agent-loop card's staged form over the `agent-loop` settings namespace. */ import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' +import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts' /** * Namespace of the agent loop's user-owned settings. Spelled here rather than @@ -21,40 +21,37 @@ export interface AgentLoopSettings { /** What the agent-loop card renders. */ export interface AgentLoopCardState extends CardShell { /** Parallel tool-call cap. */ - maxParallelToolCalls: CardField + maxParallelToolCalls: CardFieldState } /** The registration-side face the agent-loop card's slot entry injects. */ -export interface AgentLoopCardFace { +export interface AgentLoopCardFace extends CardActions { hooks: { /** Card snapshot bound by the renderer as useAgentLoopCard. */ agentLoopCard: SnapshotStore } - /** Write the parallel tool-call cap. */ - setMaxParallelToolCalls: (next: number) => void - /** Clear the cap so it re-inherits the composition layer. */ - resetMaxParallelToolCalls: () => void } -/** Bridges the `agent-loop` scope onto the card's state and writes. */ -export class AgentLoopCardController extends CardController { +/** Bridges the `agent-loop` scope onto the card's staged form. */ +export class AgentLoopCardController { + private readonly form: CardForm + private readonly store: SnapshotStore + /** @param scope - the bound settings scope for the `agent-loop` namespace. */ constructor(scope: SettingsScope) { - super(scope, snapshot => ({ - ...shellOf(snapshot), - maxParallelToolCalls: fieldOf(snapshot, 'maxParallelToolCalls', undefined), - })) + this.form = new CardForm(scope, [numberField('maxParallelToolCalls')]) + this.store = this.form.bind(() => this.projection()) + } + + private projection(): AgentLoopCardState { + return { ...this.form.shell(), maxParallelToolCalls: this.form.field('maxParallelToolCalls') } } /** * Build the face the card's slot registration injects. - * @returns the card's snapshot and its write actions. + * @returns the card's snapshot and its form actions. */ inject(): AgentLoopCardFace { - return { - hooks: { agentLoopCard: this.store }, - setMaxParallelToolCalls: (next: number) => { void this.scope.set('maxParallelToolCalls', next) }, - resetMaxParallelToolCalls: () => { void this.scope.unset('maxParallelToolCalls') }, - } + return { hooks: { agentLoopCard: this.store }, ...this.form.actions() } } } diff --git a/packages/client/ui-plugin-config/src/client/bash-store.ts b/packages/client/ui-plugin-config/src/client/bash-store.ts index 837a6fab04..c107ebc225 100644 --- a/packages/client/ui-plugin-config/src/client/bash-store.ts +++ b/packages/client/ui-plugin-config/src/client/bash-store.ts @@ -1,7 +1,7 @@ -/** The shell card's state and writes over the `bash` settings namespace. */ +/** The shell card's staged form over the `bash` settings namespace. */ import type { SettingsScope, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' +import { CardForm, numberField, type CardActions, type CardFieldState, type CardShell } from './card-store.ts' /** * Namespace of the shell capability. Spelled here rather than imported: a @@ -21,51 +21,43 @@ export interface BashSettings { /** What the shell card renders. */ export interface BashCardState extends CardShell { /** Command timeout in milliseconds. */ - timeoutMs: CardField + timeoutMs: CardFieldState /** Per-stream output cap in bytes. */ - maxOutputBytes: CardField + maxOutputBytes: CardFieldState } /** The registration-side face the shell card's slot entry injects. */ -export interface BashCardFace { +export interface BashCardFace extends CardActions { hooks: { /** Card snapshot bound by the renderer as useBashCard. */ bashCard: SnapshotStore } - /** Write the foreground command timeout. */ - setTimeoutMs: (next: number) => void - /** Clear the timeout so it re-inherits the composition layer. */ - resetTimeoutMs: () => void - /** Write the per-stream output cap. */ - setMaxOutputBytes: (next: number) => void - /** Clear the output cap so it re-inherits the composition layer. */ - resetMaxOutputBytes: () => void } -/** Bridges the `bash` scope onto the shell card's state and writes. */ -export class BashCardController extends CardController { +/** Bridges the `bash` scope onto the shell card's staged form. */ +export class BashCardController { + private readonly form: CardForm + private readonly store: SnapshotStore + /** @param scope - the bound settings scope for the `bash` namespace. */ constructor(scope: SettingsScope) { - super(scope, snapshot => ({ - ...shellOf(snapshot), - // The fallbacks only show before the Host serves a section; every served - // section is already schema-defaulted by the owning executor. - timeoutMs: fieldOf(snapshot, 'timeoutMs', undefined), - maxOutputBytes: fieldOf(snapshot, 'maxOutputBytes', undefined), - })) + this.form = new CardForm(scope, [numberField('timeoutMs'), numberField('maxOutputBytes')]) + this.store = this.form.bind(() => this.projection()) + } + + private projection(): BashCardState { + return { + ...this.form.shell(), + timeoutMs: this.form.field('timeoutMs'), + maxOutputBytes: this.form.field('maxOutputBytes'), + } } /** * Build the face the card's slot registration injects. - * @returns the card's snapshot and its write actions. + * @returns the card's snapshot and its form actions. */ inject(): BashCardFace { - return { - hooks: { bashCard: this.store }, - setTimeoutMs: (next: number) => { void this.scope.set('timeoutMs', next) }, - resetTimeoutMs: () => { void this.scope.unset('timeoutMs') }, - setMaxOutputBytes: (next: number) => { void this.scope.set('maxOutputBytes', next) }, - resetMaxOutputBytes: () => { void this.scope.unset('maxOutputBytes') }, - } + return { hooks: { bashCard: this.store }, ...this.form.actions() } } } diff --git a/packages/client/ui-plugin-config/src/client/card-store.ts b/packages/client/ui-plugin-config/src/client/card-store.ts index ea18e79a9d..255450bdb9 100644 --- a/packages/client/ui-plugin-config/src/client/card-store.ts +++ b/packages/client/ui-plugin-config/src/client/card-store.ts @@ -1,84 +1,351 @@ /** - * Shared projection from one settings scope onto a card's fields. + * Shared form model behind every plugin card. * - * A card shows the effective value of each field and whether the user set it. - * Both come from the scope snapshot: `value` is what the plugin resolves, and - * the presence of a key in the raw `user` layer is what makes it overridden — - * an override equal to the composition default is still an override, and - * comparing values could not tell them apart. + * A card stages what the user types and writes it only when they save. Each + * settings write is a durable, revision-fenced document mutation, so a control + * that committed as it settled turned one edit into a write the user never + * asked for and could not preview; staged text makes what is on screen exactly + * what a save would store. + * + * A field shows its effective value — the user layer over the composition + * layer over the schema default — and whether the user layer carries it. That + * presence, not a value comparison, is what marks a field overridden: an + * override equal to the composition default is still an override. */ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -/** One field as a card renders it. */ -export interface CardField { - /** Effective value: the user layer over the composition layer over the schema default. */ - value: V - /** Whether the raw user layer carries this field. */ - overridden: boolean +/** The write one field's staged text performs when the card is saved. */ +export type FieldWrite = + | { kind: 'set'; value: unknown } + | { kind: 'clear' } + +/** How one section field converts between its stored value and its draft text. */ +export interface CardFieldSpec { + /** Field name inside the namespace section. */ + field: string + /** Render a stored value as draft text; the empty string when the section carries none. */ + format: (value: unknown) => string + /** + * The write this draft text stages, or undefined when the text is not a + * value this field accepts — which blocks the save rather than discarding it. + */ + parse: (text: string) => FieldWrite | undefined } -/** State every plugin card shares. */ +/** + * A control whose value is written outside the settings section. A credential + * literal never rides a response, so its draft has nothing to seed from: it is + * blank until typed, and a blank draft writes nothing. + */ +export interface CardSecretSpec { + /** Field name addressing this control inside the card's form. */ + field: string + /** Write the staged text; resolves to whether the Host accepted it. */ + write: (text: string) => Promise +} + +/** One field as a card's control renders it. */ +export interface CardFieldState { + /** Draft text the control renders. */ + text: string + /** + * Whether saving would leave a user-layer entry for this field. A staged + * edit answers for itself, so the badge previews the save rather than + * reporting a state the pending edit already contradicts. + */ + overridden: boolean + /** Whether the draft is not a value this field accepts, which blocks saving. */ + invalid: boolean +} + +/** Form state every plugin card shares. */ export interface CardShell { /** False while the namespace is not served to this client; the card renders nothing. */ available: boolean /** Whether the Host document accepts writes. */ writable: boolean + /** Whether the form holds edits that a save would write. */ + dirty: boolean + /** Whether any staged draft is invalid, which blocks the save. */ + invalid: boolean + /** Whether a save is crossing the wire. */ + saving: boolean + /** Whether the last save did not land as staged; cleared by the next edit or save. */ + failed: boolean +} + +/** The write actions every plugin card's slot entry injects. */ +export interface CardActions { + /** Stage draft text for one field. */ + edit: (field: string, text: string) => void + /** Stage a clear, so saving lets the field re-inherit the composition layer. */ + resetField: (field: string) => void + /** Write every staged edit, then re-seed from what the Host accepted. */ + save: () => void + /** Drop every staged edit. */ + discard: () => void +} + +/** One field's staged edit. */ +interface StagedEdit { + /** Draft text the control renders. */ + text: string + /** True when this edit clears the field whatever text it shows. */ + clear: boolean +} + +/** One staged edit resolved into the write a save performs. */ +interface PlannedWrite { + /** Field this entry writes. */ + field: string + /** + * Perform the write and report whether the Host holds the staged value + * afterwards; undefined when the draft is not a value the field accepts. + */ + run: (() => Promise) | undefined } /** - * Read one field out of a scope snapshot. - * @param snapshot - the scope snapshot to project. - * @param field - the section field to read. - * @param fallback - value shown before the Host serves a section. - * @returns the field as a card renders it. + * A whole-number field. An empty draft clears the field; any other draft that + * is not a finite number blocks the save. + * @param field - field name inside the namespace section. + * @returns the field's conversion spec. */ -export function fieldOf( - snapshot: SettingsScopeSnapshot, - field: string, - fallback: V, -): CardField { - const section = snapshot.value as Record | undefined - const user = snapshot.user as Record | undefined - const value = section?.[field] +export function numberField(field: string): CardFieldSpec { return { - value: value === undefined ? fallback : value as V, - overridden: user !== undefined && Object.hasOwn(user, field), + field, + // A section that carries no number for this field renders empty rather + // than as a value nobody chose. + format: value => typeof value === 'number' ? String(value) : '', + parse: (text) => { + const trimmed = text.trim() + if (trimmed === '') return { kind: 'clear' } + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? { kind: 'set', value: parsed } : undefined + }, } } /** - * Project the shell every card shares. - * @param snapshot - the scope snapshot to project. - * @returns availability and writability. + * A free-text field. An empty draft clears the field, so emptying the control + * and saving is the same gesture as resetting it. + * @param field - field name inside the namespace section. + * @returns the field's conversion spec. */ -export function shellOf(snapshot: SettingsScopeSnapshot): CardShell { - return { available: snapshot.status === 'ready', writable: snapshot.writable } +export function textField(field: string): CardFieldSpec { + return { + field, + format: value => typeof value === 'string' ? value : '', + parse: (text) => { + const trimmed = text.trim() + return trimmed === '' ? { kind: 'clear' } : { kind: 'set', value: trimmed } + }, + } } /** - * Keep a snapshot store synchronized with one settings scope. + * Stages one card's edits over one settings namespace and writes them on save. * - * The store exists because slot components read through a snapshot selector, - * while the scope publishes its own snapshot; this bridges the two and gives - * each card a state shaped for rendering rather than for the wire. + * The form publishes through a snapshot store because slot components read + * through a snapshot selector, while both the scope and the local drafts + * change underneath; every projection is rebuilt from the two together. */ -export class CardController { - /** Snapshot the card's component reads through its bound selector. */ - readonly store: SnapshotStore +export class CardForm { + private readonly specs: Map + private readonly secretSpecs: Map + private readonly staged = new Map() + private readonly listeners = new Set<() => void>() + private saving = false + private failed = false /** * @param scope - the bound settings scope for this card's namespace. - * @param project - build the card state from a scope snapshot. + * @param specs - the section fields this card edits. + * @param secrets - the card's write-only controls, written outside the section. */ constructor( - protected readonly scope: SettingsScope, - private readonly project: (snapshot: SettingsScopeSnapshot) => S, + private readonly scope: SettingsScope, + specs: CardFieldSpec[], + secrets: CardSecretSpec[] = [], ) { - this.store = createSnapshotStore(project(scope.getSnapshot())) - scope.subscribe(() => { - this.store.set(this.project(this.scope.getSnapshot())) - }) + this.specs = new Map(specs.map(spec => [spec.field, spec])) + this.secretSpecs = new Map(secrets.map(spec => [spec.field, spec])) + scope.subscribe(() => { this.publish() }) + } + + /** + * Publish a projection of this form, rebuilt whenever the scope or a draft changes. + * @param project - build the card's state from the form's current reads. + * @returns the store the card's component reads through its bound selector. + */ + bind(project: () => S): SnapshotStore { + const store = createSnapshotStore(project()) + this.listeners.add(() => { store.set(project()) }) + return store + } + + /** + * Read the card-level state: what the Host serves, and what a save would do. + * @returns the form state every card shares. + */ + shell(): CardShell { + const snapshot = this.scope.getSnapshot() + const plan = this.plan() + return { + available: snapshot.status === 'ready', + writable: snapshot.writable, + dirty: plan.length > 0, + invalid: plan.some(item => item.run === undefined), + saving: this.saving, + failed: this.failed, + } + } + + /** + * Read one control's state. + * @param field - field name of a section field or of a write-only control. + * @returns the draft text, whether a save would leave an override, and whether it is invalid. + */ + field(field: string): CardFieldState { + const staged = this.staged.get(field) + if (this.secretSpecs.has(field)) { + return { text: staged?.text ?? '', overridden: false, invalid: false } + } + const spec = this.spec(field) + if (staged === undefined) { + return { text: spec.format(this.sectionValue(field)), overridden: this.stored(field), invalid: false } + } + const write = staged.clear ? { kind: 'clear' as const } : spec.parse(staged.text) + return { + text: staged.text, + overridden: write?.kind === 'set', + invalid: write === undefined, + } + } + + /** + * Build the edit, reset, save, and discard actions bound to this form. + * @returns the actions a card's slot entry injects. + */ + actions(): CardActions { + return { + edit: (field, text) => { this.stage(field, { text, clear: false }) }, + resetField: (field) => { + this.stage(field, { text: this.spec(field).format(this.baseValue(field)), clear: true }) + }, + save: () => { void this.save() }, + discard: () => { + if (this.staged.size === 0 && !this.failed) return + this.staged.clear() + this.failed = false + this.publish() + }, + } + } + + /** + * Write every staged edit, then re-seed from what the Host accepted. + * + * The Host is the only authority on whether a value was accepted — its + * validators own the constraints no schema can express — so the outcome is + * read back from the section rather than predicted here. A save that did not + * land keeps its drafts, so the user can correct them instead of retyping. + * @returns settlement after every write and the read-back. + */ + async save(): Promise { + const plan = this.plan() + const writes = plan.flatMap(item => item.run === undefined ? [] : [item.run]) + if (plan.length === 0 || this.saving || writes.length !== plan.length) return + this.saving = true + this.failed = false + this.publish() + let landed = true + for (const write of writes) { + landed = await write() && landed + } + if (landed) this.staged.clear() + this.saving = false + this.failed = !landed + this.publish() + } + + /** + * Every staged edit a save would write. An entry whose draft is not a value + * its field accepts carries no write: the form is still dirty, and the save + * refuses rather than dropping the edit. + * @returns the planned writes, in the order the fields were staged. + */ + private plan(): PlannedWrite[] { + const plan: PlannedWrite[] = [] + for (const [field, staged] of this.staged) { + const secret = this.secretSpecs.get(field) + if (secret !== undefined) { + const value = staged.text.trim() + if (value !== '') plan.push({ field, run: () => secret.write(value) }) + continue + } + const spec = this.spec(field) + if (staged.clear) { + if (this.stored(field)) plan.push({ field, run: () => this.clear(field) }) + continue + } + if (staged.text === spec.format(this.sectionValue(field))) continue + const write = spec.parse(staged.text) + if (write === undefined) plan.push({ field, run: undefined }) + else if (write.kind === 'clear') plan.push({ field, run: () => this.clear(field) }) + else plan.push({ field, run: () => this.store(field, write.value) }) + } + return plan + } + + private async clear(field: string): Promise { + await this.scope.unset(field) + return !this.stored(field) + } + + private async store(field: string, value: unknown): Promise { + await this.scope.set(field, value) + return this.userLayer()?.[field] === value + } + + private stage(field: string, edit: StagedEdit): void { + this.staged.set(field, edit) + this.failed = false + this.publish() + } + + private spec(field: string): CardFieldSpec { + const spec = this.specs.get(field) + // Every call site names a field this card declared; a missing one is a + // wiring mistake that must not degrade into a silently inert control. + if (spec === undefined) throw new Error(`plugin card has no field ${field}`) + return spec + } + + private snapshotOf(): SettingsScopeSnapshot { + return this.scope.getSnapshot() + } + + private sectionValue(field: string): unknown { + return (this.snapshotOf().value as Record | undefined)?.[field] + } + + private baseValue(field: string): unknown { + return (this.snapshotOf().base as Record | undefined)?.[field] + } + + private userLayer(): Record | undefined { + return this.snapshotOf().user as Record | undefined + } + + private stored(field: string): boolean { + const user = this.userLayer() + return user !== undefined && Object.hasOwn(user, field) + } + + private publish(): void { + for (const listener of this.listeners) listener() } } diff --git a/packages/client/ui-plugin-config/src/client/fields.module.css b/packages/client/ui-plugin-config/src/client/fields.module.css index a344e61a4b..261dcb0fe9 100644 --- a/packages/client/ui-plugin-config/src/client/fields.module.css +++ b/packages/client/ui-plugin-config/src/client/fields.module.css @@ -93,6 +93,18 @@ cursor: default; } +.inputInvalid { + composes: input; + border-color: var(--dsw-alias-label-error); +} + +.invalid { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--dsw-alias-label-error); +} + .hint { margin: 0; font-size: 12px; diff --git a/packages/client/ui-plugin-config/src/client/fields.tsx b/packages/client/ui-plugin-config/src/client/fields.tsx index 0fece80a62..a30998ddad 100644 --- a/packages/client/ui-plugin-config/src/client/fields.tsx +++ b/packages/client/ui-plugin-config/src/client/fields.tsx @@ -1,12 +1,11 @@ /** * Hand-written controls for the plugin configuration forms. Each renders one - * field's label, its current effective value, whether the user overrode it, - * and — when overridden — the reset that clears it back to the composition - * layer. Commits happen on blur and on Enter rather than per keystroke: a - * write per keystroke would burn namespace revisions and race its own reads. + * field's label, its staged text, whether saving would leave an override, and + * — when one stands — the reset that stages a clear back to the composition + * layer. Nothing here writes: a control reports what the user typed, and the + * card's save is the single point where a draft becomes a document mutation. */ -import { useState, type KeyboardEvent } from 'react' import css from './fields.module.css' /** What every field control needs regardless of its value type. */ @@ -17,20 +16,39 @@ export interface FieldProps { label: string /** One-line explanation rendered under the control. */ hint: string - /** True when the raw user layer carries this field. */ + /** Draft text this control renders. */ + text: string + /** True when saving would leave a user-layer entry for this field. */ overridden: boolean + /** True when the draft is not a value this field accepts. */ + invalid: boolean /** Copy for the overridden badge. */ overriddenLabel: string /** Copy for the reset control. */ resetLabel: string + /** Copy shown in place of the hint while the draft is invalid. */ + invalidLabel: string /** Disables every control (read-only document, or an unavailable namespace). */ disabled: boolean - /** Clear the field so it re-inherits the composition layer. */ + /** Stage draft text. */ + onEdit: (text: string) => void + /** Stage a clear so the field re-inherits the composition layer. */ onReset: () => void } -/** Label, badge, and reset chrome shared by every control. */ -function FieldFrame(props: FieldProps & { children: React.ReactNode }) { +/** + * A staged value field. `numeric` only hints the keypad: which drafts a field + * accepts is decided by its spec, so the control never silently rewrites what + * the user typed. + * @param props - the field's copy, its staged text, and the edit actions. + * @returns the labelled control. + */ +export function ValueField(props: FieldProps & { + /** Hints a numeric keypad without narrowing what the control accepts. */ + numeric?: boolean + /** Placeholder shown while the draft is empty. */ + placeholder?: string +}) { return (
    @@ -51,139 +69,37 @@ function FieldFrame(props: FieldProps & { children: React.ReactNode }) { ) : null}
    - {props.children} -

    {props.hint}

    + { props.onEdit(event.target.value) }} + /> +

    + {props.invalid ? props.invalidLabel : props.hint} +

    ) } /** - * Keep a draft seeded from the authoritative value, re-seeding whenever that - * value changes underneath (a Host acceptance, or a reset). - * @param value - the current authoritative text. - * @returns the draft and its setter. + * A write-only credential control. The value never rides a response, so the + * control reports only whether one is configured and starts blank; a blank + * draft writes nothing, which keeps the stored key rather than clearing it. + * @param props - the field's copy, its staged text, and the configured state. + * @returns the labelled control. */ -function useDraft(value: string): [string, (next: string) => void] { - const [draft, setDraft] = useState(value) - const [seed, setSeed] = useState(value) - if (seed !== value) { - setSeed(value) - setDraft(value) - } - return [draft, setDraft] -} - -/** Blur the input so its own blur handler is the single commit path. */ -function commitOnEnter(event: KeyboardEvent): void { - if (event.key === 'Enter') event.currentTarget.blur() -} - -/** - * The text input both editable fields render: a draft seeded from the - * authoritative text, committed on blur and on Enter. - */ -function DraftInput(props: { - /** Stable id associating the label with this control. */ - id: string - /** Authoritative text the draft re-seeds from. */ - value: string - /** Disables editing. */ - disabled: boolean - /** Placeholder shown while the draft is empty. */ - placeholder?: string | undefined - /** Hints a numeric keypad without narrowing the value type. */ - numeric?: boolean | undefined - /** Settle the draft; the returned text replaces it (a rejected draft restores the value). */ - onSettle: (draft: string, restore: (text: string) => void) => void -}) { - const [draft, setDraft] = useDraft(props.value) - return ( - { setDraft(event.target.value) }} - onBlur={() => { props.onSettle(draft, setDraft) }} - onKeyDown={commitOnEnter} - /> - ) -} - -/** A whole-number field committed on blur or Enter. */ -export function NumberField(props: FieldProps & { - /** - * Current effective value, or undefined when the Host served none — which - * renders empty rather than as a number nobody chose. - */ - value: number | undefined - /** Commit a parsed value; a draft that is not a finite number is discarded. */ - onCommit: (next: number) => void -}) { - return ( - - { - const parsed = Number(draft) - if (draft.trim() === '' || !Number.isFinite(parsed)) { - restore(props.value === undefined ? '' : String(props.value)) - return - } - if (parsed === props.value) return - props.onCommit(parsed) - }} - /> - - ) -} - -/** A free-text field committed on blur or Enter; an empty draft clears the field. */ -export function TextField(props: FieldProps & { - /** Current effective value; the empty string when the field is unset. */ - value: string - /** Placeholder shown while the draft is empty. */ - placeholder?: string - /** Commit the trimmed draft. */ - onCommit: (next: string) => void -}) { - return ( - - { - const next = draft.trim() - if (next === props.value) return - props.onCommit(next) - }} - /> - - ) -} - -/** - * A write-only credential field. The value never rides a response, so the - * control reports only whether one is configured, and an empty draft commits - * nothing — leaving the field blank keeps the stored key rather than clearing it. - */ -export function SecretField(props: Omit & { +export function SecretField(props: Pick & { /** Whether the Host reports a configured credential for this reference. */ configured: boolean /** Copy describing the configured state. */ stateLabel: string - /** Commit a non-empty draft. */ - onCommit: (next: string) => void }) { - const [draft, setDraft] = useState('') return (
    @@ -197,16 +113,9 @@ export function SecretField(props: Omit & className={css.input} type="password" autoComplete="off" - value={draft} + value={props.text} disabled={props.disabled} - onChange={(event) => { setDraft(event.target.value) }} - onBlur={() => { - const next = draft.trim() - if (next === '') return - setDraft('') - props.onCommit(next) - }} - onKeyDown={commitOnEnter} + onChange={(event) => { props.onEdit(event.target.value) }} />

    {props.hint}

    diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index 098485ebee..d097a8a756 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -28,7 +28,11 @@ import { en, zh } from './locales.ts' export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' -export { NumberField, SecretField, TextField, type FieldProps } from './fields.tsx' +export { SecretField, ValueField, type FieldProps } from './fields.tsx' +export { + CardForm, numberField, textField, + type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell, +} from './card-store.ts' export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts' export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts' export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts' diff --git a/packages/client/ui-plugin-config/src/client/locales.ts b/packages/client/ui-plugin-config/src/client/locales.ts index 7babcc1bdd..18fc943f7b 100644 --- a/packages/client/ui-plugin-config/src/client/locales.ts +++ b/packages/client/ui-plugin-config/src/client/locales.ts @@ -4,6 +4,7 @@ export type PluginConfigKey = | 'nav' | 'title' | 'intro' | 'empty' | 'overridden' | 'reset' | 'readOnly' | 'expand' | 'collapse' + | 'save' | 'saving' | 'discard' | 'unsaved' | 'saveFailed' | 'invalidNumber' | 'bashTitle' | 'bashDescription' | 'bashTimeoutMs' | 'bashTimeoutMsHint' | 'bashMaxOutputBytes' | 'bashMaxOutputBytesHint' | 'agentLoopTitle' | 'agentLoopDescription' | 'agentLoopMaxParallel' | 'agentLoopMaxParallelHint' @@ -22,6 +23,12 @@ export const en: Record = { readOnly: 'This deployment stores settings read-only.', expand: 'Show settings', collapse: 'Hide settings', + save: 'Save', + saving: 'Saving…', + discard: 'Discard', + unsaved: 'Unsaved', + saveFailed: 'The deployment did not accept these values; they were left for you to correct.', + invalidNumber: 'Enter a number, or leave blank to use the default.', bashTitle: 'Shell', bashDescription: 'Limits every command the agent runs.', bashTimeoutMs: 'Command timeout (ms)', @@ -55,6 +62,12 @@ export const zh: Record = { readOnly: '本部署的设置为只读。', expand: '展开设置', collapse: '收起设置', + save: '保存', + saving: '保存中…', + discard: '放弃修改', + unsaved: '未保存', + saveFailed: '本部署没有接受这些值,已保留供你修改。', + invalidNumber: '请填数字;留空表示使用默认值。', bashTitle: '终端', bashDescription: '限制 agent 运行的每一条命令。', bashTimeoutMs: '命令超时(毫秒)', diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts index 2def74cb79..5fc4ad7ab5 100644 --- a/packages/client/ui-plugin-config/src/client/web-search-store.ts +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -1,16 +1,20 @@ /** - * The web-search card's state and writes over the `web-search-deepseek` - * settings namespace. + * The web-search card's staged form over the `web-search-deepseek` settings + * namespace. * - * The key is the one field that does not live in the section: its literal + * The key is the one control that does not live in the section: its literal * never rides a response, so the card learns only whether one is configured - * and writes it through the credentials domain, addressed by the reference - * the section names. + * and writes it through the credentials domain, addressed by the reference the + * section names. It is still staged with the rest of the form, so one save + * covers everything the card shows. */ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import { CardController, fieldOf, shellOf, type CardField, type CardShell } from './card-store.ts' +import { + CardForm, numberField, textField, + type CardActions, type CardFieldState, type CardShell, +} from './card-store.ts' /** * Namespace of the DeepSeek search provider. Spelled here rather than @@ -21,6 +25,9 @@ export const WEB_SEARCH_NS = 'web-search-deepseek' /** Credential reference the provider resolves when the section names none. */ const DEFAULT_API_KEY_REF = 'DEEPSEEK_API_KEY' +/** Form field the credential control stages under. */ +const API_KEY_FIELD = 'apiKey' + /** The search-provider fields this card edits. */ export interface WebSearchSettings { /** Credential reference naming the environment key. */ @@ -34,59 +41,57 @@ export interface WebSearchSettings { /** What the web-search card renders. */ export interface WebSearchCardState extends CardShell { /** Provider endpoint. */ - baseURL: CardField + baseURL: CardFieldState /** Searches allowed per request. */ - maxUses: CardField - /** Credential reference the key is written under. */ - apiKeyRef: string - /** Whether the Host reports a credential configured for that reference. */ + maxUses: CardFieldState + /** The staged credential, which starts blank on every load. */ + apiKey: CardFieldState + /** Whether the Host reports a credential configured for the referenced key. */ apiKeyConfigured: boolean } /** The registration-side face the web-search card's slot entry injects. */ -export interface WebSearchCardFace { +export interface WebSearchCardFace extends CardActions { hooks: { /** Card snapshot bound by the renderer as useWebSearchCard. */ webSearchCard: SnapshotStore } - /** Write the provider endpoint; the empty string clears it. */ - setBaseUrl: (next: string) => void - /** Clear the endpoint so it re-inherits the composition layer. */ - resetBaseUrl: () => void - /** Write the per-request search budget. */ - setMaxUses: (next: number) => void - /** Clear the budget so it re-inherits the composition layer. */ - resetMaxUses: () => void - /** Write the credential the section references. */ - setApiKey: (next: string) => void } /** Bridges the `web-search-deepseek` scope and the credentials domain onto the card. */ -export class WebSearchCardController extends CardController { - private readonly credential: { configured: boolean } +export class WebSearchCardController { + private readonly form: CardForm + private readonly store: SnapshotStore + private configured = false /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. * @param api - wire face used for the credential the section references. */ - constructor(scope: SettingsScope, private readonly api: Pick) { - // Held in its own object because the projection runs during `super()`, - // before `this` exists, and must still see the latest credential state: - // that state comes from its own domain, so a settings change must not - // silently reset it to unknown. - const credential = { configured: false } - super(scope, snapshot => ({ - ...shellOf(snapshot), - baseURL: fieldOf(snapshot, 'baseURL', ''), - maxUses: fieldOf(snapshot, 'maxUses', undefined), - apiKeyRef: refOf(snapshot), - apiKeyConfigured: credential.configured, - })) - this.credential = credential + constructor( + private readonly scope: SettingsScope, + private readonly api: Pick, + ) { + this.form = new CardForm( + scope, + [textField('baseURL'), numberField('maxUses')], + [{ field: API_KEY_FIELD, write: text => this.writeKey(text) }], + ) + this.store = this.form.bind(() => this.projection()) scope.subscribe(() => { void this.readCredential() }) void this.readCredential() } + private projection(): WebSearchCardState { + return { + ...this.form.shell(), + baseURL: this.form.field('baseURL'), + maxUses: this.form.field('maxUses'), + apiKey: this.form.field(API_KEY_FIELD), + apiKeyConfigured: this.configured, + } + } + /** Ask the credentials domain whether the referenced key exists. */ private async readCredential(): Promise { const ref = refOf(this.scope.getSnapshot()) @@ -100,35 +105,33 @@ export class WebSearchCardController extends CardController { void this.scope.set('baseURL', next) }, - resetBaseUrl: () => { void this.scope.unset('baseURL') }, - setMaxUses: (next: number) => { void this.scope.set('maxUses', next) }, - resetMaxUses: () => { void this.scope.unset('maxUses') }, - setApiKey: (next: string) => { void this.writeKey(next) }, - } + return { hooks: { webSearchCard: this.store }, ...this.form.actions() } } - private async writeKey(value: string): Promise { - const ref = refOf(this.scope.getSnapshot()) + /** + * Write the staged key, then re-read whether the Host now holds one. + * @param value - the staged credential literal. + * @returns whether the Host reports a configured credential afterwards. + */ + private async writeKey(value: string): Promise { try { - await this.api.credentials.set({ ref, value }) + await this.api.credentials.set({ ref: refOf(this.scope.getSnapshot()), value }) } catch (_credentialWriteFailure) { // Refusals surface through the re-read below: the Host is the only // authority on whether the key now exists. } await this.readCredential() + return this.configured } } @@ -138,7 +141,6 @@ export class WebSearchCardController extends CardController): string { - const section = snapshot.value - const declared = section?.apiKeyEnv + const declared = snapshot.value?.apiKeyEnv return declared !== undefined && declared.length > 0 ? declared : DEFAULT_API_KEY_REF } diff --git a/packages/client/ui-plugin-config/tests/fields.spec.tsx b/packages/client/ui-plugin-config/tests/fields.spec.tsx index 0248fee10d..f9370106e0 100644 --- a/packages/client/ui-plugin-config/tests/fields.spec.tsx +++ b/packages/client/ui-plugin-config/tests/fields.spec.tsx @@ -1,12 +1,12 @@ // @vitest-environment jsdom /** - * Field-control behavior: when a draft becomes a write, what a bad draft does - * instead, and how an overridden field offers its reset. + * Field-control behavior: what a control renders for a staged draft, how an + * overridden field offers its reset, and that a control never writes on its own. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { NumberField, SecretField, TextField } from '../src/client/fields.tsx' +import { SecretField, ValueField } from '../src/client/fields.tsx' afterEach(cleanup) @@ -16,328 +16,138 @@ const frame = { hint: 'How long one command may run.', overriddenLabel: 'Overridden', resetLabel: 'Reset to default', + invalidLabel: 'Enter a number.', disabled: false, + overridden: false, + invalid: false, } -describe('NumberField', () => { - it('commits a changed draft on blur', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Command timeout') +describe('ValueField', () => { + it('stages every keystroke without writing', () => { + const onEdit = vi.fn() + render() - fireEvent.change(input, { target: { value: '9000' } }) - fireEvent.blur(input) + fireEvent.change(screen.getByLabelText('Command timeout'), { target: { value: '9000' } }) - expect(onCommit).toHaveBeenCalledWith(9_000) + expect(onEdit).toHaveBeenCalledWith('9000') }) - it('commits on Enter through the blur the key triggers', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Command timeout') + it('renders the staged text it is given rather than a draft of its own', () => { + const { rerender } = render() + expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000') - fireEvent.change(input, { target: { value: '1234' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - fireEvent.blur(input) + rerender() - expect(onCommit).toHaveBeenCalledWith(1_234) + expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000') }) - it('restores the last good value instead of committing a draft that is not a number', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Command timeout') - - fireEvent.change(input, { target: { value: 'soon' } }) - fireEvent.blur(input) - - expect(onCommit).not.toHaveBeenCalled() - expect(input).toHaveProperty('value', '60000') - }) - - it('writes nothing when the draft settles on the value already shown', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Command timeout') - - fireEvent.change(input, { target: { value: '60000' } }) - fireEvent.blur(input) - - expect(onCommit).not.toHaveBeenCalled() - }) - - it('offers the reset only while the field is overridden', () => { + it('offers the reset only while an override would stand', () => { const onReset = vi.fn() - const { rerender } = render( - , - ) + const { rerender } = render() expect(screen.queryByRole('button', { name: 'Reset to default' })).toBeNull() - rerender( - , - ) + rerender() fireEvent.click(screen.getByRole('button', { name: 'Reset to default' })) expect(screen.getByText('Overridden')).toBeTruthy() expect(onReset).toHaveBeenCalledOnce() }) - it('re-seeds the draft when the authoritative value changes underneath', () => { - const { rerender } = render( - , - ) - expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '9000') + it('replaces the hint with the reason an invalid draft cannot be saved', () => { + render() - rerender( - , - ) - - expect(screen.getByLabelText('Command timeout')).toHaveProperty('value', '60000') + expect(screen.getByText('Enter a number.')).toBeTruthy() + expect(screen.queryByText('How long one command may run.')).toBeNull() + expect(screen.getByLabelText('Command timeout').getAttribute('aria-invalid')).toBe('true') }) - it('ignores a keystroke that is not Enter', () => { - const onCommit = vi.fn() + it('hints a numeric keypad and renders a placeholder when asked', () => { render( - , - ) - const input = screen.getByLabelText('Command timeout') - - fireEvent.change(input, { target: { value: '9000' } }) - fireEvent.keyDown(input, { key: 'Escape' }) - - expect(onCommit).not.toHaveBeenCalled() - }) - - it('renders an absent value as empty rather than as a number nobody chose', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Command timeout') - expect(input).toHaveProperty('value', '') - - // A draft typed and then cleared restores the same emptiness, not a zero. - fireEvent.change(input, { target: { value: 'abc' } }) - fireEvent.blur(input) - - expect(input).toHaveProperty('value', '') - expect(onCommit).not.toHaveBeenCalled() - }) - - it('suppresses every interaction while disabled', () => { - const onCommit = vi.fn() - const onReset = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Command timeout') - - expect(input).toHaveProperty('disabled', true) - expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true) - expect(onCommit).not.toHaveBeenCalled() - expect(onReset).not.toHaveBeenCalled() - }) -}) - -describe('TextField', () => { - it('commits the trimmed draft', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Endpoint') - - fireEvent.change(input, { target: { value: ' https://search.test/v1 ' } }) - fireEvent.blur(input) - - expect(onCommit).toHaveBeenCalledWith('https://search.test/v1') - }) - - it('commits an emptied draft, which clears the field', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Endpoint') - - fireEvent.change(input, { target: { value: '' } }) - fireEvent.blur(input) - - expect(onCommit).toHaveBeenCalledWith('') - }) - - it('renders its placeholder and commits on Enter', () => { - const onCommit = vi.fn() - render( - , ) - const input = screen.getByLabelText('Endpoint') + const input = screen.getByLabelText('Command timeout') + + expect(input.getAttribute('inputmode')).toBe('numeric') expect(input).toHaveProperty('placeholder', 'https://api.deepseek.com') - - fireEvent.change(input, { target: { value: 'https://other.test' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - fireEvent.blur(input) - - expect(onCommit).toHaveBeenCalledWith('https://other.test') }) - it('ignores a keystroke that is not Enter and writes nothing unchanged', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('Endpoint') + it('disables the control and its reset while the document is read-only', () => { + render() - fireEvent.keyDown(input, { key: 'a' }) - fireEvent.blur(input) - - expect(onCommit).not.toHaveBeenCalled() + expect(screen.getByLabelText('Command timeout')).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: 'Reset to default' })).toHaveProperty('disabled', true) }) }) describe('SecretField', () => { - it('commits a non-empty draft and clears the control after writing', () => { - const onCommit = vi.fn() + const secret = { + id: 'key', + label: 'API key', + hint: 'Stored outside the settings file.', + disabled: false, + } + + it('stages the draft and never renders it', () => { + const onEdit = vi.fn() render( , - ) - const input = screen.getByLabelText('API key') - - fireEvent.change(input, { target: { value: ' ds-secret ' } }) - fireEvent.blur(input) - - expect(onCommit).toHaveBeenCalledWith('ds-secret') - expect(input).toHaveProperty('value', '') - }) - - it('keeps the stored key when the draft is left blank', () => { - const onCommit = vi.fn() - render( - , - ) - const input = screen.getByLabelText('API key') - - fireEvent.change(input, { target: { value: ' ' } }) - fireEvent.blur(input) - - expect(onCommit).not.toHaveBeenCalled() - expect(screen.getByText('A key is configured.')).toBeTruthy() - }) - - it('ignores a keystroke that is not Enter', () => { - const onCommit = vi.fn() - render( - , ) const input = screen.getByLabelText('API key') fireEvent.change(input, { target: { value: 'ds-secret' } }) - fireEvent.keyDown(input, { key: 'Tab' }) - expect(onCommit).not.toHaveBeenCalled() + expect(onEdit).toHaveBeenCalledWith('ds-secret') + expect(input).toHaveProperty('type', 'password') }) - it('never renders the value it writes', () => { - render( - , - ) - - expect(screen.getByLabelText('API key')).toHaveProperty('type', 'password') - }) - - it('commits on Enter and stays disabled when the document is read-only', () => { - const onCommit = vi.fn() + it('reports the configured state the Host holds', () => { const { rerender } = render( , ) - const input = screen.getByLabelText('API key') - fireEvent.change(input, { target: { value: 'ds-secret' } }) - fireEvent.keyDown(input, { key: 'Enter' }) - fireEvent.blur(input) - expect(onCommit).toHaveBeenCalledWith('ds-secret') + expect(screen.getByText('No key is configured.')).toBeTruthy() rerender( , + ) + + expect(screen.getByText('A key is configured.')).toBeTruthy() + expect(screen.getByLabelText('API key')).toHaveProperty('value', 'ds-secret') + }) + + it('disables the control when it is told to', () => { + render( + , ) diff --git a/packages/client/ui-plugin-config/tests/section.spec.tsx b/packages/client/ui-plugin-config/tests/section.spec.tsx index 891fa071f8..1452345804 100644 --- a/packages/client/ui-plugin-config/tests/section.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.spec.tsx @@ -2,7 +2,7 @@ /** * What the section and its cards show: the empty line when no plugin * contributed one, a card that renders nothing while its namespace is - * unavailable, and the read-only notice a locked document produces. + * unavailable, and the save footer that decides when staged edits are written. */ import { cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -19,6 +19,7 @@ import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-store.ts' import type { BashCardState } from '../src/client/bash-store.ts' +import type { CardFieldState, CardShell } from '../src/client/card-store.ts' import type { WebSearchCardState } from '../src/client/web-search-store.ts' import { en } from '../src/client/locales.ts' @@ -26,6 +27,25 @@ afterEach(cleanup) const t = (key: keyof typeof en) => en[key] +/** A settled form: nothing staged, everything served. */ +const settled: CardShell = { + available: true, + writable: true, + dirty: false, + invalid: false, + saving: false, + failed: false, +} + +/** One control's state, defaulting to an inherited value. */ +function field(text: string, rest: Partial = {}): CardFieldState { + return { text, overridden: false, invalid: false, ...rest } +} + +function cardActions() { + return { edit: vi.fn(), resetField: vi.fn(), save: vi.fn(), discard: vi.fn() } +} + function renderSection(cardCount: number, cards = 'cards') { const props = { t, @@ -37,23 +57,13 @@ function renderSection(cardCount: number, cards = 'cards') { function renderBash(state: Partial = {}) { const store = createSnapshotStore({ - available: true, - writable: true, - timeoutMs: { value: 60_000, overridden: false }, - maxOutputBytes: { value: 64_000, overridden: false }, + ...settled, + timeoutMs: field('60000'), + maxOutputBytes: field('64000'), ...state, }) - const actions = { - setTimeoutMs: vi.fn(), - resetTimeoutMs: vi.fn(), - setMaxOutputBytes: vi.fn(), - resetMaxOutputBytes: vi.fn(), - } - const props = { - ...actions, - t, - useBashCard: bindSnapshotSelector(store), - } as unknown as BashCardProps + const actions = cardActions() + const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() return actions } @@ -101,26 +111,86 @@ describe('BashCard', () => { expect(screen.getByLabelText(en.bashMaxOutputBytes)).toBeTruthy() }) - it('commits an edited field through its action', () => { + it('stages an edit instead of writing it', () => { const actions = renderBash() fireEvent.click(screen.getByText(en.bashTitle)) - const input = screen.getByLabelText(en.bashTimeoutMs) - fireEvent.change(input, { target: { value: '9000' } }) - fireEvent.blur(input) + fireEvent.change(screen.getByLabelText(en.bashTimeoutMs), { target: { value: '9000' } }) - expect(actions.setTimeoutMs).toHaveBeenCalledWith(9_000) + expect(actions.edit).toHaveBeenCalledWith('timeoutMs', '9000') + expect(actions.save).not.toHaveBeenCalled() }) it('offers the reset for an overridden field only', () => { - const actions = renderBash({ timeoutMs: { value: 9_000, overridden: true } }) + const actions = renderBash({ timeoutMs: field('9000', { overridden: true }) }) fireEvent.click(screen.getByText(en.bashTitle)) // One badge and one reset: the output cap is still inherited. expect(screen.getAllByText(en.overridden)).toHaveLength(1) fireEvent.click(screen.getByRole('button', { name: en.reset })) - expect(actions.resetTimeoutMs).toHaveBeenCalledOnce() + expect(actions.resetField).toHaveBeenCalledWith('timeoutMs') + }) + + it('addresses each of its two fields separately', () => { + const actions = renderBash({ maxOutputBytes: field('64000', { overridden: true }) }) + fireEvent.click(screen.getByText(en.bashTitle)) + + fireEvent.change(screen.getByLabelText(en.bashMaxOutputBytes), { target: { value: '1024' } }) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.edit).toHaveBeenCalledWith('maxOutputBytes', '1024') + expect(actions.resetField).toHaveBeenCalledWith('maxOutputBytes') + }) + + it('keeps save and discard inert until something is staged', () => { + renderBash() + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true) + expect(screen.queryByText(en.unsaved)).toBeNull() + }) + + it('writes the staged edits when saved, and drops them when discarded', () => { + const actions = renderBash({ dirty: true, timeoutMs: field('9000', { overridden: true }) }) + fireEvent.click(screen.getByText(en.bashTitle)) + + fireEvent.click(screen.getByRole('button', { name: en.save })) + fireEvent.click(screen.getByRole('button', { name: en.discard })) + + expect(actions.save).toHaveBeenCalledOnce() + expect(actions.discard).toHaveBeenCalledOnce() + }) + + it('marks a card holding unsaved edits, collapsed or not', () => { + renderBash({ dirty: true }) + + expect(screen.getByText(en.unsaved)).toBeTruthy() + }) + + it('blocks the save while a draft is invalid, and says why', () => { + renderBash({ dirty: true, invalid: true, timeoutMs: field('soon', { invalid: true }) }) + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByRole('button', { name: en.save })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', false) + expect(screen.getByText(en.invalidNumber)).toBeTruthy() + }) + + it('reports a save in flight and refuses another', () => { + renderBash({ dirty: true, saving: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByRole('button', { name: en.saving })).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: en.discard })).toHaveProperty('disabled', true) + }) + + it('reports a save the deployment did not accept', () => { + renderBash({ dirty: true, failed: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.getByText(en.saveFailed)).toBeTruthy() }) it('says the document is read-only and disables its controls', () => { @@ -130,56 +200,73 @@ describe('BashCard', () => { expect(screen.getByRole('status')).toHaveProperty('textContent', en.readOnly) expect(screen.getByLabelText(en.bashTimeoutMs)).toHaveProperty('disabled', true) }) + + it('collapses again on a second click', () => { + renderBash() + fireEvent.click(screen.getByText(en.bashTitle)) + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + + fireEvent.click(screen.getByText(en.bashTitle)) + + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + }) }) describe('AgentLoopCard', () => { - it('edits the only field it owns', () => { + it('stages and saves the only field it owns', () => { const store = createSnapshotStore({ - available: true, - writable: true, - maxParallelToolCalls: { value: 10, overridden: false }, + ...settled, + dirty: true, + maxParallelToolCalls: field('10'), }) - const setMaxParallelToolCalls = vi.fn() + const actions = cardActions() const props = { + ...actions, t, useAgentLoopCard: bindSnapshotSelector(store), - setMaxParallelToolCalls, - resetMaxParallelToolCalls: vi.fn(), } as unknown as AgentLoopCardProps render() fireEvent.click(screen.getByText(en.agentLoopTitle)) - const input = screen.getByLabelText(en.agentLoopMaxParallel) - fireEvent.change(input, { target: { value: '2' } }) - fireEvent.blur(input) + fireEvent.change(screen.getByLabelText(en.agentLoopMaxParallel), { target: { value: '2' } }) + fireEvent.click(screen.getByRole('button', { name: en.save })) - expect(setMaxParallelToolCalls).toHaveBeenCalledWith(2) + expect(actions.edit).toHaveBeenCalledWith('maxParallelToolCalls', '2') + expect(actions.save).toHaveBeenCalledOnce() + }) + + it('stages a reset for the field it owns', () => { + const store = createSnapshotStore({ + ...settled, + maxParallelToolCalls: field('2', { overridden: true }), + }) + const actions = cardActions() + const props = { + ...actions, + t, + useAgentLoopCard: bindSnapshotSelector(store), + } as unknown as AgentLoopCardProps + render() + + fireEvent.click(screen.getByText(en.agentLoopTitle)) + fireEvent.click(screen.getByRole('button', { name: en.reset })) + + expect(actions.resetField).toHaveBeenCalledWith('maxParallelToolCalls') }) }) describe('WebSearchCard', () => { function renderWebSearch(state: Partial = {}) { const store = createSnapshotStore({ - available: true, - writable: true, - baseURL: { value: '', overridden: false }, - maxUses: { value: 5, overridden: false }, - apiKeyRef: 'DEEPSEEK_API_KEY', + ...settled, + baseURL: field(''), + maxUses: field('5'), + apiKey: field(''), apiKeyConfigured: false, ...state, }) - const actions = { - setBaseUrl: vi.fn(), - resetBaseUrl: vi.fn(), - setMaxUses: vi.fn(), - resetMaxUses: vi.fn(), - setApiKey: vi.fn(), - } - const props = { - ...actions, - t, - useWebSearchCard: bindSnapshotSelector(store), - } as unknown as WebSearchCardProps + const actions = cardActions() + const props = { ...actions, t, useWebSearchCard: bindSnapshotSelector(store) } as unknown as WebSearchCardProps render() return actions } @@ -201,23 +288,27 @@ describe('WebSearchCard', () => { expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', true) fireEvent.change(key, { target: { value: 'ds-secret' } }) - fireEvent.blur(key) - expect(actions.setApiKey).toHaveBeenCalledWith('ds-secret') + expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') }) - it('commits the endpoint and the search budget', () => { - const actions = renderWebSearch() + it('stages the endpoint, the search budget, and their resets', () => { + const actions = renderWebSearch({ + baseURL: field('https://search.test/v1', { overridden: true }), + maxUses: field('3', { overridden: true }), + }) fireEvent.click(screen.getByText(en.webSearchTitle)) - const endpoint = screen.getByLabelText(en.webSearchBaseUrl) - fireEvent.change(endpoint, { target: { value: 'https://search.test/v1' } }) - fireEvent.blur(endpoint) - const budget = screen.getByLabelText(en.webSearchMaxUses) - fireEvent.change(budget, { target: { value: '3' } }) - fireEvent.blur(budget) + fireEvent.change(screen.getByLabelText(en.webSearchBaseUrl), { target: { value: 'https://other.test' } }) + fireEvent.change(screen.getByLabelText(en.webSearchMaxUses), { target: { value: '4' } }) + const resets = screen.getAllByRole('button', { name: en.reset }) + expect(resets).toHaveLength(2) + for (const reset of resets) fireEvent.click(reset) - expect(actions.setBaseUrl).toHaveBeenCalledWith('https://search.test/v1') - expect(actions.setMaxUses).toHaveBeenCalledWith(3) + expect(actions.edit.mock.calls).toEqual([ + ['baseURL', 'https://other.test'], + ['maxUses', '4'], + ]) + expect(actions.resetField.mock.calls).toEqual([['baseURL'], ['maxUses']]) }) }) diff --git a/packages/client/ui-plugin-config/tests/stores.spec.ts b/packages/client/ui-plugin-config/tests/stores.spec.ts index 4797d3c262..60d540f121 100644 --- a/packages/client/ui-plugin-config/tests/stores.spec.ts +++ b/packages/client/ui-plugin-config/tests/stores.spec.ts @@ -1,14 +1,29 @@ /** - * Card controllers: how a scope snapshot becomes card state, and which wire - * call each action reaches. + * The staged card form: what a draft shows before it is written, which wire + * call a save reaches, and what happens to drafts the Host did not accept. */ import { describe, expect, it, vi } from 'vitest' -import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { CardForm, numberField, textField } from '../src/client/card-store.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-store.ts' import { BashCardController, type BashSettings } from '../src/client/bash-store.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-store.ts' +/** Make the stub behave like a Host that accepts every write. */ +function acceptWrites(host: StubSettingsScope): void { + const section = (): Record => ({ ...host.scope.getSnapshot().value as object }) + const layer = (): Record => ({ ...host.scope.getSnapshot().user as object }) + host.set.mockImplementation((field: string, value: unknown) => { + host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) + }) + host.unset.mockImplementation((field: string) => { + const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) + const base = host.scope.getSnapshot().base as Record | undefined + host.publish({ value: { ...section(), [field]: base?.[field] } as T, user }) + }) +} + function credentialsApi(configured: boolean) { const describe = vi.fn(() => Promise.resolve({ rpcId: 'c-1' as never, @@ -18,89 +33,340 @@ function credentialsApi(configured: boolean) { return { api: { credentials: { describe, set } } as never, describe, set } } -describe('BashCardController', () => { - it('publishes the effective value and marks only user-layer fields overridden', () => { - const host = stubSettingsScope() - const controller = new BashCardController(host.scope) - +describe('CardForm', () => { + function form() { + const host = stubSettingsScope>() + const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')]) + host.publish({ + status: 'ready', + writable: true, + value: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, + base: { timeoutMs: 60_000, baseURL: 'https://search.test/v1' }, + user: {}, + }) + return { host, subject } + } + + it('shows the effective value and stays clean until something is staged', () => { + const { subject } = form() + + expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) + expect(subject.shell()).toMatchObject({ available: true, writable: true, dirty: false, invalid: false }) + }) + + it('marks a field the user layer carries as overridden', () => { + const { host, subject } = form() + + host.publish({ value: { timeoutMs: 60_000 }, user: { timeoutMs: 60_000 } }) + + // An override equal to the composition default is still an override. + expect(subject.field('timeoutMs').overridden).toBe(true) + }) + + it('writes nothing until the form is saved', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('timeoutMs', '9000') + + expect(subject.field('timeoutMs')).toEqual({ text: '9000', overridden: true, invalid: false }) + expect(subject.shell().dirty).toBe(true) + expect(host.set).not.toHaveBeenCalled() + + await subject.save() + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000]]) + expect(subject.shell()).toMatchObject({ dirty: false, failed: false, saving: false }) + }) + + it('drops a draft that settles back on the value already shown', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + subject.actions().edit('timeoutMs', '60000') + + expect(subject.shell().dirty).toBe(false) + await subject.save() + + expect(host.set).not.toHaveBeenCalled() + }) + + it('refuses to save while a draft is not a value the field accepts', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', 'soon') + + expect(subject.field('timeoutMs')).toEqual({ text: 'soon', overridden: false, invalid: true }) + expect(subject.shell()).toMatchObject({ dirty: true, invalid: true }) + + await subject.save() + + expect(host.set).not.toHaveBeenCalled() + expect(subject.field('timeoutMs').text).toBe('soon') + }) + + it('stages a reset that clears the field only once saved', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ value: { timeoutMs: 9_000 }, user: { timeoutMs: 9_000 } }) + + subject.actions().resetField('timeoutMs') + + // The badge previews the save: the field will no longer be overridden. + expect(subject.field('timeoutMs')).toEqual({ text: '60000', overridden: false, invalid: false }) + expect(host.unset).not.toHaveBeenCalled() + + await subject.save() + + expect(host.unset.mock.calls).toEqual([['timeoutMs']]) + expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) + }) + + it('treats resetting an inherited field as no change at all', async () => { + const { host, subject } = form() + + subject.actions().resetField('timeoutMs') + + expect(subject.shell().dirty).toBe(false) + await subject.save() + + expect(host.unset).not.toHaveBeenCalled() + }) + + it('clears a number field by emptying it', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ user: { timeoutMs: 9_000 } }) + + subject.actions().edit('timeoutMs', '') + + expect(subject.field('timeoutMs')).toEqual({ text: '', overridden: false, invalid: false }) + await subject.save() + + expect(host.unset.mock.calls).toEqual([['timeoutMs']]) + }) + + it('clears a text field by emptying it', async () => { + const { host, subject } = form() + acceptWrites(host) + host.publish({ user: { baseURL: 'https://search.test/v1' } }) + + subject.actions().edit('baseURL', ' ') + await subject.save() + + expect(host.unset.mock.calls).toEqual([['baseURL']]) + }) + + it('writes the trimmed text of a text field', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('baseURL', ' https://other.test ') + await subject.save() + + expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test']]) + }) + + it('keeps the drafts a save did not land, and reports the failure', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + await subject.save() + + // The stub Host accepted the call without storing it, exactly as a + // validator that refuses the value does. + expect(host.set).toHaveBeenCalledWith('timeoutMs', 9_000) + expect(subject.shell()).toMatchObject({ dirty: true, failed: true, saving: false }) + expect(subject.field('timeoutMs').text).toBe('9000') + }) + + it('reports a reset the Host did not apply as a failure', async () => { + const { host, subject } = form() + host.publish({ user: { timeoutMs: 9_000 } }) + + subject.actions().resetField('timeoutMs') + await subject.save() + + expect(host.unset).toHaveBeenCalledWith('timeoutMs') + expect(subject.shell().failed).toBe(true) + }) + + it('clears the failure as soon as the user edits again', async () => { + const { subject } = form() + + subject.actions().edit('timeoutMs', '9000') + await subject.save() + expect(subject.shell().failed).toBe(true) + + subject.actions().edit('timeoutMs', '9001') + + expect(subject.shell().failed).toBe(false) + }) + + it('discards every staged edit', async () => { + const { host, subject } = form() + + subject.actions().edit('timeoutMs', '9000') + subject.actions().discard() + + expect(subject.field('timeoutMs').text).toBe('60000') + expect(subject.shell()).toMatchObject({ dirty: false, failed: false }) + + // A discard with nothing staged publishes nothing. + const before = subject.shell() + subject.actions().discard() + expect(subject.shell()).toEqual(before) + + await subject.save() + expect(host.set).not.toHaveBeenCalled() + }) + + it('refuses a second save while one is in flight', async () => { + const { host, subject } = form() + acceptWrites(host) + + subject.actions().edit('timeoutMs', '9000') + const first = subject.save() + expect(subject.shell().saving).toBe(true) + const second = subject.save() + await Promise.all([first, second]) + + expect(host.set).toHaveBeenCalledTimes(1) + }) + + it('publishes a projection whenever the scope or a draft changes', () => { + const { host, subject } = form() + const store = subject.bind(() => subject.field('timeoutMs').text) + expect(store.getSnapshot()).toBe('60000') + + host.publish({ value: { timeoutMs: 1_000 } }) + expect(store.getSnapshot()).toBe('1000') + + subject.actions().edit('timeoutMs', '2000') + expect(store.getSnapshot()).toBe('2000') + }) + + it('refuses to address a field the card never declared', () => { + const { subject } = form() + + expect(() => subject.field('nope')).toThrow('plugin card has no field nope') + }) + + it('renders an absent section value as an empty draft', () => { + const host = stubSettingsScope>() + const subject = new CardForm(host.scope, [numberField('timeoutMs'), textField('baseURL')]) + + host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: undefined }) + + expect(subject.field('timeoutMs').text).toBe('') + expect(subject.field('baseURL').text).toBe('') + expect(subject.shell().available).toBe(true) + }) + + it('stays unavailable while the namespace is not served', () => { + const host = stubSettingsScope>() + const subject = new CardForm(host.scope, [numberField('timeoutMs')]) + + host.publish({ status: 'unavailable' }) + + expect(subject.shell()).toMatchObject({ available: false, writable: false }) + }) +}) + +describe('BashCardController', () => { + it('projects both fields and saves them in one write pass', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new BashCardController(host.scope) host.publish({ status: 'ready', writable: true, - revision: 3, value: { timeoutMs: 5_000, maxOutputBytes: 64_000 }, base: { timeoutMs: 60_000, maxOutputBytes: 64_000 }, user: { timeoutMs: 5_000 }, }) + const face = controller.inject() - expect(controller.store.getSnapshot()).toMatchObject({ + expect(face.hooks.bashCard.getSnapshot()).toMatchObject({ available: true, writable: true, - timeoutMs: { value: 5_000, overridden: true }, - maxOutputBytes: { value: 64_000, overridden: false }, + dirty: false, + timeoutMs: { text: '5000', overridden: true }, + maxOutputBytes: { text: '64000', overridden: false }, }) + + face.edit('timeoutMs', '9000') + face.edit('maxOutputBytes', '1024') + expect(face.hooks.bashCard.getSnapshot().dirty).toBe(true) + + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) + + expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) + expect(face.hooks.bashCard.getSnapshot().dirty).toBe(false) }) - it('treats an override equal to the composition default as an override', () => { + it('stages a reset and applies it on save', async () => { const host = stubSettingsScope() + acceptWrites(host) const controller = new BashCardController(host.scope) - host.publish({ status: 'ready', writable: true, - value: { timeoutMs: 60_000 }, + value: { timeoutMs: 5_000 }, base: { timeoutMs: 60_000 }, - user: { timeoutMs: 60_000 }, + user: { timeoutMs: 5_000 }, }) + const face = controller.inject() - expect(controller.store.getSnapshot().timeoutMs).toEqual({ value: 60_000, overridden: true }) + face.resetField('timeoutMs') + expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('60000') + + face.save() + await vi.waitFor(() => { expect(host.unset).toHaveBeenCalledWith('timeoutMs') }) + + expect(face.hooks.bashCard.getSnapshot()).toMatchObject({ + dirty: false, + timeoutMs: { text: '60000', overridden: false }, + }) }) - it('routes each action to its field write', async () => { + it('discards staged edits without writing', () => { const host = stubSettingsScope() const controller = new BashCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 } }) - const actions = controller.inject() + host.publish({ status: 'ready', writable: true, value: { timeoutMs: 5_000 }, user: {} }) + const face = controller.inject() - actions.setTimeoutMs(9_000) - actions.resetTimeoutMs() - actions.setMaxOutputBytes(1_024) - actions.resetMaxOutputBytes() - await Promise.resolve() + face.edit('timeoutMs', '9000') + face.discard() - expect(host.set.mock.calls).toEqual([['timeoutMs', 9_000], ['maxOutputBytes', 1_024]]) - expect(host.unset.mock.calls).toEqual([['timeoutMs'], ['maxOutputBytes']]) - }) - - it('stays unavailable while the namespace is not served', () => { - const host = stubSettingsScope() - const controller = new BashCardController(host.scope) - - host.publish({ status: 'unavailable' }) - - expect(controller.store.getSnapshot().available).toBe(false) + expect(face.hooks.bashCard.getSnapshot().timeoutMs.text).toBe('5000') + expect(host.set).not.toHaveBeenCalled() }) }) describe('AgentLoopCardController', () => { - it('publishes the cap and routes its two actions', async () => { + it('saves the only field it owns', async () => { const host = stubSettingsScope() + acceptWrites(host) const controller = new AgentLoopCardController(host.scope) host.publish({ status: 'ready', writable: true, - value: { maxParallelToolCalls: 2 }, + value: { maxParallelToolCalls: 10 }, base: { maxParallelToolCalls: 10 }, - user: { maxParallelToolCalls: 2 }, + user: {}, }) - expect(controller.store.getSnapshot().maxParallelToolCalls).toEqual({ value: 2, overridden: true }) + const face = controller.inject() - const actions = controller.inject() - actions.setMaxParallelToolCalls(4) - actions.resetMaxParallelToolCalls() - await Promise.resolve() + face.edit('maxParallelToolCalls', '4') + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) }) - expect(host.set).toHaveBeenCalledWith('maxParallelToolCalls', 4) - expect(host.unset).toHaveBeenCalledWith('maxParallelToolCalls') + expect(face.hooks.agentLoopCard.getSnapshot()).toMatchObject({ + dirty: false, + maxParallelToolCalls: { text: '4', overridden: true }, + }) }) it('reports a read-only document so the card can disable its controls', () => { @@ -109,7 +375,7 @@ describe('AgentLoopCardController', () => { host.publish({ status: 'ready', writable: false, value: { maxParallelToolCalls: 10 } }) - expect(controller.store.getSnapshot().writable).toBe(false) + expect(controller.inject().hooks.agentLoopCard.getSnapshot().writable).toBe(false) }) }) @@ -118,76 +384,133 @@ describe('WebSearchCardController', () => { const host = stubSettingsScope() const credentials = credentialsApi(true) const controller = new WebSearchCardController(host.scope, credentials.api) + const state = () => controller.inject().hooks.webSearchCard.getSnapshot() await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) - host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } }) - await vi.waitFor(() => { - expect(controller.store.getSnapshot().apiKeyConfigured).toBe(true) - }) + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) + await vi.waitFor(() => { expect(state().apiKeyConfigured).toBe(true) }) - expect(controller.store.getSnapshot()).toMatchObject({ - baseURL: { value: 'https://search.test/v1', overridden: false }, - apiKeyRef: 'DEEPSEEK_API_KEY', + expect(state()).toMatchObject({ + baseURL: { text: 'https://search.test/v1', overridden: false }, + apiKey: { text: '', overridden: false }, }) }) - it('writes the key through the credentials domain, never the settings section', async () => { + it('writes the staged key through the credentials domain, never the settings section', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) const controller = new WebSearchCardController(host.scope, credentials.api) - host.publish({ status: 'ready', writable: true, value: {} }) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() - controller.inject().setApiKey('ds-secret') + face.edit('apiKey', ' ds-secret ') + expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(true) + expect(credentials.set).not.toHaveBeenCalled() + + credentials.describe.mockImplementation(() => Promise.resolve({ + rpcId: 'c-1' as never, + result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } }, + })) + face.save() await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) expect(credentials.set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'ds-secret' }) - expect(host.set).not.toHaveBeenCalledWith('apiKey', expect.anything()) + expect(host.set).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ dirty: false, apiKeyConfigured: true }) + }) + }) + + it('keeps the stored key when the draft is left blank', () => { + const host = stubSettingsScope() + const credentials = credentialsApi(true) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() + + face.edit('apiKey', ' ') + + expect(face.hooks.webSearchCard.getSnapshot().dirty).toBe(false) + face.save() + + expect(credentials.set).not.toHaveBeenCalled() }) it('addresses the reference the section declares rather than the default', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) const controller = new WebSearchCardController(host.scope, credentials.api) - host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' } }) + host.publish({ status: 'ready', writable: true, value: { apiKeyEnv: 'SEARCH_KEY' }, user: {} }) + const face = controller.inject() - controller.inject().setApiKey('ds-secret') + face.edit('apiKey', 'ds-secret') + face.save() await vi.waitFor(() => { expect(credentials.set).toHaveBeenCalled() }) expect(credentials.set).toHaveBeenCalledWith({ ref: 'SEARCH_KEY', value: 'ds-secret' }) }) - it('keeps the card usable when the credential read fails', async () => { + it('reports a key the Host did not store as a failed save', async () => { const host = stubSettingsScope() - const describe = vi.fn(() => Promise.reject(new Error('offline'))) - const controller = new WebSearchCardController( - host.scope, - { credentials: { describe, set: vi.fn() } } as never, - ) - await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + const face = controller.inject() - host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' } }) + face.edit('apiKey', 'ds-secret') + face.save() - expect(controller.store.getSnapshot()).toMatchObject({ - available: true, - apiKeyConfigured: false, - baseURL: { value: 'https://search.test/v1' }, + await vi.waitFor(() => { + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) }) }) - it('routes the endpoint and budget actions to their field writes', async () => { + it('keeps the card usable when the credential read fails', async () => { const host = stubSettingsScope() + const describe = vi.fn(() => Promise.reject(new Error('offline'))) + const set = vi.fn(() => Promise.reject(new Error('offline'))) + const controller = new WebSearchCardController(host.scope, { credentials: { describe, set } } as never) + const face = controller.inject() + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + host.publish({ status: 'ready', writable: true, value: { baseURL: 'https://search.test/v1' }, user: {} }) + face.edit('apiKey', 'ds-secret') + face.save() + await vi.waitFor(() => { expect(set).toHaveBeenCalled() }) + + expect(face.hooks.webSearchCard.getSnapshot()).toMatchObject({ + available: true, + apiKeyConfigured: false, + baseURL: { text: 'https://search.test/v1' }, + }) + }) + + it('ignores a credential read the Host refused', async () => { + const host = stubSettingsScope() + const describe = vi.fn(() => Promise.resolve({ + rpcId: 'c-1' as never, + result: { ok: false as const, error: { code: 'credentials-unavailable', message: 'no provider' } }, + })) + const controller = new WebSearchCardController(host.scope, { credentials: { describe, set: vi.fn() } } as never) + await vi.waitFor(() => { expect(describe).toHaveBeenCalled() }) + + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(false) + }) + + it('saves the endpoint and the search budget together', async () => { + const host = stubSettingsScope() + acceptWrites(host) const credentials = credentialsApi(true) const controller = new WebSearchCardController(host.scope, credentials.api) - host.publish({ status: 'ready', writable: true, value: {} }) - const actions = controller.inject() + host.publish({ status: 'ready', writable: true, value: {}, base: {}, user: {} }) + const face = controller.inject() - actions.setBaseUrl('https://other.test') - actions.resetBaseUrl() - actions.setMaxUses(3) - actions.resetMaxUses() - await Promise.resolve() + face.edit('baseURL', 'https://other.test') + face.edit('maxUses', '3') + face.save() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledTimes(2) }) expect(host.set.mock.calls).toEqual([['baseURL', 'https://other.test'], ['maxUses', 3]]) - expect(host.unset.mock.calls).toEqual([['baseURL'], ['maxUses']]) + expect(credentials.set).not.toHaveBeenCalled() }) }) From ed4d7e778445947b30168e9d864bff9a65760abf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 23:56:12 +0800 Subject: [PATCH 11/19] fix(client-ui-plugin-config): declare the browser half under dsh.client Merging master's rename of the client manifest field left this package on the old `dshClient` name. The row still composed and its empty node half still activated, but the browser roster scan never matched it, so the whole settings section vanished with no error anywhere. verify-cordis-config now requires a packages/client package's "./client" export and its dsh.client declaration to agree in both directions; the composition file cannot tell a surface plugin from a Host plugin, so the manifests are where this is checkable. The check is scoped to that group because a Host package's "./client" export is the typed wire face its browser consumers import, not a plugin the roster serves. --- ...6-08-10-web-plugin-configuration.i18n.yaml | 4 +-- .../2026-08-10-web-plugin-configuration.md | 2 ++ .../2026-08-10-web-plugin-configuration.zh.md | 2 ++ packages/client/ui-plugin-config/package.json | 18 ++++++----- packages/client/ui-plugin-config/src/index.ts | 2 +- scripts/verify-cordis-config.ts | 30 +++++++++++++++++++ 6 files changed, 47 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index 94d2e8fc8e..9295085481 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: cfc7108d3f241ba91c102e32f58b0b4fc2966f0c -2026-08-10-web-plugin-configuration.zh.md: cb6c5e0903a2f3034ff365fc50dd78e4fb655a06 +2026-08-10-web-plugin-configuration.md: ed81f94ac7dcc66236907b6994b850a6731bce1d +2026-08-10-web-plugin-configuration.zh.md: 61b63903d5a9974469b0acb6dd1f680aad8f95b0 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index cfc7108d3f..ed81f94ac7 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -45,3 +45,5 @@ A user edits the shell's command timeout and output cap, the agent loop's parall Two costs are real. Adding a fourth plugin still requires an entry in the apiproxy allowlist, so the page's reach is a Host decision rather than a plugin's. And the plugins the web deployment moved into the agent plane — the file tools, the skills, compaction, the todo tool — appear nowhere here, which is most of what a user might expect to find; their configuration remains the preset editor's. The bash and pwsh executors now expose `config` as a getter over a source thunk rather than a readonly field. Every read site was already per-call, so nothing else changed, but a subclass that captured `this.config` at construction would silently pin the composition entry. + +`verify-cordis-config` gained one check, paid for by this branch: merging master's rename of the client manifest field (`dshClient` → `dsh.client`) left this package declaring the old name, and the whole section vanished from the browser with no error anywhere — the row composed, the empty node half activated, and the browser roster scan simply never matched it. Nothing could catch that, because the composition file cannot tell a surface plugin from a Host plugin: the difference lives in the manifest. The gate now requires a `packages/client` package's `./client` export and its `dsh.client` declaration to agree in both directions. The check is scoped to that group because a Host package's `./client` export is the typed wire face its browser consumers import, not a plugin the roster serves. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index cb6c5e0903..61b63903d5 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -45,3 +45,5 @@ Status: implemented 有两项真实代价。加入第四个插件仍需要在 apiproxy 白名单里添一条,因此本页的覆盖面是 Host 的决定而非插件的决定。而 web 部署移入 agent 平面的那些插件——文件工具、技能、压缩、todo 工具——在这里一个都不出现,而它们恰恰是用户最可能期待找到的;它们的配置仍归 preset 编辑器。 bash 与 pwsh 执行器现在把 `config` 暴露为 source thunk 之上的 getter,而不再是 readonly 字段。所有读取点本就是按次读取,因此别无变化;但若某个子类在构造期捕获 `this.config`,就会悄然把组装条目钉死。 + +`verify-cordis-config` 新增一项检查,代价由本分支付过:合并 master 对客户端清单字段的重命名(`dshClient` → `dsh.client`)后,本包仍声明旧名,于是整个分区从浏览器上消失,且任何地方都不报错——行照常组装、空的 node 半侧照常激活,只是浏览器 roster 扫描永远匹配不到它。这一点无从被既有门禁发现,因为组装文件区分不了 surface 插件与 Host 插件:差别在清单里。现在门禁要求 `packages/client` 包的 `./client` 导出与 `dsh.client` 声明双向一致。之所以只限这一组:Host 包的 `./client` 导出是给浏览器消费方 import 的类型化 wire face,不是 roster 要服务的插件。 diff --git a/packages/client/ui-plugin-config/package.json b/packages/client/ui-plugin-config/package.json index 726dde43a9..6c81334e9f 100644 --- a/packages/client/ui-plugin-config/package.json +++ b/packages/client/ui-plugin-config/package.json @@ -22,14 +22,16 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dshClient": { - "inject": [ - "@deepseek-ai/dsh-client-connection", - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-settings" - ], - "platform": "web" + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-connection", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-settings" + ], + "platform": "web" + } }, "scripts": { "bundle": "tsdown", diff --git a/packages/client/ui-plugin-config/src/index.ts b/packages/client/ui-plugin-config/src/index.ts index 96ac4efa2e..0bb50fba35 100644 --- a/packages/client/ui-plugin-config/src/index.ts +++ b/packages/client/ui-plugin-config/src/index.ts @@ -2,7 +2,7 @@ * Plugin configuration surface, node half. The empty apply exists so the * plugin appears in the host cordis.yml / Loader; the browser half ships the * settings section through exports["./client"], discovered from the - * package.json dshClient declaration. Every section this page edits is owned + * package.json dsh.client declaration. Every section this page edits is owned * by the Host plugin that registered it, so this package registers no * namespace of its own. */ diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index a70dae4d1c..3f760c9ae1 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -79,6 +79,7 @@ errors.push(...validateExampleResolution()) errors.push(...validateAppResolution()) errors.push(...validateSourcePlaneResolution()) errors.push(...validatePresetPlaneSeparation()) +errors.push(...validateClientHalvesDeclared()) if (errors.length > 0) { console.error('verify-cordis-config: invalid Loader metadata or plugin package resolution:') @@ -88,6 +89,35 @@ if (errors.length > 0) { console.log(`verify-cordis-config: ${files.length} config files passed.`) } +/** + * A browser plugin must declare the browser half it ships. + * + * The browser roster is discovered by scanning composed packages for a + * `dsh.client` block, and the node half of a surface plugin is an empty + * `apply`. A `packages/client` package that exports `./client` without that + * block therefore composes, activates, and contributes nothing — its bundle is + * never served and no error is raised anywhere. The mismatch is invisible in + * the composition file, so it is checked against the manifests instead. Only + * this group is checked: a Host package's `./client` export is the typed wire + * face its browser consumers import, not a plugin the roster serves. + * @returns one violation per client package whose `./client` export and + * `dsh.client` declaration disagree. + */ +function validateClientHalvesDeclared(): string[] { + return globSync('packages/client/*/package.json', { cwd: root }).flatMap((manifestPath) => { + const manifest = readManifest(manifestPath) as PackageManifest & { + exports?: Record + dsh?: { client?: unknown } + } + const shipsClient = manifest.exports !== undefined && Object.hasOwn(manifest.exports, './client') + const declaresClient = manifest.dsh?.client !== undefined + if (shipsClient === declaresClient) return [] + return [shipsClient + ? `${manifestPath}: exports "./client" but declares no dsh.client, so its browser half is never served` + : `${manifestPath}: declares dsh.client but exports no "./client" entry to serve`] + }) +} + /** * No shipped agent preset may repeat a row the host composition still runs. * From 5dbb52a472f58598b2c78a44d5203756e0e0d0c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 00:32:54 +0800 Subject: [PATCH 12/19] test(client-ui-settings): cover the nav glyph every settings section gets The plugin-config section added a fourth id to the nav-glyph branch, and no test rendered one, so CI's per-file gate caught the uncovered path. The nav now asserts what it is for: each named id draws its own glyph, and a section this package never heard of still renders the gear. --- .../ui-settings/tests/settings-root.spec.tsx | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx index 1f34a47cf5..fae618d899 100644 --- a/packages/client/ui-settings/tests/settings-root.spec.tsx +++ b/packages/client/ui-settings/tests/settings-root.spec.tsx @@ -170,6 +170,28 @@ describe('SettingsPanel navigation', () => { expect(screen.getByTestId('section-general')).toBeTruthy() }) + it('gives every section a nav glyph, distinct for the ids the shell knows', () => { + mount({ + rows: [ + { id: 'general', order: 0, label: 'General' }, + { id: 'models', order: 10, label: 'Models' }, + { id: 'agent-presets', order: 20, label: 'Agent presets' }, + { id: 'plugins', order: 30, label: 'Plugins' }, + { id: 'contributed', order: 40, label: 'Contributed' }, + ], + }) + openPanel() + // Glyphs carry no id of their own, so the drawn paths are what tells them apart. + const glyphs = ['General', 'Models', 'Agent presets', 'Plugins', 'Contributed'] + .map(name => screen.getByRole('button', { name }).querySelector('svg')?.innerHTML) + + expect(glyphs.every(glyph => glyph !== undefined && glyph !== '')).toBe(true) + // The three ids the shell names get their own glyph; every other section — + // including one this package never heard of — shares the gear. + expect(new Set(glyphs.slice(0, 4)).size).toBe(4) + expect(glyphs[4]).toBe(glyphs[0]) + }) + it('switches the rendered section on nav click', () => { mount() openPanel() From ca119b0e1034e17b028bbeb522c112f374408264 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 11:16:42 +0800 Subject: [PATCH 13/19] =?UTF-8?q?fix(web-plugin-config):=20address=20revie?= =?UTF-8?q?w=20=E2=80=94=20one=20options=20snapshot=20per=20search,=20no?= =?UTF-8?q?=20public=20value=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from review survived against the staged-save head: The search provider read its options thunk per property, so a settings write landing inside credential resolution sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution; a regression test drives a commit into the middle of a search and pins that the endpoint, model, and key all come from the section the search started on. The /client entry exported components, controllers, and namespace constants with no consumer, which the client export discipline allows only with sign-off. Only types remain. The duplicate per-card Injected/Face interface pairs are one declaration each now, so a member added to one side cannot silently miss the other. The credential state carries the reference it describes and its writability: a reference change no longer projects the old answer onto the new name, an out-of-order response for a stale reference is dropped, and a key that a deployment sources from the process environment disables the control instead of inviting a write the Host must refuse. Also corrected three prose claims against the code they describe: the card's fields do not differ by platform (the served schema does), the section's empty line counts registered rather than visible cards and is read once, and the search README overstated what a configuration surface learns about a key. --- ...6-08-10-web-plugin-configuration.i18n.yaml | 4 +- .../2026-08-10-web-plugin-configuration.md | 3 +- .../2026-08-10-web-plugin-configuration.zh.md | 3 +- .../client/ui-plugin-config/README.i18n.yaml | 4 +- packages/client/ui-plugin-config/README.md | 3 +- packages/client/ui-plugin-config/README.zh.md | 3 +- .../src/client/AgentLoopCard.tsx | 14 +---- .../ui-plugin-config/src/client/BashCard.tsx | 14 +---- .../src/client/WebSearchCard.tsx | 18 ++---- .../ui-plugin-config/src/client/index.ts | 23 ++++---- .../src/client/web-search-store.ts | 49 +++++++++++++--- .../ui-plugin-config/tests/section.spec.tsx | 11 ++++ .../web/web-search-deepseek/README.i18n.yaml | 4 +- packages/web/web-search-deepseek/README.md | 2 +- packages/web/web-search-deepseek/README.zh.md | 2 +- .../web/web-search-deepseek/src/provider.ts | 58 ++++++++++--------- .../tests/deepseek.spec.ts | 28 +++++++++ 17 files changed, 150 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml index 9295085481..a27cb812e9 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md -2026-08-10-web-plugin-configuration.md: ed81f94ac7dcc66236907b6994b850a6731bce1d -2026-08-10-web-plugin-configuration.zh.md: 61b63903d5a9974469b0acb6dd1f680aad8f95b0 +2026-08-10-web-plugin-configuration.md: 7375f496c7af1a695243444fe56aca7262d3dedd +2026-08-10-web-plugin-configuration.zh.md: 59d65db39bcc2306983f2a26dcf252164d7a6f37 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md index ed81f94ac7..7375f496c7 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.md @@ -31,11 +31,12 @@ Three host-plane plugins register their own settings namespace, and one browser- ## Alternatives considered - **A registration-time exposure declaration replacing the allowlist.** The honest shape — the namespace's owner declares its own exposure, and a plugin distributed outside this repository can surface its configuration without a change in `packages/host/apiproxy`. Deferred because it changes the seam contract, every existing registration site, and the anti-enumeration semantics at once, and because a plugin exposing an arbitrary schema needs a fail-closed redaction path first: a secret reachable only through a union or transform is currently returned verbatim. -- **A generic schema-driven form renderer.** Declined again for the reason recorded in the web-config-plane note: field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package. +- **A generic schema-driven form renderer.** Declined again for the reason recorded in the [web-config-plane note](../architecture/2026-07-30-web-config-plane.md): field truth without a presentation vocabulary produced an unusable card. Three plugins of hand-written controls cost about the same and read better, and the slot keeps the fourth plugin from having to negotiate with this package. - **Editing preset-mounted plugins from this page.** Out of scope, and not merely unbuilt: a preset's rows carry their configuration inline in `agent.cordis.yml` and cannot register a settings namespace at all, because a second session mounting the same preset would fail on a duplicate registration. A user layer shared across presets would also overwrite the fields a preset uses to define its agent's identity — its persona text, its delegation wiring — which are per-preset by design. - **One namespace per executor package instead of the capability-named `bash`.** Declined because the composed executor differs by platform while the settings document does not: a user who set a timeout on macOS would silently lose it on Windows. - **Writing the search key into the settings section.** Declined because the literal would then have to ride a `describe` response to be rendered. The card reports only whether a key is configured and writes through the credentials domain, addressed by the reference the section names. - **Committing each control as it settles, with no save.** Built first, and replaced: blur is not a decision. It spent a namespace revision per control, gave the user nothing to preview or undo before the write, and left an invalid draft silently discarded — a value the Host's validator refuses simply snapped back with no reason given. One save per card makes the write a gesture the user performs. +- **Letting the provider read its options per property.** The thunk was read at each use site so read sites could stay unchanged, which quietly broke the contract the constructor states: `search()` awaits credential resolution and then reads the endpoint, model, and budget, so a settings write landing inside that await sent the key resolved from the old section to the endpoint named by the new one. Each operation now snapshots once at its entry and threads that snapshot into credential resolution. - **Validating the fields in the browser to keep the save honest.** Declined: the constraints live in the owning plugin's section validator, and restating them here would make two homes for one rule that could disagree per release. The card checks only what its own control can decide — that a numeric draft is a number — and lets the Host answer for the rest, which is why the save reads the section back. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md index 61b63903d5..59d65db39b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-plugin-configuration.zh.md @@ -31,11 +31,12 @@ Status: implemented ## 备选方案 - **用注册期的暴露声明取代白名单。** 这才是诚实的形状——命名空间的拥有方声明自己的暴露,在本仓库之外分发的插件也无需改动 `packages/host/apiproxy` 就能呈现自己的配置。之所以暂缓,是因为它会同时改变 seam 契约、全部现有注册点与防枚举语义;而且插件要暴露任意 schema,还得先有 fail-closed 的脱敏路径:目前只能经由 union 或 transform 抵达的 secret 会被原样返回。 -- **通用 schema 驱动的表单渲染器。** 再次否决,理由与 web-config-plane 笔记所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。 +- **通用 schema 驱动的表单渲染器。** 再次否决,理由与 [web-config-plane 笔记](../architecture/2026-07-30-web-config-plane.md)所记一致:没有呈现词汇的字段真值产出的是无法使用的卡片。三个插件的手写控件成本相当而可读性更好,且该 slot 让第四个插件无需与本包协商。 - **在本页编辑 preset 挂载的插件。** 超出范围,而且不只是「尚未实现」:preset 的行把配置内联在 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间——同一 preset 挂载第二个会话时会因重复注册而失败。跨 preset 共享的用户层还会覆盖 preset 用来定义其 agent 身份的字段——人设文本、委派接线——而这些字段按设计就是各 preset 各自的。 - **按执行器包各取一个命名空间,而非按能力命名的 `bash`。** 否决,因为被组装的执行器随平台不同,而设置文档不随平台不同:在 macOS 上设过超时的用户,到 Windows 上会悄无声息地失去它。 - **把搜索密钥写进 settings 分节。** 否决,因为那样字面值就必须搭乘 `describe` 响应才能被渲染。卡片只报告是否已配置密钥,并按分节所命名的引用经由 credentials 领域写入。 - **每个控件失焦即提交,不设保存。** 最初就是这么做的,后被替换:失焦不是决定。它每个控件花掉一个命名空间 revision,写入前不给用户任何预览或撤销的余地,还会把无效草稿悄悄丢弃——被 Host 校验器拒绝的值只是弹回原样,不给任何理由。每张卡片一个保存,才让写入成为用户执行的动作。 +- **让提供方按属性逐次读取 options。** 最初为了不改动读取点而在每个使用处读 thunk,这悄悄违背了构造函数自己声明的契约:`search()` 先 await 凭据解析,之后才读端点、模型与预算,因此落在那段 await 里的设置写入会把按旧分节解析出的密钥发往新分节命名的端点。现在每次操作在入口只快照一次,并把该快照传进凭据解析。 - **在浏览器端校验字段,好让保存诚实。** 否决:这些约束住在拥有方插件的分节校验器里,在这里重述一遍就会让同一条规则有两个家,且可能随版本各说各话。卡片只判断自己的控件能判断的事——数字草稿是不是数字——其余交给 Host 回答,这正是保存要回读分节的原因。 ## 影响 diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index 3e8e6143a9..ea6b60fb95 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/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-plugin-config/README.md -README.md: 51d86e8fbfd19d3d37f68650163180751fb27061 -README.zh.md: 48a68900a20aaaabcc9763b5aa61bc23cf3d16d8 +README.md: 9297379a940d004acc27e2dcb1bb879fa2142f30 +README.zh.md: 2b3d73bc6e1ccd72cad9fe86acb357c2ab269a41 diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 51d86e8fbf..9297379a94 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -34,4 +34,5 @@ None; this package neither assembles nor sends a provider request. - **Only host-plane plugins appear** — a plugin an agent preset mounts carries its configuration inline in that preset's `agent.cordis.yml` and cannot register a settings namespace at all (a second session mounting the same preset would fail on a duplicate registration), so this section lists nothing for it. Editing those values remains the preset editor's job. - **Exposure is a Host allowlist, not a plugin declaration** — a namespace absent from the api-proxy's allowlist answers `settings-not-exposed` even when its owner registered it, so a plugin distributed outside this repository cannot surface its own configuration here without a change in `packages/host/apiproxy`. -- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the card's fields differ by platform and a deployment composing neither shows no card. +- **The shell card follows the composed executor** — the POSIX and PowerShell executor families share the `bash` namespace because a host composes exactly one of them, so the served schema differs by platform (PowerShell adds `pwshPath`) even though the card edits the same two fields on both, and a deployment composing neither shows no card. +- **The empty line counts registered cards, not visible ones** — a card whose namespace this deployment does not expose renders nothing, but still counts, so a deployment that exposes none shows an empty list rather than the empty line. The count is also read once, because the renderer caches a root entry's inject face; a card registered later does not raise it. diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index 48a68900a2..2b3d73bc6e 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -34,4 +34,5 @@ - **只有宿主平面的插件会出现**——由 agent preset 挂载的插件把配置内联在该 preset 的 `agent.cordis.yml` 中,且根本无法注册 settings 命名空间(同一 preset 挂载第二个会话时会因重复注册而失败),因此本分区不会列出它。编辑那些值仍是 preset 编辑器的职责。 - **暴露是 Host 的白名单,而非插件的声明**——不在 api-proxy 白名单中的命名空间,即便其拥有方已注册,也只会得到 `settings-not-exposed`,因此在本仓库之外分发的插件无法在不改动 `packages/host/apiproxy` 的前提下让自己的配置出现在这里。 -- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以该卡片的字段随平台不同,而两者都不组装的部署不会显示这张卡片。 +- **shell 卡片跟随被组装的执行器**——POSIX 与 PowerShell 两个执行器家族共用 `bash` 命名空间,因为一个宿主只组装其中之一,所以被服务的 schema 随平台不同(PowerShell 多出 `pwshPath`),尽管卡片在两者下编辑的都是同样两个字段;而两者都不组装的部署不会显示这张卡片。 +- **空态数的是已注册卡片,不是可见卡片**——命名空间未被本部署暴露的卡片什么都不渲染,但仍计入数量,因此一个都不暴露的部署看到的是空列表而非那行空态文案。该计数还只读取一次,因为渲染器会缓存根级 entry 的 inject face;之后注册的卡片不会让它变大。 diff --git a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx index 5231b73b64..450e7f4b3e 100644 --- a/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx +++ b/packages/client/ui-plugin-config/src/client/AgentLoopCard.tsx @@ -1,26 +1,16 @@ /** The agent loop's card: how many tool calls one step may run at once. */ -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' -import type { CardActions } from './card-store.ts' -import type { AgentLoopCardState } from './agent-loop-store.ts' +import type { AgentLoopCardFace } from './agent-loop-store.ts' import type {} from './slot-contract.ts' -/** Registration-side business face for the agent-loop card. */ -export interface AgentLoopCardInjected extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useAgentLoopCard. */ - agentLoopCard: SnapshotStore - } -} - /** Props the renderer binds for the agent-loop card. */ export type AgentLoopCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.pluginConfig'> - & InjectFace + & InjectFace /** * Render the agent-loop card. diff --git a/packages/client/ui-plugin-config/src/client/BashCard.tsx b/packages/client/ui-plugin-config/src/client/BashCard.tsx index ade767fa56..d7f9918a39 100644 --- a/packages/client/ui-plugin-config/src/client/BashCard.tsx +++ b/packages/client/ui-plugin-config/src/client/BashCard.tsx @@ -1,26 +1,16 @@ /** The shell plugin's card: the limits every command the agent runs is bound by. */ -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' -import type { CardActions } from './card-store.ts' -import type { BashCardState } from './bash-store.ts' +import type { BashCardFace } from './bash-store.ts' import type {} from './slot-contract.ts' -/** Registration-side business face for the shell card. */ -export interface BashCardInjected extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useBashCard. */ - bashCard: SnapshotStore - } -} - /** Props the renderer binds for the shell card. */ export type BashCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.pluginConfig'> - & InjectFace + & InjectFace /** * Render the shell card. diff --git a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx index 302bbf09e5..02762a802f 100644 --- a/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx +++ b/packages/client/ui-plugin-config/src/client/WebSearchCard.tsx @@ -4,27 +4,17 @@ * the settings section, so the literal never rides a response. */ -import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { SecretField, ValueField } from './fields.tsx' import { PluginCard } from './PluginCard.tsx' -import type { CardActions } from './card-store.ts' -import type { WebSearchCardState } from './web-search-store.ts' +import type { WebSearchCardFace } from './web-search-store.ts' import type {} from './slot-contract.ts' -/** Registration-side business face for the web-search card. */ -export interface WebSearchCardInjected extends CardActions { - hooks: { - /** Card snapshot bound by the renderer as useWebSearchCard. */ - webSearchCard: SnapshotStore - } -} - /** Props the renderer binds for the web-search card. */ export type WebSearchCardProps = PropsRuntime<'settings.plugin.item'> & PropsLocale<'settings.pluginConfig'> - & InjectFace + & InjectFace /** * Render the web-search card. @@ -50,7 +40,9 @@ export function WebSearchCard(props: WebSearchCardProps) { hint={t('webSearchApiKeyHint')} // The credentials domain accepts a key even when the settings document // itself is read-only; they are separate stores with separate refusals. - disabled={false} + // Its own writability is what disables this control — a key sourced + // from the process environment cannot be written from here. + disabled={!state.apiKeyWritable} text={state.apiKey.text} configured={state.apiKeyConfigured} stateLabel={state.apiKeyConfigured ? t('webSearchApiKeySet') : t('webSearchApiKeyUnset')} diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index d097a8a756..d49728998b 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -28,14 +28,13 @@ import { en, zh } from './locales.ts' export type { PluginConfigSectionInjected, PluginConfigSectionProps } from './PluginConfigSection.tsx' export type { PluginCardProps } from './PluginCard.tsx' export type { SettingsPluginItemOwnerProps } from './slot-contract.ts' -export { SecretField, ValueField, type FieldProps } from './fields.tsx' -export { - CardForm, numberField, textField, - type CardActions, type CardFieldSpec, type CardFieldState, type CardSecretSpec, type CardShell, +export type { FieldProps } from './fields.tsx' +export type { + CardActions, CardFieldSpec, CardFieldState, CardSecretSpec, CardShell, } from './card-store.ts' -export { AGENT_LOOP_NS, AgentLoopCardController, type AgentLoopCardState } from './agent-loop-store.ts' -export { BASH_NS, BashCardController, type BashCardState } from './bash-store.ts' -export { WEB_SEARCH_NS, WebSearchCardController, type WebSearchCardState } from './web-search-store.ts' +export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-store.ts' +export type { BashCardFace, BashCardState } from './bash-store.ts' +export type { WebSearchCardFace, WebSearchCardState } from './web-search-store.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.pluginConfig' @@ -56,9 +55,13 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api) - // The section renders the empty line rather than an empty list when no card - // is registered; the ledger is read at render time so a card arriving later - // (or leaving with its plugin) is reflected without the section subscribing. + // The section renders the empty line rather than an empty list when no plugin + // contributed a card. The count is read once: the renderer caches a root + // entry's inject face per registration, so this reports what was registered + // when the section mounted, not what is visible now. Both gaps are bounded by + // this deployment always registering the three cards below — a card that + // arrives later would not raise the count, and a namespace this deployment + // does not expose leaves its card rendering nothing inside a non-empty list. ctx.slots.inject('settings.section', () => ctx.slots.register({ name: 'settings.section', id: 'plugins', diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts index 5fc4ad7ab5..718aa414bd 100644 --- a/packages/client/ui-plugin-config/src/client/web-search-store.ts +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -38,6 +38,16 @@ export interface WebSearchSettings { maxUses?: number } +/** What the credentials domain last reported, and for which reference. */ +interface CredentialState { + /** Reference this answer describes; a stale response for another one is dropped. */ + ref: string + /** Whether any layer supplies a value for it. */ + configured: boolean + /** Whether `credentials.set` can affect it; false disables the control. */ + writable: boolean +} + /** What the web-search card renders. */ export interface WebSearchCardState extends CardShell { /** Provider endpoint. */ @@ -48,6 +58,8 @@ export interface WebSearchCardState extends CardShell { apiKey: CardFieldState /** Whether the Host reports a credential configured for the referenced key. */ apiKeyConfigured: boolean + /** Whether the credentials domain accepts a write for it; false disables the control. */ + apiKeyWritable: boolean } /** The registration-side face the web-search card's slot entry injects. */ @@ -62,7 +74,7 @@ export interface WebSearchCardFace extends CardActions { export class WebSearchCardController { private readonly form: CardForm private readonly store: SnapshotStore - private configured = false + private credential: CredentialState = { ref: '', configured: false, writable: true } /** * @param scope - the bound settings scope for the `web-search-deepseek` namespace. @@ -88,13 +100,27 @@ export class WebSearchCardController { baseURL: this.form.field('baseURL'), maxUses: this.form.field('maxUses'), apiKey: this.form.field(API_KEY_FIELD), - apiKeyConfigured: this.configured, + apiKeyConfigured: this.credential.configured, + apiKeyWritable: this.credential.writable, } } - /** Ask the credentials domain whether the referenced key exists. */ + /** + * Ask the credentials domain about the reference the section currently names. + * + * The answer is stored with the reference it describes: `apiKeyEnv` can + * change between the request and its response, and two reads can settle out + * of order, so a response is published only while it still answers for the + * reference in force. + */ private async readCredential(): Promise { const ref = refOf(this.scope.getSnapshot()) + if (ref !== this.credential.ref) { + // A new reference knows nothing yet; keeping the old answer would claim + // the key is configured under a name nobody has checked. + this.credential = { ref, configured: false, writable: true } + this.store.set(this.projection()) + } let response: Awaited> try { response = await this.api.credentials.describe({ refs: [ref] }) @@ -103,10 +129,17 @@ export class WebSearchCardController { // last state it knew, and a write still reaches the Host. return } - if (!response.result.ok) return - const next = response.result.value.credentials[ref]?.configured ?? false - if (next === this.configured) return - this.configured = next + if (!response.result.ok || ref !== refOf(this.scope.getSnapshot())) return + const view = response.result.value.credentials[ref] + const next: CredentialState = { + ref, + configured: view?.configured ?? false, + // An unknown reference is treated as writable: the control stays usable + // and the Host is what refuses, rather than the card guessing a refusal. + writable: view?.writable ?? true, + } + if (next.configured === this.credential.configured && next.writable === this.credential.writable) return + this.credential = next this.store.set(this.projection()) } @@ -131,7 +164,7 @@ export class WebSearchCardController { // authority on whether the key now exists. } await this.readCredential() - return this.configured + return this.credential.configured } } diff --git a/packages/client/ui-plugin-config/tests/section.spec.tsx b/packages/client/ui-plugin-config/tests/section.spec.tsx index 1452345804..3945092587 100644 --- a/packages/client/ui-plugin-config/tests/section.spec.tsx +++ b/packages/client/ui-plugin-config/tests/section.spec.tsx @@ -263,6 +263,7 @@ describe('WebSearchCard', () => { maxUses: field('5'), apiKey: field(''), apiKeyConfigured: false, + apiKeyWritable: true, ...state, }) const actions = cardActions() @@ -292,6 +293,16 @@ describe('WebSearchCard', () => { expect(actions.edit).toHaveBeenCalledWith('apiKey', 'ds-secret') }) + it('disables the key control when the reference itself is not writable', () => { + // A key coming from the process environment: the settings document is + // writable, the credential is not. + renderWebSearch({ apiKeyConfigured: true, apiKeyWritable: false }) + fireEvent.click(screen.getByText(en.webSearchTitle)) + + expect(screen.getByLabelText(en.webSearchApiKey)).toHaveProperty('disabled', true) + expect(screen.getByLabelText(en.webSearchBaseUrl)).toHaveProperty('disabled', false) + }) + it('stages the endpoint, the search budget, and their resets', () => { const actions = renderWebSearch({ baseURL: field('https://search.test/v1', { overridden: true }), diff --git a/packages/web/web-search-deepseek/README.i18n.yaml b/packages/web/web-search-deepseek/README.i18n.yaml index 09e251b340..04c3822b16 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: 3e8b631b5775793e6e6e542c810edce9d92a0d59 -README.zh.md: 9bd59000c2a0054265444aa90fc46ff5f34fd607 +README.md: 1e90f947b4a58288722307aaed50c4889cfe7cb4 +README.zh.md: 21962bcd390f253f899000be7273a79485821018 diff --git a/packages/web/web-search-deepseek/README.md b/packages/web/web-search-deepseek/README.md index 3e8b631b57..1e90f947b4 100644 --- a/packages/web/web-search-deepseek/README.md +++ b/packages/web/web-search-deepseek/README.md @@ -34,7 +34,7 @@ It reuses the `DEEPSEEK_API_KEY` credential reference (no new secret) but **not* baseURL: https://gateway.internal/anthropic/v1 ``` -The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only that a key is set. +The entry above is the base layer of the `web-search-deepseek` Settings section: a user layer over it reaches the NEXT search, because the provider projects the section per call rather than capturing it at registration. The seam's provider selection therefore never flickers when an endpoint or model changes. `apiKey` carries `role('secret')`, so it never rides a `describe()` response in any layer — a configuration surface learns only whether the credentials domain holds a value for the reference `apiKeyEnv` names, never whether a layer carries a literal key. ## Mapping diff --git a/packages/web/web-search-deepseek/README.zh.md b/packages/web/web-search-deepseek/README.zh.md index 9bd59000c2..21962bcd39 100644 --- a/packages/web/web-search-deepseek/README.zh.md +++ b/packages/web/web-search-deepseek/README.zh.md @@ -34,7 +34,7 @@ Exa 和 Perplexity 提供专用搜索端点,DeepSeek 则没有。该提供方 baseURL: https://gateway.internal/anthropic/v1 ``` -上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道密钥是否已设置。 +上面的条目是 `web-search-deepseek` Settings 段的 base 层:叠加其上的用户层会作用于**下一次**搜索,因为提供方是按次投影该段,而不是在注册时固化它。因此端点或模型变化时,seam 的提供方选择不会闪断。`apiKey` 带有 `role('secret')`,所以它在任何一层都不会出现在 `describe()` 响应中——配置表层只能知道 credentials 领域是否为 `apiKeyEnv` 所命名的引用持有值,而无从知道某一层是否带着字面密钥。 ## 映射 diff --git a/packages/web/web-search-deepseek/src/provider.ts b/packages/web/web-search-deepseek/src/provider.ts index 4b37f26bee..ce19474ab0 100644 --- a/packages/web/web-search-deepseek/src/provider.ts +++ b/packages/web/web-search-deepseek/src/provider.ts @@ -178,41 +178,42 @@ export class DeepSeekSearchProvider implements WebSearchProvider { readonly id = DEEPSEEK_PROVIDER_ID /** - * @param resolveOptions - the options for the NEXT operation. A thunk rather - * than a value because the plugin's settings section can change between - * searches, and re-registering the provider to carry a new endpoint would - * make the seam's selection observable to the user as a flicker. + * @param resolveOptions - the options for the NEXT operation, snapshotted + * once at each operation's entry so one search never mixes two sections. A + * thunk rather than a value because the plugin's settings section can change + * between searches, and re-registering the provider to carry a new endpoint + * would make the seam's selection observable to the user as a flicker. */ constructor(private readonly resolveOptions: () => DeepSeekSearchProviderOptions) {} - /** Options resolved per read, so a committed settings change reaches the next search. */ - private get options(): DeepSeekSearchProviderOptions { - return this.resolveOptions() - } - available(): boolean { - return ((this.options.apiKey?.length ?? 0) > 0 || this.options.resolveApiKey !== undefined) - && URL.canParse(this.options.baseURL) - && isPositiveInteger(this.options.maxTokens) - && isPositiveInteger(this.options.maxUses) + const options = this.resolveOptions() + return ((options.apiKey?.length ?? 0) > 0 || options.resolveApiKey !== undefined) + && URL.canParse(options.baseURL) + && isPositiveInteger(options.maxTokens) + && isPositiveInteger(options.maxUses) } async search(request: WebSearchRequest, signal?: AbortSignal): Promise { - const apiKey = await this.apiKey(signal) + // One snapshot for the whole operation: credential resolution awaits, and a + // settings write landing inside that await must not send the key resolved + // from the old section to the endpoint named by the new one. + const options = this.resolveOptions() + const apiKey = await this.apiKey(options, signal) throwIfSearchAborted(signal) - const endpoint = `${this.options.baseURL}/messages` + const endpoint = `${options.baseURL}/messages` const body: DeepSeekSearchLlmRequest['body'] = { - model: this.options.model, - max_tokens: this.options.maxTokens, + model: options.model, + max_tokens: 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 }], + tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: options.maxUses }], } - this.options.recordRequest?.({ + options.recordRequest?.({ endpoint, - apiVersion: this.options.apiVersion, + apiVersion: options.apiVersion, body, }) throwIfSearchAborted(signal) @@ -226,7 +227,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { // may expect `Authorization: Bearer` — send both so either resolves. 'x-api-key': apiKey, 'authorization': `Bearer ${apiKey}`, - 'anthropic-version': this.options.apiVersion, + 'anthropic-version': options.apiVersion, 'content-type': 'application/json', 'accept': 'application/json', 'user-agent': USER_AGENT, @@ -268,13 +269,18 @@ export class DeepSeekSearchProvider implements WebSearchProvider { } } - /** Resolve one operation's credential without retaining it on the provider. */ - private async apiKey(signal?: AbortSignal): Promise { + /** + * Resolve one operation's credential without retaining it on the provider. + * @param options - the caller's snapshot, so the key and the endpoint it is sent to come from one section. + * @param signal - abort signal for the surrounding search. + * @returns the resolved key. + */ + private async apiKey(options: DeepSeekSearchProviderOptions, signal?: AbortSignal): Promise { throwIfSearchAborted(signal) - if (this.options.apiKey !== undefined && this.options.apiKey.length > 0) return this.options.apiKey + if (options.apiKey !== undefined && options.apiKey.length > 0) return options.apiKey let resolved: string | undefined try { - resolved = await abortable(this.options.resolveApiKey?.() ?? Promise.resolve(undefined), signal) + resolved = await abortable(options.resolveApiKey?.() ?? Promise.resolve(undefined), signal) } catch (error: unknown) { if (signal?.aborted === true || isAbortError(error)) throw searchAborted(signal, error) throw new WebError( @@ -284,7 +290,7 @@ export class DeepSeekSearchProvider implements WebSearchProvider { ) } if (resolved !== undefined && resolved.length > 0) return resolved - const ref = this.options.apiKeyEnv ?? 'DEEPSEEK_API_KEY' + const ref = 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' diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index b57c44c731..c1769f42fc 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -205,6 +205,34 @@ describe('DeepSeekSearchProvider request mapping', () => { }) }) +describe('DeepSeekSearchProvider settings changes mid-search', () => { + it('serves one search from one section even when settings land during credential resolution', async () => { + // The section the search starts on, and the one a user commits while the + // credential is still resolving. + const before = { ...options, apiKey: '', baseURL: 'https://before.test/v1', model: 'model-before', maxUses: 2 } + const after = { ...options, apiKey: '', baseURL: 'https://after.test/v1', model: 'model-after', maxUses: 9 } + let current = before + let commitSettings = () => {} + const resolveApiKey = () => new Promise((resolve) => { + commitSettings = () => { current = after; resolve('key-from-before') } + }) + const fetchMock = vi.fn(async () => jsonResponse(searchResponse())) + vi.stubGlobal('fetch', fetchMock) + + const provider = new DeepSeekSearchProvider(() => ({ ...current, resolveApiKey })) + const search = provider.search({ query: 'q' }) + await vi.waitFor(() => { expect(typeof commitSettings).toBe('function') }) + commitSettings() + await search + + const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] + // The key resolved from `before` must never reach `after`'s origin. + expect(endpoint).toBe('https://before.test/v1/messages') + expect((init.headers as Record)['x-api-key']).toBe('key-from-before') + expect(JSON.parse(String(init.body))).toMatchObject({ model: 'model-before' }) + }) +}) + 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') From 0b3ac6356bcecd3455e8cbf7aff6acddd6ffa92d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 17:43:09 +0800 Subject: [PATCH 14/19] fix(preset): correct the composition-authoring skill and give it a real check The `cordis` preset's `editing-cordis-compositions` skill is the only guidance an agent has when it authors a preset, and four of its statements were false. `tool-bash` was named as the worked example of a row that hides a service; it provides nothing and injects `bashEnv` from the host's own `bash-env` row, so following that advice strands the row behind its realm and the preset fails to mount. The `isolate` example composed `tasks-local` with `tool-tasks`, which the shipped compositions' own comments say breaks `run_in_background`. A string realm label was described as pooling one instance; labels join realms and `provide()` still throws on the second registration. Rows were to be checked against a package README, which no harness package publishes. Verification is now the agent's own: `standingKeyFor(id)` runs the same mount a session start performs and rejects an unresolvable package, an invalid config, a service in the root realm, and a row that never activated. The skill states that `list()`'s `broken` field is a shape check that every one of those passes, ships the `cordis_mount` plugin that reaches the roster service, and names `copy()` as the authoring write. The prohibition on touching the shipped install is promoted to its own section and extended to the host composition. Fixes #2266 --- ...-08-09-broken-preset-roster-rows.i18n.yaml | 4 +- .../2026-08-09-broken-preset-roster-rows.md | 2 +- ...2026-08-09-broken-preset-roster-rows.zh.md | 2 +- ...nt-validates-its-own-composition.i18n.yaml | 6 + ...ing-agent-validates-its-own-composition.md | 68 +++++++++ ...-agent-validates-its-own-composition.zh.md | 68 +++++++++ .../editing-cordis-compositions/SKILL.md | 144 ++++++++++++------ 7 files changed, 246 insertions(+), 48 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml index 9ad1682b62..2a62045ef1 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.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/bug-fix/2026-08-09-broken-preset-roster-rows.md -2026-08-09-broken-preset-roster-rows.md: fef6a183b10f98b8ae9d2b42701380c69bc83462 -2026-08-09-broken-preset-roster-rows.zh.md: 196bcf4ef16325a1d7692d2ea13d9fa683d500f4 +2026-08-09-broken-preset-roster-rows.md: 069585957d4d99598cc38e4a7c6bc8c8d82490ca +2026-08-09-broken-preset-roster-rows.zh.md: d541292b59496464eb91bc03278e0800af16c4a3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md index fef6a183b1..069585957d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.md @@ -26,7 +26,7 @@ Surfaces split by their job: the management section renders broken rows as marke - **`PRESET_ID` moved to `types.ts`** so discovery and authoring share one containment vocabulary; authoring re-exports it unchanged. - **The reason is one line.** js-yaml appends a multi-line code-frame snippet; the roster card is not a terminal, so `compositionProblem` keeps the first line. - **Two mount.spec races were left untouched deliberately**: `ensureStanding` is still reachable with a preset resolved just before deletion (the private-path tests), and its stamp/unstampable semantics are unchanged — the health check happens before, in the public route. -- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona now forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`; its skill teaches `preset.yml` metadata, the copy-first workflow, the one-escalation sandbox reality (the preset root lies outside the session workspace), and honest verification (the agent cannot start sessions; the settings page's red marking is the user's check). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME`, batches writes into one escalation, self-checks with the loader dialect, and hands verification to the user. +- **Creator-mode guidance rides the same PR**: the `cordis` preset's persona forbids editing the shipped install (corrupting `cordis` would disable the mode itself) and points authoring at `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`; its skill teaches `preset.yml` metadata, the copy-first workflow, and the one-escalation sandbox reality (the preset root lies outside the session workspace). Verified live: asked to edit the shipped `cordis` composition directly, the composed agent refuses citing both rules and offers the copy path; asked for a real preset, it lands it under `$DSH_HOME` and batches writes into one escalation. The verification half of that guidance — that the agent cannot start sessions, so the settings page's red marking is the user's check — is superseded by [the authoring agent mount-validates its own composition](2026-08-11-preset-authoring-agent-validates-its-own-composition.md): the shape check below is not validation, and `standingKeyFor` gives the agent the real one. The health decision in this note is unchanged. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md index 196bcf4ef1..d541292b59 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-09-broken-preset-roster-rows.zh.md @@ -26,7 +26,7 @@ Status: implemented - **`PRESET_ID` 移到 `types.ts`**,让发现与创作共享同一份包含边界词汇;authoring 原样转发导出。 - **原因只留一行。** js-yaml 会附上多行代码框摘录;名单卡片不是终端,`compositionProblem` 只保留首行。 - **mount.spec 的两个竞态用例特意不动**:`ensureStanding` 仍可能拿到删除前一刻解析出的 preset(私有路径测试),其 stamp/unstampable 语义不变——健康检查发生在此之前的公开路径上。 -- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 现在禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`;其技能新教了 `preset.yml` 元信息、先复制再改的流程、一次升级的沙箱现实(preset 根目录在会话工作区之外)与诚实的验证方式(agent 无法自己启动会话;设置页的红色标记是用户的检查项)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下、把写入合并为一次升级、用加载器方言自查、并把验证交还用户。 +- **创造模式的引导随同一 PR 落地**:`cordis` preset 的 persona 禁止编辑随附安装(损坏 `cordis` 会禁用这一模式本身),并把创作指向 `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`;其技能教了 `preset.yml` 元信息、先复制再改的流程与一次升级的沙箱现实(preset 根目录在会话工作区之外)。已实测:被要求直接改随附 `cordis` 组装时,组装出的 agent 援引两条规则拒绝并给出复制路径;被要求真正创建 preset 时,它落在 `$DSH_HOME` 下并把写入合并为一次升级。该引导中关于验证的那一半——agent 无法自己启动会话,因而设置页的红色标记是用户的检查项——已由[创作 preset 的 agent 自行挂载校验其组装](2026-08-11-preset-authoring-agent-validates-its-own-composition.md)取代:下文的结构检查不是校验,而 `standingKeyFor` 才给了 agent 真正的校验手段。本篇的健康检查决策不变。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml new file mode 100644 index 0000000000..d932d7f6f4 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 77bd30d4c8f6599ccde50b4d814f55d065266763 +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: cdc0d30bcd769e1a17ddcce364002f0136a26242 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md new file mode 100644 index 0000000000..77bd30d4c8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -0,0 +1,68 @@ +# Agent Note: The preset-authoring agent mount-validates its own composition + +Status: implemented + +English | [中文](2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md) + +## Problem + +The `cordis` preset ships `editing-cordis-compositions`, the only guidance an agent has when it authors a preset. Four of its statements were false, and the two that carried the most weight pointed at the rule the skill itself calls "the rule that catches people". + +It named `tool-bash` as the worked example of a row whose name hides a service — "reads like a tool but provides `bashEnv`". `tool-bash` provides nothing; it declares `inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`, and `bashEnv` comes from the host composition's own `bash-env` row. An agent wrapping `tool-bash` in an `isolate` realm on that advice strands the row waiting for a service its realm hides, and the whole preset fails to mount. + +Its `isolate` example composed `tasks-local` with `tool-tasks`. `tasks-local` is host-plane, and the shipped compositions say in their own comments that an entry-local realm around `tool-tasks` makes `run_in_background` answer "background tasks unavailable". The example contradicted the file next to it. + +It described a string realm label as pooling one instance across subtrees. Labels join realms; `provide()` still throws on the second registration under the same realm symbol, which `standard`'s header comment already stated. + +It sent the agent to a package's README to learn whether a row publishes a service. Outside `apps/cli` (`files: ["lib/*.js", "config"]`), every harness package publishes only `lib/index.js`, `lib/invariant.js`, and `lib/types/**/*.d.ts` — no README, no `src/`, no `docs/`. In an installed deployment that instruction cannot be followed at all. + +Underneath all four sat a capability claim: the agent "cannot start one \[a session\] yourself", so verification was hand-reading YAML fields and handing the result to the user through the settings page's red marking. That marking is discovery's shape check, which is far weaker than the sentence implied. + +## Decision + +The skill teaches the agent to mount-validate its own composition through `ctx.agentPresets`, and every remaining example is taken from a shipped composition in the same repository. + +`standingKeyFor(id)` is the check. It runs `ensureStanding()` — the same real mount a session start performs, minus the agent — so it rejects a row whose package does not resolve, a row whose config is invalid, a service published into the root realm, and a row that never activated. A failed mount deletes the standing entry and disposes its scope, leaving nothing behind; a successful one installs the standing generation the first real session would have installed anyway. The skill therefore places it as the final check on a finished edit rather than a per-line loop. + +The skill states plainly that `list()`'s `broken` field is **not** validation. Discovery's health check proves the file parses in the loader's dialect and holds named rows, and every one of the four failures above passes it. + +The agent reaches the roster service the way `cordis_mount` documents: a temporary plugin declaring `inject: ['agentPresets', 'tools']` that registers a tool for itself, because a mount returns only its own acknowledgement and a registered tool is how a service answer reaches the model on the next step. The skill ships that plugin verbatim. `agentPresets` is in the generated `cordis_inspect what:"api"` catalog with full JSDoc, and the sandbox façade gates services on `fiber.inject` alone rather than an allowlist, so nothing about this path is special-cased for the skill. + +`copy(from, id, name)` is named as the authoring write, in place of a shell copy: it validates the id, refuses one any root supplies, rolls a failed copy back, rewrites the copy's `preset.yml`, and runs host-side without sandbox escalation. The escalation guidance stays, moved to where it applies — editing `agent.cordis.yml` afterwards still writes outside the session workspace. + +"Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. + +The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. + +## Measured behavior + +Each row was produced by booting the shipped Web composition and calling the tools through `ctx.tools.execute` on an agent composed from `cordis` — no model in the loop. + +| Composition under test | `list()` `broken` | `standingKeyFor()` | +|---|---|---| +| row names an absent package | empty | `Cannot find package '@deepseek-ai/dsh-does-not-exist'` | +| service row with no realm | empty | `service "tasks" has been registered at ` | +| same row inside `isolate` | empty | mounts | +| consumer row with no provider | empty | `1 row(s) did not activate: … waiting for workflows` | +| row missing a required config field | empty | `invalid config: $.allowParallelInProgress missing required value` | + +The skill's own `cordis_mount` snippet was executed verbatim through the tool registry: it mounts, its `preset_check` tool appears in the composing agent's catalog on the next read, and it answers `mounted OK` for a valid preset and the mount rejection for an invalid one. + +## Alternatives considered + +**Leaving verification with the user and only fixing the four errors.** The errors and the capability claim share a cause — the guidance was written from the preset layer's public surface rather than from what the composed agent can reach — and an agent that cannot check its work hands over compositions whose defects the settings page cannot see either. + +**Teaching `list()`'s `broken` field as the check.** It is the one the settings page shows, so it reads like the intended answer. It passes every failure that matters, and presenting it as validation is what made the original guidance feel complete. + +**Adding a first-class preset-validation tool to the preset.** The composed path already exists and is documented by `cordis_mount`'s own schema; a dedicated tool would add a model-facing row to a preset whose point is that the runtime is reachable without one. + +## Consequences + +- A successful validation leaves a standing generation that is never reclaimed, which is the [standing-mount](../architecture/2026-08-08-per-preset-standing-mounts.md) cost the roster already carries per generation — the agent pays it once at the end of an edit instead of the user paying it at the first session. +- The skill now depends on `cordis_inspect`'s generated API catalog staying current for `agentPresets`; `verify-cordis-api` in `doc-sync` is what holds that. +- Two examples are now quotations of `standard`'s composition. They drift if that file's `delegation` group changes, which the `web-agent-presets` e2e does not catch. +- The four corrected statements were the skill's only concrete illustrations of the realm rule. Replacing rather than deleting them keeps the rule teachable; the replacements are verifiable by reading one shipped file. + +## Related + +Supersedes the creator-guidance bullet in [broken presets are roster rows](2026-08-09-broken-preset-roster-rows.md), whose health-check decision remains current — this note reverses only its "the agent cannot start sessions; the settings page's red marking is the user's check" conclusion. Authoring's copy-only shape is owned by [copy-only preset authoring](../simplification/2026-08-08-copy-only-preset-authoring.md). diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md new file mode 100644 index 0000000000..cdc0d30bcd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -0,0 +1,68 @@ +# Agent Note: 创作 preset 的 agent 自行挂载校验其组装 + +Status: implemented + +[English](2026-08-11-preset-authoring-agent-validates-its-own-composition.md) | 中文 + +## Problem + +`cordis` preset 随包发布 `editing-cordis-compositions`,它是 agent 创作 preset 时唯一的指导来源。其中四条陈述与事实不符,而分量最重的两条恰好指向该 skill 自称「最容易让人栽跟头的规则」。 + +它把 `tool-bash` 当作「行名看不出发布服务」的示例——「看着像工具,其实 provides `bashEnv`」。`tool-bash` 不发布任何服务,它声明 `inject: ['tools', 'bash', 'systemPrompt', 'bashEnv']`,`bashEnv` 来自宿主组装自己的 `bash-env` 行。agent 照此给 `tool-bash` 套上 `isolate` realm,该行会永远等待被自己的 realm 挡住的服务,整个 preset 挂载失败。 + +它的 `isolate` 示例把 `tasks-local` 与 `tool-tasks` 组在一起。`tasks-local` 位于宿主平面,而已发布组装在自己的注释里写明:给 `tool-tasks` 套 entry-local realm 会让 `run_in_background` 回答「background tasks unavailable」。示例与紧挨着它的文件互相矛盾。 + +它把字符串 realm label 描述为跨子树共享一个实例。label 只是加入同一 realm,`provide()` 在同一 realm symbol 下第二次注册仍然抛错——`standard` 的头部注释早已如此说明。 + +它让 agent 去读包的 README 判断某行是否发布服务。除 `apps/cli`(`files: ["lib/*.js", "config"]`)外,所有 harness 包只发布 `lib/index.js`、`lib/invariant.js` 与 `lib/types/**/*.d.ts`,没有 README、没有 `src/`、没有 `docs/`。在装机部署中该指令根本无法执行。 + +四条之下还压着一个能力断言:agent「自己起不了会话」,于是校验退化成肉眼核对 YAML 字段,再把结果经设置页的红色标记交给用户。那个标记是发现阶段的结构检查,远弱于这句话给人的印象。 + +## Decision + +skill 教 agent 通过 `ctx.agentPresets` 自行挂载校验其组装,其余每个示例都取自同一仓库中已发布的组装。 + +`standingKeyFor(id)` 是校验手段。它走 `ensureStanding()`——与会话启动完全相同的真实挂载,只是不创建 agent——因此能拒绝包无法解析的行、配置非法的行、把服务发布进根 realm 的行,以及始终未激活的行。挂载失败会删除常驻条目并 dispose 其 scope,不留残留;挂载成功则装上首次真实会话本来也会装上的那个常驻代际。因此 skill 把它安排为完成编辑后的最终检查,而不是逐行循环。 + +skill 明确写出:`list()` 的 `broken` 字段**不是**校验。发现阶段的健康检查只证明文件能被 Loader 的方言解析且行带 `name`,上述四类失败全部能通过它。 + +agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂一个声明 `inject: ['agentPresets', 'tools']` 的临时插件,并为自己注册一个工具——因为挂载只返回自身的确认信息,而已注册的工具才是服务结果在下一步抵达模型的途径。skill 逐字附上该插件。`agentPresets` 位于生成的 `cordis_inspect what:"api"` 目录中并带完整 JSDoc,沙箱 façade 仅凭 `fiber.inject` 而非白名单放行服务,因此这条路径没有为该 skill 做任何特例。 + +`copy(from, id, name)` 被指定为创作写入手段,取代 shell 复制:它校验 id、拒绝任何根已提供的 id、失败时回滚、重写副本的 `preset.yml`,并在宿主侧运行而无需沙箱升级。沙箱升级的说明保留,移到真正适用之处——其后编辑 `agent.cordis.yml` 仍然写在会话工作区之外。 + +「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 + +禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 + +## Measured behavior + +下表每一行都由启动已发布的 Web 组装、并在由 `cordis` 组装出的 agent 上经 `ctx.tools.execute` 调用工具得出——全程无模型参与。 + +| 被测组装 | `list()` 的 `broken` | `standingKeyFor()` | +|---|---|---| +| 行指向不存在的包 | 空 | `Cannot find package '@deepseek-ai/dsh-does-not-exist'` | +| 服务行未套 realm | 空 | `service "tasks" has been registered at ` | +| 同一行置于 `isolate` 内 | 空 | 挂载成功 | +| 消费者行无人提供服务 | 空 | `1 row(s) did not activate: … waiting for workflows` | +| 行缺少必填配置字段 | 空 | `invalid config: $.allowParallelInProgress missing required value` | + +skill 自带的 `cordis_mount` 代码片段经工具注册表逐字执行:它成功挂载,其 `preset_check` 工具在下一次读取时出现在组装该 agent 的目录中,对有效 preset 回答 `mounted OK`,对无效 preset 回答挂载拒绝原因。 + +## Alternatives considered + +**把校验留给用户,只修四处错误。** 这些错误与那句能力断言同源——指导是按 preset 层的公开面写的,而不是按被组装出的 agent 实际够得到的东西写的——而无法自查的 agent 交出的组装,其缺陷设置页同样看不见。 + +**把 `list()` 的 `broken` 字段教成校验手段。** 它正是设置页展示的字段,看起来像是预期答案。它对所有要紧的失败一律放行,而把它当成校验,正是原指导显得完整的原因。 + +**给 preset 加一个一等的 preset 校验工具。** 组合出的路径已经存在,且由 `cordis_mount` 自己的 schema 记载;专用工具会给一个「无需专用工具即可够到运行时」的 preset 再添一个面向模型的行。 + +## Consequences + +- 校验成功会留下一个永不回收的常驻代际,这是 roster 按代际本就承担的[常驻挂载](../architecture/2026-08-08-per-preset-standing-mounts.md)代价——由 agent 在编辑收尾时付一次,而不是由用户在首次会话时付。 +- skill 现在依赖 `cordis_inspect` 生成的 API 目录对 `agentPresets` 保持最新;`doc-sync` 中的 `verify-cordis-api` 是守住这一点的门禁。 +- 有两个示例现在是对 `standard` 组装的引用。若该文件的 `delegation` 组发生变化它们会漂移,而 `web-agent-presets` e2e 捕捉不到。 +- 被修正的四条陈述原本是该 skill 对 realm 规则仅有的具体图示。选择替换而非删除,规则才仍然可教;替换后的示例读一个已发布文件即可核验。 + +## Related + +取代[破损 preset 是 roster 行](2026-08-09-broken-preset-roster-rows.md)中关于创作模式指导的那一条,其健康检查决策依然有效——本篇只推翻它「agent 起不了会话;设置页的红色标记是用户的检查手段」这一结论。创作的 copy-only 形态由[copy-only preset 创作](../simplification/2026-08-08-copy-only-preset-authoring.md)负责。 diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index ce751f039d..7a37a61e0c 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -1,12 +1,18 @@ --- name: editing-cordis-compositions -description: Use when creating or changing a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, or diagnosing a row that mounted but contributed nothing. +description: Use when creating, changing, or validating a Cordis composition for this harness — writing or editing an agent preset, adding or removing a plugin row, deciding whether something belongs to the host composition or to one session, checking whether a preset you authored actually mounts, or diagnosing a row that mounted but contributed nothing. --- # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. +## Off-limits + +**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. + +To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. + ## Decide the plane first Two planes, and the choice is not about how "agent-related" something feels — it is about whether the thing must be shared. @@ -17,16 +23,103 @@ Two planes, and the choice is not about how "agent-related" something feels — **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. -A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. The shipped presets live beside the deployment's composition; locally authored ones live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`. +A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`. + +## The roster service + +`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. + +Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on: + +- `list()` — every preset with its `id`, `trust`, and absolute `path`. This is how you locate the shipped compositions without knowing the install layout. +- `read(id)` — one preset's composition text. +- `copy(from, id, name?)` — the only authoring write (see below). +- `standingKeyFor(id)` — mount-validate one preset (see below). + +```js +return { + name: 'preset-tools', + inject: ['agentPresets', 'tools'], + apply(ctx) { + harness.registerTool(ctx, harness.defineTool({ + name: 'preset_check', + description: 'Mount-validate one preset by id.', + parameters: { id: { type: 'string', required: true } }, + output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, + async execute(args) { + try { + await ctx.agentPresets.standingKeyFor(args.id) + return 'mounted OK' + } catch (error) { + return error.message + } + }, + })) + }, +} +``` + +Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset -1. **Start from a copy.** Read a shipped composition close to what you want (the `standard` preset is the full coding agent) and copy its whole directory into `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` — the id must be lowercase letters, digits, and hyphens, because it becomes the directory name. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. -2. **Expect the file sandbox.** The preset root lies outside the session workspace, so under the default `workspace-write` policy the first write is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. -3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. -4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. +1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id (lowercase letters, digits, and hyphens, because it becomes the directory name), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, and the copy is exactly as loadable as its source. `standard` is the full coding agent and the usual source. +2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. +3. **Give the copy its own `name` and `description`** in `preset.yml`. +4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. +5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. -### Native product subagents +A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. + +## The rule that catches people + +**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. + +Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:"services"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. + +When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: + +```yaml +- id: delegation + name: cordis:group + group: true + isolate: + workflows: true + config: + - id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + - id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' +``` + +`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. + +A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. + +Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-tasks`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. + +## Verifying a change + +**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: + +- a row whose package does not resolve (`Cannot find package …`); +- a row whose config is invalid (`invalid config: $. missing required value`); +- a service published into the root realm (`service "" has been registered at `); +- a row that never activated (`N row(s) did not activate: : waiting for `). + +It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. + +**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. + +`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. + +After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. + +`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. + +## Native product subagents Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field. @@ -54,43 +147,6 @@ Copy these disabled templates from a shipped full preset and remove `disabled` o The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. -The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. - -## The rule that catches people - -**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. - -Whether a row publishes a service is not visible from its name. `tool-bash` reads like a tool but provides `bashEnv`. Check the package's README, or mount the preset and read the rejection — it names the offending service. - -When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm: - -```yaml -- id: tasks - name: cordis:group - group: true - isolate: - tasks: true - config: - - id: tasks-local - name: '@deepseek-ai/dsh-tasks-local' - - id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' -``` - -`true` means a realm private to each mounting session. A string label instead pools one instance across every subtree naming that label — use it only for something genuinely expensive to duplicate. - -A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. That is the quietest failure here: the mount succeeds and a tool is simply missing. - -Host capabilities exposed through registries need no realm: the host `tools` and `skills` registries are layered per scope, so rows like `skill-local` and `tool-skill` sit loose in the preset and their registrations file into this preset's layer automatically — the agent's catalog merges them with whatever the deployment registered globally. - -## Verifying a change - -Read the live runtime with `cordis_inspect` — it reports the services, the plugin fibers, and the registered tools as they actually are, which is the only reliable check that a row did what its name suggests. Note it shows THIS session's composition: a preset you just wrote is not mounted anywhere until a session starts on it. - -To check a preset you authored, re-read the files and validate these fields: the top level is a YAML list, every row is a map with a `name`, every group carries its own list, and service-publishing rows sit behind an `isolate` realm. The settings page's preset roster validates the same fields and marks an unloadable preset broken in red — point the user there, and ask them to start a session on the new preset to confirm the tool list; you cannot start one yourself. - -`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. - ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. From 3c8cf3a564097a1536218e5c5c8151ed5734ba56 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 18:00:24 +0800 Subject: [PATCH 15/19] fix(client-ui-plugin-config): refresh the key badge when the Host reports the credential changed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card read the credential only when its settings scope published, and a credential is not part of any settings section: a key written from the Models page — which addresses the same reference — left this badge reporting a state the Host had already replaced. It now re-reads on credentials/changed for the reference it watches, and ignores the event for any other reference. --- .../client/ui-plugin-config/README.i18n.yaml | 4 +-- packages/client/ui-plugin-config/README.md | 2 ++ packages/client/ui-plugin-config/README.zh.md | 2 ++ .../ui-plugin-config/src/client/index.ts | 8 +++++ .../src/client/web-search-store.ts | 13 ++++++++ .../ui-plugin-config/tests/apply.spec.ts | 32 +++++++++++++++++-- .../ui-plugin-config/tests/stores.spec.ts | 24 ++++++++++++++ 7 files changed, 81 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-plugin-config/README.i18n.yaml b/packages/client/ui-plugin-config/README.i18n.yaml index ea6b60fb95..ccd91999c5 100644 --- a/packages/client/ui-plugin-config/README.i18n.yaml +++ b/packages/client/ui-plugin-config/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-plugin-config/README.md -README.md: 9297379a940d004acc27e2dcb1bb879fa2142f30 -README.zh.md: 2b3d73bc6e1ccd72cad9fe86acb357c2ab269a41 +README.md: cd5ea70e7ca8e126418b73447c7ef2f1aed94b4d +README.zh.md: 2859fa9b92d622ff36eb71f099289509fcd84fec diff --git a/packages/client/ui-plugin-config/README.md b/packages/client/ui-plugin-config/README.md index 9297379a94..cd5ea70e7c 100644 --- a/packages/client/ui-plugin-config/README.md +++ b/packages/client/ui-plugin-config/README.md @@ -20,6 +20,8 @@ A card stages what the user types and writes it only when they save. Each contro Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. +A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the Host's credential-changed signal for the reference it watches. + A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key. ## Model Experience diff --git a/packages/client/ui-plugin-config/README.zh.md b/packages/client/ui-plugin-config/README.zh.md index 2b3d73bc6e..2859fa9b92 100644 --- a/packages/client/ui-plugin-config/README.zh.md +++ b/packages/client/ui-plugin-config/README.zh.md @@ -20,6 +20,8 @@ 保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 +密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在 Host 报告它所关注的引用发生变化时重读。 + 字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。 ## 模型体验 diff --git a/packages/client/ui-plugin-config/src/client/index.ts b/packages/client/ui-plugin-config/src/client/index.ts index d49728998b..6680024308 100644 --- a/packages/client/ui-plugin-config/src/client/index.ts +++ b/packages/client/ui-plugin-config/src/client/index.ts @@ -55,6 +55,14 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(bindSettingsScope(ctx, { namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController(bindSettingsScope(ctx, { namespace: WEB_SEARCH_NS }), api) + // The credential a card reports is not part of any settings section, so its + // scope publishes nothing when one is written. This is the only signal that + // a key written on another surface reached the Host. + ctx.effect( + () => ctx.on('credentials/changed', (ref) => { webSearch.refreshCredential(ref) }), + 'ui-plugin-config: credential invalidations', + ) + // The section renders the empty line rather than an empty list when no plugin // contributed a card. The count is read once: the renderer caches a root // entry's inject face per registration, so this reports what was registered diff --git a/packages/client/ui-plugin-config/src/client/web-search-store.ts b/packages/client/ui-plugin-config/src/client/web-search-store.ts index 718aa414bd..5b6907d464 100644 --- a/packages/client/ui-plugin-config/src/client/web-search-store.ts +++ b/packages/client/ui-plugin-config/src/client/web-search-store.ts @@ -143,6 +143,19 @@ export class WebSearchCardController { this.store.set(this.projection()) } + /** + * Re-read after the Host reports a change to the reference this card watches. + * + * A key can be written from somewhere else — the Models page addresses the + * same reference — and the settings section does not change when it is, so + * without this the badge keeps reporting a state the Host already replaced. + * @param ref - the reference the Host reports as changed. + */ + refreshCredential(ref: string): void { + if (ref !== this.credential.ref) return + void this.readCredential() + } + /** * Build the face the card's slot registration injects. * @returns the card's snapshot and its form actions. diff --git a/packages/client/ui-plugin-config/tests/apply.spec.ts b/packages/client/ui-plugin-config/tests/apply.spec.ts index 7c933ca9d2..dad56debe1 100644 --- a/packages/client/ui-plugin-config/tests/apply.spec.ts +++ b/packages/client/ui-plugin-config/tests/apply.spec.ts @@ -17,14 +17,15 @@ async function bench() { await ctx.plugin(SlotsService).await() const locale = new LocaleService(ctx) ctx.provide('locale', locale) + const describeCredentials = vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) ctx.provide('connection', { isLoopback: true, api: { settings: { describe: vi.fn(() => Promise.resolve({ rpcId: 's', result: { ok: false, error: {} } })) }, - credentials: { describe: vi.fn(() => Promise.resolve({ rpcId: 'c', result: { ok: false, error: {} } })) }, + credentials: { describe: describeCredentials }, }, } as never) - return { ctx, slots: ctx.get('slots') as SlotsService } + return { ctx, slots: ctx.get('slots') as SlotsService, describeCredentials } } function declareRoot(slots: SlotsService): () => void { @@ -76,6 +77,33 @@ describe('ui-plugin-config apply', () => { } }) + it('re-reads the credential when the Host reports the watched reference changed', async () => { + const { ctx, slots, describeCredentials } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) + describeCredentials.mockClear() + + // A key written on another surface changes no settings section, so this + // event is the only thing that reaches the card. + ctx.emit('credentials/changed', 'DEEPSEEK_API_KEY') + + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) + }) + + it('ignores a credential change for a reference no card watches', async () => { + const { ctx, slots, describeCredentials } = await bench() + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() }) + describeCredentials.mockClear() + + ctx.emit('credentials/changed', 'SOME_OTHER_KEY') + await Promise.resolve() + + expect(describeCredentials).not.toHaveBeenCalled() + }) + it('registers into a declaration that arrives after apply', async () => { const { ctx, slots } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-plugin-config/tests/stores.spec.ts b/packages/client/ui-plugin-config/tests/stores.spec.ts index 60d540f121..78e1ee90c7 100644 --- a/packages/client/ui-plugin-config/tests/stores.spec.ts +++ b/packages/client/ui-plugin-config/tests/stores.spec.ts @@ -436,6 +436,30 @@ describe('WebSearchCardController', () => { expect(credentials.set).not.toHaveBeenCalled() }) + it('re-reads when the Host reports the watched reference changed', async () => { + const host = stubSettingsScope() + const credentials = credentialsApi(false) + const controller = new WebSearchCardController(host.scope, credentials.api) + host.publish({ status: 'ready', writable: true, value: {}, user: {} }) + await vi.waitFor(() => { expect(credentials.describe).toHaveBeenCalled() }) + credentials.describe.mockClear() + + // Another reference is not this card's business. + controller.refreshCredential('OTHER_KEY') + expect(credentials.describe).not.toHaveBeenCalled() + + // A key written on another surface reaches this card only through this signal. + credentials.describe.mockImplementation(() => Promise.resolve({ + rpcId: 'c-1' as never, + result: { ok: true as const, value: { credentials: { DEEPSEEK_API_KEY: { configured: true, writable: true } } } }, + })) + controller.refreshCredential('DEEPSEEK_API_KEY') + + await vi.waitFor(() => { + expect(controller.inject().hooks.webSearchCard.getSnapshot().apiKeyConfigured).toBe(true) + }) + }) + it('addresses the reference the section declares rather than the default', async () => { const host = stubSettingsScope() const credentials = credentialsApi(false) From 9333fad6e1ec78984118c9aa94d95c38f149b23d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 18:58:37 +0800 Subject: [PATCH 16/19] test(web): carry the plugin-config nav item into the models-settings golden Master regenerated this dialog golden on its own side, where the section does not exist; the refreshed file differs from it by exactly the one nav row. --- .../tests/snapshots/models-settings/declared-edit.expected.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md index e36c7ff2e8..1acd03b4aa 100644 --- a/apps/web/tests/snapshots/models-settings/declared-edit.expected.md +++ b/apps/web/tests/snapshots/models-settings/declared-edit.expected.md @@ -10,6 +10,9 @@ - button "Agent 预设": - img - text: Agent 预设 + - button "插件配置": + - img + - text: 插件配置 - button "打开配置文件" - button "关闭": - img From 63b564bb4b82d6336fe8d4053a935e6b431e5ae6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 19:49:47 +0800 Subject: [PATCH 17/19] fix(preset): cover both root-realm rejections and narrow the packaging claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found two errors in the first pass. The skill quoted `service "" has been registered at ` as the rejection for a service published into the root realm. That message only appears when the loose name collides with one the host already supplies. A preset publishing its OWN new service name reaches the root realm successfully and is rejected by the mount audit instead, with `row(s) published process-global service(s) [...]` — which is the shape a forgotten realm actually takes. Both messages are now listed, verified by mounting one preset of each kind. The Agent Note claimed every harness package publishes exactly three file patterns. 139 do; 28 also publish `lib/client.js`, 22 publish `lib/types/**/*.js`, and `dsh-message-feedback` publishes `src`. The note now states only the fact it needs: no package's `files` includes its README. Also from review: the copy step no longer repeats the display name it already set, the id rule spells out `[a-z0-9][a-z0-9-]*`, and the sandbox step says reads need no escalation. --- ...oring-agent-validates-its-own-composition.i18n.yaml | 4 ++-- ...et-authoring-agent-validates-its-own-composition.md | 5 +++-- ...authoring-agent-validates-its-own-composition.zh.md | 5 +++-- .../cordis/skills/editing-cordis-compositions/SKILL.md | 10 +++++----- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index d932d7f6f4..9c173b1cd7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.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/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 77bd30d4c8f6599ccde50b4d814f55d065266763 -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: cdc0d30bcd769e1a17ddcce364002f0136a26242 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: b7d20ef20c611a0ea0c694bac6bd3948bd194b2b +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 512ac5ea0f47f4a796170b070c140f14b0072879 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index 77bd30d4c8..b7d20ef20c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -14,7 +14,7 @@ Its `isolate` example composed `tasks-local` with `tool-tasks`. `tasks-local` is It described a string realm label as pooling one instance across subtrees. Labels join realms; `provide()` still throws on the second registration under the same realm symbol, which `standard`'s header comment already stated. -It sent the agent to a package's README to learn whether a row publishes a service. Outside `apps/cli` (`files: ["lib/*.js", "config"]`), every harness package publishes only `lib/index.js`, `lib/invariant.js`, and `lib/types/**/*.d.ts` — no README, no `src/`, no `docs/`. In an installed deployment that instruction cannot be followed at all. +It sent the agent to a package's README to learn whether a row publishes a service. Every harness package declares `files`, and no declaration includes its README, so an installed deployment carries none. There that instruction cannot be followed at all. Underneath all four sat a capability claim: the agent "cannot start one \[a session\] yourself", so verification was hand-reading YAML fields and handing the result to the user through the settings page's red marking. That marking is discovery's shape check, which is far weaker than the sentence implied. @@ -41,7 +41,8 @@ Each row was produced by booting the shipped Web composition and calling the too | Composition under test | `list()` `broken` | `standingKeyFor()` | |---|---|---| | row names an absent package | empty | `Cannot find package '@deepseek-ai/dsh-does-not-exist'` | -| service row with no realm | empty | `service "tasks" has been registered at ` | +| service row with no realm, name the host supplies | empty | `service "tasks" has been registered at ` | +| service row with no realm, name the host does not supply | empty | `row(s) published process-global service(s) [workflows]; …` | | same row inside `isolate` | empty | mounts | | consumer row with no provider | empty | `1 row(s) did not activate: … waiting for workflows` | | row missing a required config field | empty | `invalid config: $.allowParallelInProgress missing required value` | diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index cdc0d30bcd..512ac5ea0f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -14,7 +14,7 @@ Status: implemented 它把字符串 realm label 描述为跨子树共享一个实例。label 只是加入同一 realm,`provide()` 在同一 realm symbol 下第二次注册仍然抛错——`standard` 的头部注释早已如此说明。 -它让 agent 去读包的 README 判断某行是否发布服务。除 `apps/cli`(`files: ["lib/*.js", "config"]`)外,所有 harness 包只发布 `lib/index.js`、`lib/invariant.js` 与 `lib/types/**/*.d.ts`,没有 README、没有 `src/`、没有 `docs/`。在装机部署中该指令根本无法执行。 +它让 agent 去读包的 README 判断某行是否发布服务。每个 harness 包都声明了 `files`,且没有任何一份声明包含自己的 README,因此装机部署中一份也没有。在那里该指令根本无法执行。 四条之下还压着一个能力断言:agent「自己起不了会话」,于是校验退化成肉眼核对 YAML 字段,再把结果经设置页的红色标记交给用户。那个标记是发现阶段的结构检查,远弱于这句话给人的印象。 @@ -41,7 +41,8 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 | 被测组装 | `list()` 的 `broken` | `standingKeyFor()` | |---|---|---| | 行指向不存在的包 | 空 | `Cannot find package '@deepseek-ai/dsh-does-not-exist'` | -| 服务行未套 realm | 空 | `service "tasks" has been registered at ` | +| 服务行未套 realm,名字宿主已提供 | 空 | `service "tasks" has been registered at ` | +| 服务行未套 realm,名字宿主未提供 | 空 | `row(s) published process-global service(s) [workflows]; …` | | 同一行置于 `isolate` 内 | 空 | 挂载成功 | | 消费者行无人提供服务 | 空 | `1 row(s) did not activate: … waiting for workflows` | | 行缺少必填配置字段 | 空 | `invalid config: $.allowParallelInProgress missing required value` | diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 7a37a61e0c..32a59dbb1b 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -63,9 +63,9 @@ Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a ## Authoring a preset -1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id (lowercase letters, digits, and hyphens, because it becomes the directory name), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, and the copy is exactly as loadable as its source. `standard` is the full coding agent and the usual source. -2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. -3. **Give the copy its own `name` and `description`** in `preset.yml`. +1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, and the copy is exactly as loadable as its source. `standard` is the full coding agent and the usual source. +2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. +3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. @@ -106,8 +106,8 @@ Realms are for services a preset owns, not for every group. A host capability th - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); -- a service published into the root realm (`service "" has been registered at `); -- a row that never activated (`N row(s) did not activate: : waiting for `). +- a row that never activated (`N row(s) did not activate: : waiting for `); +- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service "" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. From 782d670f24161ff41c0fb8de4f83aeb063a5e6c9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 19:56:45 +0800 Subject: [PATCH 18/19] fix(preset): stop presenting the preset roots as constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither root is a property of the preset layer. `Config.roots` defaults to `[]`; the two the shipped agent sees are patched in by the dsh CLI, whose user root is `dshHomePath('.agent-presets')` and whose system root ships beside the install. `writableRoot()` then takes the first `user` root from whatever configuration supplied it. The skill and the persona stated `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` as where authored presets live, which is only the default install's answer, and no call reports either root: `authorable` says whether a writable one exists, and `list()` cannot reveal a user root that is still empty. Both now keep that path as what to tell a user asking where to look, and direct every path an agent acts on to `list()` or `resolve()` — which is also what `copy()` already relies on, since it chooses the writable root itself and reports the file it created. --- ...oring-agent-validates-its-own-composition.i18n.yaml | 4 ++-- ...et-authoring-agent-validates-its-own-composition.md | 2 ++ ...authoring-agent-validates-its-own-composition.zh.md | 2 ++ apps/cli/config/agent-presets/cordis/agent.cordis.yml | 2 +- .../cordis/skills/editing-cordis-compositions/SKILL.md | 10 ++++++---- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 9c173b1cd7..2b373ff1d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.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/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: b7d20ef20c611a0ea0c694bac6bd3948bd194b2b -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 512ac5ea0f47f4a796170b070c140f14b0072879 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: c513cd169f5e837bfe425ace882f0715b3107c7a +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 92966ca90e036bddbbd9b8aa9534ba8e7ec5d488 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index b7d20ef20c..c513cd169f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,6 +32,8 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. +Neither preset root is a constant the guidance may state as fact. `writableRoot()` takes the first `user` root from configuration, and no call reports either root's path — `authorable` answers only whether a writable one exists, and `list()` cannot reveal a user root that holds nothing yet. The skill and the persona therefore name `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the default install's answer for a user asking where to look, and tell the agent to read every path it acts on back from `list()` or `resolve()`. + The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. ## Measured behavior diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index 512ac5ea0f..92966ca90e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,6 +32,8 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 +两个 preset 根都不是指导可以当作事实陈述的常量。`writableRoot()` 取配置中第一个 `user` 根,且没有任何调用会报告任一根的路径——`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此 skill 与 persona 只把 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为默认安装下「用户问它在哪」的答案,并要求 agent 把每一个将要操作的路径都从 `list()` 或 `resolve()` 读回。 + 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 ## Measured behavior diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 6fa6030c97..2d08a8998d 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -24,7 +24,7 @@ Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it. - Presets you author live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`, one directory per preset. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. + Presets you author live one directory per preset under this deployment's writable preset root — `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` in a default install, but the roster reports every real path, so read one back rather than assuming it. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. Load the `editing-cordis-compositions` skill before writing or changing a composition. diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 32a59dbb1b..3a80c67af4 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -23,7 +23,9 @@ Two planes, and the choice is not about how "agent-related" something feels — **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. -A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`. +A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. + +**Do not assume where presets live.** Both roots come from this deployment's configuration, and no call reports them directly: `authorable` says only whether a writable root exists. `copy()` picks that root itself, and every preset's absolute path comes back from `list()` and `resolve()` — read it from there. A default install puts locally authored presets under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` and the shipped set beside the deployment's own config, which is what to tell the user when they ask where to look, but never what to hand a file tool. ## The roster service @@ -31,8 +33,8 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre Read `cordis_inspect what:"api" name:"agentPresets"` for the current signatures before writing the code. The four calls this skill relies on: -- `list()` — every preset with its `id`, `trust`, and absolute `path`. This is how you locate the shipped compositions without knowing the install layout. -- `read(id)` — one preset's composition text. +- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. +- `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). @@ -63,7 +65,7 @@ Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a ## Authoring a preset -1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, and the copy is exactly as loadable as its source. `standard` is the full coding agent and the usual source. +1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. From 3fb325cfeccf475853638603eafe81c5e3cf2693 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 11 Aug 2026 20:04:32 +0800 Subject: [PATCH 19/19] fix(preset): state the user preset root plainly, resolve the acting path The previous pass over-corrected: presenting both roots as unknowable made the guidance vaguer without making it truer. Every `dsh` launcher puts authored presets under `$DSH_HOME/.agent-presets`, so the skill and persona state that as the answer to "where do my presets live" and reserve the read-back for the path an agent actually opens or edits, which `copy()` already reports. --- ...et-authoring-agent-validates-its-own-composition.i18n.yaml | 4 ++-- ...11-preset-authoring-agent-validates-its-own-composition.md | 2 +- ...preset-authoring-agent-validates-its-own-composition.zh.md | 2 +- apps/cli/config/agent-presets/cordis/agent.cordis.yml | 2 +- .../cordis/skills/editing-cordis-compositions/SKILL.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml index 2b373ff1d9..67d2b330b1 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.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/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md -2026-08-11-preset-authoring-agent-validates-its-own-composition.md: c513cd169f5e837bfe425ace882f0715b3107c7a -2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: 92966ca90e036bddbbd9b8aa9534ba8e7ec5d488 +2026-08-11-preset-authoring-agent-validates-its-own-composition.md: 6b9cdf32b70e3ab4adc9f3b0e20bb3d2245486c7 +2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md: e6e8dabcd886a6331d294744b667552caa01e7b4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md index c513cd169f..6b9cdf32b7 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.md @@ -32,7 +32,7 @@ The agent reaches the roster service the way `cordis_mount` documents: a tempora "Whether a row publishes a service" resolves through `cordis_inspect what:"services"`, which names the owning fiber of every live service. -Neither preset root is a constant the guidance may state as fact. `writableRoot()` takes the first `user` root from configuration, and no call reports either root's path — `authorable` answers only whether a writable one exists, and `list()` cannot reveal a user root that holds nothing yet. The skill and the persona therefore name `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the default install's answer for a user asking where to look, and tell the agent to read every path it acts on back from `list()` or `resolve()`. +The guidance keeps `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` as the answer to "where do my presets live" — it is where every `dsh` launcher puts them — while routing the path an agent actually reads or edits through `list()` or `resolve()`. `Config.roots` defaults to `[]` and `apps/cli` patches both roots in, `writableRoot()` takes the first `user` one, and no call reports either path; `authorable` answers only whether a writable root exists, and `list()` cannot reveal a user root that holds nothing yet. Stating the path is therefore right for talking to a person and wrong for feeding a file tool. The prohibition on touching the shipped install is promoted from a paragraph inside the authoring steps to a top `## Off-limits` section, extended to cover editing the host composition as a workaround. The new self-validation calls do not weaken it: `copy()` refuses an id any root supplies, and `remove()` refuses a preset that ships with the deployment. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md index 92966ca90e..e6e8dabcd8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-authoring-agent-validates-its-own-composition.zh.md @@ -32,7 +32,7 @@ agent 按 `cordis_mount` 自身文档所述的方式够到 roster 服务:挂 「某行是否发布服务」改由 `cordis_inspect what:"services"` 回答,它会给出每个存活服务的持有 fiber。 -两个 preset 根都不是指导可以当作事实陈述的常量。`writableRoot()` 取配置中第一个 `user` 根,且没有任何调用会报告任一根的路径——`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此 skill 与 persona 只把 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为默认安装下「用户问它在哪」的答案,并要求 agent 把每一个将要操作的路径都从 `list()` 或 `resolve()` 读回。 +指导保留 `${DSH_HOME:-$HOME/.dsh}/.agent-presets/` 作为「我的 preset 在哪」的答案——每个 `dsh` 启动器都把它们放在那里——同时把 agent 实际读取或编辑的路径改走 `list()` 或 `resolve()`。`Config.roots` 默认为 `[]`,两个根均由 `apps/cli` 补入,`writableRoot()` 取其中第一个 `user` 根,且没有任何调用会报告任一路径;`authorable` 只回答是否存在可写根,而 `list()` 无法揭示一个尚且为空的用户根。因此写出该路径对人讲是对的,喂给文件工具是错的。 禁止改动随发布安装的约束,从创作步骤中的一段提升为顶部的 `## Off-limits` 一节,并扩展到禁止改宿主组装绕行。新增的自校验调用不削弱它:`copy()` 拒绝任何根已提供的 id,`remove()` 拒绝随部署发布的 preset。 diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 2d08a8998d..2cfb9edf28 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -24,7 +24,7 @@ Two planes decide where an edit belongs. The HOST composition holds the registries and anything shared across sessions — persistence, the sandbox and approval stack, the model route, the subagent registry and its backends. An AGENT PRESET holds what one session contributes to those registries: its tools, its persona, its prompt sections. A row that publishes a service belongs in the host composition, or inside an `isolate` realm if the preset genuinely owns that service and nothing outside one agent reads it. - Presets you author live one directory per preset under this deployment's writable preset root — `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` in a default install, but the roster reports every real path, so read one back rather than assuming it. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. + Presets you author live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//`; the roster reports each preset's real path, so take the one you edit from there. NEVER edit or delete the shipped preset install (the `agent-presets` directory beside the deployment's own config): it belongs to the deployment, an upgrade overwrites it, and corrupting the `cordis` preset would disable this very mode. To change what a shipped preset does, copy its composition into a new preset directory and edit the copy. Load the `editing-cordis-compositions` skill before writing or changing a composition. diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index 3a80c67af4..744d2f13c1 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -25,7 +25,7 @@ Two planes, and the choice is not about how "agent-related" something feels — A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. -**Do not assume where presets live.** Both roots come from this deployment's configuration, and no call reports them directly: `authorable` says only whether a writable root exists. `copy()` picks that root itself, and every preset's absolute path comes back from `list()` and `resolve()` — read it from there. A default install puts locally authored presets under `${DSH_HOME:-$HOME/.dsh}/.agent-presets//` and the shipped set beside the deployment's own config, which is what to tell the user when they ask where to look, but never what to hand a file tool. +Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. Both roots are configuration rather than fixed locations, though, and no call reports them — `authorable` says only whether a writable one exists — so take the path you actually read or edit from `list()` or `resolve()`, which is also where `copy()` reports what it just created. ## The roster service