Issue 6 · P0 · bug
[client-ui-model-selection] 当前模型“不可路由”时锁死 composer;catalog 进入 error/loading 后旧 routable:false 不清除,发送按钮只能靠刷新恢复
组件 @deepseek-ai/dsh-client-ui-model-selection(每个会话的模型目录 / composer 锁定)
版本 0.1.2-rc.1(安装 bundle,2026-09-05 复核;上游 HEAD 待确认)
严重度 高:发送框被禁用且点击无反应,用户必须刷新或重选模型才能继续;子代理(subagent)越多、模型 catalog 刷新越频繁时越容易命中。
证据 dsh-client-ui-model-selection/lib/client.js:202-234(syncInputs)、:295-325(directoryFor + composer block 发布);消费端 dsh-client-ui-conversation/lib/client.js:15365,15368,15683
用户可感知现象
输入框还在,但发送按钮恒灰、点不动;按 Enter 也不发(机器进入/保持 busy 或 input 被 block)。
底部模型下拉仍然可以点 (代码刻意让“消息输入”失效但保留模型选择)。输入框占位可能显示「当前模型不可用,请先选择模型」。
刷新整页或重选一次模型后立刻恢复;在飞 subagent 多、网络/凭据/catalog 抖动时反复出现。
一句话根因(先给结论)
每个会话都有一个“当前模型是否可路由”的状态字段 routable:false 表示选中的模型不可用,此时输入被锁;null 表示“还没算出来”,按设计不锁 。问题出在:一旦某会话算过一次 routable:false,之后模型目录(catalog)进入 loading/error 时,syncInputs 直接 return,不把旧的 routable:false 重置为 null 。于是 composer block 永久残留,直到刷新重建整个 directory。
源码走查(最小充分证据)
复制 // dsh-client-ui-model-selection/lib/client.js (0.1.2-rc.1 反混淆,行号对应源)
private syncInputs() {
if (this.disposed) return
const catalog = this.catalog.store.getSnapshot()
const projected = modelSelectionProjection(this.projected.getSnapshot())
if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
if (this.resolved) {
if (catalog.status === 'error') this.store.update((state) => {
state.status = 'error'
state.error = catalog.error
})
return // ← BUG:曾 resolved,保留旧的 routable:false,block 不清
}
this.store.set({ current: null, routable: null /* not blocked */, groups: [], failures: [], status: catalog.status === 'error' ? 'error' : 'loading', error: catalog.error })
return
}
const current = projected.next ?? catalog.value.default
this.resolved = true
this.store.set({
current,
routable: catalog.value.routableProviders.includes(current.provider), // false 时下面会 block
groups: catalog.value.groups, failures: catalog.value.failures,
status: this.store.getSnapshot().status === 'selecting' ? 'selecting' : 'ready', error: null
})
}
// 同一文件 directoryFor():routable === false 才注册 composer block
const publish = () => {
conversation.blocks.set(sessionId,
directory.store.getSnapshot().routable === false ? { reason: this.blockReason() } : void 0)
}
publish()
actx.effect(() => { const stop = directory.store.subscribe(publish); ... })
// 消费端 dsh-client-ui-conversation/lib/client.js
const disabled = removed || inert || !live || blocked !== void 0 || parentOffline // :15365
const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' // :15368
// 发送按钮 disabled: empty || disabled || machineBusy // :15683
类型定义本身写明了正确语义(lib/types/client/directory.d.ts):routable: null “是还没首次加载,不等于被 blocked ;请以 === false 判断”。也就是说,catalog 不健康时把 routable 归 null 解锁,完全符合原设计。
复现步骤
在一个会话把当前模型切成一个可能不在 routableProviders 里的模型(例如实验模型 deepseek-v4-flash-vision-exp-dev0),让目录算得 routable:false、composer 被 block。
让全局模型 catalog 下一次刷新失败或进入 loading(断一下凭据/远端/网络,或压测制造 catalog 抖动)。观察会话的状态变成 status:error,但 routable 仍是 false。
回到该会话:发送框禁用;模型下拉可点;选别的模型或刷新后恢复。
在大量并发 subagent 场景重复:每个子会话各持一份 directory,catalog 抖动窗口内很容易有会话被永久锁住。
期望 vs 实际
期望: 模型可用性“未知”不是“不可用”。当 catalog 进入 loading/error、或映射(projection)暂时缺失时,应清除旧的 block(routable = null),让用户仍能输入/排队;不可用状态应只在 catalog ready 且明确 false 时成立。实际: 早期 return 保留了陈旧的 routable:false,block 粘死到整页刷新。
建议补丁(最小、低风险)
把 syncInputs 里“已 resolved 且 catalog 非 ready”的早退分支,改成把 routable 重置为 null(同时保留 error/loading 展示)。用 store.update 只动可用性三字段,不动已加载的 current/groups/failures ,避免闪空模型列表:
复制 --- a/packages/client/ui-model-selection/src/client/directory.ts
+++ b/packages/client/ui-model-selection/src/client/directory.ts
@@ -202,13 +202,17 @@ export class ModelDirectory {
const catalog = this.catalog.store.getSnapshot()
const projected = modelSelectionProjection(this.projected.getSnapshot())
if (catalog.status !== 'ready' || catalog.value === null || projected === undefined) {
if (this.resolved) {
- if (catalog.status === 'error') this.store.update((state) => {
- state.status = 'error'
- state.error = catalog.error
- })
+ // Availability is unknown again: null is "not blocked", never false.
+ // Keep last-good current/groups/failures so the selector does not flash empty.
+ this.store.update((state) => {
+ state.routable = null
+ state.status = catalog.status === 'error'
+ ? 'error'
+ : 'loading'
+ state.error = catalog.status === 'error' ? (catalog.error ?? null) : null
+ })
return
}
this.store.set({
current: null,
routable: null,
为什么安全: composer block 的发布函数只认 routable === false(见上);改成 null 会触发订阅、publish 把 block 清成 void 0。catalog 稍后回到 ready 时会重新计算真实的 routable 并再次按需 block。没有放开真正不可用的模型:catalog.status === 'ready' 且 provider 不在白名单时仍为 false,照锁不误。
可选的第二处加固(同目录,非必须)
directoryFor() 里先做一次 publish() 再注册 store.subscribe(publish),两步之间理论上存在一次竞态;订阅注册放在初始发布之前,或让初始发布也走幂等封装,可消除该窗口(blocks.set 已按 reason 去重,所以影响小于主 bug)。
验证 / 回归测试建议
正面(负例对照): 构造目录:先 ready 算出 routable:false(断言 composer 被 block),再把 catalog 置为 error/loading,断言 store routable === null 且 block 被清除。
真不可用仍锁: catalog ready 且 provider 不在 routableProviders,断言 routable === false 且 composer 仍 block。
恢复: catalog 重新 ready 且 provider 进入白名单,断言解锁;provider 移出则重新锁。
浏览器侧:在输入框有文字时复现,修复后发送按钮应立即可用;可用 DevTools 看输入框占位是否仍为「当前模型不可用」。
Workaround(当前生产) 复现时先在底部模型下拉重选一次当前模型;若模型菜单因 catalog error 打不开,则只能刷新页面。这是本 issue 的直接止损,不替代上游修复。
与 approval “ask→never” 无关: 曾怀疑是审批/问题面板的 composer 接管残留,但实测安装包中接管只在有 pending interaction 时生效,且审批改成 never 后没有 pending。根因在模型可用性字段的陈旧值,不在审批链路。