From e5f36cc70fd63fe4e8ca12ef8d4723eee861e8f3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 17:13:45 +0800 Subject: [PATCH] feat(plugin-inventory): carry every agent preset's composition and group the settings plugin list by scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings plugin list projected ctx.loader.entries() alone, hiding the plugins sessions actually run and rendering the web overlay's deliberate disabled tombstones (tool-bash, tool-fs, ...) as two dozen plainly disabled rows while the same modules ran in every standard-preset session. - dsh-agent-presets: compositionInventory() answers flattened rows per preset — newest live standing generation when mounted, composition file otherwise with !!js disabled gates evaluated against the Loader context; reading never mounts (regression-tested), refusal stays 'conditional', raced files report broken with the reason. - dsh-host-plugin-inventory: list() gains an optional agentPresets block, resolving the roster as an optional peer and mapping fiber states to the public phase vocabulary. - ui-settings-plugin-inventory: preset group first behind a display-only switcher opening on the default preset; global group collapsed with failures floated; host-disabled modules enabled by >=1 preset fold into a session-plugins drawer naming providers; search spans scopes and points at matches in unselected presets. - ui-agent-preset: the General-settings default-preset row is deleted — the roster section's make-default and the new-session chip keep the field — and the settings store slims to the display roster the header label reads. Docs, catalogs, module graph, settings-chrome goldens, and the bilingual Agent Note ride along. --- ...in-inventory-agent-preset-scopes.i18n.yaml | 6 + ...29-plugin-inventory-agent-preset-scopes.md | 33 ++ ...plugin-inventory-agent-preset-scopes.zh.md | 33 ++ .../settings-chrome/dialog-en.expected.md | 4 - .../settings-chrome/dialog.expected.md | 4 - .../settings-chrome/plugins.expected.md | 4 +- apps/web/tests/settings-chrome.e2e.ts | 15 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 9 +- docs/module-graph.zh.md | 9 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 16 + docs/subsystems/core.zh.md | 16 + .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 8 +- packages/client/ui-agent-preset/README.zh.md | 8 +- .../src/client/AgentPresetRow.module.css | 60 --- .../src/client/AgentPresetRow.tsx | 89 ---- .../ui-agent-preset/src/client/PresetMenu.tsx | 84 ---- .../ui-agent-preset/src/client/index.ts | 47 +- .../ui-agent-preset/src/client/locales.ts | 10 +- .../src/client/settings-store.ts | 93 +--- .../tests/apply.client.spec.ts | 32 +- .../tests/components.client.spec.tsx | 140 +----- .../tests/settings-store.client.spec.ts | 129 ++--- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 14 +- .../ui-settings-plugin-inventory/README.zh.md | 14 +- .../PluginInventorySettingsTab.module.css | 172 ++++++- .../src/client/PluginInventorySettingsTab.tsx | 441 ++++++++++++++---- .../src/client/locales.ts | 56 ++- .../tests/components.client.spec.tsx | 270 ++++++++--- .../src/client/slot-catalog.ts | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 22 + .../host/plugin-inventory/README.i18n.yaml | 4 +- packages/host/plugin-inventory/README.md | 15 +- packages/host/plugin-inventory/README.zh.md | 15 +- packages/host/plugin-inventory/package.json | 7 + packages/host/plugin-inventory/src/index.ts | 26 +- packages/host/plugin-inventory/src/types.ts | 40 ++ .../plugin-inventory/tests/inventory.spec.ts | 43 +- packages/host/plugin-inventory/tsconfig.json | 3 + .../preset/agent-presets/README.i18n.yaml | 4 +- packages/preset/agent-presets/README.md | 5 + packages/preset/agent-presets/README.zh.md | 5 + .../src/composition-inventory.ts | 195 ++++++++ .../preset/agent-presets/src/discovery.ts | 5 +- packages/preset/agent-presets/src/index.ts | 55 ++- .../tests/composition-inventory.spec.ts | 309 ++++++++++++ packages/preset/agent-presets/tsconfig.json | 3 + pnpm-lock.yaml | 3 + scripts/gen-cordis-catalog.ts | 1 + 52 files changed, 1769 insertions(+), 824 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md create mode 100644 .agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md delete mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css delete mode 100644 packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx delete mode 100644 packages/client/ui-agent-preset/src/client/PresetMenu.tsx create mode 100644 packages/preset/agent-presets/src/composition-inventory.ts create mode 100644 packages/preset/agent-presets/tests/composition-inventory.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.i18n.yaml new file mode 100644 index 0000000000..bb1705fca7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.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/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md +2026-08-29-plugin-inventory-agent-preset-scopes.md: ec738ee0ff325e14677407da0ce4af8ffe3fe718 +2026-08-29-plugin-inventory-agent-preset-scopes.zh.md: 7804203350b4173a6dfd058447aee95e918cda74 diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md new file mode 100644 index 0000000000..ec738ee0ff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.md @@ -0,0 +1,33 @@ +# Agent Note: The plugin inventory carries every agent preset's composition + +Status: implemented + +English | [中文](2026-08-29-plugin-inventory-agent-preset-scopes.zh.md) + +## Problem + +[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and the settings plugin list kept projecting `ctx.loader.entries()` alone. The surface therefore hid the plugins sessions actually run — a directly-plugged preset subtree never appears in the Loader's entries — and actively misled about the rest: the web overlay's deliberate `disabled: true` tombstones (`tool-bash`, `tool-fs`, `plan-mode`, …) rendered as two dozen plainly "disabled" rows while the same modules ran in every standard-preset session. Beside it, General settings carried a default-preset dropdown that wrote the same `agent-presets.default` field as the roster section's own make-default action — two editors for one fact, one of them blind to the roster it was choosing from. + +## Decision + +**The inventory speaks for both planes.** `pluginInventory/list` gains an optional `agentPresets` block — one group per roster preset with id, display name, default marking, health, and flattened composition rows — supplied by the new `AgentPresets.compositionInventory()`: a preset with a live standing mount answers from its newest generation's Loader entries, and one never composed since boot answers from its composition file. `dsh-host-plugin-inventory` resolves the roster as an optional peer through `ctx.get('agentPresets')` (the `plugin-package-inventory-deepseek` pattern) and only maps root-fiber states onto its public phase vocabulary, so deployments without a roster keep serving Loader entries alone with the field absent. + +**File answers are evaluated, not guessed, and reading never mounts.** `!!js` disabled gates are platform/environment conditions the [Loader itself evaluates at every mount decision](2026-08-11-loader-entry-disabled-interpolation.md), so the file read evaluates them against the Loader context and reports the decision a mount on this host would make; a gate the evaluator refuses stays `'conditional'` with its expression text carried for display. The read parses and evaluates only — no import, no compose — so listing every preset's plugins activates none of them, and a regression test pins `livePresetMounts()` empty after a full inventory read. + +**The list is grouped by scope, with the misleading rows given their own state.** The preset group renders first behind a display-only switcher that opens on the default preset and writes no settings — inspecting `minimal` must not change what new sessions run. The global group follows collapsed, failures float first, and a global entry that is disabled while at least one preset row for the same module specifier is actually enabled folds into a "session plugins" drawer that names its providers — a third state instead of the generic "disabled" that started this. The provider rule is strict `enabled === true`: counting conditional declarations would claim per-session provision `tool-pwsh` never delivers on POSIX. Search spans both groups, forces the disclosures open, and points at matches sitting in unselected presets. + +**The General row is deleted, not relocated.** The default keeps two surfaces that can still act on it — the roster section's make-default beside the visible roster, and the new-session chip for the session about to start — so `ui-agent-preset` drops the row, its menu, and the write/writability half of its settings store, which slims to the display roster the header label reads. + +## Alternatives considered + +**Render every preset as its own always-open section.** Four shipped presets already put ~100 rows behind the fold; the switcher keeps one composition in view while the drawer's provider list and the search pointers preserve the cross-scope answer the all-at-once layout was buying. + +**Keep file-state gates unevaluated (`conditional` until first mount).** Honest but it re-created the misleading reading this change removes: on a cold host the default preset's `tool-bash` read as "conditional" and its host row fell back to plain "disabled" until the first session mounted the preset. + +**A structured composition viewer in the Agent presets section.** A second home for the same rows; the section keeps its raw-YAML viewer for authors and the plugin list owns the structured view. + +**Enable/disable toggles in the same change.** Writing a row's `disabled` back into a custom preset's `agent.cordis.yml` needs comment-preserving partial YAML edits, applies-to-new-sessions messaging, and a copy-then-edit path for shipped presets — deliberately its own change; this one is read-side truth. + +## Consequences + +Searching "bash" now answers the question that motivated the change in one screen: enabled in the standard preset, provided per session where the global plane disabled it, plainly disabled only where nothing enables it. The wire snapshot's row enablement is the union `boolean | 'conditional'` with the gate expression beside it, and the settings-chrome goldens pin the grouped layout. `ui-agent-preset` loses `AgentPresetRow` and `PresetMenu`; the `settings.agentPreset` locale namespace declaration moved to the plugin entry, and the `settings-chrome` English scenario probes locale resolution through the nav label instead of the deleted row. diff --git a/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md new file mode 100644 index 0000000000..7804203350 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-29-plugin-inventory-agent-preset-scopes.zh.md @@ -0,0 +1,33 @@ +# Agent Note:插件清单携带每个 Agent 预设的组合 + +状态:已实现 + +[English](2026-08-29-plugin-inventory-agent-preset-scopes.md) | 中文 + +## 问题 + +[按会话的 agent preset](2026-08-03-per-session-agent-presets.zh.md) 把所有模型侧行移到了 agent 平面,而设置页的插件列表仍只投影 `ctx.loader.entries()`。这个表面因此看不见会话实际运行的插件——直接 plug 的预设子树从不出现在 Loader 条目里——还对其余部分构成误导:web overlay 刻意的 `disabled: true` 墓碑(`tool-bash`、`tool-fs`、`plan-mode`……)渲染成二十多行看似单纯"已停用"的条目,而同名模块在每个标准模式会话里运行。旁边,通用设置还有一个默认预设下拉,与名单分区自己的设为默认动作写同一个 `agent-presets.default` 字段——同一事实两个编辑器,其中一个还看不见它在选择的名单。 + +## 决定 + +**清单同时陈述两个平面。**`pluginInventory/list` 增加可选的 `agentPresets` 块——每个名单预设一组,含 id、显示名、默认标记、健康状态与压平的组合行——由新增的 `AgentPresets.compositionInventory()` 提供:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答,开机以来从未被组合的预设由其组合文件作答。`dsh-host-plugin-inventory` 经 `ctx.get('agentPresets')` 把名单当作可选伙伴解析(即 `plugin-package-inventory-deepseek` 的模式),自己只把根 Fiber 状态映射到公共阶段词汇,因此没有名单的部署继续只提供 Loader 条目、字段缺席。 + +**文件答案靠求值而非猜测,且读取从不挂载。**`!!js` disabled 门是平台/环境条件,[Loader 自己在每次挂载决策时都会求值](2026-08-11-loader-entry-disabled-interpolation.zh.md),因此文件读取用 Loader 上下文对它们求值,报告本机挂载会做出的决定;求值器拒绝的门保持 `'conditional'` 并携带表达式文本供展示。该读取只解析和求值——不 import、不组合——所以列出所有预设的插件不会激活其中任何一个,回归测试钉住完整清单读取后 `livePresetMounts()` 为空。 + +**列表按作用域分组,误导行获得自己的状态。**预设组在前,其切换器只改显示、初始停在默认预设且不写任何设置——查看 `minimal` 绝不能改变新会话运行什么。全局组随后且默认收起,失败行浮在最前;一个全局停用、而同一模块标识至少有一个预设行实际启用的条目,收进"会话插件"抽屉并列出提供它的预设——用第三种状态取代引发这一切的笼统"已停用"。提供者规则严格取 `enabled === true`:把条件声明也算作提供者,会替 `tool-pwsh` 在 POSIX 上宣称一个它从不兑现的按会话提供。搜索横跨两组、强制撑开折叠,并指出未选中预设里的匹配。 + +**通用设置行是删除,不是搬家。**默认值保留两个仍能作用于它的表面——名单分区的设为默认(名单可见)与新会话 chip(针对即将开始的会话)——因此 `ui-agent-preset` 删掉该行、它的菜单以及 settings store 的写入/可写性半边,后者收敛为标题标签读取的展示名单 store。 + +## 考虑过的替代方案 + +**把每个预设都渲染成常开分节。**四个内置预设已把约 100 行压到折叠线以下;切换器保持一次一个组合可见,抽屉的提供者列表与搜索指引保留了全展开布局想买到的跨作用域答案。 + +**文件态门保持不求值(首次挂载前一律 `conditional`)。**诚实,但重演了本次要消除的误导:冷启动的宿主上,默认预设的 `tool-bash` 读作"条件启用",其全局行在第一个会话挂载预设之前退回单纯的"已停用"。 + +**在 Agent 预设分区做结构化组合查看器。**同一批行的第二个家;分区保留面向作者的原始 YAML 查看器,插件列表拥有结构化视图。 + +**启停开关随本次一起做。**把行的 `disabled` 写回自定义预设的 `agent.cordis.yml` 需要保注释的局部 YAML 编辑、"对新会话生效"的提示,以及内置预设的复制后编辑路径——刻意留作独立改动;本次只做读侧真相。 + +## 后果 + +搜索 "bash" 现在一屏回答引发本次改动的问题:在标准模式里启用、在全局平面被停用处按会话提供、只有真的无人启用之处才是单纯的已停用。线上快照的行启停是联合类型 `boolean | 'conditional'` 并携带门表达式,settings-chrome 的 golden 钉住分组布局。`ui-agent-preset` 失去 `AgentPresetRow` 与 `PresetMenu`;`settings.agentPreset` 文案命名空间声明移到插件入口,`settings-chrome` 的英文场景改用导航标签而非已删除的行来探测 locale 解析。 diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index 10f4e568fe..c47e53bf6c 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -17,10 +17,6 @@ - button "Close": - img - text: Close - - text: Agent preset Applies to sessions you start from now on. Running sessions keep the preset they began with. - - button "Standard mode": - - text: Standard mode - - img - text: Permission Choose the default permission mode for new sessions - button "Workspace Write": - text: Workspace Write diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 1aa949a86d..369dc19e71 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -17,10 +17,6 @@ - button "关闭": - img - text: 关闭 - - text: Agent 预设 对此后新建的会话生效。运行中的会话保持它开始时的预设。 - - button "标准模式": - - text: 标准模式 - - img - text: 权限 选择新会话的默认权限模式 - button "Workspace Write": - text: Workspace Write diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index 9e8362a942..c4e0cbf9f0 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -1,6 +1,6 @@ - listitem: - - button "ui-settings, 已挂载, 已启用": + - button "ui-settings, 已启用": - strong: ui-settings - - img "已挂载" + - img "运行中" - text: 已启用 - img diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 96673df5f8..4a8fe18989 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -102,13 +102,22 @@ describe('web e2e: settings modal and General preferences', () => { await dialog.getByRole('button', { name: '插件', exact: true }).click() await dialog.getByRole('heading', { name: '插件', exact: true }).waitFor({ timeout: 10_000 }) await dialog.getByRole('tab', { name: '插件列表', exact: true }).click() + // The preset group opens first with its display-only switcher; the global + // plane starts collapsed and expands on demand, session plugins deeper still. + const presetSwitcher = dialog.getByRole('combobox', { name: '选择要查看的 Agent 预设' }) + await presetSwitcher.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: /^全局/ }).click() const pluginRow = dialog.locator(PLUGIN_ROW_SELECTOR) await pluginRow.waitFor({ timeout: 10_000 }) + await dialog.getByRole('button', { name: /^会话插件/ }).click() const expectedPluginCount = [...scaffold.ctx.loader.entries()] .filter(entry => !entry.options.group) .length expect(await dialog.getByRole('searchbox', { name: '搜索插件' }).count()).toBe(1) - expect(await dialog.locator('[data-plugin-entry]').count()).toBe(expectedPluginCount) + // Every Loader entry appears exactly once in the global group — the + // session-plugin drawer included, preset compositions excluded. + expect(await dialog.locator('[data-plugin-scope="global"] [data-plugin-entry]').count()) + .toBe(expectedPluginCount) expect(await dialog.locator('[data-plugin-count]').getAttribute('data-plugin-count')) .toBe(String(expectedPluginCount)) expect(await dialog.getByRole('button', { name: '插件', exact: true }).getAttribute('aria-current')).toBe('true') @@ -597,8 +606,8 @@ describe('web e2e: settings modal and General preferences', () => { const dialog = frPage.getByRole('dialog', { name: 'Settings' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'English' }).waitFor({ timeout: 10_000 }) - const preset = dialog.getByRole('button', { name: 'Standard mode' }) - await expect.poll(() => preset.isEnabled(), { timeout: 10_000 }).toBe(true) + // A locale-owned nav label proves the dictionaries resolved to en. + await dialog.getByRole('button', { name: 'Agent presets' }).waitFor({ timeout: 10_000 }) // The markup already ships `en`, so this alone cannot prove the sync ran // — the zh scenario above is the discriminating half. Asserted here too // so a future change that resolves en but writes the wrong tag is caught. diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 445c9ac17a..dee3e77226 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: b48c197299f1afbf53bf60962f544148f7f6defc -module-graph.zh.md: 1f85ab06c3ca98d6060dab400d2bf8dbfbda8cc5 +module-graph.md: 118aab0cf70b0c7dc9f1279941f9160e158db73d +module-graph.zh.md: d22990142c454d8ec7649b52dd27557c6b71350e diff --git a/docs/module-graph.md b/docs/module-graph.md index b48c197299..118aab0cf7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -405,9 +405,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout - pkg_host_plugin_inventory --> pkg_brand - pkg_host_plugin_inventory --> pkg_invariants - pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants @@ -1040,6 +1037,10 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_typert_protocol pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1753,7 +1754,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1881,6 +1881,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1f85ab06c3..d22990142c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -407,9 +407,6 @@ flowchart TD pkg_subprocess_e2b --> pkg_invariants pkg_subprocess_e2b --> pkg_subprocess pkg_subprocess_e2b --> pkg_timeout - pkg_host_plugin_inventory --> pkg_brand - pkg_host_plugin_inventory --> pkg_invariants - pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants @@ -1042,6 +1039,10 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools + pkg_host_plugin_inventory --> pkg_agent_presets + pkg_host_plugin_inventory --> pkg_brand + pkg_host_plugin_inventory --> pkg_invariants + pkg_host_plugin_inventory --> pkg_typert_protocol pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1755,7 +1756,6 @@ flowchart TD | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1883,6 +1883,7 @@ flowchart TD | [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 06bfe0a937..2ce0d29de1 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: dbe12dfc9bfdafe0f59d7e52eefc5695b1c8d063 -core.zh.md: 50fd2e4e76ee72653c02c2ffeca06042191aa9c4 +core.md: 8af708c4e12677e376bfc2adf2ff52490f5ec1a5 +core.zh.md: 1680e51a9f3a0363c28b3582d8a3e5399d5b2530 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index dbe12dfc9b..8af708c4e1 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -410,6 +410,22 @@ async list(): Promise */ @Remote('list') async remoteExportList(): Promise +/** + * Every preset's composition as flattened plugin rows, for plugin-listing + * surfaces beside the roster's own picker. + * + * A preset with a live standing mount answers from its newest generation's + * Loader entries — the composition new sessions join — and one never + * composed since boot answers from its file, with `!!js` disabled gates + * evaluated against the Loader context so both answers reflect the same + * host. Reading never mounts: an unmounted preset is parsed, not composed, + * so listing a preset's plugins cannot activate them early. A composition + * that stopped reading between discovery's health verdict and this read is + * reported broken with the raced reason rather than dropped. + * @returns one composition per roster preset, in roster order. + */ +async compositionInventory(): Promise + /** * Resolve one preset by id. * diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 50fd2e4e76..1680e51a9f 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -420,6 +420,22 @@ async list(): Promise */ @Remote('list') async remoteExportList(): Promise +/** + * Every preset's composition as flattened plugin rows, for plugin-listing + * surfaces beside the roster's own picker. + * + * A preset with a live standing mount answers from its newest generation's + * Loader entries — the composition new sessions join — and one never + * composed since boot answers from its file, with `!!js` disabled gates + * evaluated against the Loader context so both answers reflect the same + * host. Reading never mounts: an unmounted preset is parsed, not composed, + * so listing a preset's plugins cannot activate them early. A composition + * that stopped reading between discovery's health verdict and this read is + * reported broken with the raced reason rather than dropped. + * @returns one composition per roster preset, in roster order. + */ +async compositionInventory(): Promise + /** * Resolve one preset by id. * diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 042caf6f1b..3e1e1a14de 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 60a7f0ec9356c6107a32bb7c828746d54511d0f6 -README.zh.md: 64663fd76e0a8e3fd4cb799b05e81db7b62c0bde +README.md: e6bf3d1633e3d6bd6988692241803fa9b59898ac +README.zh.md: 5f202f53a20ac8a35574ddf715d27d86926a45d1 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 60a7f0ec93..e6bf3d1633 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the agent-preset surfaces of the Web GUI: a General-settings row choosing which preset new sessions are composed from, a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with. When a deployment composes no presets, all four surfaces render nothing and every session shares the host composition. +This package provides the agent-preset surfaces of the Web GUI: a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with; the default preset is edited in the settings section, where the roster is visible, so General settings carries no duplicate control for the same field. When a deployment composes no presets, all three surfaces render nothing and every session shares the host composition. ## Table of Contents @@ -25,7 +25,7 @@ This package provides the agent-preset surfaces of the Web GUI: a General-settin ## Use this package -Mount this plugin alongside the settings and conversation packages; the preset surfaces then appear where their slots render. The General-settings row opens on the deployment default and applies to sessions started afterwards; the new-session chip stages a pick that lands on the next blank session and is spent on first use, so the following new session opens on the default again. +Mount this plugin alongside the settings and conversation packages; the preset surfaces then appear where their slots render. The new-session chip opens on the deployment default and stages a pick that lands on the next blank session; the stage is spent on first use, so the following new session opens on the default again. ### Managing the roster @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card
Implementation internals — click to expand -Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +The display options come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so no surface introspects the settings schema — and the default write, the settings section's make-default action, targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
@@ -56,7 +56,7 @@ Read these pages when the preset surface is not enough. They move from the brows - [dsh-agent-presets](../../preset/agent-presets/README.md) — the host roster and composition the surfaces read and manage. - [ui-conversation](../ui-conversation/README.md) — declares the hero and session-header slots the chip and label fill. -- [ui-settings](../ui-settings/README.md) — the settings shell that hosts the General row and the roster section. +- [ui-settings](../ui-settings/README.md) — the settings shell that hosts the roster section. - [Client package map](../README.md) — adjacent browser UI packages. ----- diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 64663fd76e..5f202f53a2 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的 agent preset 表面:通用设置中的一行,选择新建会话据以组装的 preset;新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装。当部署未组装任何 preset 时,四个表面都不渲染任何内容,每个会话共用宿主组装。 +本包提供 Web GUI 的 agent preset 表面:新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装;默认 preset 在能看到名单的设置分区里编辑,通用设置不再为同一字段保留重复控件。当部署未组装任何 preset 时,三个表面都不渲染任何内容,每个会话共用宿主组装。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与设置与对话包一起挂载本插件;preset 表面随即出现在各自槽位渲染之处。通用设置行以部署默认值打开,作用于此后开启的会话;新建会话 chip 暂存一个选择,落到下一个空白会话上,一经使用即被清空,因此再下一个新会话重新以默认值打开。 +与设置与对话包一起挂载本插件;preset 表面随即出现在各自槽位渲染之处。新建会话 chip 以部署默认值打开并暂存一个选择,落到下一个空白会话上;暂存一经使用即被清空,因此再下一个新会话重新以默认值打开。 ### 管理名单 @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。 +展示选项来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此任何表面都无需对 settings schema 做内省——默认值的写入即设置分区的设为默认动作,目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
@@ -56,7 +56,7 @@ kind: "package-reference" - [dsh-agent-presets](../../preset/agent-presets/README.zh.md)——这些表面读取并管理的宿主名单与组装。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 chip 与标签填充的首屏与会话头部槽位。 -- [ui-settings](../ui-settings/README.zh.md)——承载通用行与名单分区的设置外壳。 +- [ui-settings](../ui-settings/README.zh.md)——承载名单分区的设置外壳。 - [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 ----- diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css deleted file mode 100644 index d0f7134329..0000000000 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css +++ /dev/null @@ -1,60 +0,0 @@ -/* Agent-preset row: title/description plus the preset selector pill. */ - -.row { - display: flex; - align-items: center; - gap: 8px; - padding: 16px 0; - border-bottom: 1px solid var(--dsw-alias-border-l2); -} - -.rowText { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 4px; - padding-right: 48px; -} - -.title { - font-size: 14px; - font-weight: 400; - line-height: 22px; - color: var(--dsw-alias-label-primary); -} - -.desc { - font-size: 12px; - font-weight: 400; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -.selector { - display: inline-flex; - align-items: center; - gap: 12px; - height: 36px; - padding: 0 14px; - border: none; - border-radius: 18px; - background: var(--dsw-alias-bg-module-platform); - font: inherit; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-primary); - cursor: pointer; -} - -.selector:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.selector:disabled { - cursor: default; -} - -.chevron { - flex: none; -} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx deleted file mode 100644 index d2338596ba..0000000000 --- a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * Agent-preset preference row: the preset new sessions are composed from. - * A running session keeps the composition it began with, so this row never - * disturbs work in progress. - */ - -import { useEffect, useState } from 'react' -import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { AgentPresetSettingsState } from './settings-store.ts' -import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' -import { PresetMenu } from './PresetMenu.tsx' -import css from './AgentPresetRow.module.css' - -/** Registration-side business face for the host-backed preference. */ -export interface AgentPresetRowInjected { - hooks: { - /** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */ - agentPreset: SnapshotStore - } - /** Load the roster when the row first renders. */ - load: () => Promise - /** Persist one preset as the default for later sessions. */ - select: (id: string) => Promise -} - -/** Full component props. */ -export type AgentPresetRowProps = - PropsRuntime<'settings.general.item'> - & PropsLocale<'settings.agentPreset'> - & InjectFace - -/** - * Render the new-session agent-preset selector. - * @param props - composed slot props. - * @returns the row, or null when the deployment composes no presets. - */ -export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) { - const state = useAgentPreset(snapshot => snapshot) - const [open, setOpen] = useState(false) - - useEffect(() => { - void load() - }, [load]) - - useEffect(() => { - if (state.writable && state.status !== 'unavailable') return - setOpen(false) - }, [state.status, state.writable]) - - // A deployment that composes no presets has nothing to choose between, and - // every session shares the host composition — the row simply does not exist. - if (state.status === 'unavailable') return null - const busy = state.status === 'loading' || state.status === 'saving' - // Every preset surface applies the same display-copy rule. The id remains - // addressing rather than a label, except where no display name exists. - const chosen = state.options.find(option => option.id === state.currentValue) - const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) - const label = state.currentValue === '' ? t('loading') : (chosenText?.name ?? state.currentValue) - const description: string = state.error ?? t('description') - - return ( -
-
-
{t('title')}
-
{description}
-
- { void select(id) }} - /> -
- ) -} - -declare module '@deepseek-ai/dsh-client-ui-slots' { - interface LocaleNamespaceMap { - /** Agent-preset row copy. */ - 'settings.agentPreset': AgentPresetSettingsKey - } -} diff --git a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx b/packages/client/ui-agent-preset/src/client/PresetMenu.tsx deleted file mode 100644 index 4b78d8ce6e..0000000000 --- a/packages/client/ui-agent-preset/src/client/PresetMenu.tsx +++ /dev/null @@ -1,84 +0,0 @@ -/** - * The preset picker both surfaces render: a menu of presets over a button - * naming the current one. - * - * The settings row and the composer seat differ in where they sit, what they - * call the current value, and when they refuse a pick — not in how the picker - * itself behaves. Trust is the one thing the list always says: a locally - * authored preset is exactly as privileged as the plugins it names, so the - * label marks it rather than presenting every preset as shipped and vetted. - */ - -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' -import type { AgentPresetOption } from './settings-store.ts' -import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts' - -/** What one surface passes to the shared picker. */ -export interface PresetMenuProps { - /** Presets to offer, in roster order. */ - options: readonly AgentPresetOption[] - /** The preset the button names and the menu marks selected. */ - selectedId: string - /** Text on the button; the surfaces word a pending roster differently. */ - label: string - /** Active Web locale lookup. */ - t: (key: AgentPresetSettingsKey) => string - /** Class for the trigger button, owned by the calling surface. */ - buttonClassName: string | undefined - /** Class for the chevron, owned by the calling surface. */ - chevronClassName: string | undefined - /** Whether the trigger refuses interaction. */ - disabled: boolean - /** Whether the menu is open — the surface owns this so it can force it shut. */ - open: boolean - /** Report the menu's next open state. */ - onOpenChange: (open: boolean) => void - /** Called with the picked preset once the menu has closed. */ - onSelect: (id: string) => void -} - -/** - * Render the preset picker. - * @param props - the calling surface's copy, styling, and handlers. - * @returns the menu and its trigger. - */ -export function PresetMenu({ - options, selectedId, label, t, buttonClassName, chevronClassName, - disabled, open, onOpenChange, onSelect, -}: PresetMenuProps) { - return ( - { onOpenChange(false) }} - items={options.map((option) => { - const name = presetDisplayText(option, t).name - return { - id: option.id, - // All preset surfaces resolve copy the same way; the id is addressing, - // not a label, except where no display name exists. - label: option.trust === 'user' ? `${name} · ${t('userTrust')}` : name, - } - })} - selectedId={selectedId} - onSelect={(id) => { - onOpenChange(false) - onSelect(id) - }} - align="end" - portal - anchor={( - - )} - /> - ) -} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4926d7c001..3b1e608de4 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -1,14 +1,15 @@ /** - * Agent-preset surface plugin, browser half — four surfaces over one roster: - * a General-settings row for the default preset, a chip on the new-session - * screen for the session about to start, a read-only label in the session - * header, and a settings section that manages the roster (copy, delete, - * default, and the way into a preset's own files). + * Agent-preset surface plugin, browser half — three surfaces over one roster: + * a chip on the new-session screen for the session about to start, a + * read-only label in the session header, and a settings section that manages + * the roster (copy, delete, default, and the way into a preset's own files). * * A running session keeps the composition it began with (the host refuses to * adopt an existing session under a different preset). That is what splits - * the choice from the display: the General row and the hero chip are both - * before-the-fact, while the header only reports what a session already runs. + * the choice from the display: the hero chip is before-the-fact, while the + * header only reports what a session already runs. The default preset is + * edited where the roster is visible — the settings section's "make default" + * — so General settings carries no duplicate control for the same field. */ // Type-only: pulls the Session Controller service merge (ctx.sessions). @@ -26,19 +27,23 @@ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' import { AgentPresetLabel } from './AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx' -import { AgentPresetRow } from './AgentPresetRow.tsx' -import type { AgentPresetRowInjected } from './AgentPresetRow.tsx' import { AgentPresetSeat } from './AgentPresetSeat.tsx' import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx' import { AgentPresetSection } from './AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx' import { AgentPresetSeatController } from './seat-store.ts' import { AgentPresetSectionController } from './section-store.ts' -import { en, zh } from './locales.ts' +import { en, zh, type AgentPresetSettingsKey } from './locales.ts' import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts' +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent-preset surface copy. */ + 'settings.agentPreset': AgentPresetSettingsKey + } +} + export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx' -export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx' export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx' export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx' export type { AgentPresetSeatState } from './seat-store.ts' @@ -50,16 +55,15 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', ] /** - * Mount the General-settings row. + * Mount the roster surfaces: hero chip, session-header label, settings section. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const settingsWire = { settings: ctx.remote.settings } - const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) + const controller = new AgentPresetSettingsController(ctx.remote) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() @@ -70,12 +74,6 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries') - const injected = (): AgentPresetRowInjected => ({ - hooks: { agentPreset: controller.store }, - load: () => controller.load(), - select: (id: string) => controller.select(id), - }) - ctx.effect(() => { // The roster is a live directory and the default is a settings field, so // both an external settings edit and a reconnect can move this row. @@ -193,13 +191,6 @@ export function apply(ctx: ClientContext): void { makeDefault: (id: string) => section.makeDefault(id), }) - ctx.slots.inject('settings.general.item', () => ctx.slots.register({ - name: 'settings.general.item', - id: 'agent-preset', - order: -25, - locale: 'settings.agentPreset', - inject: injected, - }, AgentPresetRow)) // Ordered after Models: choosing a model is routine, and composing an // agent is the deployment-shaping act behind it. ctx.slots.inject('settings.section', () => ctx.slots.register({ diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index b8e9f8b6e2..64b9d4b0ad 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -1,8 +1,8 @@ -/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */ +/** Locale bundles for the agent-preset hero chip, header label, and management section. */ /** Locale keys these surfaces render. */ export type AgentPresetSettingsKey = - | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' + | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' | 'presetStandardName' | 'presetStandardDescription' | 'presetPtcName' | 'presetPtcDescription' @@ -20,9 +20,6 @@ export type AgentPresetSettingsKey = /** English copy. */ export const en: Record = { - title: 'Agent preset', - description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.', - loading: 'Loading presets…', error: 'Could not load agent presets.', userTrust: 'Custom', seatHint: 'Agent preset for the session you are about to start', @@ -87,9 +84,6 @@ export const en: Record = { /** Simplified Chinese copy. */ export const zh: Record = { - title: 'Agent 预设', - description: '对此后新建的会话生效。运行中的会话保持它开始时的预设。', - loading: '正在加载预设…', error: '无法加载 Agent 预设。', userTrust: '自定义', seatHint: '即将开始的这个会话所用的 Agent 预设', diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 398b781e67..df8bed68d4 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -1,16 +1,15 @@ /** - * Agent-preset default-settings controller. + * Agent-preset roster store shared by the display surfaces. * - * Options and the current default both come from one `agentPresets.list` call: - * the roster already reports which id a session with no explicit choice gets, - * so the row needs no schema introspection. Writes target the settings - * namespace's `default` field, which is what the host resolves at creation. + * Options come from one `agentPresets.list` call. Writes target the settings + * namespace's `default` field, which is what the host resolves at creation; + * the management section is the surface that writes it. */ import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' -import type { SettingsDescribeFace, SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ export const AGENT_PRESET_SETTINGS_NS = 'agent-presets' @@ -29,9 +28,9 @@ export function messageOf(error: unknown): string { /** * Persist one preset as the default for sessions created later. * - * The default is a settings field rather than a preset property, so both the - * General row and the management section write it here — one home for which - * namespace and field the host resolves at session creation. + * The default is a settings field rather than a preset property; the + * management section writes it here — one home for which namespace and field + * the host resolves at session creation. * @param api - the settings wire face. * @param id - the preset to make default. * @returns the failure message, or undefined once the write landed. @@ -126,14 +125,14 @@ export async function beginRosterRead = createSnapshotStore(INITIAL) /** - * @param api - the settings wire face (the default write). * @param remote - the agent-preset Remote namespace (the roster read). - * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: SettingsWireFace, private readonly remote: Pick, - private readonly describeFace: SettingsDescribeFace, ) {} private set(patch: Partial): void { @@ -196,53 +179,23 @@ export class AgentPresetSettingsController { /** * Load the roster. An empty roster means the deployment composes no - * presets, which is a valid deployment rather than a failure — the row - * reports `unavailable` and renders nothing. + * presets, which is a valid deployment rather than a failure — the + * surfaces report `unavailable` and render nothing. * @returns once the snapshot reflects the host. */ async load(): Promise { const roster = await beginRosterRead(this.remote, this.store) if (roster === undefined) return const { presets } = roster - const [first] = presets - if (first === undefined) { - this.set({ status: 'unavailable', options: [], currentValue: '' }) + if (presets.length === 0) { + this.set({ status: 'unavailable', options: [] }) return } - // The roster says what may be chosen; the shared mirror says whether this - // browser may write the choice down. A non-loopback browser's mirror never - // answers, so the row stays read-only rather than offering a control - // whose write the Host would refuse. - await this.describeFace.ensure() this.set({ status: 'ready', error: null, - writable: this.describeFace.getSnapshot().view?.writable ?? false, options: presetOptions(presets), - // A roster can mark nothing default: settings can name a preset that - // was since deleted, and the picker still has to show something. - currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id, }) } - /** - * Persist one preset as the default for sessions created later. Running - * sessions keep the composition they were created with, so this never - * disturbs work in progress. - * @param id - the preset to make default. - * @returns once the write settled and the roster was re-read. - */ - async select(id: string): Promise { - const before = this.store.getSnapshot() - if (before.status === 'saving' || id === before.currentValue) return - this.set({ status: 'saving', error: null, currentValue: id }) - const failure = await writeDefaultPreset(this.api, id) - if (failure !== undefined) { - this.set({ status: 'ready', currentValue: before.currentValue, error: failure }) - return - } - // Re-read rather than trust the patch: the host resolves the default - // through the same roster the row displays. - await this.load() - } } diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index c2488de4af..a4fc61c717 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -16,8 +16,6 @@ import { apply as settingsApply, inject as settingsInject } from '@deepseek-ai/d import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx' -import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' -import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx' import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' import type { AgentPresetSectionInjected } from '../src/client/AgentPresetSection.tsx' import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' @@ -177,19 +175,19 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', ]) }) - it('registers the General row and the settings section', async () => { + it('registers the settings section and no General row', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - const row = slots.entries('settings.general.item')[0]! - expect(row.component).toBe(AgentPresetRow) - expect(row.options).toMatchObject({ id: 'agent-preset', order: -25 }) + // The default preset is edited in the section, where the roster is + // visible; a General row would duplicate the same settings field. + expect(slots.entries('settings.general.item')).toHaveLength(0) const section = slots.entries('settings.section')[0]! expect(section.component).toBe(AgentPresetSection) expect(section.options).toMatchObject({ id: 'agent-presets', order: 20 }) @@ -206,21 +204,14 @@ describe('ui-agent-preset apply', () => { await vi.waitFor(() => { expect(slots.entries('settings.section')).toHaveLength(1) }) }) - it('hands each surface its own store and actions', async () => { + it('hands the section its own store and default write', async () => { const { ctx, slots } = await bench() declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() - const row = (slots.entries('settings.general.item')[0]!.inject as unknown as () => AgentPresetRowInjected)() const section = (slots.entries('settings.section')[0]!.inject as unknown as () => AgentPresetSectionInjected)() - expect(row.hooks.agentPreset).not.toBe(section.hooks.agentPresetSection) - // Each thunk reaches its own controller: the row's load fills the row's - // store, and the section's default write does not go through the row. - await row.load() - await row.select('standard') await section.makeDefault('standard') - expect(row.hooks.agentPreset.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) expect(section.hooks.agentPresetSection.getSnapshot().rows) .toEqual([{ id: 'standard', trust: 'system', isDefault: true }]) }) @@ -294,8 +285,8 @@ describe('ui-agent-preset apply', () => { remote.emit('settings/document-updated', ['agent-presets', 1]) await vi.waitFor(() => { expect(calls.length).toBeGreaterThan(before) }) - // Only the General row reloads: a section nobody opened has nothing to - // converge, and reading the roster for it would be a wasted round trip. + // Only the header label's roster reloads: a section nobody opened has + // nothing to converge, and reading the roster for it would be wasted. expect(calls.length - before).toBe(1) }) @@ -471,7 +462,7 @@ describe('ui-agent-preset apply', () => { expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent) }) - it('gives the header label the same roster the General row reads', async () => { + it('loads the header label from the shared roster store', async () => { const { ctx, slots } = await bench() declareRoot(slots) declareConversation(slots) @@ -481,14 +472,9 @@ describe('ui-agent-preset apply', () => { await ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply }).await() const label = (slots.entries('conversation.session.header.actions')[0]! .inject as unknown as () => AgentPresetLabelInjected)() - const row = (slots.entries('settings.general.item')[0]! - .inject as unknown as () => AgentPresetRowInjected)() await label.load() - // One roster behind both: the label resolves a name the settings row's own - // load already fetched, rather than issuing a second read per session. - expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset) expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }]) }) diff --git a/packages/client/ui-agent-preset/tests/components.client.spec.tsx b/packages/client/ui-agent-preset/tests/components.client.spec.tsx index b2c9bcfac6..5a3904efa3 100644 --- a/packages/client/ui-agent-preset/tests/components.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.client.spec.tsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom /** - * The three conversation-adjacent surfaces: the General-settings row naming the - * default for later sessions, the new-session chip naming the next one's, and - * the session header's read-only label. The split is the host's rule — a - * session's history is produced under its preset's tools, so the choice is - * only ever offered before one starts. + * The two conversation-adjacent surfaces: the new-session chip naming the + * next session's preset, and the session header's read-only label. The split + * is the host's rule — a session's history is produced under its preset's + * tools, so the choice is only ever offered before one starts. */ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -13,8 +12,6 @@ import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx' import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx' -import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx' -import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx' import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSeatProps } from '../src/client/AgentPresetSeat.tsx' import type { AgentPresetSettingsState } from '../src/client/settings-store.ts' @@ -23,13 +20,9 @@ import { en } from '../src/client/locales.ts' afterEach(cleanup) -const ROW_READY: AgentPresetSettingsState = { +const ROSTER_READY: AgentPresetSettingsState = { status: 'ready', error: null, - writable: true, - currentValue: 'standard', - // `mine` deliberately names itself nothing: the row must fall back to the - // id for a preset whose author wrote no metadata. options: [{ id: 'standard', trust: 'system', name: '标准模式' }, { id: 'mine', trust: 'user' }], } @@ -44,17 +37,6 @@ const SEAT_READY: AgentPresetSeatState = { introduce: false, } -function renderRow(state: Partial = {}) { - const store = createSnapshotStore({ ...ROW_READY, ...state }) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } - render( en[key], - } as unknown as AgentPresetRowProps)} />) - return actions -} - /** The runtime's own `{name}` substitution, so a test reads the shown text. */ function translate(key: keyof typeof en, params?: Record): string { const template = en[key] @@ -83,7 +65,7 @@ function renderLabel( ) { // The chip and the label read the same roster, metadata included. const store = createSnapshotStore({ - ...ROW_READY, options: SEAT_READY.options, ...roster, + ...ROSTER_READY, options: SEAT_READY.options, ...roster, }) const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } }) const load = vi.fn(() => Promise.resolve()) @@ -97,116 +79,6 @@ function renderLabel( return { load, view } } -describe('the General-settings row', () => { - it('reads the roster once and shows the current default', async () => { - const actions = renderRow() - - await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) }) - expect(screen.getByRole('button').textContent).toContain(en.presetStandardName) - }) - - it('marks a locally authored option as local', () => { - renderRow() - - fireEvent.click(screen.getByRole('button')) - - // A local preset is exactly as privileged as the plugins it names, so the - // list says which rows are local rather than presenting all as vetted. - expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() - // The shipped one carries no marker; only local rows are called out. - expect(screen.getAllByText(en.presetStandardName)).toHaveLength(2) - }) - - it('falls back to the id for a preset that published no name', () => { - renderRow({ - currentValue: 'mine', - options: [ - { id: 'standard', trust: 'system', name: '标准模式' }, - { id: 'bare', trust: 'system' }, - { id: 'mine', trust: 'user' }, - { id: 'ours', trust: 'user', name: '团队模式' }, - ], - }) - - // The trigger names the preset; with no metadata the id is all there is. - expect(screen.getByRole('button').textContent).toContain('mine') - - fireEvent.click(screen.getByRole('button')) - - // A locally authored preset is marked whether or not it named itself. - expect(screen.getByText(`团队模式 · ${en.userTrust}`)).toBeTruthy() - expect(screen.getByText(`mine · ${en.userTrust}`)).toBeTruthy() - // A shipped preset with no metadata is listed by id and carries no mark. - expect(screen.getByText('bare')).toBeTruthy() - }) - - it('shows the selected id until a stale roster contains it', () => { - renderRow({ currentValue: 'arriving', options: [] }) - - expect(screen.getByRole('button').textContent).toContain('arriving') - }) - - it('writes the picked preset and closes the menu', () => { - const actions = renderRow() - fireEvent.click(screen.getByRole('button')) - - fireEvent.click(screen.getByText(`mine · ${en.userTrust}`)) - - expect(actions.select).toHaveBeenCalledWith('mine') - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - }) - - it('closes on an outside dismissal', () => { - renderRow() - fireEvent.click(screen.getByRole('button')) - - fireEvent.keyDown(document, { key: 'Escape' }) - - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - }) - - it('says it is loading before the roster answers', () => { - renderRow({ status: 'loading', currentValue: '' }) - - expect(screen.getByRole('button').textContent).toContain(en.loading) - expect(screen.getByRole('button')).toHaveProperty('disabled', true) - }) - - it('shows a failure in place of the description', () => { - renderRow({ error: 'roster unavailable' }) - - expect(screen.getByRole('alert').textContent).toBe('roster unavailable') - }) - - it('renders nothing when the deployment composes no presets', () => { - const { container } = render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - useAgentPreset: bindSnapshotSelector( - createSnapshotStore({ ...ROW_READY, status: 'unavailable', options: [] })), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetRowProps)} />) - - expect(container.firstChild).toBeNull() - }) - - it('closes and locks the menu when the settings turn read-only', () => { - const store = createSnapshotStore(ROW_READY) - render( Promise.resolve()), - select: vi.fn(() => Promise.resolve()), - useAgentPreset: bindSnapshotSelector(store), - t: (key: keyof typeof en) => en[key], - } as unknown as AgentPresetRowProps)} />) - fireEvent.click(screen.getByRole('button')) - - act(() => { store.set({ ...ROW_READY, writable: false }) }) - - expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false') - expect(screen.getByRole('button')).toHaveProperty('disabled', true) - }) -}) - describe('the new-session chip', () => { it('reads the roster once and shows the staged preset by name', async () => { const actions = renderSeat() diff --git a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts index 4d8544dfcf..2b1022de62 100644 --- a/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/settings-store.client.spec.ts @@ -1,29 +1,29 @@ /** - * The agent-preset settings controller: it derives both the options and the - * current default from one roster call, writes only the `default` field, and - * treats an empty roster as "this deployment composes no presets" rather than - * as a failure. + * The agent-preset roster store: it derives the display options from one + * roster call and treats an empty roster as "this deployment composes no + * presets" rather than as a failure. The default is written by the + * management section through `writeDefaultPreset`, which targets only the + * `default` field of the agent-presets namespace. */ import { describe, expect, it } from 'vitest' import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' -import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, + AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf, writeDefaultPreset, } from '../src/client/settings-store.ts' -/** The two faces the row reads: the roster Remote and the settings wire. */ +/** The two faces these tests drive: the roster Remote and the settings wire. */ interface FakeWire { api: SettingsWireFace remote: Pick } -/** Controller over a real mirror derived from the same fake wire. */ +/** The roster store over the fake wire's Remote face. */ function derivedController(wire: FakeWire) { - return new AgentPresetSettingsController(wire.api, wire.remote, new SettingsDescribeMirror(wire.api)) + return new AgentPresetSettingsController(wire.remote) } import { AgentPresetSeatController } from '../src/client/seat-store.ts' @@ -59,17 +59,10 @@ function fakeApi( failWrite?: string failList?: string failWriteWith?: Error - readOnly?: boolean } = {}, ): FakeWire { const api = { settings: { - // Host persistence is enabled in production only on the selected client path; a read-only provider answers writable:false - // and the row disables its control instead of offering a refused write. - describe: () => Promise.resolve({ - ok: true as const, - value: { writable: options.readOnly !== true, hasDocument: true, namespaces: [] }, - }), update: (ns: string, patch: { default?: unknown }) => { options.writes?.push({ ns, ops: patch }) if (options.failWriteWith !== undefined) return Promise.reject(options.failWriteWith) @@ -90,22 +83,8 @@ function fakeApi( } } -describe('the agent-preset settings controller', () => { - it('disables the control when this browser may not write settings', async () => { - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - ], { readOnly: true })) - - await controller.load() - - // The enabled `settings.describe` path reports a read-only provider; - // offering a control whose write answers `settings-rejected` would promise - // a switch the host refuses. - expect(controller.store.getSnapshot().writable).toBe(false) - expect(controller.store.getSnapshot().currentValue).toBe('standard') - }) - - it('derives options and the current default from one roster call', async () => { +describe('the agent-preset roster store', () => { + it('derives the display options from one roster call', async () => { const controller = derivedController(fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'mine', trust: 'user', isDefault: false }, @@ -115,7 +94,6 @@ describe('the agent-preset settings controller', () => { const state = controller.store.getSnapshot() expect(state.status).toBe('ready') - expect(state.currentValue).toBe('standard') expect(state.options).toEqual([ { id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }, @@ -156,7 +134,7 @@ describe('the agent-preset settings controller', () => { await controller.load() // A deployment composing no presets is valid: every session shares the - // host composition and the row renders nothing. + // host composition and the surfaces render nothing. expect(controller.store.getSnapshot().status).toBe('unavailable') expect(controller.store.getSnapshot().error).toBeNull() }) @@ -175,48 +153,27 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: null, options: [] }) }) - it('writes only the default field, into the agent-presets namespace', async () => { + it('writeDefaultPreset writes only the default field, into the agent-presets namespace', async () => { const writes: Recorded[] = [] - const controller = derivedController(fakeApi([ + const wire = fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'minimal', trust: 'system', isDefault: false }, - ], { writes })) - await controller.load() + ], { writes }) - await controller.select('minimal') + expect(await writeDefaultPreset(wire.api, 'minimal')).toBeUndefined() expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, ops: { default: 'minimal' }, }]) - expect(controller.store.getSnapshot().currentValue).toBe('minimal') }) - it('restores the previous value and surfaces the message when the write fails', async () => { - const controller = derivedController(fakeApi([ + it('writeDefaultPreset surfaces the refusal message when the write fails', async () => { + const wire = fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, - { id: 'minimal', trust: 'system', isDefault: false }, - ], { failWrite: 'read-only settings' })) - await controller.load() + ], { failWrite: 'read-only settings' }) - await controller.select('minimal') - - const state = controller.store.getSnapshot() - expect(state.currentValue).toBe('standard') - expect(state.error).toBe('read-only settings') - expect(state.status).toBe('ready') - }) - - it('ignores a pick that is already the default', async () => { - const writes: Recorded[] = [] - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: true }, - ], { writes })) - await controller.load() - - await controller.select('standard') - - expect(writes).toEqual([]) + expect(await writeDefaultPreset(wire.api, 'minimal')).toBe('read-only settings') }) it('surfaces a roster failure without claiming the deployment has no presets', async () => { @@ -229,19 +186,6 @@ describe('the agent-preset settings controller', () => { expect(state.error).toBe('host down') }) - it('shows the first preset when the roster marks none default', async () => { - // Settings can name a preset that was since deleted; the picker still has - // to show something rather than an empty control. - const controller = derivedController(fakeApi([ - { id: 'standard', trust: 'system', isDefault: false }, - { id: 'mine', trust: 'user', isDefault: false }, - ])) - - await controller.load() - - expect(controller.store.getSnapshot().currentValue).toBe('standard') - }) - it('ignores a load while one is already in flight', async () => { const writes: Recorded[] = [] const controller = derivedController(fakeApi( @@ -270,18 +214,15 @@ describe('the agent-preset settings controller', () => { expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' }) }) - it('reports a transport that rejects mid-write and keeps the old default showing', async () => { - const controller = derivedController(fakeApi([ + it('writeDefaultPreset reports a transport that rejects mid-write', async () => { + const wire = fakeApi([ { id: 'standard', trust: 'system', isDefault: true }, { id: 'mine', trust: 'user', isDefault: false }, - ], { failWriteWith: new Error('socket closed') })) - await controller.load() + ], { failWriteWith: new Error('socket closed') }) - await controller.select('mine') - - // The value snaps back because the host never took it; a picker still - // showing "mine" would be claiming a default that does not exist. - expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' }) + // The caller must be able to say the host never took the value; a surface + // still showing "mine" would be claiming a default that does not exist. + expect(await writeDefaultPreset(wire.api, 'mine')).toBe('socket closed') }) }) @@ -567,22 +508,4 @@ describe('the new-session chip controller', () => { expect(controller.store.getSnapshot().error).toBe('socket closed') }) - it('degrades to a read-only row while the mirror holds no answer', async () => { - const controller = derivedController({ - // The roster answered; the mirror's read is what failed, so the row - // shows the current default without offering a write it never confirmed. - api: { settings: { describe: () => Promise.reject(new Error('socket closed')) } } as unknown as SettingsWireFace, - remote: fakeRoster([{ id: 'standard', trust: 'system', isDefault: true }]), - }) - - await controller.load() - - expect(controller.store.getSnapshot()).toMatchObject({ - status: 'ready', - writable: false, - currentValue: 'standard', - }) - }) - - }) diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 198b22aa97..f1a56b6c18 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-settings-plugin-inventory/README.md -README.md: c44e150e528f20349429f9a49d416367f1ca3c41 -README.zh.md: ae2e2cd1105464f0fb262a07e96b66082a6e7166 +README.md: bc89b60e89e0fe257c6f422a2749bd4188fe796c +README.zh.md: 13c09aaeb108120ba22f05d9828da75088a7deb8 diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index c44e150e52..bc89b60e89 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -1,5 +1,5 @@ --- -description: "Read-only Cordis Loader inventory tab in Web Plugins settings for the dsh web client: searchable plugin catalog with enablement state and configuration." +description: "Scope-grouped read-only plugin inventory tab in Web Plugins settings for the dsh web client: agent-preset compositions first, the global plane behind a disclosure, search across both." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders a searchable two-column catalog of compact disclosure cards: each collapsed card shows the short module name, an effective-enablement tag, and (for enabled entries) a colored root-fiber status dot; expanding a card reveals the Loader-tree entry id, effective configuration, and Cordis status. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details. +`dsh-client-ui-settings-plugin-inventory` contributes the read-only **Plugin list** tab to the Web Settings Plugins section. The tab lazily calls `ctx.remote.pluginInventory.list()` the first time it is selected and renders the inventory in two groups. The agent-preset group comes first: a display-only switcher over the roster opens on the default preset, and each composition row is a compact disclosure card carrying its enablement — including `conditional` for a disabled gate only a mount can decide — with provenance facts behind the disclosure. The global group follows collapsed, its header carrying the entry count and a failure count; expanded, failures float first, and entries disabled globally but enabled by presets fold into a session-plugins drawer naming their providers instead of reading as plainly disabled. Search filters both groups, forces the collapsed disclosures open, and points at matches sitting in unselected presets. Loading, empty, no-match, and generic failure states stay local to the mounted component, and a failed read can be retried without exposing transport details; without a roster the tab renders the global plane alone, expanded. ## Table of Contents @@ -29,7 +29,11 @@ Open the Plugins section in Settings and select the **Plugin list** tab to inspe ### Reading a card -Each collapsed card uses the short module name as its title and a small effective-enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals its Loader-tree entry id, followed by the effective configuration and, for enabled entries, Cordis status; disabled entries omit the redundant unmounted runtime state. Search filters the catalog by name and entry id. +Each collapsed card uses the short module name as its title and a small enablement tag; enabled entries also show a colored root-fiber status dot. Expanding one card reveals the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its enable condition when it carries one; a session-plugins drawer row explains that agent presets provide it per session, names the presets that enable it, and offers a jump into the preset group. Search filters both groups by module name and entry id. + +### The preset switcher + +The switcher lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen. ### Retrying a failed read @@ -51,7 +55,7 @@ The browser plugin registers one localized `settings.plugins.tab` contribution w ### Rendering -The entry id remains the React key, disclosure identity, detail value, and an additional search target; it is never classified by string shape. +Row keys are scope-qualified (`global:`, `drawer:`, `preset::`), so one module appearing in several scopes keeps distinct disclosure state; an entry id is shown as detail only when the row declares one and is never classified by string shape. The session-plugins drawer is derived client-side: a global entry joins it when it is disabled there while at least one preset row for the same module specifier is actually enabled, so a module every preset gates off (or declares only conditionally) stays plainly disabled rather than over-claiming provision. @@ -86,7 +90,7 @@ None; this package neither assembles nor sends a provider request. These limits define the freshness and reach of the inventory view; they are current package constraints. - **One snapshot per Settings mount or retry** — the tab does not subscribe to Loader changes or automatically refetch after reconnect; switching tabs preserves the current snapshot, while reopening Settings obtains a new one. -- **Read-only Loader view** — local search does not add provenance, current-browser activation diagnosis, grouping by source, or plugin mutation controls. +- **Read-only in both planes** — the tab shows global and preset enablement but mutates neither; enable/disable controls that write a custom preset's own composition file are deliberate follow-up work. ### Dev Note diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index ae2e2cd110..13c09aaeb1 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -1,5 +1,5 @@ --- -description: "dsh Web 客户端设置中的只读 Cordis Loader 清单标签页:可搜索的插件目录,含启停状态与配置。" +description: "dsh Web 客户端设置中按作用域分组的只读插件清单标签页:Agent 预设组合在前,全局平面收在折叠分组里,搜索跨两组。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并以可搜索的双列紧凑折叠卡片展示清单:每张收起的卡片显示模块短名称、有效启停标签,以及(对已启用条目)彩色根 fiber 状态圆点;展开卡片会显示 Loader 树条目 id、有效配置与 Cordis 状态。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节。 +`dsh-client-ui-settings-plugin-inventory` 向 Web 设置的「插件」分区贡献只读的**插件列表**标签页。该标签页在首次被选择时懒调用 `ctx.remote.pluginInventory.list()`,并把清单分成两组渲染。Agent 预设组在前:一个只改显示的切换器覆盖 roster、初始停在默认预设,每个组合行是一张紧凑折叠卡片,携带其启停状态——含只有挂载才能裁决的 disabled 门对应的 `conditional`——出处事实收在折叠里。全局组随后且默认收起,组头带条目计数与失败计数;展开后失败行浮在最前,全局停用但被预设启用的条目收进「会话插件」抽屉并列出提供它的预设,而不是读作单纯的已停用。搜索同时过滤两组、强制撑开收起的折叠,并指出未选中预设里的匹配。加载、空结果、无匹配与通用失败状态只属于已挂载组件,读取失败后可以重试,且不会暴露传输细节;没有 roster 时标签页只渲染全局平面并保持展开。 ## 目录 @@ -29,7 +29,11 @@ kind: "package-reference" ### 阅读卡片 -每张收起的卡片使用模块短名称作为标题,并以小标签表示有效启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会直接展示 Loader 树条目 id、有效配置,已启用条目还会显示 Cordis 状态;已停用条目省略重复的「未挂载」运行状态。搜索按名称与条目 id 过滤目录。 +每张收起的卡片使用模块短名称作为标题,并以小标签表示启停状态;已启用的条目还会显示彩色根 fiber 状态圆点。展开卡片后会显示声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的启用条件;「会话插件」抽屉行说明它由 Agent 预设按会话提供、列出启用它的预设,并提供跳转到预设组的入口。搜索按模块名称与条目 id 过滤两组。 + +### 预设切换器 + +切换器列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选默认预设或某个会话的预设仍在原处:Agent 预设分区与新会话页。 ### 重试失败的读取 @@ -51,7 +55,7 @@ kind: "package-reference" ### 渲染 -条目 id 仍作为 React key、展开标识、详情值与额外的搜索目标;代码不按字符串形状对它分类。 +行 key 按作用域限定(`global:`、`drawer:`、`preset::`),因此同一模块出现在多个作用域时保持各自的展开状态;条目 id 只在行声明了它时作为详情展示,代码不按字符串形状对它分类。「会话插件」抽屉在客户端推导:一个全局条目在全局被停用、且至少一个预设行对同一模块标识实际启用时才归入抽屉,因此被所有预设关掉(或仅条件声明)的模块保持单纯的已停用,而不是夸大提供关系。 @@ -86,7 +90,7 @@ kind: "package-reference" 这些限制定义清单视图的新鲜度与触达范围;它们是当前包约束。 - **每次 Settings 挂载或重试只读取一份快照**:标签页不订阅 Loader 变化,也不会在重连后自动重新读取;切换标签页会保留当前快照,重新打开 Settings 则会取得新快照。 -- **只读 Loader 视图**:本地搜索不会额外引入来源、按来源分组、当前浏览器激活诊断或插件修改控件。 +- **两个平面都只读**:标签页展示全局与预设的启停状态但都不修改;写回自定义预设组合文件的启停控件是刻意留作后续的工作。 ### 开发备注 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index de10a3bd36..41c2d7ef59 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -201,11 +201,181 @@ white-space: nowrap; } -.configTag[data-enabled='true'] { +.configTag[data-kind='enabled'] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); } +.configTag[data-kind='preset'] { + background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 10%, transparent); + color: var(--dsw-alias-state-business-primary); +} + +.configTag[data-kind='conditional'] { + background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, #b45309) 12%, transparent); + color: var(--dsw-alias-state-warning-primary, #b45309); +} + +.configTag[data-kind='failed'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.group { + display: flex; + flex-direction: column; + gap: 10px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + padding: 10px 12px 12px; +} + +.groupHeader, +.groupToggle { + display: flex; + align-items: center; + gap: 8px; + min-height: 28px; +} + +.groupToggle { + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.groupToggle:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.groupToggle > .chevron { + transform: rotate(-90deg); +} + +.groupToggle[aria-expanded='true'] > .chevron { + transform: none; +} + +.groupTitle { + font-size: 13px; + line-height: 20px; + font-weight: 600; +} + +.groupSubtitle { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.groupCount { + margin-left: auto; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + font-variant-numeric: tabular-nums; +} + +.failedCount { + flex: none; + color: var(--dsw-alias-state-error-primary); + font-size: 12px; + line-height: 18px; +} + +.switcher { + max-width: 60%; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; + padding: 3px 8px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 13px; + font-weight: 600; +} + +.badge { + flex: none; + border-radius: 999px; + padding: 1px 8px; + font-size: 11px; + line-height: 16px; +} + +.badge[data-kind='default'] { + background: var(--dsw-alias-label-primary); + color: var(--dsw-alias-bg-layer-3); +} + +.badge[data-kind='broken'] { + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); + color: var(--dsw-alias-state-error-primary); +} + +.brokenNote { + margin: 0; + border-radius: 8px; + padding: 8px 10px; + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); + color: var(--dsw-alias-state-error-primary); + font-size: 12.5px; + line-height: 18px; + overflow-wrap: anywhere; + white-space: pre-line; +} + +.hint { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + margin: 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12.5px; + line-height: 18px; +} + +.jumpLink { + border: 0; + padding: 0; + background: transparent; + color: var(--dsw-alias-state-business-primary); + font: inherit; + font-size: 12.5px; + cursor: pointer; +} + +.drawer { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 10px; + border: 1px dashed var(--dsw-alias-border-l1); + border-radius: 10px; + padding: 8px 10px 10px; +} + +.enabledIn { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; +} + +.card[data-failed='true'] { + border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent); +} + .chevron { flex: none; color: var(--dsw-alias-label-tertiary); diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx index 12fd3d2a7a..7726a9b1ab 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.tsx @@ -15,6 +15,8 @@ export interface PluginInventorySettingsTabInjected { } type PluginInventoryEntry = PluginInventorySnapshot['entries'][number] +type AgentPresetGroup = NonNullable[number] +type AgentPresetRow = AgentPresetGroup['rows'][number] type PluginFiberPhase = PluginInventoryEntry['fiberPhase'] /** Full component props assembled by the Settings slot renderer. */ @@ -23,6 +25,8 @@ export type PluginInventorySettingsTabProps = & PropsLocale<'settings.pluginInventory'> & InjectFace +type Translate = PluginInventorySettingsTabProps['t'] + type ViewState = | { readonly status: 'loading' } | { readonly status: 'error' } @@ -37,10 +41,7 @@ const PHASE_KEYS = { } satisfies Record, PluginInventoryLocaleKey> /** Localized accessible label for one root Fiber phase. */ -function phaseLabel( - phase: PluginFiberPhase, - t: PluginInventorySettingsTabProps['t'], -): string { +function phaseLabel(phase: PluginFiberPhase, t: Translate): string { return phase === null ? t('unobserved') : t(PHASE_KEYS[phase]) } @@ -53,19 +54,121 @@ function moduleShortName(moduleName: string): string { .replace(/^dsh-(?:host-|client-)?/, '') } -/** Whether an inventory row matches the local catalog query. */ -function matches(entry: PluginInventoryEntry, normalizedQuery: string): boolean { +/** Whether one row's module name or entry id matches the catalog query. */ +function matches(moduleName: string, entryId: string | null, normalizedQuery: string): boolean { if (normalizedQuery.length === 0) return true - return [entry.moduleName, entry.entryId] + return [moduleName, ...entryId === null ? [] : [entryId]] .some(value => value.toLocaleLowerCase().includes(normalizedQuery)) } -/** Render the read-only current Loader inventory. */ +/** The roster row shown when the preset switcher has no explicit choice. */ +function fallbackPreset(presets: readonly AgentPresetGroup[]): AgentPresetGroup | undefined { + return presets.find(preset => preset.isDefault) ?? presets[0] +} + +/** The switcher's display label for one preset. */ +function presetLabel(preset: AgentPresetGroup, t: Translate): string { + const name = preset.name ?? preset.id + if (preset.broken !== undefined) return t('presetOptionBroken', { name }) + if (preset.isDefault) return t('presetOptionDefault', { name }) + return name +} + +/** One expandable plugin card; the caller owns the trailing status content. */ +function PluginCard({ rowKey, moduleName, entryId, trailing, ariaLabel, failed, expanded, onToggle, children }: { + readonly rowKey: string + readonly moduleName: string + readonly entryId: string | null + readonly trailing: ReactNode + readonly ariaLabel: string + readonly failed: boolean + readonly expanded: string | null + readonly onToggle: (key: string) => void + readonly children: ReactNode +}): ReactNode { + const open = expanded === rowKey + const detailId = `plugin-details-${encodeURIComponent(rowKey)}` + return ( +
  • + + {open ?
    {children}
    : null} +
  • + ) +} + +/** Detail rows shared by every card: the Loader identity, then labeled facts. */ +function CardFacts({ moduleName, moduleLabel, entryId, facts }: { + readonly moduleName: string + readonly moduleLabel: string + readonly entryId: string | null + readonly facts: readonly (readonly [label: string, value: ReactNode])[] +}): ReactNode { + return ( + <> + {entryId === null ? null : {entryId}} +
    +
    +
    {moduleLabel}
    +
    {moduleName}
    +
    + {facts.map(([label, value]) => ( +
    +
    {label}
    +
    {value}
    +
    + ))} +
    + + ) +} + +/** Status dot naming the root-fiber phase. */ +function PhaseDot({ phase, t }: { readonly phase: PluginFiberPhase; readonly t: Translate }): ReactNode { + const status = phaseLabel(phase, t) + return ( + + ) +} + +/** Enablement tag; `kind` selects the palette. */ +function StateTag({ kind, label }: { readonly kind: string; readonly label: string }): ReactNode { + return {label} +} + +/** Render the read-only plugin inventory: agent presets first, then the global plane. */ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsTabProps): ReactNode { - const catalogId = useId() + const sectionId = useId() const [request, setRequest] = useState(0) const [query, setQuery] = useState('') - const [expanded, setExpanded] = useState(null) + const [expanded, setExpanded] = useState(null) + const [chosenPreset, setChosenPreset] = useState(null) + const [globalOpen, setGlobalOpen] = useState(null) + const [drawerOpen, setDrawerOpen] = useState(false) const [state, setState] = useState({ status: 'loading' }) useEffect(() => { @@ -78,23 +181,160 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT }, [list, request]) const normalizedQuery = query.trim().toLocaleLowerCase() - const filteredEntries = useMemo( - () => state.status === 'ready' - ? state.snapshot.entries.filter(entry => matches(entry, normalizedQuery)) - : [], - [normalizedQuery, state], - ) + const searching = normalizedQuery.length > 0 + const snapshot = state.status === 'ready' ? state.snapshot : undefined + const presets = snapshot?.agentPresets ?? [] + const selected = presets.find(preset => preset.id === chosenPreset) ?? fallbackPreset(presets) - useEffect(() => { - if (expanded !== null && !filteredEntries.some(entry => entry.entryId === expanded)) { - setExpanded(null) + /** Presets that actually enable a module, keyed by module name. */ + const enabledIn = useMemo(() => { + const found = new Map() + for (const preset of presets) { + for (const row of preset.rows) { + if (row.enabled !== true) continue + const groups = found.get(row.moduleName) + if (groups === undefined) found.set(row.moduleName, [preset]) + else if (!groups.includes(preset)) groups.push(preset) + } } - }, [expanded, filteredEntries]) + return found + }, [presets]) + + const entries = snapshot?.entries ?? [] + const failedEntries: PluginInventoryEntry[] = [] + const drawerEntries: { entry: PluginInventoryEntry; providers: readonly [AgentPresetGroup, ...AgentPresetGroup[]] }[] = [] + const regularEntries: PluginInventoryEntry[] = [] + for (const entry of entries) { + const providers = enabledIn.get(entry.moduleName) + if (entry.fiberPhase === 'failed') failedEntries.push(entry) + else if (!entry.enabled && providers !== undefined) drawerEntries.push({ entry, providers }) + else regularEntries.push(entry) + } + + const entryMatch = (entry: PluginInventoryEntry): boolean => matches(entry.moduleName, entry.entryId, normalizedQuery) + const rowMatch = (row: AgentPresetRow): boolean => matches(row.moduleName, row.entryId, normalizedQuery) + const filteredFailed = failedEntries.filter(entryMatch) + const filteredDrawer = drawerEntries.filter(drawerRow => entryMatch(drawerRow.entry)) + const filteredRegular = regularEntries.filter(entryMatch) + const globalCount = filteredFailed.length + filteredDrawer.length + filteredRegular.length + const selectedRows = selected === undefined ? [] : selected.rows.filter(rowMatch) + const otherPresetMatches = searching + ? presets.filter(preset => preset !== selected && preset.rows.some(rowMatch)) + : [] + const otherMatchCount = otherPresetMatches + .reduce((total, preset) => total + preset.rows.filter(rowMatch).length, 0) + + const globalEffectiveOpen = searching || (globalOpen ?? presets.length === 0) + const drawerEffectiveOpen = searching || drawerOpen + const nothingMatches = searching && globalCount === 0 && selectedRows.length === 0 + && otherPresetMatches.length === 0 const retry = (): void => { setState({ status: 'loading' }) setRequest(value => value + 1) } + const toggleRow = (key: string): void => { + setExpanded(current => current === key ? null : key) + } + + /** Trailing status and detail facts for one row of the selected preset. */ + const presetRowCard = (preset: AgentPresetGroup, row: AgentPresetRow, index: number): ReactNode => { + const key = `preset:${preset.id}:${String(index)}` + const title = moduleShortName(row.moduleName) + const failed = row.fiberPhase === 'failed' + const stateText = failed + ? t('failedTag') + : row.enabled === true ? t('enabledTag') : row.enabled === false ? t('disabledTag') : t('conditionalTag') + const kind = failed ? 'failed' : row.enabled === true ? 'enabled' : row.enabled === false ? 'disabled' : 'conditional' + return ( + + {row.enabled === true && !failed ? : null} + + + )} + > + {row.condition}] as const], + ]} + /> + + ) + } + + /** One global-plane row; a drawer row carries the presets that enable it. */ + const globalRowCard = ( + entry: PluginInventoryEntry, + providers?: readonly [AgentPresetGroup, ...AgentPresetGroup[]], + ): ReactNode => { + const key = `${providers === undefined ? 'global' : 'drawer'}:${entry.entryId}` + const title = moduleShortName(entry.moduleName) + const failed = entry.fiberPhase === 'failed' + const stateText = failed + ? t('failedTag') + : providers !== undefined ? t('presetEnabledTag') : t(entry.enabled ? 'enabledTag' : 'disabledTag') + const kind = failed ? 'failed' : providers !== undefined ? 'preset' : entry.enabled ? 'enabled' : 'disabled' + return ( + + {entry.enabled && !failed ? : null} + + + )} + > + + {providers.map(preset => preset.name ?? preset.id).join(' · ')} + + + )], + ] + : [ + [t('configuration'), t(entry.enabled ? 'enabledTag' : 'disabledTag')], + ...entry.enabled ? [[t('runtime'), phaseLabel(entry.fiberPhase, t)] as const] : [], + ]} + /> + + ) + } return (
    @@ -105,7 +345,7 @@ export function PluginInventorySettingsTab({ list, t }: PluginInventorySettingsT
    ) : null} - {state.status === 'ready' ? ( + {snapshot !== undefined ? (
    -
    -

    {t('catalog')}

    - {filteredEntries.length} -
    - {state.snapshot.entries.length === 0 ?

    {t('empty')}

    : null} - {state.snapshot.entries.length > 0 && filteredEntries.length === 0 - ?

    {t('emptySearch')}

    - : null} - {filteredEntries.length > 0 ? ( -
      - {filteredEntries.map((entry) => { - const status = phaseLabel(entry.fiberPhase, t) - const title = moduleShortName(entry.moduleName) - const configuration = t(entry.enabled ? 'enabledTag' : 'disabledTag') - const open = expanded === entry.entryId - const detailId = `${catalogId}-details-${encodeURIComponent(entry.entryId)}` - return ( -
    • + {entries.length === 0 && presets.length === 0 ?

      {t('empty')}

      : null} + {nothingMatches ?

      {t('emptySearch')}

      : null} + + {selected !== undefined ? ( +
      +
      + + {t('presetSubtitle')} + + {selectedRows.length} + +
      + {selected.broken !== undefined ? ( +

      {selected.broken}

      + ) : null} + {selectedRows.length > 0 ? ( +
        + {selectedRows.map((row, index) => presetRowCard(selected, row, index))} +
      + ) : null} + {otherMatchCount > 0 ? ( +

      + {t('matchesInOtherPresets', { count: String(otherMatchCount) })} + {otherPresetMatches.map(preset => ( - {open ? ( -

      - {entry.entryId} -
      -
      -
      {t('configuration')}
      -
      {configuration}
      -
      - {entry.enabled ? ( -
      -
      {t('cordis')}
      -
      {status}
      -
      - ) : null} -
      -
      - ) : null} -
    • - ) - })} -
    + ))} +

    + ) : null} + + ) : null} + + {entries.length > 0 ? ( +
    + + {globalEffectiveOpen ? ( +
    + {filteredFailed.length + filteredRegular.length > 0 ? ( +
      + {filteredFailed.map(entry => globalRowCard(entry))} + {filteredRegular.map(entry => globalRowCard(entry))} +
    + ) : null} + {drawerEntries.length > 0 ? ( +
    + + {drawerEffectiveOpen && filteredDrawer.length > 0 ? ( +
      + {filteredDrawer.map(drawerRow => globalRowCard(drawerRow.entry, drawerRow.providers))} +
    + ) : null} +
    + ) : null} +
    + ) : null} +
    ) : null}
    ) : null} diff --git a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts index 866937016c..3857204a93 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/locales.ts +++ b/packages/client/ui-settings-plugin-inventory/src/client/locales.ts @@ -7,18 +7,36 @@ export const zh = { error: '暂时无法读取插件。', retry: '重试', search: '搜索插件', - catalog: '插件列表', empty: '暂无插件。', emptySearch: '没有匹配的插件。', + presetSubtitle: '会话使用的插件', + switcherLabel: '选择要查看的 Agent 预设', + presetOptionDefault: '{name}(默认)', + presetOptionBroken: '{name}(加载失败)', + globalTitle: '全局', + globalSubtitle: '系统与所有会话共用', + drawerTitle: '会话插件', + drawerSubtitle: '不在全局运行,由 Agent 预设按会话提供', + drawerDetail: '全局已停用,由 Agent 预设按会话提供', + enabledIn: '启用于', + viewInPreset: '去预设分组查看', + matchesInOtherPresets: '其他预设中还有 {count} 个匹配:', + failedCountLabel: '个失败', enabledTag: '已启用', disabledTag: '已停用', + conditionalTag: '条件启用', + presetEnabledTag: '预设中启用', + failedTag: '启动失败', + moduleLabel: '完整名称', + fromPreset: '来自', + condition: '启用条件', configuration: '配置状态', - cordis: 'Cordis 状态', - unobserved: '未挂载', + runtime: '运行状态', + unobserved: '未运行', pending: '等待依赖', loadingPhase: '加载中', - active: '已挂载', - failed: '挂载失败', + active: '运行中', + failed: '启动失败', unloading: '卸载中', } satisfies Record @@ -32,17 +50,35 @@ export const en = { error: 'Plugins are temporarily unavailable.', retry: 'Retry', search: 'Search plugins', - catalog: 'Plugin list', empty: 'No plugins are available.', emptySearch: 'No matching plugins.', + presetSubtitle: 'Plugins your sessions run', + switcherLabel: 'Choose the agent preset to inspect', + presetOptionDefault: '{name} (default)', + presetOptionBroken: '{name} (failed to load)', + globalTitle: 'Global', + globalSubtitle: 'Shared by the system and every session', + drawerTitle: 'Session plugins', + drawerSubtitle: 'Not running globally; agent presets provide them per session', + drawerDetail: 'Disabled globally; agent presets provide it per session', + enabledIn: 'Enabled in', + viewInPreset: 'View in the preset group', + matchesInOtherPresets: '{count} more matches in other presets: ', + failedCountLabel: 'failed', enabledTag: 'Enabled', disabledTag: 'Disabled', + conditionalTag: 'Conditional', + presetEnabledTag: 'Enabled via presets', + failedTag: 'Failed', + moduleLabel: 'Module', + fromPreset: 'From', + condition: 'Enable condition', configuration: 'Configuration', - cordis: 'Cordis status', - unobserved: 'Not mounted', + runtime: 'Status', + unobserved: 'Not running', pending: 'Waiting for dependencies', loadingPhase: 'Loading', - active: 'Mounted', - failed: 'Mount failed', + active: 'Running', + failed: 'Failed to start', unloading: 'Unloading', } satisfies Record diff --git a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx index bec1d64706..3310d87e6d 100644 --- a/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-plugin-inventory/tests/components.client.spec.tsx @@ -11,7 +11,11 @@ import { en, type PluginInventoryLocaleKey } from '../src/client/locales.ts' afterEach(cleanup) type Snapshot = Awaited> -const t = ((key: PluginInventoryLocaleKey): string => en[key]) as PluginInventorySettingsTabProps['t'] +const t = ((key: PluginInventoryLocaleKey, params?: Record): string => + Object.entries(params ?? {}).reduce( + (text, [name, value]) => text.replaceAll(`{${name}}`, value), + en[key], + )) as PluginInventorySettingsTabProps['t'] function props(list: PluginInventorySettingsTabInjected['list']): PluginInventorySettingsTabProps { return { @@ -20,79 +24,234 @@ function props(list: PluginInventorySettingsTabInjected['list']): PluginInventor } as PluginInventorySettingsTabProps } +/** A deployment with a roster: one failed global row, two preset-provided rows. */ const SNAPSHOT = { entries: [ + { entryId: 'telemetry', moduleName: '@fixture/telemetry', enabled: true, fiberPhase: 'failed' }, + { entryId: 'timer', moduleName: 'cordis:timer', enabled: true, fiberPhase: 'active' }, { entryId: '8a1b2c3d', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, - { entryId: 'pending', moduleName: 'cordis:pending-name', enabled: true, fiberPhase: 'pending' }, - { entryId: 'loading', moduleName: '@fixture/loading-name', enabled: true, fiberPhase: 'loading' }, - { entryId: 'failed', moduleName: '@fixture/failed-name', enabled: true, fiberPhase: 'failed' }, - { entryId: 'unloading', moduleName: '@fixture/unloading-name', enabled: true, fiberPhase: 'unloading' }, { entryId: 'unobserved', moduleName: '@fixture/unobserved-name', enabled: true, fiberPhase: null }, - { entryId: 'disabled-entry', moduleName: '@deepseek-ai/dsh-host-directory-picker-native', enabled: false, fiberPhase: null }, + { entryId: 'bash-host', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: false, fiberPhase: null }, + { entryId: 'fs-host', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: false, fiberPhase: null }, + { entryId: 'dormant', moduleName: '@fixture/dormant', enabled: false, fiberPhase: null }, + ], + agentPresets: [ + { + id: 'standard', + name: '标准模式', + isDefault: true, + rows: [ + { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: 'active' }, + { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: true, fiberPhase: null }, + { + entryId: 'pwsh', + moduleName: '@fixture/pwsh', + enabled: 'conditional', + condition: 'process.platform === \'win32\'', + fiberPhase: null, + }, + { entryId: 'codex', moduleName: '@fixture/codex', enabled: false, fiberPhase: null }, + { entryId: 'crashy', moduleName: '@fixture/crashy', enabled: true, fiberPhase: 'failed' }, + { entryId: null, moduleName: '@fixture/anonymous', enabled: true, fiberPhase: null }, + ], + }, + { + id: 'ptc', + isDefault: false, + rows: [ + { entryId: 'bash', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null }, + { entryId: 'bash-fork', moduleName: '@deepseek-ai/dsh-tool-bash', enabled: true, fiberPhase: null }, + { entryId: 'fs', moduleName: '@deepseek-ai/dsh-tool-fs', enabled: 'conditional', fiberPhase: null }, + ], + }, + { id: 'shattered', name: '坏预设', isDefault: false, broken: 'the composition file is missing', rows: [] }, ], } as unknown as Snapshot +async function renderReady(snapshot: Snapshot = SNAPSHOT): Promise> { + const view = render( snapshot)} />) + await screen.findByRole('searchbox', { name: en.search }) + return view +} + +const globalToggle = (): HTMLElement => + screen.getByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) }) +const drawerToggle = (): HTMLElement => + screen.getByRole('button', { name: (name: string) => name.startsWith(en.drawerTitle) }) + describe('PluginInventorySettingsTab', () => { - it('renders runtime status only for enabled plugins', async () => { - const deferred = Promise.withResolvers() - const list = vi.fn(() => deferred.promise) - const view = render() - expect(screen.getByText(en.loading)).toBeTruthy() + it('shows the default preset first and keeps the global plane collapsed', async () => { + const view = await renderReady() - await act(async () => { deferred.resolve(SNAPSHOT) }) - expect(list).toHaveBeenCalledOnce() - expect(screen.getByRole('searchbox', { name: en.search })).toBeTruthy() - expect(screen.getByRole('heading', { name: en.catalog })).toBeTruthy() - expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') - expect(screen.getAllByRole('listitem')).toHaveLength(7) - expect(screen.getAllByText(en.enabledTag)).toHaveLength(6) + const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + expect((switcher as HTMLSelectElement).value).toBe('standard') + expect(screen.getAllByRole('option').map(option => option.textContent)).toEqual([ + '标准模式 (default)', + 'ptc', + '坏预设 (failed to load)', + ]) + expect(screen.getByText(en.presetSubtitle)).toBeTruthy() + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('6') + + // Only the preset group lists rows while the global plane stays collapsed. + expect(screen.getAllByRole('listitem')).toHaveLength(6) + expect(screen.getAllByText(en.enabledTag)).toHaveLength(3) + expect(screen.getByText(en.conditionalTag)).toBeTruthy() expect(screen.getByText(en.disabledTag)).toBeTruthy() - for (const value of [ - 'Mounted', - 'Waiting for dependencies', - 'Loading', - 'Mount failed', - 'Unloading', - 'Not mounted', - ]) { - expect(screen.getByRole('img', { name: value })).toBeTruthy() - } - const active = screen.getByRole('button', { name: 'hmr, Mounted, Enabled' }) - expect(active.getAttribute('aria-expanded')).toBe('false') - fireEvent.click(active) - expect(active.getAttribute('aria-expanded')).toBe('true') - expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('8a1b2c3d') - expect(screen.getByText(en.configuration)).toBeTruthy() - expect(screen.getByText(en.cordis)).toBeTruthy() - fireEvent.click(active) - expect(view.container.querySelector('[data-loader-entry]')).toBeNull() + expect(screen.getByText(en.failedTag)).toBeTruthy() + expect(screen.getByRole('img', { name: 'Running' })).toBeTruthy() + expect(screen.getAllByRole('img', { name: 'Not running' })).toHaveLength(2) - fireEvent.click(active) - fireEvent.change(screen.getByRole('searchbox', { name: en.search }), { - target: { value: 'disabled-entry' }, - }) + expect(globalToggle().getAttribute('aria-expanded')).toBe('false') + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('7') + expect(screen.getByText(`1 ${en.failedCountLabel}`)).toBeTruthy() + + // A preset row expands into its provenance facts. + fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' })) + expect(screen.getByText(en.fromPreset)).toBeTruthy() + expect(screen.getByText('标准模式')).toBeTruthy() + expect(screen.getByText(en.condition)).toBeTruthy() + expect(screen.getByText('process.platform === \'win32\'')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'pwsh, Conditional' })) + expect(screen.queryByText(en.condition)).toBeNull() + + // A failed preset row names its runtime state instead of a condition. + fireEvent.click(screen.getByRole('button', { name: 'crashy, Failed' })) + expect(screen.getByText(en.runtime)).toBeTruthy() + expect(screen.getByText('Failed to start')).toBeTruthy() + + // A row declaring no id has no Loader identity line, only its module. + fireEvent.click(screen.getByRole('button', { name: 'anonymous, Enabled' })) expect(view.container.querySelector('[data-loader-entry]')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'directory-picker-native, Disabled' })) - expect(screen.getAllByText(en.disabledTag)).toHaveLength(2) - expect(screen.queryByText(en.cordis)).toBeNull() - expect(screen.queryByText(en.unobserved)).toBeNull() + expect(screen.getByText(en.moduleLabel).nextElementSibling?.textContent).toBe('@fixture/anonymous') }) - it('filters by module name or Loader entry id', async () => { - render( SNAPSHOT)} />) - const search = await screen.findByRole('searchbox', { name: en.search }) + it('expands the global plane with failures first and the session-plugin drawer', async () => { + const view = await renderReady() - fireEvent.change(search, { target: { value: 'disabled-entry' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('directory-picker-native')).toBeTruthy() + fireEvent.click(globalToggle()) + expect(globalToggle().getAttribute('aria-expanded')).toBe('true') + const failed = view.container.querySelector('[data-plugin-scope="global"] [data-failed="true"]') + expect(failed?.getAttribute('data-plugin-entry')).toBe('telemetry') + // Failures float above the Loader-ordered remainder. + expect(view.container.querySelector('[data-plugin-scope="global"] li')).toBe(failed) - fireEvent.change(search, { target: { value: 'cordis-plugin-hmr' } }) - expect(screen.getAllByRole('listitem')).toHaveLength(1) - expect(screen.getByText('hmr')).toBeTruthy() + // The drawer stays collapsed until opened, then names its providers. + expect(drawerToggle().getAttribute('aria-expanded')).toBe('false') + expect(screen.queryByText(en.presetEnabledTag)).toBeNull() + fireEvent.click(drawerToggle()) + expect(screen.getAllByText(en.presetEnabledTag)).toHaveLength(2) + + fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' })) + expect(screen.getByText(en.drawerDetail)).toBeTruthy() + expect(screen.getByText(en.enabledIn)).toBeTruthy() + expect(screen.getByText('标准模式 · ptc')).toBeTruthy() + + // The failed global card reports its runtime state. + fireEvent.click(screen.getByRole('button', { name: 'telemetry, Failed' })) + expect(screen.getByText('Failed to start')).toBeTruthy() + + // A disabled row outside every preset stays plainly disabled. + fireEvent.click(screen.getByRole('button', { name: 'dormant, Disabled' })) + expect(screen.queryByText(en.drawerDetail)).toBeNull() + + fireEvent.click(drawerToggle()) + expect(screen.queryByText(en.presetEnabledTag)).toBeNull() + fireEvent.click(globalToggle()) + expect(globalToggle().getAttribute('aria-expanded')).toBe('false') + }) + + it('switches the inspected preset in place, including broken ones', async () => { + const view = await renderReady() + const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + + fireEvent.change(switcher, { target: { value: 'ptc' } }) + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('3') + fireEvent.click(screen.getAllByRole('button', { name: 'tool-bash, Enabled' })[0]!) + // An unnamed preset labels provenance by its id. + expect(screen.getByText(en.fromPreset).nextElementSibling?.textContent).toBe('ptc') + + fireEvent.change(switcher, { target: { value: 'shattered' } }) + expect(screen.getByRole('alert').textContent).toBe('the composition file is missing') + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') + }) + + it('jumps from a drawer row to the preset that enables it', async () => { + await renderReady() + const switcher = screen.getByRole('combobox', { name: en.switcherLabel }) + fireEvent.change(switcher, { target: { value: 'ptc' } }) + + fireEvent.click(globalToggle()) + fireEvent.click(drawerToggle()) + fireEvent.click(screen.getByRole('button', { name: 'tool-bash, Enabled via presets' })) + fireEvent.click(screen.getByRole('button', { name: en.viewInPreset })) + expect((switcher as HTMLSelectElement).value).toBe('standard') + }) + + it('searches across scopes and points at matches in other presets', async () => { + const view = await renderReady() + const search = screen.getByRole('searchbox', { name: en.search }) + + fireEvent.change(search, { target: { value: 'tool-bash' } }) + // Searching forces the collapsed global plane and drawer open. + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('1') + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('1') + expect(screen.getByText(en.presetEnabledTag)).toBeTruthy() + expect(screen.queryByText(`1 ${en.failedCountLabel}`)).toBeNull() + const hint = screen.getByText((text: string) => text.startsWith('2 more matches')) + expect(hint).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'ptc' })) + expect(screen.getByRole('combobox', { name: en.switcherLabel }).value).toBe('ptc') + + // A match visible only in another preset keeps the pointer without rows. + fireEvent.change(search, { target: { value: 'crashy' } }) + expect(view.container.querySelector('[data-preset-plugin-count]')?.textContent).toBe('0') + expect(screen.getByText((text: string) => text.startsWith('1 more matches'))).toBeTruthy() + expect(screen.queryByText(en.emptySearch)).toBeNull() + + // A match on a Loader entry id only reaches the global plane. + fireEvent.change(search, { target: { value: '8a1b2c3d' } }) + expect(view.container.querySelector('[data-plugin-count]')?.textContent).toBe('1') + expect(screen.queryByText((text: string) => text.includes('more matches'))).toBeNull() fireEvent.change(search, { target: { value: 'not-a-plugin' } }) - expect(screen.queryAllByRole('listitem')).toHaveLength(0) expect(screen.getByText(en.emptySearch)).toBeTruthy() + expect(screen.queryAllByRole('listitem')).toHaveLength(0) + }) + + it('renders a rosterless deployment as one expanded global list', async () => { + const view = await renderReady({ + entries: [ + { entryId: 'hmr', moduleName: '@deepseek-ai/cordis-plugin-hmr', enabled: true, fiberPhase: 'active' }, + { entryId: 'off', moduleName: '@fixture/off', enabled: false, fiberPhase: null }, + ], + } as unknown as Snapshot) + + expect(screen.queryByRole('combobox', { name: en.switcherLabel })).toBeNull() + expect(globalToggle().getAttribute('aria-expanded')).toBe('true') + expect(screen.getAllByRole('listitem')).toHaveLength(2) + + fireEvent.click(screen.getByRole('button', { name: 'hmr, Enabled' })) + expect(screen.getByText(en.runtime)).toBeTruthy() + expect(view.container.querySelector('[data-loader-entry]')?.textContent).toBe('hmr') + fireEvent.click(screen.getByRole('button', { name: 'off, Disabled' })) + expect(screen.getAllByText(en.moduleLabel).length).toBeGreaterThan(0) + expect(screen.queryByText(en.runtime)).toBeNull() + }) + + it('renders a preset-only snapshot without the global section', async () => { + await renderReady({ + entries: [], + agentPresets: [{ + id: 'solo', + isDefault: false, + rows: [{ entryId: 'one', moduleName: '@fixture/one', enabled: true, fiberPhase: null }], + }], + }) + + expect(screen.queryByRole('button', { name: (name: string) => name.startsWith(en.globalTitle) })).toBeNull() + expect(screen.queryByText(en.empty)).toBeNull() + expect(screen.getAllByRole('listitem')).toHaveLength(1) }) it('shows a generic failure and retries into the empty state', async () => { @@ -116,6 +275,7 @@ describe('PluginInventorySettingsTab', () => { const deferred = Promise.withResolvers() const pending = render( deferred.promise)} />) + expect(screen.getByText(en.loading)).toBeTruthy() pending.unmount() await act(async () => { deferred.resolve(SNAPSHOT) }) diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index cb8f1a7ebc..939c9abaca 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1526,7 +1526,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ declaredBy: 'an entry in \'settings.section\' (client-ui-settings-general), so it exists while that entry is mounted', occupants: [ 'client-locale LanguageRow id \'language\'', - 'client-ui-agent-preset AgentPresetRow id \'agent-preset\'', 'client-ui-chat TranscriptViewRow id \'transcript-view\'', 'client-ui-conversation EnterBehaviorRow id \'composer-enter\'', 'client-ui-permission-presets PermissionRow id \'permission\'', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 8edd7adb9d..c68d5dd823 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -147,6 +147,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'the rows and the authoring capability.', }, + { + signature: 'async compositionInventory(): Promise', + description: 'Every preset\'s composition as flattened plugin rows, for plugin-listing surfaces beside the roster\'s own picker.\n\nA preset with a live standing mount answers from its newest generation\'s Loader entries — the composition new sessions join — and one never composed since boot answers from its file, with `!!js` disabled gates evaluated against the Loader context so both answers reflect the same host. Reading never mounts: an unmounted preset is parsed, not composed, so listing a preset\'s plugins cannot activate them early. A composition that stopped reading between discovery\'s health verdict and this read is reported broken with the raced reason rather than dropped.', + parameters: [], + returns: 'one composition per roster preset, in roster order.', + }, { signature: 'async resolve(id?: string): Promise', description: 'Resolve one preset by id.\n\nA broken preset resolves — deleting one, reading one, and reporting one all need the row — and the mounting paths refuse it AFTER resolution through resolveMountable.', @@ -3410,6 +3416,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPreset', declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', }, + { + name: 'AgentPresetComposition', + declaration: 'export interface AgentPresetComposition {\n readonly id: string;\n readonly name?: string;\n readonly isDefault: boolean;\n readonly broken?: string;\n readonly rows: readonly AgentPresetCompositionRow[];\n}', + }, + { + name: 'AgentPresetCompositionRow', + declaration: 'export interface AgentPresetCompositionRow {\n readonly entryId: string | null;\n readonly moduleName: string;\n readonly enabled: CompositionRowEnablement;\n readonly condition?: string;\n readonly fiberState?: FiberState;\n}', + }, { name: 'AgentPresetDirectoryOpenValue', declaration: 'export type AgentPresetDirectoryOpenValue = {\n readonly opened: true;\n} | {\n readonly opened: false;\n readonly path: string;\n};', @@ -3674,6 +3688,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CompactionTrigger', declaration: 'export type CompactionTrigger = \'pressure\' | \'context-overflow\';', }, + { + name: 'CompositionRowEnablement', + declaration: 'export type CompositionRowEnablement = boolean | \'conditional\';', + }, { name: 'ConfinedArgv', declaration: 'export interface ConfinedArgv {\n argv: string[];\n enforcement: SandboxEnforcement;\n denialSignatures: readonly string[];\n runnerFailureRules: readonly RunnerFailureRule[];\n}', @@ -3966,6 +3984,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'EpochHeader', declaration: 'export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n}', }, + { + name: 'FiberState', + declaration: 'export type FiberState = FiberStateEnum;', + }, { name: 'FileDiff', declaration: 'export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n}', diff --git a/packages/host/plugin-inventory/README.i18n.yaml b/packages/host/plugin-inventory/README.i18n.yaml index 93797f6893..bdb058c1cf 100644 --- a/packages/host/plugin-inventory/README.i18n.yaml +++ b/packages/host/plugin-inventory/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/plugin-inventory/README.md -README.md: 3f982ebcfdc85f3abd81d1615efccbec6b6bbbed -README.zh.md: eab5409c230ce64f2da66e267c0e203b709daa91 +README.md: 84b437b9952e06bf5231515d15c8fa6419b990be +README.zh.md: 6dacf80788ad7641bea162624e608f340dac1dca diff --git a/packages/host/plugin-inventory/README.md b/packages/host/plugin-inventory/README.md index 3f982ebcfd..84b437b995 100644 --- a/packages/host/plugin-inventory/README.md +++ b/packages/host/plugin-inventory/README.md @@ -1,5 +1,5 @@ --- -description: "Read-only projection of the current Cordis Loader plugin state: the pluginInventory service and its pluginInventory/list Remote for web GUI host clients." +description: "Read-only projection of the current Cordis Loader plugin state with each agent preset's composition beside it: the pluginInventory service and its pluginInventory/list Remote for web GUI host clients." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Clients and settings pages can show what is currently composed in the host: calling `pluginInventory/list` returns the current non-group Loader entries in Loader order — entry id, module specifier, effective enablement, and root Fiber phase (`pending`, `loading`, `active`, `failed`, or `unloading`, or `null` when an entry has no live root Fiber). The snapshot is point-in-time: the Loader is the sole lifecycle authority, and this package owns no cache, history, provenance model, event stream, or mutation path. Client packages consume the Remote through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. +Clients and settings pages can show what is currently composed in the host: calling `pluginInventory/list` returns the current non-group Loader entries in Loader order — entry id, module specifier, effective enablement, and root Fiber phase (`pending`, `loading`, `active`, `failed`, or `unloading`, or `null` when an entry has no live root Fiber). When an agent-preset roster is composed, the snapshot also carries one group per preset — id, display name, default marking, health, and flattened composition rows — because a deployment that mounts the roster runs its model-facing plugins there rather than on the Loader's own entries. The snapshot is point-in-time: the Loader is the sole lifecycle authority, and this package owns no cache, history, provenance model, event stream, or mutation path. Client packages consume the Remote through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation. ## Table of Contents @@ -25,12 +25,16 @@ Clients and settings pages can show what is currently composed in the host: call ## Use this package -Call `pluginInventory/list` when a client or settings page needs to show what is currently composed in the host — which plugins are loaded, enabled, and alive. The Remote is the only entry point: the service is Remote-only and deliberately declares no same-process Cordis `Context` merge. +Call `pluginInventory/list` when a client or settings page needs to show what is currently composed in the host — which plugins are loaded, enabled, and alive, and what each agent preset would give a session. The Remote is the only entry point: the service is Remote-only and deliberately declares no same-process Cordis `Context` merge. ### What a snapshot contains Each row is one non-group Loader entry: its entry id, the exact module specifier, the effective enablement (including disabled ancestor groups), and the current root Fiber phase. `pending` means the entry waits to load, `loading` that it is being read, `active` that it is running, `failed` that its fiber rejected, and `unloading` that it is being torn down; `null` means no live root Fiber exists at all. Structural group rows are skipped. +### Per-preset compositions + +With a roster composed, `agentPresets` carries one group per preset in roster order: its id, published display name, whether a session naming no preset composes it, and flattened plugin rows — entry id (null when the file row declares none), module specifier, effective enablement, the row's own `!!js` disabled expression when it carries one, and a root-fiber phase when the composition is live. A preset some session already composed answers from its newest standing generation; one never composed since boot answers from its composition file with disabled gates evaluated against the Loader context, and reading never mounts a preset. `conditional` enablement marks a gate the Host could not evaluate, and a broken preset stays listed with its reason and no rows. Without a roster the field is absent. + ### What you can and cannot do with it The inventory is a snapshot for display and diagnostics: a client can render the roster, flag failed entries, and detect changes by comparing snapshots. It cannot enable, disable, add, or remove plugins, and it carries no history — a fiber that already failed and was removed is absent. Because the service reads the Loader on every call, the answer always reflects the current composition rather than a cached view. @@ -45,7 +49,7 @@ The inventory is a snapshot for display and diagnostics: a client can render the ### Design concept -The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal plugin/status events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. +The gateway is a direct projection with no second lifecycle truth: every `list()` call reads `ctx.loader.entries()` and maps each non-group entry to its public row. Cordis's internal plugin/status events already maintain `Entry.fiber` and `Fiber.state`, so a cache would only add another lifecycle truth to keep synchronized. The agent-preset roster is an optional peer resolved per call through `ctx.get('agentPresets')`: its `compositionInventory()` owns every preset read, and this package only maps root-fiber states onto the public phase vocabulary. ### The phase mapping @@ -93,7 +97,8 @@ None; this package neither assembles nor sends a provider request. These limits define what a point-in-time inventory cannot tell a client. They are current package constraints, not a task backlog. - **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists. -- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins. +- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins in either plane. +- **Presets appear only with a roster** — a deployment without `dsh-agent-presets` serves Loader entries alone; the `agentPresets` field is absent rather than empty. ### Dev Note diff --git a/packages/host/plugin-inventory/README.zh.md b/packages/host/plugin-inventory/README.zh.md index eab5409c23..6dacf80788 100644 --- a/packages/host/plugin-inventory/README.zh.md +++ b/packages/host/plugin-inventory/README.zh.md @@ -1,5 +1,5 @@ --- -description: "当前 Cordis Loader 插件状态的只读投影:面向 web GUI 宿主客户端的 pluginInventory 服务及其 pluginInventory/list Remote。" +description: "当前 Cordis Loader 插件状态的只读投影,并附带每个 Agent 预设的组合:面向 web GUI 宿主客户端的 pluginInventory 服务及其 pluginInventory/list Remote。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -客户端与设置页可以展示宿主当前组合了什么:调用 `pluginInventory/list` 即按 Loader 顺序返回当前的非组条目——条目 id、模块标识、有效启用状态与根 Fiber 阶段(`pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活根 Fiber 时为 `null`)。该快照只表示调用当下:Loader 是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.zh.md) 组合消费这个 Remote,而不导入 Host 实现。 +客户端与设置页可以展示宿主当前组合了什么:调用 `pluginInventory/list` 即按 Loader 顺序返回当前的非组条目——条目 id、模块标识、有效启用状态与根 Fiber 阶段(`pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活根 Fiber 时为 `null`)。当部署组合了 Agent 预设 roster 时,快照还携带每个预设一组——id、显示名、默认标记、健康状态与压平后的组合行——因为挂载 roster 的部署把模型侧插件运行在预设组合里,而不是 Loader 自己的条目上。该快照只表示调用当下:Loader 是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.zh.md) 组合消费这个 Remote,而不导入 Host 实现。 ## 目录 @@ -25,12 +25,16 @@ kind: "package-reference" ## 使用本包 -当客户端或设置页需要展示宿主当前组合了什么——哪些插件已加载、已启用、是否存活——时调用 `pluginInventory/list`。Remote 是唯一入口:该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。 +当客户端或设置页需要展示宿主当前组合了什么——哪些插件已加载、已启用、是否存活,以及每个 Agent 预设会给会话什么——时调用 `pluginInventory/list`。Remote 是唯一入口:该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。 ### 快照包含什么 每一行是一个非组 Loader 条目:其条目 id、精确模块标识、有效启用状态(含被禁用的祖先组)与当前根 Fiber 阶段。`pending` 表示条目等待加载,`loading` 表示正在读取,`active` 表示正在运行,`failed` 表示其 fiber 被拒绝,`unloading` 表示正在拆除;`null` 表示完全不存在存活的根 Fiber。结构性的 group 行会被跳过。 +### 每个预设的组合 + +组合了 roster 时,`agentPresets` 按 roster 顺序携带每个预设一组:其 id、发布的显示名、未指名预设的会话是否组合它,以及压平后的插件行——条目 id(文件行未声明时为 null)、模块标识、有效启用状态、行自带的 `!!js` disabled 表达式(如有),以及组合存活时的根 Fiber 阶段。已有会话组合过的预设由其最新 standing 世代作答;开机以来从未被组合的预设由其组合文件作答,disabled 门用 Loader 上下文求值,且读取从不挂载预设。`conditional` 表示宿主无法求值的门;坏预设保留在列表中,携带原因且没有行。没有 roster 时该字段缺席。 + ### 你能用它做什么、不能做什么 该清单是供展示与诊断的快照:客户端可以渲染名单、标出失败条目,并通过比较快照检测变化。它不能启用、停用、添加或移除插件,也不携带历史——已经失败并被移除的 fiber 缺席。由于服务每次调用都读取 Loader,答案总是反映当前组合,而不是缓存视图。 @@ -45,7 +49,7 @@ kind: "package-reference" ### 设计理念 -网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 plugin/status 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。 +网关是一层没有第二个生命周期真源的直接投影:每次 `list()` 调用都读取 `ctx.loader.entries()`,并把每个非组条目映射为公共行。Cordis 内部的 plugin/status 事件已经维护了 `Entry.fiber` 与 `Fiber.state`,因此再加缓存只会多出一个需要同步的生命周期真源。Agent 预设 roster 是每次调用经 `ctx.get('agentPresets')` 解析的可选伙伴:所有预设读取都由它的 `compositionInventory()` 负责,本包只把根 Fiber 状态映射到公共阶段词汇。 ### 阶段映射 @@ -93,7 +97,8 @@ Typert 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产 这些限制说明一个点时刻清单无法告诉客户端什么。它们是当前包约束,不是任务积压。 - **仅表示调用当下**——结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。 -- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。 +- **无来源与修改能力**——服务不识别条目由哪个 bundle、profile 或 override 引入,也不能在任一平面启用、停用、添加或移除插件。 +- **预设仅随 roster 出现**——未装 `dsh-agent-presets` 的部署只提供 Loader 条目;`agentPresets` 字段缺席而非为空。 ### 开发备注 diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index e9db804471..8c96377d6a 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -53,13 +53,20 @@ }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/host/plugin-inventory/src/index.ts b/packages/host/plugin-inventory/src/index.ts index ff5394c564..3beef6f0c0 100644 --- a/packages/host/plugin-inventory/src/index.ts +++ b/packages/host/plugin-inventory/src/index.ts @@ -2,10 +2,13 @@ import type { Context, FiberState } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/cordis-plugin-loader' +// Type-only: the optional agent-preset roster resolved through `ctx.get`. +import type {} from '@deepseek-ai/dsh-agent-presets' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' // Typert-generated ./typert and ./remote artifacts import Zod at runtime. import type {} from 'zod' import type { + AgentPresetPluginGroup, PluginEntryId, PluginFiberPhase, PluginInventoryEntry, @@ -51,10 +54,16 @@ export class PluginInventoryGateway extends TypertRemoteService { * Read the Loader directly on every call. Cordis's internal plugin/status * events already maintain Entry.fiber and Fiber.state, so a second cache * would only add another lifecycle truth to keep synchronized. - * @returns Current non-group Loader entries in Loader order. + * + * When an agent-preset roster is composed, the snapshot also carries each + * preset's composition rows, because those rows — not the Loader's own + * entries — are where a deployment that mounts the roster runs its + * model-facing plugins. + * @returns Current non-group Loader entries in Loader order, with per-preset + * compositions when a roster is composed. */ @Remote('list') - list(): PluginInventorySnapshot { + async list(): Promise { const entries: PluginInventoryEntry[] = [] for (const entry of this.ctx.loader.entries()) { if (entry.options.group) continue @@ -65,7 +74,18 @@ export class PluginInventoryGateway extends TypertRemoteService { fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state], }) } - return { entries } + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return { entries } + const agentPresets: AgentPresetPluginGroup[] = (await presets.compositionInventory()).map( + composition => ({ + ...composition, + rows: composition.rows.map(({ fiberState, ...row }) => ({ + ...row, + fiberPhase: fiberState === undefined ? null : FIBER_PHASE[fiberState], + })), + }), + ) + return { entries, agentPresets } } } diff --git a/packages/host/plugin-inventory/src/types.ts b/packages/host/plugin-inventory/src/types.ts index f5678fc3c2..301e194a65 100644 --- a/packages/host/plugin-inventory/src/types.ts +++ b/packages/host/plugin-inventory/src/types.ts @@ -22,7 +22,47 @@ export interface PluginInventoryEntry { readonly fiberPhase: PluginFiberPhase } +/** Effective enablement of one preset composition row. */ +export type PresetPluginEnablement = boolean | 'conditional' + +/** One plugin row an agent preset's composition names. */ +export interface AgentPresetPluginRow { + /** Composition row id, or null when the row declares none. */ + readonly entryId: string | null + /** Module specifier the row names. */ + readonly moduleName: string + /** + * Effective enablement, including disabled ancestor groups. `'conditional'` + * marks a `!!js` disabled expression on a composition no session has + * mounted, which only a Loader context can decide. + */ + readonly enabled: PresetPluginEnablement + /** The row's own `!!js` disabled expression, when it carries one. */ + readonly condition?: string + /** Root-fiber phase when the composition is live; null otherwise. */ + readonly fiberPhase: PluginFiberPhase +} + +/** One agent preset's identity and flattened composition in the inventory. */ +export interface AgentPresetPluginGroup { + /** Stable preset id. */ + readonly id: string + /** Display name the preset published; a reader falls back to the id. */ + readonly name?: string + /** Whether a session naming no preset composes this one. */ + readonly isDefault: boolean + /** Why this preset's composition cannot be read; absent when rows answer. */ + readonly broken?: string + /** Plugin rows in composition order; empty when the preset is broken. */ + readonly rows: readonly AgentPresetPluginRow[] +} + /** Point-in-time inventory returned by the plugin inventory Remote. */ export interface PluginInventorySnapshot { readonly entries: readonly PluginInventoryEntry[] + /** + * Per-preset compositions, present only when an agent-preset roster is + * composed in this deployment. + */ + readonly agentPresets?: readonly AgentPresetPluginGroup[] } diff --git a/packages/host/plugin-inventory/tests/inventory.spec.ts b/packages/host/plugin-inventory/tests/inventory.spec.ts index cd43c492a8..107a0db6ee 100644 --- a/packages/host/plugin-inventory/tests/inventory.spec.ts +++ b/packages/host/plugin-inventory/tests/inventory.spec.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context, type Plugin } from '@deepseek-ai/cordis' +import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol' +import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets' import PluginInventoryGateway from '../src/index.ts' const contexts: Context[] = [] @@ -52,7 +53,9 @@ describe('PluginInventoryGateway', () => { }) await ctx.loader.create({ name: 'cordis:active', group: true }) - const snapshot = inventory.list() + const snapshot = await inventory.list() + // No agent-preset roster is composed, so the snapshot carries no presets. + expect(snapshot.agentPresets).toBeUndefined() expect(snapshot.entries).toHaveLength(3) expect(snapshot.entries).toEqual(expect.arrayContaining([ { @@ -76,7 +79,7 @@ describe('PluginInventoryGateway', () => { ])) await ctx.loader.update(activeId, { disabled: true }) - expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({ + expect((await inventory.list()).entries.find(entry => entry.entryId === activeId)).toEqual({ entryId: activeId, moduleName: 'cordis:active', enabled: false, @@ -84,6 +87,38 @@ describe('PluginInventoryGateway', () => { }) await ctx.loader.remove(pendingId) - expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false) + expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false) + }) + + it('carries each composed preset with root-fiber states mapped to phases', async () => { + const { ctx, inventory } = await harness() + ctx.provide('agentPresets', { + compositionInventory: async () => [ + { + id: 'standard', + name: '标准模式', + isDefault: true, + rows: [ + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, fiberState: FiberState.ACTIVE }, + { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x' }, + ], + }, + { id: 'damaged', isDefault: false, broken: 'the composition file is missing', rows: [] }, + ], + } as Partial as never) + + const snapshot = await inventory.list() + expect(snapshot.agentPresets).toEqual([ + { + id: 'standard', + name: '标准模式', + isDefault: true, + rows: [ + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, fiberPhase: 'active' }, + { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x', fiberPhase: null }, + ], + }, + { id: 'damaged', isDefault: false, broken: 'the composition file is missing', rows: [] }, + ]) }) }) diff --git a/packages/host/plugin-inventory/tsconfig.json b/packages/host/plugin-inventory/tsconfig.json index 5bd45b3f3c..b56a8291ec 100644 --- a/packages/host/plugin-inventory/tsconfig.json +++ b/packages/host/plugin-inventory/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../util/brand" }, diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index 0ff1f1d20f..401ec1eefe 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 5b83ed85a6691dabb6ec340b6462f17d5281efdd -README.zh.md: f55f1e383d11b28fa1d8ff52aec1e94a351914f7 +README.md: bc157f664ecc2aabb61baaa4527f0134133aa344 +README.zh.md: 7680fcf76653f299d068290d74779e21969cf6a6 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 5b83ed85a6..bc157f664e 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -105,6 +105,7 @@ This section explains the design behind the roster and the standing mount; obser |---|---| | [`src/index.ts`](src/index.ts) | Service entry: `Config` schema, settings namespace, roster API, standing-mount coordination | | [`src/discovery.ts`](src/discovery.ts) | Filesystem discovery: root scanning, health checks, id validation, ordering | +| [`src/composition-inventory.ts`](src/composition-inventory.ts) | Flattened composition rows for plugin-listing surfaces: file reads with evaluated disabled gates, mount reads with fiber states | | [`src/preset.ts`](src/preset.ts) | Vocabulary: preset id rule, `AgentPreset` and `PresetRoot`, error types | | [`src/mount.ts`](src/mount.ts) | Subtree mounting, host base-URL handling, mount audit, `write()` suppression | | [`src/authoring.ts`](src/authoring.ts) | Copy/delete/read of locally authored presets, permission tightening | @@ -117,6 +118,10 @@ This section explains the design behind the roster and the standing mount; obser `ensureStanding` keeps one pending promise per preset id, single-flight, so two agents racing the first use of a preset share one composition. A settled failure is removed so a later session retries a preset whose file has been fixed. The mount runs in the roster service's own untraced context — a subtree minted from a traced context would resolve services through the caller's shadow fiber — so it survives every agent and unwinds only with whole-tree teardown. `serviceForAgent` reads an agent's instance of a service its preset mounted behind an `isolate` realm, which is otherwise invisible outside the group. +### The composition inventory + +`compositionInventory()` answers plugin-listing surfaces with each preset's flattened rows beside its roster identity: a preset with a live standing mount answers from its newest generation's Loader entries, and one never composed since boot answers from its composition file with `!!js` disabled gates evaluated against the Loader context, so both answers reflect the same host. Reading never mounts a preset — a settings page listing every composition activates none of them. A gate the evaluator refuses stays `'conditional'`, and a file that stopped reading as a composition between discovery's health verdict and the row read is reported broken with the raced reason rather than dropped. + ### The mount audit A directly-plugged subtree is absent from `ctx.loader.entries()`, so no boot audit covers it; `mountPreset` proves the result usable itself and rejects three shapes: an unscoped target (the preset's tools would register globally), a row still waiting for a service the composition never supplies, and a row that published a service into the root realm (process-global, so the second preset publishing the same name collides). The invariant companion re-checks the last rule on every service notification, because a row publishing from a timer or an asynchronous continuation would escape the one-shot audit. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index f55f1e383d..7680fcf766 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -105,6 +105,7 @@ agent-presets: |---|---| | [`src/index.ts`](src/index.ts) | 服务入口:`Config` schema、settings 命名空间、名单 API、常驻挂载协调 | | [`src/discovery.ts`](src/discovery.ts) | 文件系统发现:根目录扫描、健康检查、id 校验、排序 | +| [`src/composition-inventory.ts`](src/composition-inventory.ts) | 面向插件清单表面的压平组合行:文件读取(求值 disabled 门)与挂载读取(携带 fiber 状态) | | [`src/preset.ts`](src/preset.ts) | 词汇体系:preset id 规则、`AgentPreset` 与 `PresetRoot`、错误类型 | | [`src/mount.ts`](src/mount.ts) | 子树挂载、宿主 base-URL 处理、挂载审计、`write()` 抑制 | | [`src/authoring.ts`](src/authoring.ts) | 本地创作 preset 的复制/删除/读取、权限收紧 | @@ -117,6 +118,10 @@ agent-presets: `ensureStanding` 为每个 preset id 保留一个进行中的 promise(single-flight),因此两个竞争首次使用同一 preset 的 agent 共享一份组装。已结算的失败会被移除,以便后续会话重试文件已被修复的 preset。挂载运行在 roster 服务自己的未追踪上下文中——从被追踪上下文派生的子树会经调用方的 shadow fiber 解析服务——因此它比任何 agent 都活得久,只随整棵树卸载。`serviceForAgent` 读取某 agent 对其 preset 挂在 `isolate` realm 之后(组外不可见)的某个服务实例。 +### 组合清单 + +`compositionInventory()` 向插件清单表面提供每个预设的压平行及其名单身份:已有存活 standing mount 的预设由其最新世代的 Loader 条目作答,开机以来从未被组合的预设由其组合文件作答,`!!js` disabled 门用 Loader 上下文求值,使两种答案反映同一台宿主。读取从不挂载预设——列出所有组合的设置页不会激活其中任何一个。求值器拒绝的门保持 `'conditional'`;在发现的健康裁决与行读取之间变得不可读的文件,会携带竞态原因报告为 broken,而不是被静默丢弃。 + ### 挂载审计 直接挂载的子树不会出现在 `ctx.loader.entries()` 中,因此没有启动审计能覆盖它;`mountPreset` 自行证明结果可用,并拒绝三种形态:无 scope 的目标(preset 的工具会注册成全局的)、仍在等待组装从未提供的服务的行、以及把服务发布进根 realm 的行(进程级全局,第二个发布同名服务的 preset 会相撞)。不变式伴生插件在每次服务通知时复查最后一条规则,因为从定时器或异步续体发布的行会绕过一次性审计。 diff --git a/packages/preset/agent-presets/src/composition-inventory.ts b/packages/preset/agent-presets/src/composition-inventory.ts new file mode 100644 index 0000000000..29ac401eaa --- /dev/null +++ b/packages/preset/agent-presets/src/composition-inventory.ts @@ -0,0 +1,195 @@ +/** + * Structured composition reads for plugin-listing surfaces: the plugin rows + * each preset names, with each row's effective enablement. A preset with a + * live standing mount answers from that mount's Loader entries — evaluated + * `disabled`, real root-fiber states; a preset no session has composed since + * boot answers from its composition file, with `!!js` disabled expressions + * evaluated through the caller-supplied Loader evaluator so the file answer + * matches the decision a mount on this host would make. A row whose + * expression the evaluator refuses stays `'conditional'`. + * @module @deepseek-ai/dsh-agent-presets/composition-inventory + */ + +import { readFile } from 'node:fs/promises' +import { load } from 'js-yaml' +import type { FiberState } from '@deepseek-ai/cordis' +import { isJsExpr, type EntryTree } from '@deepseek-ai/cordis-plugin-loader' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' +import { entryListProblem } from './discovery.ts' + +/** + * Effective enablement of one composition row: a literal or evaluated + * boolean, or `'conditional'` when a `!!js` disabled expression could not be + * evaluated outside a mount. + */ +export type CompositionRowEnablement = boolean | 'conditional' + +/** + * Evaluate one `!!js` disabled expression the way the Loader would at a mount + * decision. Throwing refuses the answer: the row is reported `'conditional'` + * rather than guessed. + */ +export type DisabledExpressionEvaluator = (expression: string) => unknown + +/** One plugin row a preset composition names. */ +export interface AgentPresetCompositionRow { + /** + * The Loader-tree entry id when read from a live mount, else the id the + * composition file declares; null when the file row declares none. + */ + readonly entryId: string | null + /** Module specifier the row names. */ + readonly moduleName: string + /** Effective enablement, including disabled ancestor groups. */ + readonly enabled: CompositionRowEnablement + /** The row's own `!!js` disabled expression, when it carries one. */ + readonly condition?: string + /** Root-fiber state, present only when read from a live mount. */ + readonly fiberState?: FiberState +} + +/** One preset's roster identity beside its composition rows. */ +export interface AgentPresetComposition { + /** Stable preset id. */ + readonly id: string + /** Display name the preset published. */ + readonly name?: string + /** Whether a session naming no preset composes this one. */ + readonly isDefault: boolean + /** Why this preset's rows cannot be read; absent when {@link rows} answers. */ + readonly broken?: string + /** Composition rows in composition order; empty when the preset is broken. */ + readonly rows: readonly AgentPresetCompositionRow[] +} + +/** + * One `disabled` node's contribution to effective enablement, mirroring the + * Loader's own reading: a `!!js` expression is asked of the evaluator — a + * refusal (throw) leaves the decision to a mount — and anything else disables + * exactly when `Boolean(value)` does. + * @param value - the raw `disabled` node of one composition row. + * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @returns true (disabled), false (enabled), or `'conditional'`. + */ +function disabledContribution( + value: unknown, + evaluateExpression: DisabledExpressionEvaluator, +): boolean | 'conditional' { + if (isJsExpr(value)) { + try { + return Boolean(evaluateExpression(value.__jsExpr)) + } catch { + // The evaluator refused (a malformed or context-dependent expression); + // only a real mount decision can answer, so the row stays conditional. + return 'conditional' + } + } + return Boolean(value) +} + +/** + * Combine an ancestor group's disabled state with a row's own, the way the + * Loader walks owning groups: any literal true disables, otherwise any + * expression leaves the decision to a mount. + * @param outer - the combined ancestor contribution. + * @param own - this row's contribution. + * @returns the row's effective disabled state. + */ +function combineDisabled( + outer: boolean | 'conditional', + own: boolean | 'conditional', +): boolean | 'conditional' { + if (outer === true || own === true) return true + if (outer === 'conditional' || own === 'conditional') return 'conditional' + return false +} + +/** A parsed composition row after {@link entryListProblem} accepted the list. */ +interface RawRow { + readonly id?: unknown + readonly name: string + readonly group?: unknown + readonly config?: unknown + readonly disabled?: unknown +} + +/** + * Flatten one parsed row list into plugin rows. Group rows are structural — + * the Loader reports a group entry as always enabled and lets children + * inherit its `disabled` — so only their children are emitted. + * @param rows - the parsed rows, shape-checked by the caller. + * @param outerDisabled - the combined ancestor-group disabled state. + * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @param found - the accumulator receiving flattened rows. + */ +function flattenRows( + rows: readonly unknown[], + outerDisabled: boolean | 'conditional', + evaluateExpression: DisabledExpressionEvaluator, + found: AgentPresetCompositionRow[], +): void { + for (const value of rows) { + const row = value as RawRow + const disabled = combineDisabled(outerDisabled, disabledContribution(row.disabled, evaluateExpression)) + if (row.group === true) { + flattenRows(row.config as readonly unknown[], disabled, evaluateExpression, found) + continue + } + found.push({ + entryId: typeof row.id === 'string' && row.id !== '' ? row.id : null, + moduleName: row.name, + enabled: disabled === true ? false : disabled === 'conditional' ? 'conditional' : true, + ...isJsExpr(row.disabled) ? { condition: row.disabled.__jsExpr } : {}, + }) + } +} + +/** + * Plugin rows of one composition file, for a preset with no live mount. + * + * Parsed with the Loader's own dialect ({@link entryListSchema}), so the rows + * reported are the rows a mount would start from. A file that stopped reading + * as a composition — discovery judged the preset healthy moments earlier, so + * only an edit racing this read gets here — answers as broken with the raced + * reason rather than dropping the rows silently. + * @param path - absolute path of the composition file. + * @param evaluateExpression - the Loader-context evaluator for `!!js` nodes. + * @returns flattened rows in composition order, or why they cannot be read. + */ +export async function fileComposition( + path: string, + evaluateExpression: DisabledExpressionEvaluator, +): Promise<{ rows: AgentPresetCompositionRow[] } | { broken: string }> { + let rows: unknown + try { + rows = load(await readFile(path, 'utf8'), { schema: entryListSchema }) + } catch (error) { + /* v8 ignore next -- fs and js-yaml throw Errors for every failure here; the fallback keeps a hostile value readable */ + return { broken: error instanceof Error ? error.message : String(error) } + } + const problem = entryListProblem(rows) + if (problem !== undefined) return { broken: problem } + const found: AgentPresetCompositionRow[] = [] + flattenRows(rows as readonly unknown[], false, evaluateExpression, found) + return { rows: found } +} + +/** + * Plugin rows of one live standing composition, in Loader-entry order. + * @param tree - the standing mount's entry tree. + * @returns rows with the Loader's evaluated enablement and root-fiber states. + */ +export function mountedCompositionRows(tree: EntryTree): AgentPresetCompositionRow[] { + const found: AgentPresetCompositionRow[] = [] + for (const entry of tree.entries()) { + if (entry.options.group) continue + found.push({ + entryId: entry.id, + moduleName: entry.options.name, + enabled: !entry.disabled, + ...isJsExpr(entry.options.disabled) ? { condition: entry.options.disabled.__jsExpr } : {}, + ...entry.fiber === undefined ? {} : { fiberState: entry.fiber.state }, + }) + } + return found +} diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index 915ee90f45..8d39410b00 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -67,11 +67,14 @@ export const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../presets/', import.m * that produces a file the loader cannot even begin with — and it must accept * everything the loader accepts, which is why rows are only required to be * maps carrying a plugin `name` (groups recurse into their own lists). + * + * Shared with the composition inventory, whose file reads race edits against + * the health verdict and must judge the raced content by the same rule. * @param rows - the parsed composition document. * @param at - row-path prefix for nested diagnostics, empty at the top level. * @returns one human-readable reason, or undefined when the shape holds. */ -function entryListProblem(rows: unknown, at = ''): string | undefined { +export function entryListProblem(rows: unknown, at = ''): string | undefined { if (!Array.isArray(rows)) { return at === '' ? 'the composition must be a top-level list of plugin rows' diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 7dbcefb3d3..3033cf47b9 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -23,6 +23,7 @@ import { stat } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' import z from '@deepseek-ai/schemastery' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' @@ -40,13 +41,20 @@ import { copyComposition, deleteComposition, readComposition, InvalidPresetIdError, PresetExistsError, PresetNotWritableError, } from './authoring.ts' -import { mountPreset, serviceForAgent, standingMountFor } from './mount.ts' +import { livePresetMounts, mountPreset, serviceForAgent, standingMountFor } from './mount.ts' +import { + fileComposition, mountedCompositionRows, + type AgentPresetComposition, +} from './composition-inventory.ts' import { PresetLockedError, PresetMountError, UnknownPresetError, type AgentPreset, type Config, type PresetRoot, } from './preset.ts' import { agentPresetProjectionDefinition } from './session.ts' export type * from './types.ts' +export type { + AgentPresetComposition, AgentPresetCompositionRow, CompositionRowEnablement, +} from './composition-inventory.ts' /** Settings namespace carrying the user's chosen default preset. */ export const SETTINGS_NAMESPACE = 'agent-presets' @@ -330,6 +338,51 @@ export class AgentPresets extends TypertRemoteService { } } + /** + * Every preset's composition as flattened plugin rows, for plugin-listing + * surfaces beside the roster's own picker. + * + * A preset with a live standing mount answers from its newest generation's + * Loader entries — the composition new sessions join — and one never + * composed since boot answers from its file, with `!!js` disabled gates + * evaluated against the Loader context so both answers reflect the same + * host. Reading never mounts: an unmounted preset is parsed, not composed, + * so listing a preset's plugins cannot activate them early. A composition + * that stopped reading between discovery's health verdict and this read is + * reported broken with the raced reason rather than dropped. + * @returns one composition per roster preset, in roster order. + */ + async compositionInventory(): Promise { + const defaultId = this.defaultId + // The Loader's own expression scope: what a mount decision would consult + // (entry.ctx only adds the entry itself, which no disabled gate reads). + const evaluateExpression = (expression: string): unknown => evaluate(this.ctx.loader.ctx, expression) + const found: AgentPresetComposition[] = [] + for (const preset of await this.list()) { + const identity = { + id: preset.id, + ...preset.name === undefined ? {} : { name: preset.name }, + isDefault: preset.id === defaultId, + } + if (preset.broken !== undefined) { + found.push({ ...identity, broken: preset.broken, rows: [] }) + continue + } + // Newest generation last: mount records keep insertion order, and a + // superseded generation's record precedes its replacement's. + const mount = livePresetMounts().findLast(candidate => candidate.presetId === preset.id) + if (mount !== undefined) { + found.push({ ...identity, rows: mountedCompositionRows(mount.tree) }) + continue + } + const read = await fileComposition(preset.path, evaluateExpression) + found.push('broken' in read + ? { ...identity, broken: read.broken, rows: [] } + : { ...identity, rows: read.rows }) + } + return found + } + /** * Resolve one preset by id. * diff --git a/packages/preset/agent-presets/tests/composition-inventory.spec.ts b/packages/preset/agent-presets/tests/composition-inventory.spec.ts new file mode 100644 index 0000000000..8240ce557f --- /dev/null +++ b/packages/preset/agent-presets/tests/composition-inventory.spec.ts @@ -0,0 +1,309 @@ +/** + * Structured composition reads: the flattened plugin rows a preset names, + * answered from the composition file while no session has mounted the preset + * and from the standing mount once one has, with a composition that cannot be + * read reported broken by reason instead of dropped. + */ + +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context, FiberState } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { afterEach, describe, expect, it, vi } from 'vitest' +import AgentPresets, { COMPOSITION_FILE, METADATA_FILE } from '@deepseek-ai/dsh-agent-presets' +import type { Config } from '@deepseek-ai/dsh-agent-presets' +import { evaluate } from '@deepseek-ai/cordis-plugin-loader' +import { fileComposition, mountedCompositionRows } from '../src/composition-inventory.ts' +import { livePresetMounts } from '../src/mount.ts' + +const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures') +const SYSTEM_ROOT = { path: join(FIXTURES, 'system'), trust: 'system' as const } +// A row naming a package installed beside the harness, the way authored rows do. +const VALID = '- id: prompt\n name: \'@deepseek-ai/dsh-system-prompt\'\n' + +const contexts: Context[] = [] + +/** A Loader-context evaluator over an empty scope, enough for literal gates. */ +const evaluateExpression = (expression: string): unknown => evaluate({}, expression) +/** An evaluator that refuses every expression, leaving rows conditional. */ +const refuseExpression = (): never => { throw new Error('no loader context') } + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function harness(roster: Config): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(FIXTURES).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(AgentPresets, roster) + return ctx +} + +describe('fileComposition', () => { + it('flattens groups and keeps refused expressions conditional', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + const path = join(dir, COMPOSITION_FILE) + await writeFile(path, [ + '- id: alpha', + ' name: pkg-alpha', + '- name: pkg-anonymous', + '- id: off', + ' name: pkg-off', + ' disabled: true', + '- id: cond', + ' name: pkg-cond', + ' disabled: !!js process.platform === \'win32\'', + '- id: grp', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: child', + ' name: pkg-child', + ' - id: child-off', + ' name: pkg-child-off', + ' disabled: true', + '- id: grp-off', + ' name: cordis:group', + ' group: true', + ' disabled: true', + ' config:', + ' - id: buried', + ' name: pkg-buried', + '- id: grp-cond', + ' name: cordis:group', + ' group: true', + ' disabled: !!js 1', + ' config:', + ' - id: maybe', + ' name: pkg-maybe', + ' - id: certainly-off', + ' name: pkg-certainly-off', + ' disabled: true', + ].join('\n')) + + expect(await fileComposition(path, refuseExpression)).toEqual({ + rows: [ + { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true }, + { entryId: null, moduleName: 'pkg-anonymous', enabled: true }, + { entryId: 'off', moduleName: 'pkg-off', enabled: false }, + { + entryId: 'cond', + moduleName: 'pkg-cond', + enabled: 'conditional', + condition: 'process.platform === \'win32\'', + }, + { entryId: 'child', moduleName: 'pkg-child', enabled: true }, + { entryId: 'child-off', moduleName: 'pkg-child-off', enabled: false }, + { entryId: 'buried', moduleName: 'pkg-buried', enabled: false }, + { entryId: 'maybe', moduleName: 'pkg-maybe', enabled: 'conditional' }, + { entryId: 'certainly-off', moduleName: 'pkg-certainly-off', enabled: false }, + ], + }) + }) + + it('evaluates decidable gates the way a mount would', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + const path = join(dir, COMPOSITION_FILE) + await writeFile(path, [ + '- id: off', + ' name: pkg-off', + ' disabled: !!js 1 === 1', + '- id: on', + ' name: pkg-on', + ' disabled: !!js 1 === 2', + ].join('\n')) + + expect(await fileComposition(path, evaluateExpression)).toEqual({ + rows: [ + { entryId: 'off', moduleName: 'pkg-off', enabled: false, condition: '1 === 1' }, + { entryId: 'on', moduleName: 'pkg-on', enabled: true, condition: '1 === 2' }, + ], + }) + }) + + it('answers broken for a file that stopped reading as a composition', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-composition-')) + + const missing = await fileComposition(join(dir, COMPOSITION_FILE), refuseExpression) + expect(missing).toHaveProperty('broken') + + const unparsable = join(dir, 'unparsable.yml') + await writeFile(unparsable, 'foo: [') + const yaml = await fileComposition(unparsable, refuseExpression) + expect('broken' in yaml && yaml.broken.length > 0).toBe(true) + + const rowless = join(dir, 'rowless.yml') + await writeFile(rowless, 'foo: bar\n') + expect(await fileComposition(rowless, refuseExpression)).toEqual({ + broken: 'the composition must be a top-level list of plugin rows', + }) + }) +}) + +describe('mountedCompositionRows', () => { + it('reads evaluated enablement and root-fiber states, skipping group rows', async () => { + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.active = () => {} + const activeId = await ctx.loader.create({ name: 'cordis:active' }) + const disabledId = await ctx.loader.create({ name: 'cordis:active', disabled: true }) + const evaluatedId = await ctx.loader.create({ + name: 'cordis:active', + // The YAML `!!js` tag deserializes to exactly this object; EntryOptions + // types the field by its literal form only. + disabled: { __jsExpr: 'false' } as unknown as boolean, + }) + await ctx.loader.create({ name: 'cordis:active', group: true }) + + expect(mountedCompositionRows(ctx.loader)).toEqual([ + { entryId: activeId, moduleName: 'cordis:active', enabled: true, fiberState: FiberState.ACTIVE }, + { entryId: disabledId, moduleName: 'cordis:active', enabled: false }, + { + entryId: evaluatedId, + moduleName: 'cordis:active', + enabled: true, + condition: 'false', + fiberState: FiberState.ACTIVE, + }, + ]) + }) +}) + +describe('AgentPresets.compositionInventory', () => { + it('reads unmounted presets from their files, marking the default and metadata', async () => { + const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + await mkdir(join(userRoot, 'documented')) + await writeFile(join(userRoot, 'documented', COMPOSITION_FILE), [ + VALID.trimEnd(), + '- id: gated', + ' name: \'@deepseek-ai/dsh-system-prompt\'', + ' disabled: !!js 1 === 1', + '- id: undecidable', + ' name: \'@deepseek-ai/dsh-system-prompt\'', + ' disabled: !!js nothing.here', + ].join('\n')) + await writeFile(join(userRoot, 'documented', METADATA_FILE), 'name: 我的模式\n') + const ctx = await harness({ + default: 'minimal', + roots: [SYSTEM_ROOT, { path: userRoot, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + }) + + expect(await ctx.agentPresets.compositionInventory()).toEqual([ + { + id: 'minimal', + isDefault: true, + rows: [{ entryId: 'beta', moduleName: '../../plugins/contribute.js', enabled: true }], + }, + { + id: 'standard', + isDefault: false, + rows: [ + { entryId: 'alpha', moduleName: '../../plugins/contribute.js', enabled: true }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, + ], + }, + { + id: 'documented', + name: '我的模式', + isDefault: false, + rows: [ + { entryId: 'prompt', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: true }, + // The platform-gate shape: the service evaluates it with the + // Loader's own scope, so the file answer matches a mount's. + { entryId: 'gated', moduleName: '@deepseek-ai/dsh-system-prompt', enabled: false, condition: '1 === 1' }, + // An expression the evaluator refuses stays a mount's decision. + { + entryId: 'undecidable', + moduleName: '@deepseek-ai/dsh-system-prompt', + enabled: 'conditional', + condition: 'nothing.here', + }, + ], + }, + ]) + // Reading is never mounting: every unmounted preset above was answered + // from its file, so listing plugins cannot activate a preset early. + expect(livePresetMounts()).toEqual([]) + }) + + it('reads a mounted preset from its standing composition', async () => { + const ctx = await harness({ + default: 'standard', + roots: [SYSTEM_ROOT], + includeShippedRoot: false, + includeUserRoot: false, + }) + await ctx.agents.create({ + sessionId: SessionId('composition-inventory'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + const standard = (await ctx.agentPresets.compositionInventory()) + .find(composition => composition.id === 'standard') + expect(standard?.rows).toEqual([ + { + entryId: 'alpha', + moduleName: '../../plugins/contribute.js', + enabled: true, + fiberState: FiberState.ACTIVE, + }, + { entryId: 'alpha-extra', moduleName: '../../plugins/contribute.js', enabled: false }, + ]) + }) + + it('keeps a broken preset on the inventory with its discovery reason', async () => { + const userRoot = await mkdtemp(join(tmpdir(), 'dsh-composition-roster-')) + await mkdir(join(userRoot, 'damaged')) + const ctx = await harness({ + default: 'minimal', + roots: [SYSTEM_ROOT, { path: userRoot, trust: 'user' }], + includeShippedRoot: false, + includeUserRoot: false, + }) + + const damaged = (await ctx.agentPresets.compositionInventory()) + .find(composition => composition.id === 'damaged') + expect(damaged?.rows).toEqual([]) + expect(damaged?.broken).toContain('is missing') + }) + + it('reports a composition that raced discovery as broken instead of dropping it', async () => { + const ctx = await harness({ + default: 'minimal', + roots: [SYSTEM_ROOT], + includeShippedRoot: false, + includeUserRoot: false, + }) + // Discovery judged the preset healthy, then the file vanished before the + // row read: the inventory keeps the preset and carries the raced reason. + vi.spyOn(ctx.agentPresets, 'list').mockResolvedValue([ + { id: 'ghost', trust: 'user', path: join(FIXTURES, 'ghost', COMPOSITION_FILE) }, + ]) + + const [ghost] = await ctx.agentPresets.compositionInventory() + expect(ghost?.rows).toEqual([]) + expect(ghost?.broken).toBeDefined() + }) +}) diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 926ed9ce51..c357b1f257 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -18,6 +18,9 @@ { "path": "../../../vendor/include" }, + { + "path": "../../../vendor/loader" + }, { "path": "../../core/agent" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d5d75a10..eebbf4be72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5919,6 +5919,9 @@ importers: '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1bae81253b..266438678a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -656,6 +656,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { AgentPreset: 'discovered preset record is owned by packages/preset/agent-presets/README.md', AgentPresetRoster: 'path-free preset roster is owned by packages/preset/agent-presets/README.md', AgentPresetDocument: 'preset composition view is owned by packages/preset/agent-presets/README.md', + AgentPresetComposition: 'flattened composition rows are owned by packages/preset/agent-presets/README.md', PresetMetadata: 'preset display text is owned by packages/preset/agent-presets/README.md', BashEnvContributor: 'service-local extension type is owned by packages/shell/tool-bash/src/index.ts', BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts',