diff --git a/.mstar/knowledge/README.md b/.mstar/knowledge/README.md index 7facdac..16e3ba4 100644 --- a/.mstar/knowledge/README.md +++ b/.mstar/knowledge/README.md @@ -9,7 +9,7 @@ | `architecture-patterns/dsh-plugin-client-half.md` | client half + settings section work | dsh web client half for a standalone plugin: dsh.client declaration (nested under dsh, post-20da39e), closure-factory CJS bundle contract (frozen externals/purity/automatic JSX), CSS-modules inline injection + style-tag lifecycle + bundle hygiene, settings.section slot registration (legacy — the advisor's configuration surface is now the settings.plugin.item card, see dsh-plugin-config-card-surface.md), settings namespace wiring | active | | `architecture-patterns/dsh-plugin-config-card-surface.md` | iteration:iter-20260811-dsh-advisor-n6/guides/plugin-config-migration.md | dsh web "插件配置" page card surface: the settings.plugin.item card slot (declared by the ui-plugin-config settings.section id 'plugins'), generator + yield registration with locale / business-only inject faces, PropsRuntime + PropsLocale + InjectFace contract, type-only peer dependency, load-on-mount invariant, settings-scope vs GatewayService data-channel routes, CSS-fragment build discipline — the advisor's current configuration surface (supersedes the settings.section recipe) | active | | `architecture-patterns/dsh-auxiliary-model-start-profile.md` | dsh-advisor-minimal-start | dsh auxiliary model calls (advisor KD-6): minimal closed-whitelist GenerateOptions (zero tools, literal-pinned caps) + capability-gated thinking-off with failure-retry caching discipline (never cache a failed capability lookup; throw and deadline abort are one failure class) | active | -| `architecture-patterns/dsh-tui-plugin-client-surface.md` | iteration:iter-20260816-dsh-advisor-n8 | dsh-TUI plugin client surface with zero dsh-TUI changes: bundle composition into the dsh-tui profile, DSH command-registry auto-merge into the TUI / menu, the tuiCommandTrees plugin-facing seam (structural TuiCommandTreeProvider types — no @deepseek-harness-tui peer), no-settings-page constraint (namespace + profile patch + global settings.yaml), and the session-less config-readback parity rule (readback resolves the composed config like the web gateway, never the per-session override) | active | +| `architecture-patterns/dsh-tui-plugin-client-surface.md` | iteration:iter-20260816-dsh-advisor-n8, iteration:iter-20260817-dsh-advisor-n9 | dsh-TUI plugin client surface with zero dsh-TUI changes: bundle composition into the dsh-tui profile, DSH command-registry auto-merge into the TUI / menu, the tuiCommandTrees plugin-facing command seam (structural TuiCommandTreeProvider types — no @deepseek-harness-tui peer), the tuiSettingsSections write seam (dsh-tui >= v0.8.0 /settings screen — register a TuiSettingsSection over the plugin's settings namespace; staged edits persist through revision-fenced in-process settings.mutate, no apiproxy allowlist gate, schema re-validation backstop), and the session-less config-readback parity rule (readback resolves the composed config like the web gateway, never the per-session override) | active | | `architecture-patterns/dsh-settings-exposure-boundary.md` | settings exposure work (patch retirement + gateway channel) | dsh host settings exposure boundary — NO registration-level opt-in exists upstream (verified at pristine 20da39e); the working fix is the official GatewayService RPC channel (`/api//`, typertGateway claims, in-process settings.update un-gated) — web section reads/writes through it, bypassing the allowlist; no host patch; circular-verification trap documented; **SRC `@Remote` claims fail for locally-linked plugins under a dlx host (module-private marker table, physically separate peers) — explicit `ctx.typert.register(contribution)` is the module-identity-proof path (2026-08-13)** | active | | `workflow-patterns/dsh-host-dispatch-concurrency.md` | core MVP port | dsh same-step tool-call scheduling: subagent calls are exclusive (serial) — isConcurrencySafe fail-closed | active | | `workflow-patterns/dsh-upstream-bump-adaptation.md` | upstream bump + patch retirement | Surviving a dsh snapshot upgrade as a plugin bundle: probe discriminators (present/absent), dshClient → dsh.client migration (no fallback, negative-verdict cache), restart + runtime verification sequence; host-patch mechanism retired; host tree must stay pristine (staging worktree edits are the same class as the retired patch) | active | diff --git a/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md b/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md index 4fc4407..fd7d3a6 100644 --- a/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md +++ b/.mstar/knowledge/architecture-patterns/dsh-tui-plugin-client-surface.md @@ -4,23 +4,24 @@ date: 2026-08-16 problem_type: architecture_pattern category: architecture-patterns severity: medium -title: Adding a plugin client surface to the dsh-TUI terminal front door (tuiCommandTrees + session-less config readback) -description: Verified pattern for surfacing a standalone dsh plugin inside the dsh-TUI terminal front end with zero dsh-TUI changes — bundle composition into the dsh-tui profile, DSH command-registry auto-merge into the TUI / menu, the plugin-facing tuiCommandTrees seam (register a TuiCommandTreeProvider for localized descriptions + subcommand completion), the no-settings-page constraint (settings = settings namespace + profile patch + global $DSH_HOME/settings.yaml), and the session-less config-readback parity rule (a readback command must resolve the composed config exactly like the web gateway — never the per-session override). -last_updated: 2026-08-16 +title: Adding a plugin client surface to the dsh-TUI terminal front door (tuiCommandTrees + tuiSettingsSections + session-less config readback) +description: Verified pattern for surfacing a standalone dsh plugin inside the dsh-TUI terminal front end with zero dsh-TUI changes — bundle composition into the dsh-tui profile, DSH command-registry auto-merge into the TUI / menu, the plugin-facing tuiCommandTrees seam (register a TuiCommandTreeProvider for localized descriptions + subcommand completion), the tuiSettingsSections write seam (dsh-tui >= v0.8.0 /settings screen: register a TuiSettingsSection over your settings namespace; staged edits persist through revision-fenced in-process settings.mutate — no apiproxy allowlist gate), and the session-less config-readback parity rule (a readback command must resolve the composed config exactly like the web gateway — never the per-session override). +last_updated: 2026-08-17 tags: - dsh - plugin - dsh-tui - client + - settings --- # Adding a plugin client surface to the dsh-TUI terminal front door ## Context -dsh-TUI (`@deepseek-harness-tui/dsh-tui`, profile `dsh-tui`, launcher `bin/dsh-tui.js` self-bootstraps `dsh --profile dsh-tui add @deepseek-harness-tui/dsh-tui@` then spawns `dsh --profile dsh-tui`) is a terminal-only Ink/TUI front door over dsh-base. It renders NO web client bundles, has NO settings page, NO typert gateway, and NO generic plugin settings UI. Verified against source @ 557a27a (2026-08-16). +dsh-TUI (`@deepseek-harness-tui/dsh-tui`, profile `dsh-tui`, launcher `bin/dsh-tui.js` self-bootstraps `dsh --profile dsh-tui add @deepseek-harness-tui/dsh-tui@` then spawns `dsh --profile dsh-tui`) is a terminal-only Ink/TUI front door over dsh-base. It renders NO web client bundles and NO typert gateway, but since **v0.8.0** it ships a `/settings` screen with a plugin settings-section extension seam (`tuiSettingsSections`). Verified against source @ dsh-TUI **v0.8.0** (tag `9ac578f1`, release PR #253; commit `02ff08e` on `main` — the seam file is identical between the tag and that commit). The pre-v0.8.0 no-settings-page surface (n8 pin @ `557a27a`, 2026-08-16) is **superseded**: plugin settings are now editable in the TUI, not only via namespace + profile patch + global `$DSH_HOME/settings.yaml` + a readback command. -The dsh-advisor plugin (a per-session reviewer) needed a first-class TUI surface: commands discoverable in the `/` menu + a settings readback, without modifying the dsh-TUI repo and without adding a dependency on it. +The dsh-advisor plugin (a per-session reviewer) needed a first-class TUI surface: commands discoverable in the `/` menu, a settings readback, and — from n9 on — an editable Advisor section in the TUI `/settings` screen, without modifying the dsh-TUI repo and without adding a dependency on it. ## Guidance @@ -32,7 +33,7 @@ The dsh-advisor plugin (a per-session reviewer) needed a first-class TUI surface The TUI merges the DSH command registry into its `/` menu (`refreshCommandList` in `src/dsh-adapter/channel.ts`: `commandService.list(target)` → merged rows; dispatch via `commandService.execute`). A plugin's registry commands (`ctx.inject(['commands'], ...)`) appear automatically. The row's `tag` comes from `CommandDefinition.input.hint` — keep it in sync when adding subcommands. -### 3. The plugin-facing TUI seam is `tuiCommandTrees` +### 3. The plugin-facing command seam is `tuiCommandTrees` `ctx.tuiCommandTrees` (cordis Service, row `dsh-tui-command-trees` — shipped in the dsh-tui bundle) lets plugins register: @@ -50,28 +51,39 @@ interface CommandCompletionNode { `descriptions(root)` overrides the root row's description; `children` drives `/` overlay completion (leaves return `[]` — the TUI asks at depth 2). **Do NOT add `@deepseek-harness-tui/dsh-tui` as a dependency** — the shapes are small and structural; replicate them locally (zero new peers). The cordis Context lacks the `tuiCommandTrees` augmentation outside dsh-TUI — use a structural cast with a conditional `ctx.inject(['tuiCommandTrees'], ...)` (absent service → clean no-op, same pattern as settings/typert/commands children). Register behind any single-instance claim (the advisor's `claimReviewer()`) and defensively catch duplicate-root ('already registered' → debug log + no-op disposer) — the multi-fiber composition that affects sibling optional registrations applies here too. -### 4. The TUI has no settings page — settings surface = namespace + readback + docs +### 4. The TUI settings screen + plugin seam (`tuiSettingsSections`) -No plugin settings UI seam exists (filed upstream: ccch1mneyyy/dsh-TUI#165). The working surface is: -- the plugin's settings namespace (registers via the dsh settings service; reads the same live composed config), -- operator edit paths: profile patch layer (`~/.dsh/profiles/dsh-tui/cordis.patch.yml`) + the GLOBAL `$DSH_HOME/settings.yaml` (shared across ALL profiles — the web Settings card writes the same user layer), -- a read-only readback command (`/advisor config`) rendering the composed config + edit hints. +Since v0.8.0 (upstream issue ccch1mneyyy/dsh-TUI#165, PR #238) the TUI has a `/settings` screen. Optional plugins declare WHAT is editable there by registering a SECTION over their settings namespace on the `tuiSettingsSections` host service — mirroring the web front door's `settings.plugin.item` slot (plugins ship sections; the host ships the chrome). Storage, validation and layering stay with the dsh settings service; the registry is display metadata only. + +**The seam.** `TuiSettingsSectionsRuntime` is a cordis Service — key `tuiSettingsSections`, row `dsh-tui-settings-sections` (cordis.yml row `name: '@deepseek-harness-tui/dsh-tui/settings-sections'`, module `src/dsh-adapter/settings-sections.ts` in dsh-TUI). `register(section)` returns a disposer, trims + validates the namespace against `^[a-z][a-z0-9_-]*$` (invalid → `TypeError`), throws on a duplicate ns (`TUI settings section "X" is already registered`), and emits to subscribers — an open screen re-reads the section list when a plugin (un)loads mid-session. + +**The contract.** `TuiSettingsSection = { ns, title, descriptions?, fields }`; `TuiSettingsField = { path: readonly string[], label, descriptions?, hint?, hintDescriptions?, kind: 'text'|'number'|'boolean'|'select', options?, placeholder?, secret?, format?, parse? }`. `path` uses the settings-service `mutate` path vocabulary (object keys; dict keys name their entry directly) — the advisor's flat §5.1 keys map 1:1 as single-element paths. `secret?: { ref }` is a credential control (mirrors the web cards' CardSecretSpec): the literal never rides the settings document — the draft starts blank on every open, a blank draft writes nothing, a typed draft writes through the credentials seam, and the screen shows only whether a value is configured. `format`/`parse` default per kind (text verbatim, number via `String`/`Number`, boolean `'true'`/`'false'`, select by option value); **default parse semantics**: an empty text/number draft stages a clear → the field re-inherits the composition layer; a draft `parse` cannot convert (non-finite number, unknown select option) is `invalid` → blocks the whole save. The screen marks a section **unavailable** when the composition serves no such namespace, and namespaces render **read-only** when the settings/credentials seams are absent (bare cordis.yml start). + +**The write path.** The screen (`src/screens/Settings.tsx` + `src/dsh-adapter/settingsEditor.ts`) stages edits and writes ONLY on save: `channel.settingsHost().write(ns, ops, expectedRevision)` → in-process `settings.mutate(ns, ops, expectedRevision)` on the section's namespace (ops = `{op:'set'|'unset', path, value?}`; one retry on a stale-revision `SETTINGS_CONFLICT`). This write path has **no apiproxy `exposedNamespaces()` allowlist gate** — that gate lives only in the web wire; in-process `ctx.settings` is the same service the gateway's `set` uses. A section is therefore fully writable whenever the plugin's namespace is registered on the settings service (the advisor's `advisor` namespace is, via `installAdvisorSettings`). + +**No cross-field validation (recorded upstream behavior, not a defect).** The seam parses fields individually, so a save may set `enabled: true` with empty `provider`/`model`. The web card blocks that save (`enabled` + empty-required gate); the TUI does not — the advisor's S4 explicit model gate resolves such a config to disabled-with-reason at runtime (visible via `/advisor status` + `/advisor config`). + +**Schema re-validation backstop.** The settings service re-resolves the merged candidate through the namespace's registered schema at the front of the namespace write queue BEFORE persist — a value the schema rejects (e.g. `immuneTurns: 2.5` / `-1`, pinned by `tests/config.test.ts`) fails the WHOLE save with nothing stored, surfaced by the TUI as the `settings-save-failed` notice (live QA in a dsh-tui v0.8.0 profile verified: the rejected mutate leaves the revision and `settings.yaml` unchanged). The TUI default parse already keeps drafts schema-shaped, so this is a backstop, not the primary UX. ### 5. Session-less config readback parity (correctness rule) A settings-readback command MUST read the composed config exactly like the web gateway (`/api/advisor/get`): resolve the bridge source through the shared resolver (`resolveAdvisorConfig`), with **no session context**. Never route the readback through the per-session effective config (`effectiveConfig`/`safeEffective`) — those bake the `/advisor off` session toggle into `enabled`, so a user who turns the advisor off for the session would see the readback misreport the persisted settings (web-vs-TUI divergence). Runtime state (on/off, pending, last activity) stays in the status command; config state stays in the config command. Containment: when the resolver throws on a rejected settings user layer, seed the readback's scalar latches from the RAW source (`raw?.immuneTurns ?? 3`, ...) — mirroring the gateway's S1 fallback — so both front ends report the same values. +The `/advisor config` edit hint is now truthful about the write surface: when the `tuiSettingsSections` seam is mounted it lists the TUI `/settings` screen (Advisor section, dsh-tui ≥ v0.8.0) FIRST, then the profile patch layer + `$DSH_HOME/settings.yaml`. The probe is a LIVE `ctx.get('tuiSettingsSections') !== undefined` at render time (the seam supports mid-session mount/unmount via `subscribe()`, so a captured boolean would go stale), keyed on the shared `TUI_SETTINGS_SECTIONS` constant — the same key the registration condition uses, so the two cannot drift apart. It is an environment signal only, never derived from the per-session override, and does not change the resolved-config read. + ## Why This Matters - A terminal front door and a web front door share one config SSOT; readback parity prevents "the TUI says 3, the web card says 5" confusion for the same settings.yaml. -- The `tuiCommandTrees` seam is the entire plugin-facing UI surface of dsh-TUI today — knowing it means future TUI work (e.g. the post-#165 write surface) starts from the right contract instead of re-deriving it from the host source. +- The plugin-facing TUI surface is now TWO seams: `tuiCommandTrees` (commands + completion) and `tuiSettingsSections` (settings write). The post-#165 write surface is implemented — future TUI work starts from the implemented contracts instead of re-deriving them from the host source. - Zero new peers keeps the plugin's dependency contract intact (mount-only, public-registry peers only). +- The schema re-validation backstop means a section registration can never write non-schema junk into the settings user layer, even though the TUI does no cross-field validation. ## When to Apply -- Adding or maintaining ANY plugin surface in a dsh-TUI profile (commands, completion, settings readback). -- The upstream settings-seam work (ccch1mneyyy/dsh-TUI#165): when dsh-TUI gains a settings UI, the advisor's TUI write surface should reuse the same composed-config resolver + namespace, adding a write path on top of this readback. +- Adding or maintaining ANY plugin surface in a dsh-TUI profile (commands, completion, settings readback, settings write). +- Making a plugin's settings editable in the TUI `/settings` screen (dsh-tui ≥ v0.8.0): register a `TuiSettingsSection` over the plugin's EXISTING settings namespace on `tuiSettingsSections`. Reuse the same composed-config resolver + namespace as the web gateway/readback — the section is display metadata over the namespace; a save lands in the same user layer the web card writes. ## Examples -- dsh-advisor iter-20260816-n8: `src/tui.ts` (structural TuiCommandTreeProvider for `/advisor` with zh/en descriptions + on|off|status|config completion), `src/commands.ts` + `src/index.ts` (`/advisor config` — `AdvisorComposedConfig` built from `safeResolved()`, session-less; `safeFallback` seeds scalars from the raw source; `input.hint` `'[on|off|status|config]'`), README dsh-tui profile section, `dsh --profile dsh-tui` live QA (dump-config + PTY boot + `/advisor status|config` rendering). +- dsh-advisor iter-20260816-n8: `src/tui.ts` (structural `TuiCommandTreeProvider` for `/advisor` with zh/en descriptions + on|off|status|config completion), `src/commands.ts` + `src/index.ts` (`/advisor config` — `AdvisorComposedConfig` built from `safeResolved()`, session-less; `safeFallback` seeds scalars from the raw source; `input.hint` `'[on|off|status|config]'`), README dsh-tui profile section, `dsh --profile dsh-tui` live QA (dump-config + PTY boot + `/advisor status|config` rendering). +- dsh-advisor iter-20260817-n9 (plan dsh-advisor-tui-settings-n9): `src/tui-settings.ts` — structural `TuiSettingsSection`/`TuiSettingsField` type copies (no `@deepseek-harness-tui/dsh-tui` peer), `installTuiSettingsSection` registers the Advisor section via conditional `ctx.inject(['tuiSettingsSections'])` (absent service → no-op), duplicate-ns containment (debug log + no-op disposer), reviewer-claim gating (called after `claimReviewer()`, next to `installTuiClient`), and the shared `TUI_SETTINGS_SECTIONS` service-key constant driving BOTH the registration condition and the `/advisor config` hint probe. The section covers the five safe §5.1 keys (enabled/provider/model/immuneTurns/maxDeltaMessages); `systemPrompt` is intentionally excluded — the TUI `text` control is single-line and would truncate/replace a multi-line prompt, so it stays editable via the web card or `$DSH_HOME/settings.yaml`. Unit tests (`tests/tui-settings.test.ts`) pin the registration contract, no-op, duplicate containment, claim gating, and field-path ↔ §5.1 schema alignment; QA ran in a real dsh-tui v0.8.0 session (mutate + schema-rejection probes: `immuneTurns: 2.5` / `-1` fail the whole save with revision + file unchanged; a stale expectedRevision → `SettingsConflictError`). diff --git a/CONCEPTS.md b/CONCEPTS.md index 31bba64..499825d 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -63,8 +63,8 @@ T4 提取与 T6 投递之间的守门员:normalize(小写 + NFKC + 非字母 *Avoid:* 把 `/advisor` 当持久配置写入入口(override 是临时的、会话级) ### dsh-tui client seam(TUI client 面) -dsh-TUI(终端前端,profile `dsh-tui`)的插件扩展面:DSH command registry 自动 merge 进 TUI `/` 菜单(dispatch 走 `commandService.execute`);`ctx.tuiCommandTrees` 是唯一插件 UI seam —— 注册 `TuiCommandTreeProvider { root, descriptions?, children }` 提供 root 行本地化描述 + 子命令补全(结构类型本地声明,不引 `@deepseek-harness-tui/dsh-tui` peer)。TUI **无设置页**(上游 issue ccch1mneyyy/dsh-TUI#165):插件 settings 面 = settings namespace + profile patch layer + 全局 `$DSH_HOME/settings.yaml`(跨 profile 共享,web 卡片写同一 user layer)+ 只读回读命令。**回读 parity 规则**:config 回读必须走与 web gateway 相同的组合配置解析(无 session),绝不读 per-session effective config(否则 `/advisor off` 会让回读误报持久配置)。 -*Avoid:* 用 per-session effective config 渲染配置回读;给 TUI 面引入宿主 peer 依赖;把 TUI 当有设置页的前端设计 +dsh-TUI(终端前端,profile `dsh-tui`)的插件扩展面:DSH command registry 自动 merge 进 TUI `/` 菜单(dispatch 走 `commandService.execute`);两个插件 seam —— `ctx.tuiCommandTrees`(注册 `TuiCommandTreeProvider { root, descriptions?, children }` 提供 root 行本地化描述 + 子命令补全)与 `ctx.tuiSettingsSections`(dsh-tui ≥ v0.8.0 的 `/settings` 设置屏:注册 `TuiSettingsSection { ns, title, descriptions?, fields[] }` 声明可编辑字段,屏幕 staged 编辑 + revision-fenced `settings.mutate` 写回该 section 的 settings namespace;均结构类型本地声明,不引 `@deepseek-harness-tui/dsh-tui` peer)。TUI 设置写面 = 插件经 `tuiSettingsSections` 注册 section + 既有 settings namespace + profile patch layer + 全局 `$DSH_HOME/settings.yaml`(跨 profile 共享,web 卡片写同一 user layer)+ 只读回读命令。**回读 parity 规则**:config 回读必须走与 web gateway 相同的组合配置解析(无 session),绝不读 per-session effective config(否则 `/advisor off` 会让回读误报持久配置)。 +*Avoid:* 用 per-session effective config 渲染配置回读;给 TUI 面引入宿主 peer 依赖;绕过 `tuiSettingsSections` 另建平行的 TUI 设置写面 ## 已决歧义 diff --git a/README.i18n.yaml b/README.i18n.yaml index f05ce02..e17d3e0 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -3,5 +3,5 @@ # editing either side, bring the other along and re-record with: # git hash-object README.md # git hash-object README.zh.md -README.md: 6da971fed9908121287601132c6e39ff66dba750 -README.zh.md: 4c770571e72e38e64c9cf48a789cc41aa544192b +README.md: ef98d163d84f2db4ee6a5345d467eee27644ff3b +README.zh.md: 228f1d220635cbf7aba0ef72dc32e293a6635eaa diff --git a/README.md b/README.md index 6da971f..ef98d16 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ The same keys compose across **three surfaces** (later layers override earlier o 2. **dsh web Settings page — the "插件配置" (Plugin Configuration) page** — the Advisor **card** (id `advisor`) with the enabled toggle, provider / model selects restricted to system-configured providers and their models, and the optional fields. Saving writes into the `advisor` settings namespace and applies to new sessions immediately — no restart. The card requires a current dsh web build whose shell declares the `settings.plugin.item` card slot and loads packages that declare `dsh.client`; it reads and writes the namespace through the official `GatewayService` RPC channel (`/api/advisor/get` + `/api/advisor/set`), which is not gated by the settings exposure allowlist. It additionally blocks saving while enabled with a required field empty. 3. **`/advisor` command** — per-session and ephemeral: it flips a session override, never the persisted config (see [Verify](#verify)). -In a **dsh-tui** profile there is no settings page: the same two persisted surfaces (profile patch layer + global `$DSH_HOME/settings.yaml`) compose the config, and `/advisor config` prints the composed config as a read-only readback with edit hints. Full reference → [docs/configuration.md](docs/configuration.md). +In a **dsh-tui** profile the same five keys are editable in the TUI `/settings` screen: run `dsh --profile dsh-tui`, open `/settings`, and edit the **Advisor** section (`enabled` / `provider` / `model` / `immuneTurns` / `maxDeltaMessages`, each with zh/en label + hint). Edits are staged and written on save through the revision-fenced `settings.mutate` into the same `advisor` namespace user layer the web card writes, and re-apply live without a restart. `systemPrompt` is NOT a TUI field (the TUI text control is single-line; a multi-line prompt would be truncated) — edit it via the web card or `$DSH_HOME/settings.yaml`. The section requires dsh-tui ≥ v0.8.0 (shipped in the `dsh-tui-settings-sections` row of the v0.8.0+ bundle); older dsh-tui versions no-op it cleanly and the two file paths — profile patch layer + global `$DSH_HOME/settings.yaml` — remain the edit paths. `/advisor config` stays a read-only readback whose edit hint names the `/settings` screen when the seam is mounted. Save behavior differs from the web card: the TUI seam has no cross-field validation, so a save may set `enabled: true` with empty `provider`/`model` — the explicit model gate resolves that to disabled-with-reason at runtime (visible via `/advisor status` and `/advisor config`); the web card blocks such a save outright. Full reference → [docs/configuration.md](docs/configuration.md). ![Advisor card on the dsh web Settings (插件配置) page](docs/screenshots/advisor-settings-card.webp) @@ -67,7 +67,7 @@ With the advisor installed and enabled, control it in-session with the `/advisor `/advisor on|off|toggle` are session-scoped and ephemeral: they flip a per-session override, never the persisted config. Enabling a session whose config lacks `provider`/`model` starts no model call — `/advisor status` (and the `/advisor on` reply) shows the gate reason: the advisor runs only when enabled **with** both configured. `/advisor on` is also the manual recovery path: a session advisor paused by a quota/rate-limit (`quota_exhausted` — no auto-resume timer) resumes in place, and a halted advisor (permanent model error, e.g. invalid credentials) is rebuilt fresh for the session. -In a **dsh-tui** profile, `/advisor config` additionally reads back the composed configuration — read-only, with edit hints: the web Settings card is web-only, and the TUI has no settings page and no write command, so edit through the profile patch layer or `$DSH_HOME/settings.yaml`. The `/advisor` / `on|off|status|config` commands are listed in the TUI `/` menu with subcommand completion (command discovery requires the `dsh-tui-command-trees` row — the shipped dsh-tui bundle has it). +In a **dsh-tui** profile, `/advisor config` additionally reads back the composed configuration — read-only, with edit hints naming the real write paths: the TUI `/settings` screen (Advisor section, dsh-tui ≥ v0.8.0), the profile patch layer, and the shared `$DSH_HOME/settings.yaml` `advisor:` section. The `/advisor` / `on|off|status|config` commands are listed in the TUI `/` menu with subcommand completion (command discovery requires the `dsh-tui-command-trees` row — the shipped dsh-tui bundle has it). ## Features diff --git a/README.zh.md b/README.zh.md index 4c77057..228f1d2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -46,7 +46,7 @@ advisor 默认关闭。启用后,`provider` 与 `model` 为**必填**:`enabl 2. **dsh web Settings 页 —— "插件配置"页** —— Advisor **卡片**(id `advisor`),含 enabled 开关、只列出系统内已配置 provider 及其模型的 provider/model 选择框与可选字段。保存写入 `advisor` settings namespace,新会话立即生效,无需重启。卡片要求当前版本的 dsh web 构建(其 web shell 声明了 `settings.plugin.item` 卡片 slot 并能加载 `dsh.client` 声明包);它通过官方 `GatewayService` RPC 通道读写该命名空间(`/api/advisor/get` + `/api/advisor/set`),不受 settings 暴露白名单门控。卡片还会在 enabled 且必填字段为空时阻止保存。 3. **`/advisor` 指令** —— 按会话且临时:翻转的是会话级 override,从不修改持久化配置(见[验证](#验证))。 -**dsh-tui** profile 没有设置页:同样由两个持久化配置面(profile 补丁层 + 全局 `$DSH_HOME/settings.yaml`)合成配置,`/advisor config` 以只读回读方式打印合成后的配置并附编辑提示。完整参考 → [docs/configuration.md](docs/configuration.md)。 +在 **dsh-tui** profile 中,同样的五个键可在 TUI `/settings` 屏幕编辑:运行 `dsh --profile dsh-tui`、打开 `/settings`,编辑 **Advisor** 分节(`enabled` / `provider` / `model` / `immuneTurns` / `maxDeltaMessages`,每项均带中英文标签与提示)。编辑先暂存,保存时经 revision 栅栏保护的 `settings.mutate` 写入 web 卡片所写的同一个 `advisor` 命名空间 user layer,并 live 重应用、无需重启。`systemPrompt` **不是** TUI 字段(TUI text 控件为单行;多行 prompt 会被截断)——请经 web 卡片或 `$DSH_HOME/settings.yaml` 编辑。该分节要求 dsh-tui ≥ v0.8.0(随 v0.8.0+ 组合包的 `dsh-tui-settings-sections` 行提供);旧版 dsh-tui 会干净地 no-op,仍以两个文件路径——profile 补丁层 + 全局 `$DSH_HOME/settings.yaml`——作为编辑路径。`/advisor config` 仍是只读回读,seam 挂载时其编辑提示指向 `/settings` 屏幕。保存行为与 web 卡片不同:TUI seam 没有跨字段校验,一次保存可能把 `enabled: true` 与空 `provider`/`model` 一起写入——显式模型门禁会在运行时把它解析为 disabled-with-reason(可见于 `/advisor status` 与 `/advisor config`);web 卡片则会直接阻止这样的保存。完整参考 → [docs/configuration.md](docs/configuration.md)。 ![dsh web Settings("插件配置")页上的 Advisor 卡片](docs/screenshots/advisor-settings-card.webp) @@ -67,7 +67,7 @@ dsh --profile web --dump-config # 显示带 advisor 配置行的 "# == dsh-adv `/advisor on|off|toggle` 是会话级且临时的:它们翻转的是按会话的 override,从不修改持久化配置。启用一个 config 缺少 `provider`/`model` 的会话不会发起模型调用——`/advisor status`(以及 `/advisor on` 的回复)会显示门禁原因:advisor 只有在启用**且**两者均已配置时才运行。`/advisor on` 也是手动恢复路径:被 quota/rate-limit 暂停的会话 advisor(`quota_exhausted`——无自动恢复定时器)会在原地恢复;被终止的 advisor(永久性模型错误,如凭据无效)会为该会话全新重建。 -在 **dsh-tui** profile 中,`/advisor config` 额外回读组合配置——只读,附编辑提示:web Settings 卡片仅限 web,TUI 没有设置页、也没有写指令,请通过 profile 补丁层或 `$DSH_HOME/settings.yaml` 修改。`/advisor` / `on|off|status|config` 指令出现在 TUI 的 `/` 菜单中并带子命令补全(指令发现要求 `dsh-tui-command-trees` 行——随附的 dsh-tui 组合包自带)。 +在 **dsh-tui** profile 中,`/advisor config` 额外回读组合配置——只读,编辑提示指向真实的写路径:TUI `/settings` 屏幕(Advisor 分节,dsh-tui ≥ v0.8.0)、profile 补丁层与共享的 `$DSH_HOME/settings.yaml` 的 `advisor:` 分节。`/advisor` / `on|off|status|config` 指令出现在 TUI 的 `/` 菜单中并带子命令补全(指令发现要求 `dsh-tui-command-trees` 行——随附的 dsh-tui 组合包自带)。 ## 能力一览 diff --git a/docs/configuration.md b/docs/configuration.md index 9be893d..b25c9d8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,9 +1,12 @@ # 配置指南(`advisor` 命名空间) -`dsh-advisor` 的配置集中在 `advisor` settings 命名空间。它有两个持久化配置面,读写同一组键: +`dsh-advisor` 的配置集中在 `advisor` settings 命名空间。它有三个并行的编辑路径,读写同一组键: 1. **插件行 config** —— 用户 profile 的 `cordis.patch.yml`(如 `profiles/web/cordis.patch.yml`)里 `id: advisor` 那一行的 `config` 字段。这是合成的 **base**(`src/settings.ts` `installAdvisorSettings` 以 entry 为 `base` 注册命名空间)。 2. **web Settings —— "插件配置"页的 Advisor 卡片**(`id advisor`,渲染在三张上游卡片 bash / agent-loop / web-search 之后)—— 卡片把编辑结果写入 `advisor` 命名空间的 **user layer**,覆盖插件行 config 而无需改动它;保存后新会话立即生效,无需重启(运行时 live 读取合成值,见 [live 重应用](#live-重应用))。 +3. **dsh-tui `/settings` 屏幕**(dsh-tui ≥ v0.8.0,随 v0.8.0+ 组合包的 `dsh-tui-settings-sections` 行提供;旧版 dsh-tui 干净地 no-op)—— `/settings` 里的 **Advisor** 分节编辑同样的五个键(`enabled` / `provider` / `model` / `immuneTurns` / `maxDeltaMessages`,各带中英文标签与提示)。编辑先暂存,保存时经 revision 栅栏保护的 `settings.mutate` 写入同一个 user layer,live 重应用、无需重启。`systemPrompt` 不是 TUI 字段(TUI text 控件为单行;多行 prompt 会被截断)——经 web 卡片或 `$DSH_HOME/settings.yaml` 编辑。 + +三条路径对等(TUI `/settings`、profile 补丁层、共享的 `$DSH_HOME/settings.yaml` 读写同一组键、同一 `advisor` 命名空间——补丁层落在合成 **base**,TUI `/settings` 与 `settings.yaml` 落在 **user layer**)。**保存行为差异(如实记录)**:web 卡片在 `enabled: true` 且必填字段为空时**阻止保存**;TUI seam 没有跨字段校验(上游行为),一次保存可能把 `enabled: true` 与空 `provider`/`model` 一起写入——S4 显式模型门禁(spec §5.2)会把该配置解析为 disabled-with-reason,可见于 `/advisor status` 与 `/advisor config`(见 [显式模型门禁(S4)](#显式模型门禁s4))。 卡片对配置的读写**只**走官方 `GatewayService` RPC 通道:`/api/advisor/get` + `/api/advisor/set`(`src/gateway.ts` 的 `AdvisorConfigGateway`,由宿主 typertGateway 认领,与 dsh 内建 `goals` 服务同一机制)。`advisor` 命名空间**不在**宿主 apiproxy 的 exposed-namespaces 白名单上(上游 dsh 没有注册级 opt-in),因此该通道也不受 settings 暴露白名单门控;进程内写入(`ctx.settings.update`)没有 exposed-namespace 检查。没有 settings service 时,source 就是插件行 entry,行为与未装插件时一致(`src/settings.ts`)。**插件不做任何宿主补丁**。 @@ -44,7 +47,7 @@ - 运行时每次读取都经过该解析器(`src/index.ts` `safeResolved` / `safeEffective`),因此 settings 编辑也永远无法绕过门禁发起模型调用; - `/advisor status` 与 `/advisor on` 的回复在门禁阻挡时展示原因(`src/commands.ts`); -- Settings 卡片在 enabled 且必填字段为空时阻止保存,但宿主侧硬门禁始终是最后防线。 +- Settings 卡片在 enabled 且必填字段为空时阻止保存(TUI `/settings` seam 无此跨字段校验——保存行为差异见文档开头),但宿主侧硬门禁始终是最后防线。 **未知键严格拒绝**:`resolveAdvisorConfig` 显式拒绝未知键(`CONFIG_KEYS` 白名单,`src/config.ts`)与非对象输入;插件行加载时未知键抛错、拒绝该行(`src/index.ts` 构造期读取仍用抛错版 `resolved()`)。settings 的 user layer 若写入了解析器拒绝的值(如未知键),live 读取会解析为 disabled-with-reason 携带错误信息 —— 永不 wedge 热路径、永不启动模型调用(`src/index.ts` `safeFallback`;`src/gateway.ts` `readConfig` 同样包含该 containment)。 @@ -53,11 +56,12 @@ 三个来源按「后一层覆盖前一层」合成,各处使用同一组键(`src/settings.ts`): ```text -schema 默认值 → 插件行 config(base)→ settings user layer(web 卡片写入) +schema 默认值 → 插件行 config(base)→ settings user layer(web 卡片 / TUI `/settings` 写入) ``` - 无 settings service(未组合 `settings` 时,条件 `ctx.inject(['settings'], ...)` 子项不激活)→ source 恰为插件行 entry; -- 有 settings service → `AdvisorSettingsBridge.source()` 读 scope 的 live 合成值;每次 committed 变更触发 `onChange`。 +- 有 settings service → `AdvisorSettingsBridge.source()` 读 scope 的 live 合成值;每次 committed 变更触发 `onChange`; +- **TUI `/settings` 与 web 卡片对等**(dsh-tui ≥ v0.8.0):两者都写同一个 user layer(`$DSH_HOME/settings.yaml`),与 profile 补丁层构成三条并行编辑路径。唯一的行为差异:web 卡片在 `enabled: true` 且必填字段为空时阻止保存;TUI seam 没有跨字段校验(上游行为,如实记录),保存可能写入 `enabled: true` + 空 `provider`/`model`——S4 门禁仍把该配置解析为 disabled-with-reason,运行时绝不发起模型调用(见 [显式模型门禁(S4)](#显式模型门禁s4))。 ## 行为要点 diff --git a/docs/install.md b/docs/install.md index c825e90..cfe7483 100644 --- a/docs/install.md +++ b/docs/install.md @@ -79,10 +79,19 @@ The bundle inserts the same `- insert: id: advisor` row into the dsh-tui profile's patch layer (`~/.dsh/profiles/dsh-tui/cordis.patch.yml`). The `advisor` settings namespace is shared across profiles via the global `$DSH_HOME/settings.yaml` `advisor:` section (the web Settings card writes -there too) — the TUI has no settings page, so `/advisor config` is the -readback (read-only, with edit hints), and `/advisor` / `on|off|status|config` -surface in the TUI `/` menu with subcommand completion (requires the -`dsh-tui-command-trees` row, shipped in the dsh-tui bundle). +there too). On dsh-tui ≥ v0.8.0 the TUI `/settings` screen also edits the +same five keys (`enabled` / `provider` / `model` / `immuneTurns` / +`maxDeltaMessages`) in its Advisor section — edits are staged and written on +save through the revision-fenced `settings.mutate` into the same namespace +user layer, re-applying live without a restart. The section ships with the +`dsh-tui-settings-sections` row in the v0.8.0+ bundle; on older dsh-tui +versions it is a clean no-op and the patch layer / settings.yaml remain the +edit paths. `systemPrompt` is not a TUI field (single-line input) — edit it +via the web card or `$DSH_HOME/settings.yaml`. `/advisor config` is the +readback (read-only, with edit hints naming the `/settings` screen when the +seam is mounted), and `/advisor` / `on|off|status|config` surface in the TUI +`/` menu with subcommand completion (requires the `dsh-tui-command-trees` +row, shipped in the dsh-tui bundle). Verify: diff --git a/docs/install.zh.md b/docs/install.zh.md index 9838fc5..db73265 100644 --- a/docs/install.zh.md +++ b/docs/install.zh.md @@ -49,7 +49,7 @@ dsh plugin --profile dsh-tui add dsh-advisor # = 你的 profile 名 dsh plugin --profile dsh-tui add . ``` -组合包把同样的 `- insert: id: advisor` 行插入 dsh-tui profile 的补丁层(`~/.dsh/profiles/dsh-tui/cordis.patch.yml`)。`advisor` settings namespace 经全局 `$DSH_HOME/settings.yaml` 的 `advisor:` 段跨 profile 共享(web Settings 卡片也写入该文件)——TUI 没有设置页,因此 `/advisor config` 是回读手段(只读,附编辑提示),`/advisor` / `on|off|status|config` 则出现在 TUI 的 `/` 菜单中并带子命令补全(要求 `dsh-tui-command-trees` 行,随附的 dsh-tui 组合包自带)。 +组合包把同样的 `- insert: id: advisor` 行插入 dsh-tui profile 的补丁层(`~/.dsh/profiles/dsh-tui/cordis.patch.yml`)。`advisor` settings namespace 经全局 `$DSH_HOME/settings.yaml` 的 `advisor:` 段跨 profile 共享(web Settings 卡片也写入该文件)。dsh-tui ≥ v0.8.0 时,TUI `/settings` 屏幕同样可编辑这五个键(`enabled` / `provider` / `model` / `immuneTurns` / `maxDeltaMessages`)——在 Advisor 分节中暂存编辑,保存时经 revision 栅栏保护的 `settings.mutate` 写入同一个命名空间 user layer,live 重应用、无需重启。该分节随 v0.8.0+ 组合包的 `dsh-tui-settings-sections` 行提供;旧版 dsh-tui 干净地 no-op,仍以补丁层 / settings.yaml 为编辑路径。`systemPrompt` 不是 TUI 字段(单行输入)——请经 web 卡片或 `$DSH_HOME/settings.yaml` 编辑。`/advisor config` 是回读手段(只读,seam 挂载时编辑提示指向 `/settings` 屏幕),`/advisor` / `on|off|status|config` 则出现在 TUI 的 `/` 菜单中并带子命令补全(要求 `dsh-tui-command-trees` 行,随附的 dsh-tui 组合包自带)。 验证: diff --git a/src/commands.ts b/src/commands.ts index ee7cd16..d9e5722 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -182,6 +182,17 @@ export interface AdvisorComposedConfig { * unset — the `` marker is the renderer's job). */ readonly systemPromptSummary: string + /** + * Whether the dsh-tui `tuiSettingsSections` seam is mounted (dsh-tui ≥ + * v0.8.0) — a RENDERER INPUT for the edit hint, computed LIVE at render + * time by the wiring (`ctx.get('tuiSettingsSections') !== undefined`, plan + * dsh-advisor-tui-settings-n9 T2). An environment signal, never derived + * from the per-session override; it does not change the resolved-config + * read. When true the hint lists the TUI `/settings` screen as a write + * path; when false the n8 hint (profile patch layer + settings.yaml) is + * shown unchanged. + */ + readonly tuiSettingsAvailable: boolean } /** @@ -200,9 +211,11 @@ export function summarizeSystemPrompt(prompt: string): string { /** * Render the composed config surface. Mirrors the status renderer's minimal - * line style; the edit hint points at the two operator edit paths (profile - * patch layer + the shared `$DSH_HOME/settings.yaml` `advisor:` section the - * web card writes). + * line style; the edit hint points at the operator edit paths — when the TUI + * `tuiSettingsSections` seam is mounted (dsh-tui ≥ v0.8.0) the TUI `/settings` + * Advisor section is listed FIRST, followed by the profile patch layer + the + * shared `$DSH_HOME/settings.yaml` `advisor:` section the web card writes; + * otherwise only the two file paths (n8 text, byte-identical). */ export function advisorConfigText(config: AdvisorComposedConfig): string { const lines: string[] = [] @@ -224,7 +237,16 @@ export function advisorConfigText(config: AdvisorComposedConfig): string { ) if (config.disabledReason !== undefined) lines.push(`Reason: ${config.disabledReason}`) lines.push('') - lines.push('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + // T2 (plan dsh-advisor-tui-settings-n9): truthful edit hint. The TUI + // `/settings` screen is a real write path only while the `tuiSettingsSections` + // seam is mounted — the renderer branch follows the LIVE + // `tuiSettingsAvailable` input the wiring supplies at render time; the + // absent-seam text is the n8 line, byte-identical. + lines.push( + config.tuiSettingsAvailable + ? 'Edit: TUI /settings screen (Advisor section, dsh-tui ≥ v0.8.0) or ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)' + : 'Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)', + ) return lines.join('\n') } diff --git a/src/index.ts b/src/index.ts index 805ef03..06a0d3a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,11 @@ * T2-tui (plan dsh-advisor-tui-client-n8): `/advisor config` — a session-less * readback of the composed `advisor` namespace (same resolved config the web * card reads), never the per-session override. + * T2 (plan dsh-advisor-tui-settings-n9): the readback's edit hint is truthful + * — `getConfig()` reports `tuiSettingsAvailable` LIVE at render time + * (`ctx.get('tuiSettingsSections') !== undefined`), so the hint names the TUI + * `/settings` Advisor section as a write path exactly while the seam is + * mounted; the readback itself stays session-less and read-only. * Settings (plan dsh-advisor-settings-n2): the plugin-row config is the * composition base of the `advisor` settings namespace (`src/settings.ts`), * read live through the bridge source; committed settings edits re-apply @@ -61,6 +66,7 @@ import { DEFAULT_ADVISOR_SYSTEM_PROMPT } from './prompts.js' import { AdvisorSessionOverrides, registerAdvisorCommands, summarizeSystemPrompt } from './commands.js' import type { AdvisorCommandController } from './commands.js' import { installTuiClient } from './tui.js' +import { TUI_SETTINGS_SECTIONS, installTuiSettingsSection } from './tui-settings.js' export const name = 'dsh-advisor' @@ -518,6 +524,24 @@ export function apply(ctx: Context, config: AdvisorConfig) { maxDeltaMessages: resolved.maxDeltaMessages, systemPromptSet: resolved.systemPrompt !== '', systemPromptSummary: summarizeSystemPrompt(resolved.systemPrompt), + // T2 (plan dsh-advisor-tui-settings-n9): the truthful edit-hint input. + // Computed LIVE at render time — `ctx.get(TUI_SETTINGS_SECTIONS) !== + // undefined` (architect ruling): the upstream seam supports mid-session + // mount/unmount via `subscribe()`, so a captured boolean set by the + // inject child would go stale without extra bookkeeping; `ctx.get` is + // the minimal truthful observable. An environment signal, NEVER derived + // from the per-session override — the readback stays session-less and + // read-only (this read only ever probes service presence). + // + // Servability assumption (QC2): a mounted `tuiSettingsSections` seam + // implies a composed settings service in real dsh-tui profiles (the + // `/settings` screen requires it), so the hint's "TUI /settings screen + // (Advisor section)" claim is truthful; in the unreachable + // seam-without-settings corner the section would render unavailable in + // the host screen. The probe key is the SHARED + // `TUI_SETTINGS_SECTIONS` constant (S-001) — the same key the section + // registration condition uses, so the two cannot drift apart. + tuiSettingsAvailable: ctx.get(TUI_SETTINGS_SECTIONS) !== undefined, } }, } @@ -537,4 +561,15 @@ export function apply(ctx: Context, config: AdvisorConfig) { // conditional like `commands`/`settings`/`typert`: profiles without the // `dsh-tui-command-trees` row keep working (clean no-op). installTuiClient(ctx) + + // T1 (plan dsh-advisor-tui-settings-n9): the dsh-tui settings-section seam — + // the `tuiSettingsSections` "Advisor" section (editable `/settings` screen + // fields: enabled/provider/model/immuneTurns/maxDeltaMessages). Runs AFTER + // the single-reviewer claim like `installTuiClient`, so the section + // registers at most once per process (duplicate-ns registration is + // contained inside the module). The inject is conditional: profiles without + // the `dsh-tui-settings-sections` row (dsh-tui < v0.8.0, non-TUI hosts) + // keep working (clean no-op). Order vs `installTuiClient` is irrelevant — + // independent services; kept adjacent for cohesion. + installTuiSettingsSection(ctx) } diff --git a/src/tui-settings.ts b/src/tui-settings.ts new file mode 100644 index 0000000..2b8b893 --- /dev/null +++ b/src/tui-settings.ts @@ -0,0 +1,213 @@ +/** + * dsh-tui settings-section seam (plan dsh-advisor-tui-settings-n9, T1) — the + * `tuiSettingsSections` Advisor section. + * + * dsh-tui ≥ v0.8.0 ships a `/settings` screen; optional plugins declare what + * is editable there by registering a SECTION over their settings namespace on + * the optional `tuiSettingsSections` host service + * (`src/dsh-adapter/settings-sections.ts` in dsh-TUI — a small host-only + * registry; storage + validation stay with the dsh settings service). The + * screen then renders the section's fields, stages edits, and writes them on + * save through the revision-fenced `settings.mutate` into the section's + * namespace user layer — for the advisor that is the already-registered + * `advisor` namespace (`src/settings.ts` `installAdvisorSettings`), so the + * section is fully writable. + * + * This module is the advisor's settings-seam surface: + * `installTuiSettingsSection` conditionally injects `tuiSettingsSections` and + * registers {@link ADVISOR_TUI_SETTINGS_SECTION} when the service exists; a + * profile without the `dsh-tui-settings-sections` row (dsh-tui < v0.8.0, or + * any non-TUI host) gets a clean no-op. The provider shapes are minimal LOCAL + * structural copies of the dsh-TUI types — the advisor MUST NOT import + * `@deepseek-harness-tui/dsh-tui` (zero new peers, plan Global Constraint), + * so drift against the upstream shape is bounded to the structural cast at + * the inject boundary and pinned by `tests/tui-settings.test.ts`. + * + * Upstream revision pin: the structural shapes mirror + * `@deepseek-harness-tui/dsh-tui` v0.8.0 (`src/dsh-adapter/settings-sections.ts`, + * dsh-TUI commit `02ff08e`). Residual drift window: an upstream + * addition/rename of a REQUIRED field would NOT fail the structural cast (the + * cast goes through `unknown`) and could cause host-side misrender rather than + * a compile error — re-verify against the dsh-TUI repo when bumping. + * + * Field subset (grill-me locked): the section covers the five SAFE §5.1 keys + * (`enabled` / `provider` / `model` / `immuneTurns` / `maxDeltaMessages`). + * `systemPrompt` is intentionally NOT a field — the TUI `text` control is + * single-line, and editing a multi-line prompt there would truncate/replace + * it (data loss). It stays editable via the web card or + * `$DSH_HOME/settings.yaml`. The TUI seam has no cross-field validation + * (upstream behavior, recorded): a save may set `enabled: true` with empty + * `provider`/`model`, which the S4 explicit model gate (spec §5.2) resolves + * to disabled-with-reason at runtime; the settings-service schema + * re-validation on mutate is the value-level backstop (a non-schema value + * fails the whole save before persist). + * + * @module dsh-advisor/tui-settings + */ + +import type { Context } from '@deepseek-ai/cordis' +import { ADVISOR_SETTINGS_NAMESPACE } from './settings.js' + +/** Localized (zh/en) descriptions — structural mirror of dsh-TUI's + * `LocalizedDescriptions` (the `/settings` section + field labels/hints). */ +export type TuiLocalizedDescriptions = Readonly>> + +/** One `kind: 'select'` choice — structural mirror of dsh-TUI's + * `TuiSettingsFieldOption`. */ +export interface TuiSettingsFieldOption { + /** Stored value. */ + value: string + /** Display label (English; also the fallback). */ + label: string + /** Provider-owned translations for the label. */ + descriptions?: TuiLocalizedDescriptions +} + +/** The write one field's draft stages when the section is saved — structural + * mirror of dsh-TUI's `TuiSettingsFieldWrite`. */ +export type TuiSettingsFieldWrite = + | { kind: 'set'; value: unknown } + | { kind: 'clear' } + +/** One editable field inside a section — structural mirror of dsh-TUI's + * `TuiSettingsField`. `path` uses the settings-service `mutate` path + * vocabulary (object keys); the advisor's §5.1 flat keys map directly as + * single-element paths. */ +export interface TuiSettingsField { + /** Key path from the section root, in the settings service's `mutate` path + * vocabulary (object keys; dict keys name their entry directly). */ + path: readonly string[] + /** Short field label (English; also the fallback). */ + label: string + /** Provider-owned translations for the label. */ + descriptions?: TuiLocalizedDescriptions + /** Optional one-line help rendered under the field. */ + hint?: string + /** Provider-owned translations for the hint. */ + hintDescriptions?: TuiLocalizedDescriptions + kind: 'text' | 'number' | 'boolean' | 'select' + /** Choices for `kind: 'select'` (ignored otherwise). */ + options?: readonly TuiSettingsFieldOption[] + /** Input placeholder for `kind: 'text' | 'number'`. */ + placeholder?: string + /** Credential control (mirrors the web cards' CardSecretSpec). */ + secret?: { ref: string } + /** Render a stored value as draft text (defaults to the kind's conversion). */ + format?(value: unknown): string + /** The write this draft text stages, or `undefined` when the text is not a + * value this field accepts (defaults to the kind's conversion — an empty + * text/number draft stages a clear, re-inheriting the composition layer). */ + parse?(text: string): TuiSettingsFieldWrite | undefined +} + +/** One plugin's section inside the TUI `/settings` screen — structural mirror + * of dsh-TUI's `TuiSettingsSection`. */ +export interface TuiSettingsSection { + /** Settings namespace this section edits; the screen marks the section + * unavailable when the composition serves no such namespace. */ + ns: string + /** Section title (English; also the fallback). */ + title: string + /** Provider-owned translations for the title. */ + descriptions?: TuiLocalizedDescriptions + /** Editable fields, in display order. */ + fields: readonly TuiSettingsField[] +} + +/** The `tuiSettingsSections` host service key — the ONE name the T1 + * registration condition (`installTuiSettingsSection`'s inject) and the T2 + * hint truthfulness probe (`getConfig`'s `ctx.get`) are both keyed on. Shared + * so the registration condition and the render-time hint can never drift + * apart (S-001, plan QC fix wave). */ +export const TUI_SETTINGS_SECTIONS = 'tuiSettingsSections' + +/** Test-friendly alias for the section namespace (`'advisor'`). The section + * itself reuses the shared {@link ADVISOR_SETTINGS_NAMESPACE} brand — a + * mismatched ns would silently render the section "unavailable" in the host + * screen (the alias exists so tests can pin the value without importing the + * settings module's brand). */ +export const ADVISOR_TUI_SETTINGS_NS = 'advisor' + +/** The declared "Advisor" section for the dsh-tui `/settings` screen: the + * five safe §5.1 keys (enabled/provider/model/immuneTurns/maxDeltaMessages) + * with zh/en labels + hints. Field paths are single-element §5.1 flat keys, + * so staged edits map 1:1 onto the namespace `mutate` paths. */ +export const ADVISOR_TUI_SETTINGS_SECTION: TuiSettingsSection = { + ns: ADVISOR_SETTINGS_NAMESPACE, + title: 'Advisor', + descriptions: { + zh: '顾问评审设置', + en: 'Advisor settings', + }, + fields: [ + { + path: ['enabled'], + kind: 'boolean', + label: 'Enabled', + descriptions: { zh: '启用', en: 'Enabled' }, + hint: 'Master switch for the advisor.', + hintDescriptions: { zh: '顾问总开关。', en: 'Master switch for the advisor.' }, + }, + { + path: ['provider'], + kind: 'text', + label: 'Provider', + descriptions: { zh: 'Provider', en: 'Provider' }, + hint: 'Provider route; required (non-empty) when enabled.', + hintDescriptions: { zh: 'Provider 路由;启用时必须非空。', en: 'Provider route; required (non-empty) when enabled.' }, + placeholder: 'e.g. deepseek-official', + }, + { + path: ['model'], + kind: 'text', + label: 'Model', + descriptions: { zh: 'Model', en: 'Model' }, + hint: 'Model id; required (non-empty) when enabled.', + hintDescriptions: { zh: '模型 ID;启用时必须非空。', en: 'Model id; required (non-empty) when enabled.' }, + placeholder: 'e.g. deepseek-v4-flash', + }, + { + path: ['immuneTurns'], + kind: 'number', + label: 'Immune turns', + descriptions: { zh: '免疫轮数', en: 'Immune turns' }, + hint: 'Cooldown after a delivered interrupt (integer ≥ 0).', + hintDescriptions: { zh: '投递一次建议后的冷却轮数(整数 ≥ 0)。', en: 'Cooldown after a delivered interrupt (integer ≥ 0).' }, + }, + { + path: ['maxDeltaMessages'], + kind: 'number', + label: 'Max delta messages', + descriptions: { zh: '最大增量消息数', en: 'Max delta messages' }, + hint: 'Delta window (integer ≥ 0; 0 = unbounded).', + hintDescriptions: { zh: '增量窗口(整数 ≥ 0;0 = 不限)。', en: 'Delta window (integer ≥ 0; 0 = unbounded).' }, + }, + ], +} + +/** + * Install the advisor's TUI settings-section surface: register the "Advisor" + * section on `tuiSettingsSections` when the host service exists (conditional + * inject; absent service → clean no-op). Called from `apply()` AFTER the + * single-reviewer claim (next to `installTuiClient`), so the section registers + * at most once per process. The structural accessor keeps the inject key in + * the standard position: the cordis Context has no `tuiSettingsSections` + * augmentation in this repo, so the service is read through a local + * structural cast. The inject child's return value (the registry disposer) + * is the child's own effect disposer, so the section withdraws when this + * fiber (or the service) goes away; a duplicate-ns registration is contained + * (debug log + no-op disposer, never throws — multi-fiber dedupe). + */ +export function installTuiSettingsSection(ctx: Context): void { + ctx.inject([TUI_SETTINGS_SECTIONS], (tctx) => { + const sections = (tctx as unknown as { tuiSettingsSections?: { register(s: TuiSettingsSection): () => void } }).tuiSettingsSections + if (sections === undefined) return + try { + return sections.register(ADVISOR_TUI_SETTINGS_SECTION) + } catch (error) { + if (!(error instanceof Error) || !error.message.includes('already registered')) throw error + tctx.logger('advisor').debug('advisor tui settings section already registered — no section on this fiber (multi-fiber dedupe)') + return () => {} + } + }) +} diff --git a/tests/commands.test.ts b/tests/commands.test.ts index 03addc7..806fd3a 100644 --- a/tests/commands.test.ts +++ b/tests/commands.test.ts @@ -19,10 +19,13 @@ * the explicit gate blocks model calls. */ -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { CommandId } from '@deepseek-ai/dsh-commands' import type { CommandDefinition, CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Context } from '@deepseek-ai/cordis' +import { apply } from '../src/index' +import type { AdvisorConfig } from '../src/config' import { AdvisorSessionOverrides, USAGE, @@ -74,6 +77,7 @@ function baseConfig(overrides: Partial = {}): AdvisorComp maxDeltaMessages: 60, systemPromptSet: false, systemPromptSummary: '', + tuiSettingsAvailable: false, ...overrides, } } @@ -481,6 +485,44 @@ describe('advisorConfigText (the /advisor config surface, composed session-less expect(text).toContain('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row)') expect(text).toContain('or $DSH_HOME/settings.yaml (advisor: section)') }) + + it('keeps the n8 edit-hint line when the TUI settings seam is unavailable (tuiSettingsAvailable: false)', () => { + // Byte-identical to the n8 hint: profile patch layer + settings.yaml only. + const text = advisorConfigText(baseConfig({ tuiSettingsAvailable: false })) + expect(text).toContain('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + expect(text).not.toContain('TUI /settings') + }) + + it('lists the TUI /settings screen first when the seam is available (tuiSettingsAvailable: true)', () => { + const text = advisorConfigText(baseConfig({ tuiSettingsAvailable: true })) + expect(text).toContain('Edit: TUI /settings screen (Advisor section, dsh-tui ≥ v0.8.0) or ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + }) + + it('all other render lines are unchanged across both hint branches', () => { + // The Edit hint is the ONLY line that differs between the branches — every + // other render line (switch, Model pair, immuneTurns, maxDeltaMessages + // incl. 0 → unbounded, systemPrompt default/summary, Reason) is identical. + const full = { + enabled: true, + provider: 'openai', + model: 'gpt-4o', + immuneTurns: 3, + maxDeltaMessages: 60, + systemPromptSet: true, + systemPromptSummary: 'You are a terse reviewer.', + } + const withoutTui = advisorConfigText(baseConfig(full)) + const withTui = advisorConfigText(baseConfig({ ...full, tuiSettingsAvailable: true })) + const dropHint = (text: string): string => + text.split('\n').filter((line) => !line.startsWith('Edit:')).join('\n') + expect(dropHint(withTui)).toBe(dropHint(withoutTui)) + const nonHint = dropHint(withoutTui) + expect(nonHint).toContain('Advisor config: enabled') + expect(nonHint).toContain('Model: openai/gpt-4o') + expect(nonHint).toContain('immuneTurns: 3') + expect(nonHint).toContain('maxDeltaMessages: 60') + expect(nonHint).toContain('systemPrompt: "You are a terse reviewer."') + }) }) describe('summarizeSystemPrompt (first line, ≤ 80 chars, never a full dump)', () => { @@ -550,6 +592,26 @@ describe('/advisor config subcommand (handler dispatch)', () => { expect(controller.setCalls).toHaveLength(0) }) + it('config stays read-only in BOTH hint branches — never calls setEnabled (no settings write)', () => { + // The readback is session-less and read-only: the handler only renders + // `controller.getConfig()`, and the controller interface exposes no write + // method — the config path can never touch the persisted settings. + for (const tuiSettingsAvailable of [false, true]) { + const controller = new FakeController( + baseStatus({ enabled: true }), + baseConfig({ enabled: true, provider: 'openai', model: 'gpt-4o', tuiSettingsAvailable }), + ) + const handler = registerAndGetHandler(controller) + const result = invoke(handler, 'config') + expect(result.kind).toBe('success') + expect(controller.setCalls).toHaveLength(0) + if (result.kind === 'success') { + if (tuiSettingsAvailable) expect(result.text).toContain('TUI /settings') + else expect(result.text).not.toContain('TUI /settings') + } + } + }) + it('config with an unknown subcommand still renders USAGE', () => { const controller = new FakeController(baseStatus()) const handler = registerAndGetHandler(controller) @@ -575,3 +637,71 @@ describe('/advisor unknown subcommand', () => { expect(controller.setCalls).toHaveLength(0) }) }) + +// --------------------------------------------------------------------------- +// apply wiring — /advisor config hint reflects the live tuiSettingsSections seam +// (plan dsh-advisor-tui-settings-n9 T2, AC-2) +// --------------------------------------------------------------------------- + +describe('apply wiring — /advisor config tuiSettingsAvailable reflects the tuiSettingsSections seam', () => { + // The single-reviewer claim is process-global; reset between cases + // (production keeps first-claim-wins; integration.test.ts does the same). + beforeEach(() => { + delete (globalThis as Record)['__dshAdvisorReviewer__'] + }) + // N-3 (QC fix wave): a future same-file `apply()` test must never inherit a + // held claim — this describe is the LAST to touch the global, so reset it + // after every case too (mirrors the beforeEach reset). + afterEach(() => { + delete (globalThis as Record)['__dshAdvisorReviewer__'] + }) + + /** Full plugin-row config shape for the apply wiring test. */ + function entryConfig(): AdvisorConfig { + return { enabled: true, provider: 'openai', model: 'gpt-4o', systemPrompt: '', immuneTurns: 3, maxDeltaMessages: 60 } + } + + /** Minimal apply()-shaped ctx that ACTIVATES the `commands` inject child + * against a fake registry (capturing the real /advisor handler wired by + * `apply`) and answers live `ctx.get('tuiSettingsSections')` probes with + * the seam presence flag. Other inject children are only recorded (no + * services), and the remaining surfaces `apply` touches are no-ops. */ + function makeConfigApplyCtx(injectKeys: string[], registry: AdvisorCommandRegistry, seamMounted: boolean): Context { + return { + inject: (names: readonly string[], child?: (tctx: never) => unknown) => { + injectKeys.push(...names) + if (names.includes('commands') && child !== undefined) child({ commands: registry } as never) + }, + get: (name: string) => + name === 'tuiSettingsSections' && seamMounted ? { register: () => () => {} } : undefined, + logger: () => ({ debug: () => {}, warn: () => {}, info: () => {}, error: () => {} }), + reflect: { provide: () => {} }, + effect: () => {}, + on: () => {}, + agents: { get: () => undefined }, + } as unknown as Context + } + + it('renders the n8 edit hint when the seam is NOT mounted (tuiSettingsAvailable: false)', () => { + const registry = new FakeRegistry() + const ctx = makeConfigApplyCtx([], registry, false) + apply(ctx, entryConfig()) + const result = invoke(registry.definitions[0]!.handler, 'config') + expect(result.kind).toBe('success') + if (result.kind === 'success') { + expect(result.text).toContain('Edit: ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + expect(result.text).not.toContain('TUI /settings') + } + }) + + it('renders the TUI /settings hint when the seam IS mounted (tuiSettingsAvailable: true)', () => { + const registry = new FakeRegistry() + const ctx = makeConfigApplyCtx([], registry, true) + apply(ctx, entryConfig()) + const result = invoke(registry.definitions[0]!.handler, 'config') + expect(result.kind).toBe('success') + if (result.kind === 'success') { + expect(result.text).toContain('Edit: TUI /settings screen (Advisor section, dsh-tui ≥ v0.8.0) or ~/.dsh/profiles//cordis.patch.yml (plugin row) or $DSH_HOME/settings.yaml (advisor: section)') + } + }) +}) diff --git a/tests/tui-settings.test.ts b/tests/tui-settings.test.ts new file mode 100644 index 0000000..1ec12df --- /dev/null +++ b/tests/tui-settings.test.ts @@ -0,0 +1,393 @@ +/** + * T1 (plan dsh-advisor-tui-settings-n9) — the dsh-tui settings-section seam: + * `installTuiSettingsSection` + the `tuiSettingsSections` Advisor section + * (src/tui-settings.ts). + * + * Contract under test (AC-1 + AC-3): + * ① With a `tuiSettingsSections` service — `installTuiSettingsSection` + * registers exactly one section whose `ns` equals + * `ADVISOR_SETTINGS_NAMESPACE` ('advisor'); title + zh/en descriptions + * are non-empty strings; the disposer returned by the inject child is + * exactly the stub registry's `register` return value (no wrapping). + * ② The section's fields: the five expected kinds in display order + * (`enabled` boolean, `provider`/`model` text, + * `immuneTurns`/`maxDeltaMessages` number), each with a non-empty `path`, + * `label`, and zh/en `hint`/`hintDescriptions`; `systemPrompt` is NOT + * among the field paths. + * ③ Field-path ↔ §5.1 schema alignment (regression pin): every field `path` + * is a single-element array whose key is a §5.1 `AdvisorConfig` key, and + * the exact allowed set is {enabled, provider, model, immuneTurns, + * maxDeltaMessages} — `systemPrompt` is the only §5.1 key intentionally + * absent (single-line TUI text input would truncate a multi-line prompt). + * ④ No `tuiSettingsSections` service → `installTuiSettingsSection` completes + * without error and registers nothing. + * ⑤ A duplicate-ns registration is contained: debug log + no-op disposer, + * no throw (mirrors the sibling tuiCommandTrees/typert/settings + * optional-registration pattern). + * ⑥ Wiring-level (src/index.ts `apply`): the section is requested only on + * the single-reviewer (claiming) fiber — a non-claiming apply returns + * before the wiring and must not ask for the service. The globalThis + * reviewer claim is reset between cases (as tui-client.test.ts does). + * ⑦ Wiring-level coupling pin (S-001, QC fix wave): on the claiming apply the + * section registration is requested under the shared service constant + * `TUI_SETTINGS_SECTIONS`, and the `/advisor config` readback's live probe + * (`ctx.get`) reads the SAME constant — registration condition and hint + * truthfulness are driven by one service key. + * ⑧ Behavioral dispose (S-1, QC fix wave): the stub's `register` returns a + * REAL removal disposer (mirroring the upstream contract — deletes the + * section and notifies), and invoking the disposer returned by + * `installTuiSettingsSection` withdraws the section from the registry. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import { apply } from '../src/index' +import { ADVISOR_TUI_SETTINGS_NS, TUI_SETTINGS_SECTIONS, installTuiSettingsSection } from '../src/tui-settings' +import type { TuiSettingsSection } from '../src/tui-settings' +import { ADVISOR_SETTINGS_NAMESPACE } from '../src/settings' +import type { AdvisorConfig } from '../src/config' + +// --------------------------------------------------------------------------- +// Shared fixtures +// --------------------------------------------------------------------------- + +/** Full plugin-row config shape (the `apply` wiring test only needs a valid entry). */ +function entryConfig(overrides: Partial = {}): AdvisorConfig { + return { + enabled: false, + systemPrompt: '', + immuneTurns: 3, + maxDeltaMessages: 60, + ...overrides, + } +} + +/** + * The exact §5.1 `AdvisorConfig` keys the TUI section must cover, in display + * order. The `keyof AdvisorConfig` annotation is the compile-time regression + * pin: a field key drifting off the schema stops typechecking; the runtime + * assertions below pin the exact allowed set (`systemPrompt` intentionally + * absent). + */ +const TUI_FIELD_KEYS: readonly (keyof AdvisorConfig)[] = [ + 'enabled', + 'provider', + 'model', + 'immuneTurns', + 'maxDeltaMessages', +] + +/** Stub `tuiSettingsSections` registry mirroring the dsh-TUI host contract + * (`src/dsh-adapter/settings-sections.ts`): `register` enforces the ns regex + * `^[a-z][a-z0-9_-]*$` and throws on a duplicate ns (`... is already + * registered`). By default the returned disposer is a REAL removal disposer + * mirroring the upstream contract (deletes the section from the registry and + * records the removal — the upstream "deletes + notifies"); a custom disposer + * may be injected for disposer-passthrough assertions. Records every section + * so tests can inspect the registered section; `section(ns)` mirrors the + * upstream lookup. */ +class StubSettingsSections { + readonly sections: TuiSettingsSection[] = [] + /** ns of every section removed through a returned disposer (the upstream + * "notify" analog — deletion is the observable, removal is recorded). */ + readonly removals: string[] = [] + + constructor(private readonly disposer?: () => void) {} + + /** The registered section for `ns`, or undefined when absent. */ + section(ns: string): TuiSettingsSection | undefined { + return this.sections.find((s) => s.ns === ns) + } + + register(section: TuiSettingsSection): () => void { + const ns = section.ns.trim() + if (!/^[a-z][a-z0-9_-]*$/u.test(ns)) { + throw new TypeError(`invalid TUI settings-section namespace: ${section.ns}`) + } + if (this.sections.some((s) => s.ns === ns)) { + throw new Error(`TUI settings section "${ns}" is already registered`) + } + this.sections.push(section) + if (this.disposer !== undefined) return this.disposer + // Real removal disposer (upstream contract: deletes + notifies). + return () => { + const index = this.sections.findIndex((s) => s.ns === ns) + if (index >= 0) this.sections.splice(index, 1) + this.removals.push(ns) + } + } +} + +/** Stub ctx whose `inject` immediately activates the callback with the given + * services (the dsh-TUI service map shape) and captures its return value. */ +function activateCtx(services: Record): { ctx: Context; injected: () => boolean; returned: () => unknown } { + let activated = false + let returned: unknown + const ctx = { + inject(_names: readonly string[], callback: (tctx: Record) => unknown): void { + activated = true + returned = callback(services) + }, + } as unknown as Context + return { + ctx, + injected: () => activated, + returned: () => returned, + } +} + +// --------------------------------------------------------------------------- +// ① registration with a tuiSettingsSections service +// --------------------------------------------------------------------------- + +describe('installTuiSettingsSection — registration (AC-1)', () => { + it('registers exactly one section with ns advisor and non-empty title + zh/en descriptions', () => { + const sections = new StubSettingsSections() + const { ctx, injected } = activateCtx({ tuiSettingsSections: sections }) + + installTuiSettingsSection(ctx) + + expect(injected()).toBe(true) + expect(sections.sections).toHaveLength(1) + const section = sections.sections[0]! + // The section ns REUSES the shared namespace brand — a mismatched ns + // would silently render the section "unavailable" in the host screen. + expect(section.ns).toBe(ADVISOR_SETTINGS_NAMESPACE) + // The test-friendly alias stays in sync with the shared constant. + expect(ADVISOR_TUI_SETTINGS_NS).toBe('advisor') + expect(ADVISOR_TUI_SETTINGS_NS).toBe(ADVISOR_SETTINGS_NAMESPACE) + expect(section.title).toBeTruthy() + expect(section.descriptions?.zh).toBeTruthy() + expect(section.descriptions?.en).toBeTruthy() + }) + + it('the inject child returns the registry disposer untouched', () => { + const dispose = vi.fn() + const sections = new StubSettingsSections(dispose) + const { ctx, returned } = activateCtx({ tuiSettingsSections: sections }) + + installTuiSettingsSection(ctx) + + expect(returned()).toBe(dispose) + }) + + it('invoking the returned disposer withdraws the section from the registry (behavioral, S-1)', () => { + // The default stub disposer is a REAL removal disposer (upstream contract: + // deletes the section + notifies) — the fiber's withdraw path must leave + // no stale entry behind, not just "not throw". + const sections = new StubSettingsSections() + const { ctx, returned } = activateCtx({ tuiSettingsSections: sections }) + + installTuiSettingsSection(ctx) + + expect(sections.section(ADVISOR_SETTINGS_NAMESPACE)).toBeDefined() + const disposer = returned() as () => void + expect(typeof disposer).toBe('function') + disposer() + expect(sections.section(ADVISOR_SETTINGS_NAMESPACE)).toBeUndefined() + expect(sections.sections).toHaveLength(0) + expect(sections.removals).toEqual(['advisor']) + }) +}) + +// --------------------------------------------------------------------------- +// ② + ③ the section's fields: kinds, display order, zh/en copy, schema pins +// --------------------------------------------------------------------------- + +describe('section fields — five §5.1 keys, display order, zh/en copy (AC-1)', () => { + function registeredSection(): TuiSettingsSection { + const sections = new StubSettingsSections() + const { ctx } = activateCtx({ tuiSettingsSections: sections }) + installTuiSettingsSection(ctx) + expect(sections.sections).toHaveLength(1) + return sections.sections[0]! + } + + it('declares the five fields with the expected kinds in display order', () => { + const fields = registeredSection().fields + + expect(fields.map((field) => field.path)).toEqual(TUI_FIELD_KEYS.map((key) => [key])) + expect(fields.map((field) => field.kind)).toEqual(['boolean', 'text', 'text', 'number', 'number']) + }) + + it('every field carries a non-empty path, label, and zh/en hint + hintDescriptions; systemPrompt is absent', () => { + const fields = registeredSection().fields + + expect(fields.map((field) => field.path[0])).not.toContain('systemPrompt') + for (const field of fields) { + expect(field.path.length).toBeGreaterThan(0) + expect(field.label).toBeTruthy() + expect(field.hint).toBeTruthy() + expect(field.hintDescriptions?.zh).toBeTruthy() + expect(field.hintDescriptions?.en).toBeTruthy() + } + }) + + it('field paths align with the §5.1 schema: single-element keys, exact allowed set (regression pin)', () => { + const fields = registeredSection().fields + + const keys = fields.map((field) => field.path) + for (const path of keys) { + expect(path).toHaveLength(1) + expect(TUI_FIELD_KEYS).toContain(path[0]) + } + // No §5.1 config key is silently unreachable from the TUI section except + // systemPrompt — assert the exact allowed set. + expect(new Set(keys.map(([key]) => key!))).toEqual(new Set(TUI_FIELD_KEYS)) + }) +}) + +// --------------------------------------------------------------------------- +// ④ no service → no-op +// --------------------------------------------------------------------------- + +describe('installTuiSettingsSection — no tuiSettingsSections service (AC-3)', () => { + it('completes without error, activates the inject child, and registers nothing', () => { + const { ctx, injected, returned } = activateCtx({}) + + expect(() => installTuiSettingsSection(ctx)).not.toThrow() + + // The conditional child ran (it is the standard inject position) but the + // absent service made it a clean no-op: nothing registered, no disposer. + expect(injected()).toBe(true) + expect(returned()).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// ⑤ duplicate-ns containment +// --------------------------------------------------------------------------- + +describe('installTuiSettingsSection — duplicate-ns containment (AC-1)', () => { + it('a duplicate-ns registration is contained: debug log + no-op disposer, no throw (multi-fiber dedupe)', () => { + // Another fiber already registered the 'advisor' section (multi-fiber + // duplication is observed in the host) — the duplicate-ns throw must NOT + // propagate out of the inject child. Mirrors the sibling tuiCommandTrees/ + // typert/settings optional-registration pattern. + const sections = new StubSettingsSections() + sections.register({ ns: 'advisor', title: 'Advisor', fields: [] }) + const debug = vi.fn() + const { ctx, injected, returned } = activateCtx({ + tuiSettingsSections: sections, + logger: () => ({ debug }), + }) + + expect(() => installTuiSettingsSection(ctx)).not.toThrow() + + expect(injected()).toBe(true) + // No second section is recorded; the child returns a no-op disposer and + // the dedupe is logged at debug level. + expect(sections.sections).toHaveLength(1) + expect(returned()).toEqual(expect.any(Function)) + expect(debug).toHaveBeenCalledWith('advisor tui settings section already registered — no section on this fiber (multi-fiber dedupe)') + }) +}) + +// --------------------------------------------------------------------------- +// ⑥ wiring — apply registers only on the claiming reviewer fiber +// --------------------------------------------------------------------------- + +describe('apply wiring — reviewer-claim gating (AC-1)', () => { + // The single-reviewer claim is process-global; reset between cases + // (production keeps first-claim-wins; integration.test.ts does the same). + beforeEach(() => { + delete (globalThis as Record)['__dshAdvisorReviewer__'] + }) + + /** Minimal apply()-shaped ctx: records inject requests WITHOUT activating + * them (no services), and no-ops the remaining surfaces `apply` touches + * before the reviewer guard (logger / reflect.provide / effect / on). */ + function makeApplyStubCtx(injectKeys: string[]): Context { + return { + inject: (names: readonly string[]) => { + injectKeys.push(...names) + }, + logger: () => ({ debug: () => {}, warn: () => {}, info: () => {}, error: () => {} }), + reflect: { provide: () => {} }, + effect: () => {}, + on: () => {}, + agents: { get: () => undefined }, + } as unknown as Context + } + + /** Extended apply()-shaped ctx for the S-001 coupling pin: records inject + * requests AND live `ctx.get` probe keys, and activates the `commands` + * child against a capturing registry so the `/advisor config` readback + * (whose `getConfig` runs the seam probe) can actually be invoked. */ + function makeProbeApplyCtx( + injectKeys: string[], + probeKeys: string[], + definitions: Array<{ handler: (invocation: unknown) => unknown }>, + ): Context { + return { + inject: (names: readonly string[], child?: (tctx: unknown) => unknown) => { + injectKeys.push(...names) + if (names.includes('commands') && child !== undefined) { + child({ + commands: { + register: (d: { handler: (invocation: unknown) => unknown }) => { + definitions.push(d) + return () => {} + }, + }, + }) + } + }, + get: (name: string) => { + probeKeys.push(name) + return undefined + }, + logger: () => ({ debug: () => {}, warn: () => {}, info: () => {}, error: () => {} }), + reflect: { provide: () => {} }, + effect: () => {}, + on: () => {}, + agents: { get: () => undefined }, + } as unknown as Context + } + + it('a non-claiming apply (claim already held) never requests tuiSettingsSections', () => { + ;(globalThis as Record)['__dshAdvisorReviewer__'] = true + const injectKeys: string[] = [] + const ctx = makeApplyStubCtx(injectKeys) + + apply(ctx, entryConfig()) + + expect(injectKeys).not.toContain(TUI_SETTINGS_SECTIONS) + // Same guard also skips the commands child — the whole reviewer-only + // wiring block is bypassed on a non-claiming fiber. + expect(injectKeys).not.toContain('commands') + }) + + it('the claiming apply requests tuiSettingsSections next to the commands child', () => { + const injectKeys: string[] = [] + const ctx = makeApplyStubCtx(injectKeys) + + apply(ctx, entryConfig()) + + expect(injectKeys).toContain(TUI_SETTINGS_SECTIONS) + expect(injectKeys).toContain('commands') + }) + + it('pins registration request key === probe key === TUI_SETTINGS_SECTIONS (S-001)', () => { + // The T1 registration condition (the `tuiSettingsSections` inject request) + // and the T2 hint truthfulness probe (`ctx.get` inside `getConfig`) are + // driven by ONE service key. Exercise both through the real wiring: the + // claiming apply must request the section under the shared constant, and + // running the `/advisor config` readback must probe the SAME constant — + // the registration condition and the hint are coupled by construction. + const injectKeys: string[] = [] + const probeKeys: string[] = [] + const definitions: Array<{ handler: (invocation: unknown) => unknown }> = [] + const ctx = makeProbeApplyCtx(injectKeys, probeKeys, definitions) + + apply(ctx, entryConfig()) + + // Registration request key: the shared constant (claiming path). + expect(injectKeys).toContain(TUI_SETTINGS_SECTIONS) + // Probe key: run the composed-config readback → getConfig probes the + // seam LIVE via `ctx.get` — same constant the registration used. + expect(definitions).toHaveLength(1) + definitions[0]!.handler({ rawInput: 'config', agent: { session: { id: 'session-1' } } }) + expect(probeKeys).toContain(TUI_SETTINGS_SECTIONS) + }) +})