fix: address subagent model selection review

This commit is contained in:
Dudu-0223
2026-08-27 12:00:02 +08:00
parent 9fae988691
commit 3a146064a4
21 changed files with 287 additions and 40 deletions
@@ -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
@@ -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.
@@ -48,7 +48,7 @@ Status: implemented
## 结果
- 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。
- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。
- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。
- 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。
- 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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)
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
@@ -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
@@ -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)
@@ -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
View File
@@ -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 => {
@@ -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
}
@@ -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}"`)
@@ -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, {
@@ -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', () => {