fix(web): move subagent model switch to Plugins

This commit is contained in:
Dudu-0223
2026-08-27 12:00:01 +08:00
parent f1344a4077
commit f887a8f907
37 changed files with 474 additions and 318 deletions
@@ -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-18-model-selected-subagent-routes.md
2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c
2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7
2026-08-18-model-selected-subagent-routes.md: c6e4ad70571d70b6a9a7d803b2b372e79599b184
2026-08-18-model-selected-subagent-routes.zh.md: 6fac3f60e19ce824eb07ae2df83bdd05c7e75090
@@ -18,7 +18,7 @@ Provider and model form one route and must be supplied together. An effort may b
An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service.
An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id.
An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. The Plugins settings page exposes that namespace as a direct switch. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id.
Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost.
@@ -18,7 +18,7 @@ Status: implemented
显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。
启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider``model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。
启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider``model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。插件设置页将该命名空间显示为直接开关。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。
随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。
@@ -19,10 +19,6 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- region "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- status: 已保存 minimax-cn。
- list:
- listitem:
@@ -19,10 +19,6 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- region "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- list:
- listitem:
- text: minimax-cn
@@ -19,10 +19,6 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- region "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- list:
- listitem:
- text: minimax-cn
@@ -19,10 +19,6 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- region "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- list
- text: 提供方
- combobox "提供方":
@@ -19,10 +19,6 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- region "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- list:
- listitem:
- text: DeepSeek
@@ -19,10 +19,6 @@
- text: 关闭
- heading "模型" [level=2]
- paragraph: 填入各提供方的 API 密钥即可使用其模型。
- region "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- list:
- listitem:
- text: DeepSeek
@@ -24,6 +24,10 @@
- tab "插件列表"
- tabpanel "插件配置":
- list:
- listitem "Subagent 自选模型":
- heading "Subagent 自选模型" [level=3]
- paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。
- switch "允许 subagent 自选模型"
- listitem:
- 'button "展开设置: 终端"':
- text: 终端 限制 agent 运行的每一条命令。
+19 -2
View File
@@ -75,8 +75,10 @@ describe('web e2e: plugin configuration section', () => {
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.
// Every card the shipped web composition exposes: subagent selection, the
// shell executor, the agent loop, and the DeepSeek search provider.
await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 })
expect(await dialog.getByRole('switch', { name: '允许 subagent 自选模型' }).getAttribute('aria-checked')).toBe('false')
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)
@@ -88,6 +90,21 @@ describe('web e2e: plugin configuration section', () => {
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('immediately persists the subagent model-selection preference', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection'))
const dialog = await openPlugins()
const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' })
await toggle.click()
await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true')
await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 })
.toBe(true)
expect(await settingsDocument()).toContain('enabled: true')
expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。')
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('stages an edit and writes it only when saved', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write'))
const dialog = await openPlugins()
+2 -2
View File
@@ -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/tool-catalog.md
tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622
tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819
tool-catalog.md: be8f503ed983e68e39da1a9401ffbe70a029968c
tool-catalog.zh.md: 16fd7de1235250d91dfa7df2304d1a66ba192b0d
+2 -2
View File
@@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. |
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). |
| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. |
| `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
@@ -1601,7 +1601,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its
Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.
The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.
<a id="deepseek-aidsh-tool-subagent-control"></a>
+2 -2
View File
@@ -37,7 +37,7 @@
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools``ctx.workflowEngine``ctx.subagents``ctx.systemPrompt``a calling Agent (exec.agent parents every fresh round)` | `tool/call``tool/result``workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 |
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools``ctx.agents``ctx.skills` | `tool/call``tool/result``user/message replacement catalogs via agent.inject()` | - | - |
| `@deepseek-ai/dsh-tool-session-query` | `session_event_read``session_event_search``session_event_trace``session_search``session_trace` | `ctx.tools``ctx.systemPrompt``ctx.sessionQuery``a calling Agent for workspace authority` | `tool/call``tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 |
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models``subagent` | `ctx.tools``ctx.subagents``ctx.systemPrompt``用于模型发现和所选路由校验的 ctx.llm` | `tool/call``tool/result``child session events through the chosen provider` | `subagent``subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection``modelSelectionSettings``backgroundMode``enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 |
| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models``subagent` | `ctx.tools``ctx.subagents``ctx.systemPrompt``用于模型发现和所选路由校验的 ctx.llm` | `tool/call``tool/result``child session events through the chosen provider` | `subagent``subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection``modelSelectionSettings``backgroundMode``enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 |
| `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent``list_agents``send_message` | `ctx.tools``ctx.subagents``ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call``tool/result``child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message``interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 |
| `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents``ctx.systemPrompt``a live continuable in-process child Agent` | `tool/call``tool/result``a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 |
| `@deepseek-ai/dsh-tool-jobs` | `job_kill``job_list``job_output` | `ctx.tools``ctx.jobs``ctx.systemPrompt` | `tool/call``tool/result``user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
@@ -1607,7 +1607,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts)
注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection``modelSelectionSettings``backgroundMode``enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。
注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection``modelSelectionSettings``backgroundMode``enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。
<a id="deepseek-aidsh-tool-subagent-control"></a>
+1 -1
View File
@@ -42,7 +42,7 @@
# window.__DSH_BOOT__; the modules row is simultaneously a host row.
- insert:
# Host-owned opt-in sampled when a new Web session receives its preset
# delegation tools. The Models page edits this settings namespace.
# delegation tools. The Plugins page edits this settings namespace.
- id: subagent-model-selection-settings
name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da
README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f
README.md: 658b357992a1926f1f21e109c19b7c0975da58ea
README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba
@@ -35,10 +35,6 @@ The primary field on an editor card is a single **API key** input — the page n
The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits.
### Subagent model selection
When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision.
### Adding and deleting providers
The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider.
@@ -35,10 +35,6 @@ kind: "package-reference"
收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。
### 子代理模型选择
当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。
### 新增与删除提供方
「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。
@@ -40,87 +40,6 @@
color: var(--dsw-alias-state-success-primary);
}
.preferenceCard {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px 16px;
margin-top: 4px;
padding: 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
}
.preferenceCopy {
min-width: 0;
}
.preferenceTitle {
margin: 0;
font-size: 14px;
line-height: 22px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}
.preferenceDescription {
margin: 2px 0 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-tertiary);
}
.switch {
box-sizing: border-box;
position: relative;
width: 36px;
height: 20px;
padding: 2px;
border: 0;
border-radius: 10px;
background: var(--dsw-alias-border-l3);
cursor: pointer;
}
.switchOn {
background: var(--dsw-alias-brand-primary);
}
.switch:disabled {
cursor: default;
opacity: 0.5;
}
.switch:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--dsw-alias-border-l3);
}
.switchThumb {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--dsw-alias-label-primary-foreground);
transition: transform 120ms ease;
}
.switchOn .switchThumb {
transform: translateX(16px);
}
.preferenceStatus,
.preferenceCard > .error {
grid-column: 1 / -1;
}
.preferenceStatus {
margin: 0;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-success-primary);
}
.rows {
list-style: none;
/* Extra air between the title/intro block and the first provider card. */
@@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor
import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts'
import type { SettingsSchemaOperations } from './schema-operations.ts'
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
// one whose schema names the protocols one may speak; without it mounted
// there is nothing to declare and the entry point stays disabled.
const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema)
const subagentModelSelection = state.namespaces.get('subagent-model-selection')
return (
<div className={styles['section']}>
<h2 className={styles['title']}>{t('title')}</h2>
<p className={styles['intro']}>{t('intro')}</p>
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
{subagentModelSelection === undefined
? null
: (
<SubagentModelSelectionCard
namespace={subagentModelSelection}
writable={state.writable}
api={api}
controller={controller}
t={t}
/>
)}
{savedIdentity === undefined
? null
: (
@@ -1,88 +0,0 @@
/** User control for model-selectable subagent delegation in new sessions. */
import { useState } from 'react'
import type { ReactNode } from 'react'
import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client'
import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client'
import type { ModelsSettingsStore } from './store.ts'
import type { en } from './locales.ts'
import { messageOf } from './store.ts'
import styles from './ModelsSection.module.css'
/** Props for the Host-owned subagent model-selection preference. */
export interface SubagentModelSelectionCardProps {
/** Current redacted namespace view. */
namespace: SettingsNamespaceView
/** Whether the settings provider accepts writes. */
writable: boolean
/** Settings wire face. */
api: SettingsWireFace
/** Models page controller to refresh after a commit. */
controller: ModelsSettingsStore
/** Localized Models copy. */
t: (key: keyof typeof en) => string
}
/** Read the schema-validated resolved boolean from a namespace view. */
function enabledOf(namespace: SettingsNamespaceView): boolean {
if (typeof namespace.value !== 'object' || namespace.value === null) return false
return (namespace.value as { enabled?: unknown }).enabled === true
}
/** Render and persist the default-off new-session preference. */
export function SubagentModelSelectionCard({
namespace,
writable,
api,
controller,
t,
}: SubagentModelSelectionCardProps): ReactNode {
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | undefined>(undefined)
const enabled = enabledOf(namespace)
const toggle = (): void => {
setSaving(true)
setSaved(false)
setError(undefined)
void api.settings.update(
namespace.ns,
{ enabled: !enabled },
namespace.revision,
).then(async (response) => {
if (!response.ok) throw new Error(response.error.message)
controller.acceptNamespace(response.value)
await controller.load()
setSaved(true)
}).catch((reason: unknown) => {
setError(messageOf(reason))
}).finally(() => { setSaving(false) })
}
return (
<section className={styles['preferenceCard']} aria-labelledby="subagent-model-selection-title">
<div className={styles['preferenceCopy']}>
<h3 id="subagent-model-selection-title" className={styles['preferenceTitle']}>
{t('subagentModelSelectionTitle')}
</h3>
<p className={styles['preferenceDescription']}>{t('subagentModelSelectionDescription')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={t('subagentModelSelectionToggle')}
className={`${styles['switch']} ${enabled ? styles['switchOn'] : ''}`}
disabled={!writable || saving}
onClick={toggle}
>
<span className={styles['switchThumb']} />
</button>
{saved
? <p className={styles['preferenceStatus']} role="status">{t('subagentModelSelectionSaved')}</p>
: null}
{error === undefined ? null : <p className={styles['error']} role="alert">{error}</p>}
</section>
)
}
@@ -5,10 +5,6 @@ export const en = {
nav: 'Models',
title: 'Models',
intro: 'Enter your API keys to use models from the following providers.',
subagentModelSelectionTitle: 'Subagent model selection',
subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.',
subagentModelSelectionToggle: 'Allow subagents to choose models',
subagentModelSelectionSaved: 'Saved. New sessions use this setting.',
edit: 'Edit',
editProvider: 'Edit {provider}',
remove: 'Delete',
@@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = {
nav: '模型',
title: '模型',
intro: '填入各提供方的 API 密钥即可使用其模型。',
subagentModelSelectionTitle: 'Subagent 自选模型',
subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。',
subagentModelSelectionToggle: '允许 subagent 自选模型',
subagentModelSelectionSaved: '已保存,新会话将使用此设置。',
edit: '编辑',
editProvider: '编辑 {provider}',
remove: '删除',
@@ -8,7 +8,6 @@ import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai
import {
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
} from '../src/client/ModelsSection.tsx'
import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
import { pathOps } from '../src/client/ProviderEditor.tsx'
import {
@@ -328,72 +327,6 @@ describe('ModelsSection', () => {
expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false)
})
it('persists the default-off subagent model-selection switch for new sessions', async () => {
const enabledNamespace: SettingsNamespaceView = {
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
value: { enabled: true },
user: { enabled: true },
revision: 5,
}
const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace)))
await mountSection({ update })
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
expect(toggle.getAttribute('aria-checked')).toBe('false')
fireEvent.click(toggle)
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
expect(update).toHaveBeenCalledWith(
'subagent-model-selection',
{ enabled: true },
4,
)
expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved)
})
it('reports rejected subagent model-selection updates and permits a retry', async () => {
const update = vi.fn()
.mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected'))
.mockResolvedValueOnce(remoteOk({
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
value: { enabled: true },
revision: 5,
}))
await mountSection({ update })
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
fireEvent.click(toggle)
expect((await screen.findByRole('alert')).textContent).toBe('revision changed')
fireEvent.click(toggle)
await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') })
expect(screen.queryByRole('alert')).toBeNull()
})
it('keeps malformed and read-only subagent preferences off', () => {
const namespace = {
...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!,
value: null,
} as unknown as SettingsNamespaceView
const mutate = vi.fn()
render(
<SubagentModelSelectionCard
namespace={namespace}
writable={false}
api={{ settings: { mutate } } as never}
controller={{ acceptNamespace: vi.fn(), load: vi.fn() } as never}
t={t}
/>,
)
const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
expect(toggle.getAttribute('aria-checked')).toBe('false')
expect((toggle as HTMLButtonElement).disabled).toBe(true)
fireEvent.click(toggle)
expect(mutate).not.toHaveBeenCalled()
})
it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => {
await mountFirstRun()
// Nothing is reachable yet, and DeepSeek has no configured credential and
@@ -1,5 +1,5 @@
/** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import { settingsSchema } from './settings-schema.client.ts'
@@ -84,6 +84,14 @@ function api(overrides: {
}
describe('ModelsSettingsStore', () => {
it('forwards accepted writes into the shared settings mirror', () => {
const { face } = api()
const acceptView = vi.fn()
const store = new ModelsSettingsStore(face, settingsSchema, { acceptView } as never)
store.acceptNamespace(NAMESPACES[0]!)
expect(acceptView).toHaveBeenCalledWith(NAMESPACES[0])
})
it('joins rows with configured, removable, and credential state', async () => {
const { face, mirror, seenRefs } = api()
const store = new ModelsSettingsStore(face, settingsSchema, mirror)
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
README.md: 17ef6fda253ead749eba297519ad4b0647fc482e
README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72
README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f
README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`).
Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`).
### What appears here
@@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot
### Editing and saving
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. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
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 successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. 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. The Host is the only authority on whether a value was accepted.
The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects.
### Secret-role fields
@@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom
### The write path
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. 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; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft 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; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
</details>
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`、subagent 模型选择(`subagent-model-selection`以及 DeepSeek 搜索提供方(`web-search-deepseek`)。
### 这里会出现什么
@@ -33,7 +33,9 @@ kind: "package-reference"
### 编辑与保存
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改
卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。
Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled``allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。
### secret 角色字段
@@ -55,7 +57,7 @@ kind: "package-reference"
### 写入路径
保存时,每个暂存字段通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
</details>
@@ -0,0 +1,87 @@
/* Direct preference card inside the configurable Plugins list. */
.card {
list-style: none;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px 16px;
padding: 14px 16px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 12px;
background: var(--dsw-alias-bg-layer-3);
}
.copy {
min-width: 0;
}
.title {
margin: 0;
font-size: 15px;
line-height: 1.4;
font-weight: 600;
color: var(--dsw-alias-label-primary);
}
.description {
margin: 4px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--dsw-alias-label-tertiary);
}
.switch {
box-sizing: border-box;
position: relative;
width: 36px;
height: 20px;
padding: 2px;
border: 0;
border-radius: 10px;
background: var(--dsw-alias-border-l3);
cursor: pointer;
}
.switchOn {
background: var(--dsw-alias-brand-primary);
}
.switch:disabled {
cursor: default;
opacity: 0.5;
}
.switch:focus-visible {
outline: 2px solid var(--dsw-alias-brand-primary);
outline-offset: 2px;
}
.thumb {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--dsw-alias-label-primary-foreground);
transition: transform 120ms ease;
}
.switchOn .thumb {
transform: translateX(16px);
}
.status,
.failed {
grid-column: 1 / -1;
margin: 0;
font-size: 12px;
line-height: 1.5;
}
.status {
color: var(--dsw-alias-state-success-primary);
}
.failed {
color: var(--dsw-alias-label-error);
}
@@ -0,0 +1,47 @@
/** User control for model-selectable subagent delegation in new sessions. */
import clsx from 'clsx'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts'
import type {} from './slot-contract.ts'
import css from './SubagentModelSelectionCard.module.css'
/** Props the renderer binds for the subagent model-selection card. */
export type SubagentModelSelectionCardProps =
PropsRuntime<'settings.plugin.item'>
& PropsLocale<'settings.plugins'>
& InjectFace<SubagentModelSelectionCardFace>
/**
* Render the default-off preference and persist each switch gesture.
* @param props - locale copy, the card snapshot, and its toggle action.
* @returns the preference card, or nothing when the namespace is unavailable.
*/
export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) {
const { t } = props
const state = props.useSubagentModelSelectionCard(snapshot => snapshot)
if (!state.available) return null
return (
<li className={css.card} aria-labelledby="subagent-model-selection-title">
<div className={css.copy}>
<h3 id="subagent-model-selection-title" className={css.title}>
{t('subagentModelSelectionTitle')}
</h3>
<p className={css.description}>{t('subagentModelSelectionDescription')}</p>
</div>
<button
type="button"
role="switch"
aria-checked={state.enabled}
aria-label={t('subagentModelSelectionToggle')}
className={clsx(css.switch, state.enabled && css.switchOn)}
disabled={!state.writable || state.saving}
onClick={props.toggle}
>
<span className={css.thumb} />
</button>
{state.saved ? <p className={css.status} role="status">{t('subagentModelSelectionSaved')}</p> : null}
{state.failed ? <p className={css.failed} role="alert">{t('subagentModelSelectionSaveFailed')}</p> : null}
</li>
)
}
@@ -4,7 +4,7 @@
*
* The section declares `settings.plugins.tab`; its own `configurable` tab then
* declares `settings.plugin.item` and renders whatever cards were registered
* into it. The three cards this package ships are the host-plane sections the
* into it. The cards this package ships 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 and of other tabs.
*/
@@ -25,10 +25,14 @@ import { BashCard } from './BashCard.tsx'
import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx'
import { PluginsSettingsSection } from './PluginsSettingsSection.tsx'
import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx'
import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx'
import { WebSearchCard } from './WebSearchCard.tsx'
import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts'
import { SHELL_NS, BashCardController } from './bash-card-controller.ts'
import { ConfigurablePluginsTabController } from './tab-store.ts'
import {
SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController,
} from './subagent-model-selection-card-controller.ts'
import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts'
import { en, zh } from './locales.ts'
@@ -44,6 +48,9 @@ export type {
export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts'
export type { BashCardFace, BashCardState } from './bash-card-controller.ts'
export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts'
export type {
SubagentModelSelectionCardFace, SubagentModelSelectionCardState,
} from './subagent-model-selection-card-controller.ts'
/** Dictionary namespace owned by this plugin. */
const NS = 'settings.plugins'
@@ -63,6 +70,9 @@ export function apply(ctx: ClientContext): void {
const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS }))
const webSearch = new WebSearchCardController(
ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials)
const subagentModelSelection = new SubagentModelSelectionCardController(
ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }),
)
// 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
@@ -71,6 +81,7 @@ export function apply(ctx: ClientContext): void {
() => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }),
'ui-settings-plugins: credential invalidations',
)
ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference')
// The shared SettingsScope mirror updates after document commits and reconnects.
const configurable = new ConfigurablePluginsTabController(
@@ -130,7 +141,7 @@ export function apply(ctx: ClientContext): void {
}, PluginsSettingsSection))
// The existing configuration page is one ordinary tab. It keeps ownership
// of the card slot and the three shipped card contributions below.
// of the card slot and the shipped card contributions below.
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'configurable',
@@ -142,6 +153,12 @@ export function apply(ctx: ClientContext): void {
}, ConfigurablePluginsTab))
ctx.slots.inject('settings.plugin.item', function* () {
yield ctx.slots.register({
name: 'settings.plugin.item',
key: SUBAGENT_MODEL_SELECTION_NS,
locale: NS,
inject: () => subagentModelSelection.inject(),
}, SubagentModelSelectionCard)
yield ctx.slots.register({
name: 'settings.plugin.item',
key: SHELL_NS,
@@ -11,6 +11,8 @@ export type PluginsSettingsLocaleKey =
| 'webSearchTitle' | 'webSearchDescription'
| 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset'
| 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint'
| 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription'
| 'subagentModelSelectionToggle' | 'subagentModelSelectionSaved' | 'subagentModelSelectionSaveFailed'
/** English copy. */
export const en: Record<PluginsSettingsLocaleKey, string> = {
@@ -51,6 +53,11 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
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.',
subagentModelSelectionTitle: 'Subagent model selection',
subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.',
subagentModelSelectionToggle: 'Allow subagents to choose models',
subagentModelSelectionSaved: 'Saved. New sessions use this setting.',
subagentModelSelectionSaveFailed: 'The setting could not be saved. Try again.',
}
/** Simplified Chinese copy. */
@@ -92,4 +99,9 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
webSearchBaseUrlHint: '留空则使用提供方默认地址。',
webSearchMaxUses: '单次请求最多搜索次数',
webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。',
subagentModelSelectionTitle: 'Subagent 自选模型',
subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。',
subagentModelSelectionToggle: '允许 subagent 自选模型',
subagentModelSelectionSaved: '已保存,新会话将使用此设置。',
subagentModelSelectionSaveFailed: '设置保存失败,请重试。',
}
@@ -0,0 +1,108 @@
/** Direct preference controller for model-selectable subagent delegation. */
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
/** Namespace of the Host-owned subagent model-selection preference. */
export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection'
/** Settings fields stored for subagent model selection. */
export interface SubagentModelSelectionSettings {
/** Whether new top-level Sessions may expose child model selection. */
enabled?: boolean
}
/** State rendered by the direct preference card. */
export interface SubagentModelSelectionCardState {
/** Whether the Host serves this namespace. */
available: boolean
/** Whether the settings document accepts writes. */
writable: boolean
/** Effective preference; absent values resolve off. */
enabled: boolean
/** Whether one switch write is crossing the wire. */
saving: boolean
/** Whether the latest write landed. */
saved: boolean
/** Whether the latest write settled without changing the Host value. */
failed: boolean
}
/** Registration-side face for the subagent model-selection card. */
export interface SubagentModelSelectionCardFace {
hooks: {
/** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */
subagentModelSelectionCard: SnapshotStore<SubagentModelSelectionCardState>
}
/** Flip and immediately persist the preference. */
toggle: () => void
}
/** Bridges the settings scope onto one immediate-save switch. */
export class SubagentModelSelectionCardController {
private saving = false
private saved = false
private failed = false
private disposed = false
private generation = 0
private readonly store: SnapshotStore<SubagentModelSelectionCardState>
private readonly unsubscribe: () => void
/** @param scope - the bound `subagent-model-selection` settings scope. */
constructor(private readonly scope: SettingsScope<SubagentModelSelectionSettings>) {
this.store = createSnapshotStore(this.projection())
this.unsubscribe = scope.subscribe(() => { this.publish() })
}
/** Stop observing the settings scope. */
dispose(): void {
this.disposed = true
this.generation += 1
this.unsubscribe()
}
/**
* Build the face injected into the card slot.
* @returns the card snapshot and its direct toggle action.
*/
inject(): SubagentModelSelectionCardFace {
return {
hooks: { subagentModelSelectionCard: this.store },
toggle: () => { void this.toggle() },
}
}
private async toggle(): Promise<void> {
const current = this.scope.getSnapshot()
if (this.disposed || current.status !== 'ready' || !current.writable || this.saving) return
const desired = current.value?.enabled !== true
const generation = this.generation
this.saving = true
this.saved = false
this.failed = false
this.publish()
await this.scope.set('enabled', desired)
if (generation !== this.generation) return
const landed = this.scope.getSnapshot().value?.enabled === desired
this.saving = false
this.saved = landed
this.failed = !landed
this.publish()
}
private projection(): SubagentModelSelectionCardState {
const snapshot = this.scope.getSnapshot()
return {
available: snapshot.status === 'ready',
writable: snapshot.writable,
enabled: snapshot.value?.enabled === true,
saving: this.saving,
saved: this.saved,
failed: this.failed,
}
}
private publish(): void {
this.store.set(this.projection())
}
}
@@ -117,7 +117,7 @@ describe('ui-settings-plugins apply', () => {
await ctx.plugin({ inject: [...inject], apply }).await()
expect(slots.entries('settings.plugin.item').map(entry => entry.options.key))
.toEqual(['shell', 'agent-loop', 'web-search-deepseek'])
.toEqual(['subagent-model-selection', 'shell', 'agent-loop', 'web-search-deepseek'])
})
it('dispatches the served namespaces its cards claim, and no others', async () => {
@@ -203,7 +203,7 @@ describe('ui-settings-plugins apply', () => {
declareRoot(slots)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(slots.entries('settings.plugin.item')).toHaveLength(3)
expect(slots.entries('settings.plugin.item')).toHaveLength(4)
await fiber.dispose()
@@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx
import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx'
import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx'
import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx'
import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx'
import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx'
import { WebSearchCard } from '../src/client/WebSearchCard.tsx'
import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx'
import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts'
@@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts'
import type { CardFieldState, CardShell } from '../src/client/card-form.ts'
import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts'
import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts'
import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -79,6 +82,26 @@ function renderBash(state: Partial<BashCardState> = {}) {
return actions
}
function renderSubagentModelSelection(state: Partial<SubagentModelSelectionCardState> = {}) {
const store = createSnapshotStore<SubagentModelSelectionCardState>({
available: true,
writable: true,
enabled: false,
saving: false,
saved: false,
failed: false,
...state,
})
const toggle = vi.fn()
const props = {
t,
toggle,
useSubagentModelSelectionCard: bindSnapshotSelector(store),
} as unknown as SubagentModelSelectionCardProps
render(<SubagentModelSelectionCard {...props} />)
return toggle
}
describe('PluginsSettingsSection', () => {
it('says so when no plugin contributed a tab', () => {
renderSection([])
@@ -294,6 +317,40 @@ describe('BashCard', () => {
})
})
describe('SubagentModelSelectionCard', () => {
it('renders the default-off preference directly in the Plugins list', () => {
const toggle = renderSubagentModelSelection()
const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle })
expect(control.getAttribute('aria-checked')).toBe('false')
fireEvent.click(control)
expect(toggle).toHaveBeenCalledOnce()
})
it('reports successful and rejected writes', () => {
renderSubagentModelSelection({ enabled: true, saved: true })
expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true')
expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved)
cleanup()
renderSubagentModelSelection({ failed: true })
expect(screen.getByRole('alert').textContent).toBe(en.subagentModelSelectionSaveFailed)
})
it('stays hidden when unavailable and disables writes when read-only', () => {
renderSubagentModelSelection({ available: false })
expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull()
cleanup()
const toggle = renderSubagentModelSelection({ writable: false })
const control = screen.getByRole('switch') as HTMLButtonElement
expect(control.disabled).toBe(true)
fireEvent.click(control)
expect(toggle).not.toHaveBeenCalled()
})
})
describe('AgentLoopCard', () => {
it('stages and saves the only field it owns', () => {
const store = createSnapshotStore<AgentLoopCardState>({
@@ -12,6 +12,9 @@ import {
SettingsDescribeMirror, type SettingsMirrorSnapshot,
} from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts'
import {
SubagentModelSelectionCardController, type SubagentModelSelectionSettings,
} from '../src/client/subagent-model-selection-card-controller.ts'
import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts'
/** Make the stub behave like a Host that accepts every write. */
@@ -383,6 +386,86 @@ describe('AgentLoopCardController', () => {
})
})
describe('SubagentModelSelectionCardController', () => {
it('immediately writes a switch gesture and reports the accepted value', async () => {
const host = stubSettingsScope<SubagentModelSelectionSettings>()
acceptWrites(host)
const controller = new SubagentModelSelectionCardController(host.scope)
host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} })
const face = controller.inject()
expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
face.toggle()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) })
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
enabled: true,
saving: false,
saved: true,
failed: false,
})
})
it('keeps the Host value and reports a rejected write', async () => {
const host = stubSettingsScope<SubagentModelSelectionSettings>()
const controller = new SubagentModelSelectionCardController(host.scope)
host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} })
const face = controller.inject()
face.toggle()
await vi.waitFor(() => {
expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true)
})
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
enabled: false,
saving: false,
saved: false,
})
})
it('ignores writes while read-only and scope notifications after disposal', () => {
const host = stubSettingsScope<SubagentModelSelectionSettings>()
const controller = new SubagentModelSelectionCardController(host.scope)
host.publish({ status: 'ready', writable: false, value: { enabled: false }, user: {} })
const face = controller.inject()
face.toggle()
expect(host.set).not.toHaveBeenCalled()
controller.dispose()
face.toggle()
host.publish({ value: { enabled: true } })
expect(host.set).not.toHaveBeenCalled()
expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
})
it('publishes no settlement after disposal interrupts an in-flight write', async () => {
const host = stubSettingsScope<SubagentModelSelectionSettings>()
let settle = (): void => {}
const pending = new Promise<void>((resolve) => { settle = () => { resolve() } })
host.set.mockReturnValue(pending)
const controller = new SubagentModelSelectionCardController(host.scope)
host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} })
const face = controller.inject()
face.toggle()
await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) })
expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true)
controller.dispose()
settle()
await Promise.resolve()
expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
enabled: false,
saving: true,
saved: false,
failed: false,
})
})
})
describe('WebSearchCardController', () => {
it('reads the credential state for the reference the tab names', async () => {
const host = stubSettingsScope<WebSearchSettings>()
@@ -1715,6 +1715,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted',
occupants: [
'client-ui-settings-plugins SubagentModelSelectionCard',
'client-ui-settings-plugins BashCard',
'client-ui-settings-plugins AgentLoopCard',
'client-ui-settings-plugins WebSearchCard',
+1 -1
View File
@@ -470,7 +470,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true })
},
note:
'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.',
'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.',
},
{
pkg: '@deepseek-ai/dsh-tool-subagent-control',