mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix: address subagent model selection review
This commit is contained in:
+2
-2
@@ -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
-2
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
+38
-3
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user