ソースを参照

fix: address subagent model selection review

Dudu-0223 1 ヶ月 前
親
コミット
3a146064a4
21 ファイル変更、287 行追加、40 行削除
  1. 2 2
      .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md
  3. 1 1
      .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md
  4. 2 2
      .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml
  5. 2 1
      .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
  6. 2 1
      .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md
  7. 15 1
      packages/client/ui-settings-plugins/src/client/index.ts
  8. 38 3
      packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts
  9. 29 3
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  10. 88 5
      packages/client/ui-settings-plugins/tests/stores.client.spec.ts
  11. 2 2
      packages/client/ui-settings/README.i18n.yaml
  12. 1 1
      packages/client/ui-settings/README.md
  13. 1 1
      packages/client/ui-settings/README.zh.md
  14. 5 2
      packages/client/ui-settings/src/client/settings-contract.ts
  15. 3 2
      packages/client/ui-settings/src/client/settings-scope.ts
  16. 24 0
      packages/client/ui-settings/tests/settings-scope.client.spec.ts
  17. 9 5
      packages/subagent/tool-subagent/src/index.ts
  18. 3 2
      packages/subagent/tool-subagent/src/model-selection-state.ts
  19. 14 5
      packages/subagent/tool-subagent/src/model-selection.ts
  20. 41 0
      packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts
  21. 4 0
      packages/subagent/tool-subagent/tests/model-selection.spec.ts

+ 2 - 2
.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md
-2026-08-18-model-selected-subagent-routes.md: 768da30d26f7daede6ed68dda72efcb4207ab60f
-2026-08-18-model-selected-subagent-routes.zh.md: 9cf3033af0e143589ff6806acbb4600478e83929
+2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086
+2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb

+ 1 - 1
.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md

@@ -48,7 +48,7 @@ The delegation definition is static across adapter registration and catalog chan
 
 ## Consequences
 
-- A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields.
+- A settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields.
 - The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy.
 - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse.
 - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default.

+ 1 - 1
.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md

@@ -48,7 +48,7 @@ Status: implemented
 
 ## 结果
 
-- 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。
+- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。
 - 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。
 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。
 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md
-2026-08-24-user-authorized-subagent-model-routes.md: af293dd2e74ea801892f2b73553cc15beb1c679e
-2026-08-24-user-authorized-subagent-model-routes.zh.md: 973142f0294ad2bfc10dd5729e3a4a4992d6a84b
+2026-08-24-user-authorized-subagent-model-routes.md: 3bb76eb8941dd5a7f86e95cfafe14516a4f948d7
+2026-08-24-user-authorized-subagent-model-routes.zh.md: 5defbd0ee0921666a119eaac914580dad747868c

+ 2 - 1
.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md

@@ -35,7 +35,8 @@ Model selection has no unrestricted static mode. The default-off Host setting is
 - New adapter registrations and newly advertised models do not expand user authorization.
 - Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it.
 - The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript.
-- Unit coverage pins settings validation, Session sampling and inheritance, discovery intersection, executor denial, stale UI candidates, staged whole-array writes, and rejected-write draft preservation. The assembled Web scenario pins the real settings document and Plugins card flow.
+- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it.
+- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow.
 
 ## Related decisions
 

+ 2 - 1
.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md

@@ -35,7 +35,8 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled`
 - 新适配器注册和新公布模型不会扩大用户授权。
 - 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。
 - 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。
-- 单元覆盖固定设置校验、Session 取样与继承、发现交集、执行器拒绝、UI 陈旧候选项、暂存后的整数组写入,以及写入被拒时保留草稿。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。
+- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。
+- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存后的整数组写入、过期 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。
 
 ## Related decisions
 

+ 15 - 1
packages/client/ui-settings-plugins/src/client/index.ts

@@ -9,6 +9,7 @@
  * settings scope, which keeps them unaware of one another and of other tabs.
  */
 
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
 // Type-only: pulls the locale plugin's Context merge (ctx.locale).
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 // Type-only: the settings shell's SlotMap merge (the 'settings.section' entry)
@@ -53,13 +54,14 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co
 const NS = 'settings.plugins'
 
 /** Required services (cordis fiber inject). */
-export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']
+export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']
 
 /**
  * Mount the plugin configuration section and the cards this package ships.
  * @param ctx - the browser plugin context.
  */
 export function apply(ctx: ClientContext): void {
+  const { api } = ctx.get('connection') as ConnectionHandle
   const t = ctx.locale.bind(NS)
   ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries')
 
@@ -79,6 +81,18 @@ export function apply(ctx: ClientContext): void {
     () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }),
     'ui-settings-plugins: credential invalidations',
   )
+  ctx.effect(
+    () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }),
+    'ui-settings-plugins: subagent adapter invalidations',
+  )
+  ctx.effect(
+    () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }),
+    'ui-settings-plugins: subagent settings invalidations',
+  )
+  ctx.effect(
+    () => ctx.on('connection/reset', () => { subagentModelSelection.resetCatalog() }),
+    'ui-settings-plugins: subagent connection generation',
+  )
   ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference')
 
   // The shared SettingsScope mirror updates after document commits and reconnects.

+ 38 - 3
packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts

@@ -134,6 +134,7 @@ export class SubagentModelSelectionCardController {
   private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle'
   private draftEnabled: boolean | undefined
   private draftSelected: Set<string> | undefined
+  private draftRevision: number | undefined
   private saving = false
   private saved = false
   private failed = false
@@ -153,6 +154,11 @@ export class SubagentModelSelectionCardController {
   ) {
     this.store = createSnapshotStore(this.projection())
     this.unsubscribe = scope.subscribe(() => {
+      if (!this.saving && this.draftSelected !== undefined
+        && this.scope.getSnapshot().revision !== this.draftRevision) {
+        this.saved = false
+        this.failed = true
+      }
       if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog()
       this.publish()
     })
@@ -199,8 +205,12 @@ export class SubagentModelSelectionCardController {
   }
 
   private beginDraft(): Set<string> {
-    this.draftEnabled ??= this.currentEnabled()
-    this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey))
+    if (this.draftSelected === undefined) {
+      const snapshot = this.scope.getSnapshot()
+      this.draftEnabled = snapshot.value?.enabled ?? false
+      this.draftSelected = new Set(snapshot.value?.allowedModels.map(subagentModelKey) ?? [])
+      this.draftRevision = snapshot.revision
+    }
     return this.draftSelected
   }
 
@@ -230,6 +240,7 @@ export class SubagentModelSelectionCardController {
     if (this.saving) return
     this.draftEnabled = undefined
     this.draftSelected = undefined
+    this.draftRevision = undefined
     this.saved = false
     this.failed = false
     this.publish()
@@ -252,6 +263,12 @@ export class SubagentModelSelectionCardController {
     if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving
       || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired))
       || (desiredEnabled && desired.length === 0)) return
+    if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) {
+      this.saved = false
+      this.failed = true
+      this.publish()
+      return
+    }
     const generation = this.saveGeneration
     this.saving = true
     this.saved = false
@@ -260,7 +277,7 @@ export class SubagentModelSelectionCardController {
     await this.scope.mutate([
       { op: 'set', path: ['enabled'], value: desiredEnabled },
       { op: 'set', path: ['allowedModels'], value: desired },
-    ])
+    ], this.draftRevision)
     if (generation !== this.saveGeneration) return
     const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)
     this.saving = false
@@ -269,10 +286,28 @@ export class SubagentModelSelectionCardController {
     if (landed) {
       this.draftEnabled = undefined
       this.draftSelected = undefined
+      this.draftRevision = undefined
     }
     this.publish()
   }
 
+  /** Invalidate and reload model candidates after a Host model input changes. */
+  refreshCatalog(): void {
+    if (this.disposed) return
+    this.catalogGeneration += 1
+    this.catalogStatus = 'idle'
+    this.catalogFailures = []
+    if (this.enabled()) void this.loadCatalog()
+    else this.publish()
+  }
+
+  /** Clear Host-specific candidates and reload after reconnecting. */
+  resetCatalog(): void {
+    if (this.disposed) return
+    this.catalogGroups = []
+    this.refreshCatalog()
+  }
+
   private async loadCatalog(): Promise<void> {
     if (this.disposed || this.catalogStatus === 'loading') return
     const generation = this.catalogGeneration

+ 29 - 3
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien
 import type {
   ConfigurablePluginsTabFace, PluginsSettingsSectionInjected,
 } from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
+import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts'
 
 // These specs assert the shipped Chinese copy. The lane has no jsdom `window`,
 // so browser-language detection never runs and a fresh LocaleRuntime opens on
@@ -27,6 +28,9 @@ async function bench(served?: string[]) {
   locale.setLocale('zh')
   ctx.provide('locale', locale)
   const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } }))
+  const models = vi.fn(() => Promise.resolve({
+    rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } },
+  }))
   const describeSettings = vi.fn(() => Promise.resolve(served === undefined
     ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } }
     : {
@@ -43,9 +47,14 @@ async function bench(served?: string[]) {
     credentials: { describe: describeCredentials, set: vi.fn() },
     settings: { describe: describeSettings },
   })
-  ctx.provide('connection', { isLoopback: true, api: {} } as never)
+  ctx.provide('connection', {
+    isLoopback: true,
+    api: { llm: { models } },
+  } as never)
   await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await()
-  return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote }
+  return {
+    ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote,
+  }
 }
 
 function declareRoot(slots: SlotRegistry): () => void {
@@ -57,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void {
 
 describe('ui-settings-plugins apply', () => {
   it('declares the services it uses', () => {
-    expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'])
+    expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'])
   })
 
   it('registers one Plugins section and declares the tab and card slots', async () => {
@@ -176,6 +185,23 @@ describe('ui-settings-plugins apply', () => {
     await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
   })
 
+  it('refreshes the subagent catalog after model inputs change or the connection resets', async () => {
+    const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog')
+    const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetCatalog')
+    const { ctx, slots, remote } = await bench(['subagent-model-selection'])
+    declareRoot(slots)
+    await ctx.plugin({ inject: [...inject], apply }).await()
+    refresh.mockClear()
+    reset.mockClear()
+
+    remote.emit('llm/adapters-updated', [])
+    expect(refresh).toHaveBeenCalledTimes(1)
+    remote.emit('settings/document-updated', ['llm-deepseek', 1])
+    expect(refresh).toHaveBeenCalledTimes(2)
+    ctx.emit('connection/reset')
+    expect(reset).toHaveBeenCalledTimes(1)
+  })
+
   it('ignores a credential change for a reference no card watches', async () => {
     const { ctx, slots, describeCredentials, remote } = await bench()
     declareRoot(slots)

+ 88 - 5
packages/client/ui-settings-plugins/tests/stores.client.spec.ts

@@ -456,7 +456,10 @@ describe('SubagentModelSelectionCardController', () => {
       groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
     })
     const controller = new SubagentModelSelectionCardController(host.scope, models.api)
-    host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} })
+    host.publish({
+      status: 'ready', writable: true, revision: 3,
+      value: { enabled: false, allowedModels: [] }, user: {},
+    })
     const face = controller.inject()
 
     expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false)
@@ -470,7 +473,7 @@ describe('SubagentModelSelectionCardController', () => {
       expect(host.mutate).toHaveBeenCalledWith([
         { op: 'set', path: ['enabled'], value: true },
         { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
-      ])
+      ], 3)
     })
 
     expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
@@ -482,6 +485,19 @@ describe('SubagentModelSelectionCardController', () => {
     })
   })
 
+  it('starts an empty draft when a ready test scope has no decoded value', () => {
+    const host = stubSettingsScope<SubagentModelSelectionSettings>()
+    const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api)
+    host.publish({ status: 'ready', writable: true, revision: 0, value: undefined })
+    const face = controller.inject()
+
+    face.toggleEnabled()
+
+    expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
+      enabled: true, dirty: true, invalid: true,
+    })
+  })
+
   it('keeps the Host value and reports a rejected write', async () => {
     const host = stubSettingsScope<SubagentModelSelectionSettings>()
     const models = modelsApi({
@@ -517,7 +533,7 @@ describe('SubagentModelSelectionCardController', () => {
     })
     const controller = new SubagentModelSelectionCardController(host.scope, models.api)
     host.publish({
-      status: 'ready', writable: true,
+      status: 'ready', writable: true, revision: 5,
       value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
     })
     const face = controller.inject()
@@ -541,7 +557,7 @@ describe('SubagentModelSelectionCardController', () => {
     const host = stubSettingsScope<SubagentModelSelectionSettings>()
     acceptWrites(host)
     host.publish({
-      status: 'ready', writable: true,
+      status: 'ready', writable: true, revision: 5,
       value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {},
     })
     const models = modelsApi({
@@ -557,7 +573,7 @@ describe('SubagentModelSelectionCardController', () => {
       expect(host.mutate).toHaveBeenCalledWith([
         { op: 'set', path: ['enabled'], value: false },
         { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] },
-      ])
+      ], 5)
     })
     expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
       enabled: false, dirty: false, saved: true,
@@ -578,6 +594,71 @@ describe('SubagentModelSelectionCardController', () => {
     await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) })
   })
 
+  it('rejects a draft after the Host revision changes', async () => {
+    const host = stubSettingsScope<SubagentModelSelectionSettings>()
+    const models = modelsApi({
+      groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }],
+    })
+    const controller = new SubagentModelSelectionCardController(host.scope, models.api)
+    host.publish({
+      status: 'ready', writable: true, revision: 4,
+      value: { enabled: false, allowedModels: [] }, user: {},
+    })
+    const face = controller.inject()
+    face.toggleEnabled()
+    await vi.waitFor(() => {
+      expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1)
+    })
+    face.toggleModel('alpha\0fast')
+
+    host.publish({
+      revision: 5,
+      value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] },
+    })
+    expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ failed: true, dirty: true })
+    face.save()
+    await Promise.resolve()
+
+    expect(host.mutate).not.toHaveBeenCalled()
+    face.discard()
+    expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({
+      failed: false, dirty: false, enabled: true,
+    })
+  })
+
+  it('reloads the model catalog after invalidation', async () => {
+    const host = stubSettingsScope<SubagentModelSelectionSettings>()
+    host.publish({
+      status: 'ready', writable: true, revision: 1,
+      value: { enabled: true, allowedModels: [] }, user: {},
+    })
+    const models = vi.fn()
+      .mockResolvedValueOnce({
+        rpcId: 'catalog-1',
+        result: { ok: true, value: {
+          groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }],
+          failures: [],
+        } },
+      })
+      .mockResolvedValueOnce({
+        rpcId: 'catalog-2',
+        result: { ok: true, value: {
+          groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }],
+          failures: [],
+        } },
+      })
+    const controller = new SubagentModelSelectionCardController(
+      host.scope, { llm: { models } } as never,
+    )
+    const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot()
+    await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') })
+
+    controller.refreshCatalog()
+
+    await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') })
+    expect(models).toHaveBeenCalledTimes(2)
+  })
+
   it('suppresses duplicate actions and late save settlements', async () => {
     const host = stubSettingsScope<SubagentModelSelectionSettings>()
     const catalog = modelsApi({
@@ -658,6 +739,8 @@ describe('SubagentModelSelectionCardController', () => {
     expect(host.mutate).not.toHaveBeenCalled()
 
     controller.dispose()
+    controller.refreshCatalog()
+    controller.resetCatalog()
     face.toggleEnabled()
     face.retryCatalog()
     face.save()

+ 2 - 2
packages/client/ui-settings/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings/README.md
-README.md: beda3aec1750da77af86cebdf658bde8e3834a46
-README.zh.md: 8d4fb0983c8a0ba627411fd3b653114625530639
+README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5
+README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5

+ 1 - 1
packages/client/ui-settings/README.md

@@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re-
 
 ### Binding a namespace
 
-A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten.
+A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision.
 
 ### Filling the settings slots
 

+ 1 - 1
packages/client/ui-settings/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 
 ### 绑定命名空间
 
-功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。
+功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。
 
 ### 填充设置 slot
 

+ 5 - 2
packages/client/ui-settings/src/client/settings-contract.ts

@@ -62,11 +62,14 @@ export interface SettingsScope<T> {
   subscribe(listener: () => void): () => void
   /**
    * Queue one atomic namespace mutation. All operations share one revision
-   * fence, Host validation, persistence decision, and recovery read.
+   * fence, Host validation, persistence decision, and recovery read. Supplying
+   * `expectedRevision` preserves an earlier read as the fence instead of using
+   * the latest queued or mirrored revision.
    * @param ops - ordered field operations copied when queued.
+   * @param expectedRevision - optional fixed revision read by the domain editor.
    * @returns settlement after the mutation and any latest-write recovery read.
    */
-  mutate(ops: readonly SettingsPathOpView[]): Promise<void>
+  mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void>
   /**
    * Queue one field write. Rapid writes preserve mutation order, each carries
    * the latest known namespace revision, and only the latest settlement may

+ 3 - 2
packages/client/ui-settings/src/client/settings-scope.ts

@@ -120,13 +120,14 @@ export class SettingsScopeController<T> implements SettingsScope<T> {
   /**
    * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}.
    * @param ops - ordered field operations copied when queued.
+   * @param expectedRevision - optional fixed revision read by the domain editor.
    * @returns settlement after the mutation and any latest-write recovery read.
    */
-  mutate(ops: readonly SettingsPathOpView[]): Promise<void> {
+  mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise<void> {
     const ownedOps = structuredClone(ops) as SettingsPathOpView[]
     const generation = ++this.writeGeneration
     return this.enqueue(async () => {
-      const revision = this.pendingRevision ?? this.getSnapshot().revision
+      const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision
       let response: Awaited<ReturnType<SettingsFace['settings']['mutate']>>
       try {
         response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision)

+ 24 - 0
packages/client/ui-settings/tests/settings-scope.client.spec.ts

@@ -201,6 +201,30 @@ describe('SettingsScopeController', () => {
     )
   })
 
+  it('preserves an editor-owned revision fence behind earlier queued writes', async () => {
+    const first = deferred<RpcResponse<SettingsNamespaceView>>()
+    const describeCall = vi.fn()
+      .mockResolvedValueOnce(described({ preference: 'system' }, 7))
+      .mockResolvedValueOnce(described({ preference: 'dark' }, 8))
+    const mutate = vi.fn()
+      .mockReturnValueOnce(first.promise)
+      .mockResolvedValueOnce(rejected())
+    const { mirror, scope } = derivedScope({ describe: describeCall, mutate })
+    await mirror.load()
+
+    const earlier = scope.set('preference', 'dark')
+    const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7)
+    first.resolve(ok(view({ preference: 'dark' }, 8)))
+    await Promise.all([earlier, fenced])
+
+    expect(mutate).toHaveBeenNthCalledWith(2, {
+      ns: 'ui-test',
+      ops: [{ op: 'set', path: ['preference'], value: 'light' }],
+      expectedRevision: 7,
+    })
+    expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 })
+  })
+
   it('folds the latest write answer into the mirror so a sibling scope sees it', async () => {
     const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4))
     const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5)))

+ 9 - 5
packages/subagent/tool-subagent/src/index.ts

@@ -651,11 +651,15 @@ export function apply(ctx: Context, config: Config): void {
     // Reserve before the injected fiber runs: tool registration emits
     // `tools/change` synchronously, which re-enters the reconciliation below.
     installing.add(candidate)
-    const policy = selectForAgent(candidate)
-    const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => {
-      install(runtimeCtx, policy)
-    })
-    installing.delete(candidate)
+    let fiber: ReturnType<Context['inject']>
+    try {
+      const policy = selectForAgent(candidate)
+      fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => {
+        install(runtimeCtx, policy)
+      })
+    } finally {
+      installing.delete(candidate)
+    }
     scopedInstalls.set(candidate, fiber)
   }
   const removeScoped = (candidate: Agent): void => {

+ 3 - 2
packages/subagent/tool-subagent/src/model-selection-state.ts

@@ -26,8 +26,9 @@ declare module '@deepseek-ai/dsh-session/types' {
 export function subagentModelSelectionPolicy(session: Session): AllowedModelRoute[] | undefined {
   const event = session.events.find(candidate => candidate.type === 'subagent/model-selection-policy')
   if (event?.type !== 'subagent/model-selection-policy') return undefined
-  const routes = event.data.allowedModels.map(route => ({ ...route }))
-  assertAllowedModelRoutes(routes)
+  const { allowedModels } = event.data
+  assertAllowedModelRoutes(allowedModels)
+  const routes = allowedModels.map(route => ({ ...route }))
   if (routes.length === 0) throw new Error('subagent/model-selection-policy requires at least one route')
   return routes
 }

+ 14 - 5
packages/subagent/tool-subagent/src/model-selection.ts

@@ -35,15 +35,24 @@ export function modelRouteKey(route: AllowedModelRoute): string {
 }
 
 /**
- * Reject malformed or duplicate route policy entries at a configuration boundary.
- * @param routes - Exact routes to validate.
+ * Reject malformed or duplicate route policy entries at a durable or configuration boundary.
+ * @param routes - Candidate exact routes to validate.
+ * @returns an assertion that the candidate is a validated exact-route array.
  */
-export function assertAllowedModelRoutes(routes: readonly AllowedModelRoute[]): void {
+export function assertAllowedModelRoutes(routes: unknown): asserts routes is readonly AllowedModelRoute[] {
+  if (!Array.isArray(routes)) {
+    throw new Error('subagent model selection requires an array of routes')
+  }
   const seen = new Set<string>()
-  for (const route of routes) {
-    if (route.provider.length === 0 || route.model.length === 0) {
+  const candidates: readonly unknown[] = routes
+  for (const candidate of candidates) {
+    if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)
+      || !('provider' in candidate) || typeof candidate.provider !== 'string'
+      || !('model' in candidate) || typeof candidate.model !== 'string'
+      || candidate.provider.length === 0 || candidate.model.length === 0) {
       throw new Error('subagent model selection requires non-empty provider and model ids')
     }
+    const route = { provider: candidate.provider, model: candidate.model }
     const key = modelRouteKey(route)
     if (seen.has(key)) {
       throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`)

+ 41 - 0
packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts

@@ -129,6 +129,13 @@ describe('SubagentModelSelectionConfig', () => {
     const invalid = Session.create(SessionId('empty-policy'))
     invalid.append('subagent/model-selection-policy', { allowedModels: [] })
     expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route')
+
+    const malformed = Session.create(SessionId('malformed-policy'))
+    malformed.append('subagent/model-selection-policy', {
+      allowedModels: [{ provider: 1, model: 'fast-model' }],
+    } as never)
+    expect(() => subagentModelSelectionPolicy(malformed))
+      .toThrow('requires non-empty provider and model ids')
     await ctx.fiber.dispose()
   })
 
@@ -222,6 +229,40 @@ describe('SubagentModelSelectionConfig', () => {
     await ctx.fiber.dispose()
   })
 
+  it('releases a shared-preset installation reservation after policy selection fails', async () => {
+    const ctx = await boot()
+    const preset = createScope(ctx, { preset: 'standard' })
+    const other = createScope(ctx, { preset: 'minimal' })
+    await preset.ctx.plugin(tool, {
+      provider: 'spawn',
+      modelSelectionSettings: true,
+      backgroundMode: 'continuable',
+    })
+    let binding: ReturnType<typeof bindScopeParent> | undefined
+    const handle = await ctx.agents.create({
+      sessionId: SessionId('preset-policy-retry'),
+      setup: (agentCtx) => {
+        binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!)
+      },
+    })
+    expect(selectable(ctx, handle.agent)).toBe(false)
+
+    binding!.rebind(scopeOf(other.ctx)!)
+    ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
+    binding!.rebind(scopeOf(preset.ctx)!)
+    vi.spyOn(ctx.subagentModelSelection, 'current')
+      .mockImplementationOnce(() => { throw new Error('transient settings read') })
+      .mockReturnValue({ enabled: true, allowedModels: ALLOWED_MODELS })
+
+    expect(() => { ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') })
+      .toThrow('transient settings read')
+    ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change')
+    await vi.waitFor(() => { expect(selectable(ctx, handle.agent)).toBe(true) })
+
+    await handle.dispose()
+    await ctx.fiber.dispose()
+  })
+
   it('inherits the parent decision and preserves seeded decisions across composition', async () => {
     const ctx = await boot()
     await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, {

+ 4 - 0
packages/subagent/tool-subagent/tests/model-selection.spec.ts

@@ -38,6 +38,10 @@ describe('dsh-tool-subagent model selection', () => {
       .toThrow('requires non-empty provider and model ids')
     expect(() => { assertAllowedModelRoutes([{ provider: 'provider', model: '' }]) })
       .toThrow('requires non-empty provider and model ids')
+    expect(() => { assertAllowedModelRoutes({ provider: 'provider', model: 'model' }) })
+      .toThrow('requires an array of routes')
+    expect(() => { assertAllowedModelRoutes([{ provider: 1, model: 'model' }]) })
+      .toThrow('requires non-empty provider and model ids')
   })
 
   it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => {