diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 878ed45e35..5e0f82335f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index 768da30d26..9cdbdbec3b 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.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. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 9cf3033af0..3b81f54173 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.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 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 60d872ccda..803fcddb1f 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index af293dd2e7..3bb76eb894 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 973142f029..5defbd0ee0 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.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 diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index a041ed1669..4b25ae2871 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/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. diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index b2f24d4b8e..f9a86396a8 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/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 | 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 { - 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 { if (this.disposed || this.catalogStatus === 'loading') return const generation = this.catalogGeneration diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index a764446096..cf237667fc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/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) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 853bb502c9..4b1a8317e8 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/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() + 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() 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() 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() + 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() + 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() 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() diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 06c0fe9604..9a230d0026 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index beda3aec17..3e4970bff9 100644 --- a/packages/client/ui-settings/README.md +++ b/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 diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8d4fb0983c..a527dfe21a 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/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 diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 05b459c7a5..dd06600781 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -62,11 +62,14 @@ export interface SettingsScope { 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 + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 276adebb46..2d728e5154 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -120,13 +120,14 @@ export class SettingsScopeController implements SettingsScope { /** * 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 { + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise { 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> try { response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 883beee615..fe0d4013ee 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/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>() + 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))) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 30a5f148a1..3460ac3529 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/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 + 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 => { diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index b729d6d10b..5fc6342709 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/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 } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index 42ed6d3b7c..5f30b71742 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/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() - 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}"`) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index b5ecac11b8..4078a828b7 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/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 | 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, { diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 32072573fa..9137e2a9dd 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/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', () => {