Browse Source

feat(llm-pi-ai): dormant bare mount — routes live entirely in the settings plane

An empty or omitted providers dict is now the valid dormant posture: the
adapter mounts with zero routes and no catalog entries, registers routes
the moment the llm-pi-ai settings section supplies profiles, and drops
them when it empties. The TUI demo mounts the adapter bare, so adding an
openai/anthropic provider is purely a settings.yaml (or, next PR, web
form) operation with per-request apiKeyEnv credential resolution.
Yichen Jiang 2 months ago
parent
commit
4e9916b3e5

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md
-2026-07-29-request-level-llm-config-credentials.md: 13fefff9fe2646a9ef8e7200bd908d8764e006ea
-2026-07-29-request-level-llm-config-credentials.zh.md: 5e29946ade3d8b2b8ae51075c29c14867fe29f71
+2026-07-29-request-level-llm-config-credentials.md: 67baec4b70d0c754f22573d87fb4492de5ca16a4
+2026-07-29-request-level-llm-config-credentials.zh.md: 36182b77f4494c99b0fb08107f865f85322ece6c

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.md

@@ -16,7 +16,7 @@ The [settings seam](2026-07-28-user-settings-seam.md) shipped without a producti
 
 
 **Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
 **Secrets are references, values live behind `ctx.credentials`.** Configuration (both planes) carries `apiKeyEnv: DEEPSEEK_API_KEY`; the three-package credential seam resolves it per operation. `credentials-local` layers the live process environment (read-only, wins — a launch-time override is operator intent and must be *visibly* read-only, so shadowed writes reject instead of appearing to succeed) over `$DSH_HOME/.env` (writable, byte-preserving line edits, a quoting ladder dotenv reads back verbatim, wholesale snapshot replacement on reload so a deleted entry never lingers — the Claude Code additive-reapply lesson). Resolution order in the adapters is literal `apiKey` first (preserving the historical `config.apiKey ?? env` observable semantics), then the seam, then — only without a mounted seam — the raw environment variable.
 
 
-**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions.
+**Per-plugin namespaces, schema ≡ `Config`.** Each adapter registers its own namespace (`llm-deepseek`, `llm-pi-ai`) with its plugin `Config` schema and its `cordis.yml` entry as the composition `base` — a settings section is the same YAML shape as the entry config, and `resolveAdapterOptions`/`resolveProfiles` stay the one explicit resolve step for both. A live snapshot failing a beyond-schema bound keeps the last good facts (the seam's last-good philosophy extended one level up); the entry config itself still fails load. pi-ai's `providers` became a dict keyed by route so base and user layers merge per provider and the route set is structural; the array shape fails loud with migration directions, and an empty dict is the valid dormant posture — a composition ships the adapter bare and every route stays a user-plane decision.
 
 
 ## Alternatives considered
 ## Alternatives considered
 
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-29-request-level-llm-config-credentials.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 
 **机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
 **机密是引用,值藏在 `ctx.credentials` 背后。**配置(两个面)携带 `apiKeyEnv: DEEPSEEK_API_KEY`;三包凭据 seam 按操作解析它。`credentials-local` 把活跃进程环境(只读、优先——启动时覆盖是操作者意图,必须*可见地*只读,因此被遮蔽的写入直接拒绝而不是表面成功)叠加在 `$DSH_HOME/.env` 之上(可写、保字节行级编辑、dotenv 能逐字读回的引号阶梯、重载时整体替换快照使删除的条目绝不滞留——来自 Claude Code 增量重放(additive reapply)的教训)。适配器内的解析顺序为:字面 `apiKey` 优先(保留历史 `config.apiKey ?? env` 的可观察语义),然后是 seam,最后——仅在未挂载 seam 时——原始环境变量。
 
 
-**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引。
+**按插件划分 namespace,schema ≡ `Config`。**每个适配器注册自己的 namespace(`llm-deepseek`、`llm-pi-ai`),schema 用其插件 `Config` schema,组合 `base` 用其 `cordis.yml` 条目——settings 分节与 entry 配置是同一种 YAML 形状,`resolveAdapterOptions`/`resolveProfiles` 对两者仍是唯一的显式 resolve 步骤。存活快照若违反 schema 之外的约束,则保留最后可用事实(seam 的最后可用值哲学向上延伸一层);entry 配置本身仍会加载失败。pi-ai 的 `providers` 改为以路由为键的字典,base 层与用户层因此按提供方合并,路由集合也由结构直接表达;数组形状响亮失败并给出迁移指引,而空字典是合法的休眠姿态——组合可以裸挂该适配器,把每一条路由都留给用户面决定。
 
 
 ## 曾考虑的替代方案
 ## 曾考虑的替代方案
 
 

+ 7 - 3
docs/config-catalog.md

@@ -630,10 +630,14 @@ Source: [`packages/llm/llm-deepseek/src/index.ts:49`](../packages/llm/llm-deepse
 Requires: `llm`
 Requires: `llm`
 
 
 ```ts config-catalog
 ```ts config-catalog
-/** Plugin configuration: the non-empty provider routes this instance owns. */
+/** Plugin configuration: the provider routes this instance owns. */
 export interface Config {
 export interface Config {
-  /** Non-empty dict of pi-ai provider routes, keyed by provider. */
-  providers: Record<string, PiAiProviderProfile>
+  /**
+   * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
+   * the dormant settings-driven posture: the adapter mounts with no routes
+   * and registers them the moment a settings section supplies profiles.
+   */
+  providers?: Record<string, PiAiProviderProfile>
 }
 }
 
 
 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */

+ 3 - 0
examples/tui-agent/composition.md

@@ -16,6 +16,8 @@ flowchart LR
   cfg --> plugin_tui_credentials
   cfg --> plugin_tui_credentials
   plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
   plugin_tui_llm_deepseek["llm-deepseek<br/>@deepseek-ai/dsh-llm-deepseek"]
   cfg --> plugin_tui_llm_deepseek
   cfg --> plugin_tui_llm_deepseek
+  plugin_tui_llm_pi_ai["llm-pi-ai<br/>@deepseek-ai/dsh-llm-pi-ai"]
+  cfg --> plugin_tui_llm_pi_ai
   plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
   plugin_tui_subprocess["subprocess<br/>@deepseek-ai/dsh-subprocess-local"]
   cfg --> plugin_tui_subprocess
   cfg --> plugin_tui_subprocess
   plugin_tui_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
   plugin_tui_bash["bash<br/>@deepseek-ai/dsh-bash-local"]
@@ -77,6 +79,7 @@ flowchart LR
 | `settings` | `@deepseek-ai/dsh-settings-local` |
 | `settings` | `@deepseek-ai/dsh-settings-local` |
 | `credentials` | `@deepseek-ai/dsh-credentials-local` |
 | `credentials` | `@deepseek-ai/dsh-credentials-local` |
 | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
 | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` |
+| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` |
 | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
 | `subprocess` | `@deepseek-ai/dsh-subprocess-local` |
 | `bash` | `@deepseek-ai/dsh-bash-local` |
 | `bash` | `@deepseek-ai/dsh-bash-local` |
 | `tui-agent` | `@deepseek-ai/dsh-tui-demo` |
 | `tui-agent` | `@deepseek-ai/dsh-tui-demo` |

+ 6 - 11
examples/tui-agent/cordis.yml

@@ -30,19 +30,14 @@
     thinking: enabled
     thinking: enabled
     reasoningEffort: max
     reasoningEffort: max
 
 
-# The pi-ai multi-provider twin beside the direct adapter: openai + anthropic
-# routes register keyless (the catalog stays browsable; a request needs a
-# key). Keys resolve per request through the apiKeyEnv references, and a
-# `llm-pi-ai:` settings section overrides per provider — proxy baseURL, added
-# routes — without a restart.
+# The pi-ai multi-provider twin, mounted dormant: zero routes (and no extra
+# models in the picker) until a `llm-pi-ai:` settings section supplies
+# provider profiles — then those routes register live, keys resolving per
+# request through their apiKeyEnv references, and drop again when the
+# section empties. Which adapters exist is composition; which providers run
+# is the user's settings document.
 - id: llm-pi-ai
 - id: llm-pi-ai
   name: '@deepseek-ai/dsh-llm-pi-ai'
   name: '@deepseek-ai/dsh-llm-pi-ai'
-  config:
-    providers:
-      openai:
-        apiKeyEnv: OPENAI_API_KEY
-      anthropic:
-        apiKeyEnv: ANTHROPIC_API_KEY
 
 
 # Local executor for the app bundle's bash tool.
 # Local executor for the app bundle's bash tool.
 # Managed child-process groups for the bash executor (spawn/kill/output plumbing).
 # Managed child-process groups for the bash executor (spawn/kill/output plumbing).

+ 2 - 2
packages/llm/llm-pi-ai/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
 #   pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: 21e1f6f11777d9de230f26c00046248e111cf0b0
-README.zh.md: 4f8423bd9a5b812f97a3360218ed1a35a9e58dae
+README.md: fb8145d58a7c74c70498468044282c740460a947
+README.zh.md: e49243d81d204ea0567a6930ec99e4fa97f78df4

+ 1 - 1
packages/llm/llm-pi-ai/README.md

@@ -35,7 +35,7 @@ Configure credentials and deployment-specific transport settings per provider, k
           X-Deployment: production
           X-Deployment: production
 ```
 ```
 
 
-Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
+Each dict key must exist in pi-ai's installed catalog; the dict shape makes duplicates unrepresentable, and the pre-release array shape (with per-profile `provider` fields) fails load with migration directions. `providers` may also be empty or omitted entirely: the adapter then mounts **dormant** — zero routes, no extra catalog entries — and registers routes the moment the `llm-pi-ai:` settings section supplies profiles, dropping them again when it empties. Which adapters exist is composition; which providers run can be entirely the user's settings document. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
 
 
 ## Dynamic configuration (settings + credentials)
 ## Dynamic configuration (settings + credentials)
 
 

+ 1 - 1
packages/llm/llm-pi-ai/README.zh.md

@@ -35,7 +35,7 @@
           X-Deployment: production
           X-Deployment: production
 ```
 ```
 
 
-每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
+每个字典键都必须存在于 pi-ai 已安装 catalog 中;字典形状使重复项无法表示,发布前的数组形状(每个 profile 携带 `provider` 字段)会加载失败并给出迁移指引。`providers` 也可以为空或整体省略:适配器将以**休眠**姿态挂载——零路由、模型选择器不多一条——一旦 `llm-pi-ai:` settings 分节提供了 profile 就即时注册路由,分节清空时随之撤销。哪些适配器存在归组合面;哪些提供方在运行可以完全交给用户的设置文档。向 `ctx.llm` 注册具有原子性:如果与另一适配器已拥有的任何提供方路由冲突,插件会加载失败,不注册剩余路由。模型 id 不是生命周期配置;未知模型会在发起任何提供方请求前以 `LlmError('UNKNOWN_MODEL')` 失败。
 
 
 ## 动态配置(settings + credentials)
 ## 动态配置(settings + credentials)
 
 

+ 15 - 8
packages/llm/llm-pi-ai/src/config.ts

@@ -58,10 +58,14 @@ export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, '
   retryPolicy: ResolvedRetryPolicy
   retryPolicy: ResolvedRetryPolicy
 }
 }
 
 
-/** Plugin configuration: the non-empty provider routes this instance owns. */
+/** Plugin configuration: the provider routes this instance owns. */
 export interface Config {
 export interface Config {
-  /** Non-empty dict of pi-ai provider routes, keyed by provider. */
-  providers: Record<string, PiAiProviderProfile>
+  /**
+   * pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
+   * the dormant settings-driven posture: the adapter mounts with no routes
+   * and registers them the moment a settings section supplies profiles.
+   */
+  providers?: Record<string, PiAiProviderProfile>
 }
 }
 
 
 const thinkingBudgets = z.object({
 const thinkingBudgets = z.object({
@@ -88,21 +92,24 @@ const profile = z.object({
 
 
 /** Runtime schema for {@link Config}. */
 /** Runtime schema for {@link Config}. */
 export const Config: z<Config> = z.object({
 export const Config: z<Config> = z.object({
-  providers: z.dict(profile).required(),
+  providers: z.dict(profile).default({}),
 })
 })
 
 
 /**
 /**
  * Validate profiles against the installed pi-ai catalog and return a detached
  * Validate profiles against the installed pi-ai catalog and return a detached
- * route-keyed map suitable for per-request reads.
+ * route-keyed map suitable for per-request reads. This is the one explicit
+ * resolve step, so an omitted dict resolves to the empty (dormant) route set
+ * here rather than through a hidden fallback.
  * @param providers - configured provider profiles keyed by route.
  * @param providers - configured provider profiles keyed by route.
  * @returns validated profiles in configuration order.
  * @returns validated profiles in configuration order.
  */
  */
-export function resolveProfiles(providers: Readonly<Record<string, PiAiProviderProfile>>): Map<string, ResolvedPiAiProviderProfile> {
+export function resolveProfiles(
+  providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
+): Map<string, ResolvedPiAiProviderProfile> {
   if (Array.isArray(providers)) {
   if (Array.isArray(providers)) {
     throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
     throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
   }
   }
-  const entries = Object.entries(providers)
-  if (entries.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
+  const entries = Object.entries(providers ?? {})
   const supported = new Set<string>(getBuiltinProviders())
   const supported = new Set<string>(getBuiltinProviders())
   const resolved = new Map<string, ResolvedPiAiProviderProfile>()
   const resolved = new Map<string, ResolvedPiAiProviderProfile>()
   for (const [provider, source] of entries) {
   for (const [provider, source] of entries) {

+ 10 - 5
packages/llm/llm-pi-ai/src/index.ts

@@ -91,19 +91,24 @@ export function apply(ctx: Context, config: Config): void {
 
 
   const adapter = new PiAiAdapter({ profiles, resolveApiKey })
   const adapter = new PiAiAdapter({ profiles, resolveApiKey })
   // Route effects bind to this apply fiber via the stable `ctx` reference,
   // Route effects bind to this apply fiber via the stable `ctx` reference,
-  // even when a swap runs inside the scoped settings callback below.
-  let disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter)
-  let registeredFacts = registrationFacts(profiles())
+  // even when a swap runs inside the scoped settings callback below. A bare
+  // mount (zero routes) is the dormant posture: nothing registers until a
+  // settings section supplies profiles, and routes drop when it empties.
+  let disposeRoutes: (() => void) | undefined
+  let registeredFacts: unknown
   const ensureRegistrationFacts = (): void => {
   const ensureRegistrationFacts = (): void => {
     const facts = registrationFacts(profiles())
     const facts = registrationFacts(profiles())
     if (deepEqualJson(facts, registeredFacts)) return
     if (deepEqualJson(facts, registeredFacts)) return
     // The registry captures the route set and each route's retry policy at
     // The registry captures the route set and each route's retry policy at
     // registration: swap the registration in one synchronous section (same
     // registration: swap the registration in one synchronous section (same
     // adapter instance, no NO_ADAPTER window).
     // adapter instance, no NO_ADAPTER window).
-    disposeRoutes()
-    disposeRoutes = ctx.llm.registerAdapter([...profiles().keys()], adapter)
+    disposeRoutes?.()
+    disposeRoutes = undefined
+    const routes = [...profiles().keys()]
+    if (routes.length > 0) disposeRoutes = ctx.llm.registerAdapter(routes, adapter)
     registeredFacts = facts
     registeredFacts = facts
   }
   }
+  ensureRegistrationFacts()
 
 
   installSettingsSection(ctx, NS, Config, config, {
   installSettingsSection(ctx, NS, Config, config, {
     setSource: (source) => {
     setSource: (source) => {

+ 3 - 1
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -395,7 +395,9 @@ describe('provider profile lifecycle', () => {
   })
   })
 
 
   it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
   it('validates empty, unknown, legacy-shaped, and explicitly blank profiles', () => {
-    expect(() => resolveProfiles({})).toThrow(/at least one/)
+    // Empty and omitted dicts are the dormant zero-route posture, not errors.
+    expect(resolveProfiles({}).size).toBe(0)
+    expect(resolveProfiles(undefined).size).toBe(0)
     expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
     expect(() => resolveProfiles({ '': {} })).toThrow(/non-empty/)
     expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
     expect(() => resolveProfiles({ 'not-real': {} })).toThrow(/unknown/)
     // The pre-release array shape and its per-profile provider field fail
     // The pre-release array shape and its per-profile provider field fail

+ 24 - 0
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts

@@ -42,6 +42,30 @@ async function boot(dir: string, config: LlmPiAi.Config): Promise<Context> {
 }
 }
 
 
 describe('request-level dynamic profiles', () => {
 describe('request-level dynamic profiles', () => {
+  it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
+    vi.stubEnv('PI_DYNAMIC_KEY', '')
+    const dir = await home()
+    await writeFile(join(dir, '.env'), 'PI_DYNAMIC_KEY=pk-from-settings\n')
+    const server = await mockServer([{ events: textEvents }])
+    // The exact product posture: `- id: llm-pi-ai` with no config at all.
+    const ctx = await boot(dir, {})
+
+    expect(ctx.llm.listProviders()).toEqual([])
+    await ctx.settings.update(NS, {
+      providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } },
+    })
+    expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['deepseek'])
+    await expect(ctx.llm.listModels('deepseek')).resolves.not.toHaveLength(0)
+
+    const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
+    expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
+    expect(server.headers[0]?.authorization).toBe('Bearer pk-from-settings')
+
+    // Emptying the user layer returns the adapter to its dormant state.
+    await ctx.settings.replace(NS, {})
+    expect(ctx.llm.listProviders()).toEqual([])
+  })
+
   it('adds a provider route from settings and drops it when the user layer resets', async () => {
   it('adds a provider route from settings and drops it when the user layer resets', async () => {
     const dir = await home()
     const dir = await home()
     const server = await mockServer([{ events: textEvents }])
     const server = await mockServer([{ events: textEvents }])