feat(worker,card): Claude 自动降级模型时在会话卡片上提示 - #1249
Conversation
Claude Code 在安全管控拦截、模型不可用、额度确认三种情况下会自动把会话切到 备用模型,并往会话 JSONL 写一条 type:"system" 记录(model_refusal_fallback / model_fallback / model_consent_fallback)。切换后整个会话都留在备用模型上, 但 botmux 侧完全无感:卡片脚注的模型名走的还是启动配置,用户看不出这一轮是 谁在答。 本次把这个状态接到每轮更新的那张流式卡上——按用户要求贴在卡片里,不单发消息, 也不做自动切回,只在提示里给出手动 /model 指令。 - claude-transcript:新增 model_*_fallback 记录解析、assistant 记录的实际 服务模型提取(忽略 <synthetic> 占位、sidechain、API 错误行)、模型 id 归一 化(originalModel 带 [1m] 后缀而 message.model 不带),以及有界回扫 readLatestClaudeModelFallback,供 worker 冷启动补状态 - ClaudeModelFallbackTracker 收敛 uuid 去重、scope=local(子 agent 局部回退, 主会话模型未变)过滤,以及「切换之后才算数」的 serving model 规则 - worker:bridgeIngest 后观测并在判定变化时发新的 model_fallback IPC; startBridgeWatcher baseline 之后回扫 seed 一次(baseline 只 cursor 到 EOF, 历史降级记录不会再被 drain) - daemon:新 IPC case 带 worker 世代守卫,落到 DaemonSession.modelFallback 并 随 persistStreamCardState 持久化;状态搭 CardUsageSnapshot 的车进卡片, usageDisplay 关掉用量时也照常带(这是告警不是用量) - 卡片文案贴在限额提示之后,模型名用友好名(Fable 5.1 / Opus 4.8),/model 后跟 family 别名;zh / en 各三种文案 用户 /model 切回后,新的 assistant 记录模型不再等于备用模型,提示自动消失。 验证:npx tsc --noEmit -p . 干净;新增 4 个测试文件 57 例全绿,连同 streaming-card-usage-arg / card-builder / claude-transcript 等周边共 422 例通过; 全量 unit 套件失败项与改动前基线完全一致(11 个文件 42 例,均为缺 dist/tmux/bun 的环境性失败)。
评审复核降级提示时发现两处小问题,均已补测试: - worker:jsonl 在 attach 时还不存在、之后才出现的那条惰性 baseline 路径 (bridgeIngest 里 !bridgeBaselineDone 分支)没有补种降级状态。这条路径和 startBridgeWatcher 里的 baseline 一样会跳过文件里已有的记录——非 adopt 分支 游标直接到 EOF,adopt 分支虽然从 0 读但直接进 bridgeQueue.absorb,都不经过 observeModelFallback。结果是文件出现时里面已有的降级记录永远看不到。补一次 回扫即可;回扫按 uuid 去重,重复补种不会重复发布。 - 卡片:三个模型相关字段里只有两个显示名做了长度截断,`/model` 后面的参数没有, 未识别的超长 id 会把整行撑爆(实测 375 字符,而该行本来就按 200 字符设计)。 抽出 truncateModelToken 三处共用。真实 Claude 模型 id 最长 25 字符,均不受影响。 验证:npx tsc --noEmit -p . 干净;全量 npx vitest run --project unit 与改动前 基线失败项完全一致(11 个文件 42 例,均为缺 dist/tmux/bun 的环境性失败),通过数 20624 → 20626,即本次新增的 2 条用例。两条新用例均已做变异验证:回退修复后分别 报 375 > 200 与缺少 seed 调用。
两个问题:
1. 提示对所有 CLI、所有原模型都生效。Opus 5 → Opus 4.8 这种日常安全降级
也会弹提示,用户根本没用 Fable。
2. 提示会莫名消失。worker 冷启动回扫(4MB 上限)在长会话里找不到降级记录时
会把 null 发给 daemon,daemon 顺手清掉持久化状态;worker 换代时又无条件
清 ds.modelFallback 指望回扫补回来——补不回来就永久丢了。
改法是把职责分清:worker 只上报观测到的事实,daemon 持有状态并负责判断。
- IPC 从 { state: ModelFallbackState | null } 改成
{ fallback?, servingModel? }:worker 没有任何形状能表达「已清除」。
- daemon 合并:新 uuid 的降级记录覆盖旧的;只有主线程 assistant 记录的模型
与持有的 fallbackModel 不一致(归一化后比较,兼容 [1m] 后缀)才清除。
没记录、worker 重启、回扫窗口不够,一律保持原状。
- worker 换代只在会话不是 claude-code 时清除(那种 worker 不会发来纠正观测);
claude-code 会话以持久化状态为准。
- Fable 门下沉到 parseClaudeModelFallbackEvent:originalModel 归一化后不以
claude-fable 开头的记录直接不解析,observe / 回扫 / 后续所有消费者自动继承。
- worker 侧 observe / seed 显式判断 cliId === 'claude-code'。
渲染、文案、卡片位置、持久化字段均未改动。
验证:npx tsc --noEmit -p . 无错误;claude-model-fallback-transcript、
model-fallback-daemon-state、model-fallback-wiring、card-model-fallback-notice、
card-builder、card-builder-stop-compact、streaming-card-usage-arg、
card-integration 共 8 个文件 338 条用例全绿;全量 unit 跑完与改动前失败集合
逐一致(13 个文件为环境原因的既有失败,与本改动无关)。
评审发现两处问题: 1. tracker 的 uuid 去重把「重置 servingModel」一起跳过了。jsonl 切换 (worker.ts pid resolver 分支)和 baseline 自愈会把 bridgeOffset 归零, 下一次 ingest 会把整个文件当作一批重放。此时降级记录已是重复记录,被 `continue` 直接跳过,于是记录之前的那条旧 assistant 回复成了本批最新的 servingModel 被上报,daemon 读成「已切回」把提示清掉——正是「降级前的旧 回复不能触发切回」这条规则要挡的情况。改为先重置再去重。 2. mergeModelFallbackObservation 的 changed 反映的是「哪条规则命中」而不是 「状态是否变化」。冷启动回扫同时带回降级记录和一条已经换了模型的回复时, b、c 都命中,最终仍回到「无提示」,却仍然会走一次 scheduleActiveRuntimePatch, 白白改一次飞书卡片。改为按 uuid 比较前后状态。 补了两条回归测试:重放批次里的重复记录不再上报旧模型(且同批记录之后的回复 仍然上报);两条规则都命中但状态没变时 changed 为 false。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- trigger / apiRefusalCategory 与模型名走同一套处理:去换行和控制字符、 折叠空白、超长截断(原因 24 字、模型 32 字)再转义。这两个值是 transcript 里的原始 provider 字符串,多行或超长会把一行脚注撑坏。 - claude-opus-4-20250514 之前显示成「Opus 4.20250514」:次版本号限制为 两位,正则回退到日期分支,显示为「Opus 4」;claude-haiku-4-5-20251001 仍是「Haiku 4.5」。 - 顺带改掉两处过期的测试名:提示早已去掉切回文案,颜色是黄色不是灰色。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- 新增统一观测入口 observeModelFallbackEvents:adopt baseline、日志回放 恢复、fingerprint 切换、rotation 切换、drainPathInto、secondary 路径 轮询这些同样喂 bridgeQueue 的 drain 之前全都绕开了观测,从这些路径进来 的切换记录会被整个吞掉。现在按路径的 Claude 会话 id 分流:等于当前绑定 会话就观测;不等于但它是当前主路径则视为换会话,先重绑再观测;不等于 且是 secondary/旧路径则忽略(那是别的会话的尾巴)。 - 状态绑定 Claude 会话 id 并持久化。/repo、/adopt、resume 到另一条原生 会话都会换会话,旧状态既可能让新会话显示别人的提示,也可能在新会话实际 模型正好等于旧 fallback 时永远清不掉。每次绑定或切换主路径都从新路径 尾部回扫并必发一条带会话 id 的消息(回扫为空也发);daemon 收到会话 id 与所持状态不同的消息时先丢旧状态再套用消息内容,同一会话的空消息不改 状态,worker 重启不会清掉提示。 - 接受新的切换记录时一并重置已上报的 serving model:同一批 drain 里 「降级 → 换模型回复 → 用户切回 → 原模型回复」时,批末的原模型不再因为 和降级前相同被去重吞掉,否则提示会永久挂着。 - 解析保留所有 session 级切换记录,新增 fable / neutralizedByFork 标记。 之前非 Fable 记录直接被丢弃,尾部回扫会越过它继续找到更旧的 Fable 记录, 把用户早已切走的过期提示复活;fork 会话里复制过来的中和记录也没识别。 现在以最新一条 session 级记录为准,是非 Fable 或已被中和时用 fallback: null 表达「明确没有提示」,daemon 据此清除,与「没找到」区分开。 - 卡片用量行显示实际运行模型:Claude 从不发 active_runtime,用量行一直读 启动配置。daemon 收到 servingModel 后归一化写入 activeModel,沿用 active_runtime 的「变了才改、才打卡片补丁」语义,不动 reasoning effort; worker 重启后由冷启动回扫恢复。 - 切离 claude-code 时清除提示后补上落盘,否则 daemon 重启会让提示在已经 不跑 Claude 的会话上复活。 - 源码锁测试逐个切片断言:每个喂 bridgeQueue 的 drainTranscript 调用点都 必须经过统一观测入口,新增 drain 会直接让测试失败。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
复审两处漏网: - observe() 里遇到已上报过的 session 级记录时直接 continue,把同一批里 更早的那条记录留在了输出上。从 offset 0 重放整个文件的路径(fingerprint 切换、rotation 切换、drainTranscript 发现文件被截断后的重读)会同时带上 「早于本 worker baseline、因而从没进过观测的旧记录」和「已上报过的最新 记录」,结果把最新记录已经作废的旧提示又推给 daemon:非 Fable 的新记录 压不住旧 Fable 记录,两条 Fable 记录时显示的是旧那条的模型和原因。现在 已上报的记录同样会清掉本批更早的候选——不管新旧,最新一条永远说了算。 - pid 解析器切换(maybeFollowSessionRotationViaPid)是第五处会改主路径的 地方,之前没有回扫重绑。它换路径后把游标归零、自己不 drain,所以在新 transcript 还没有可观测内容之前,daemon 一直显示上一条会话的提示;同一 窗口里 secondary 轮询还会把旧会话的尾巴当成当前会话的证据。现在与另外 四处一样在切换后必发一条带会话 id 的回扫。 测试:tracker 层三条回归用例(已上报的非 Fable 记录压住更早的 Fable 记录、 已上报的 Fable 记录不被更早的顶掉、回扫后重放整个文件不产生过期记录); 源码锁改为五处必发,并锁住主路径赋值点的总数。 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
自动评审初步意见(双审收敛)感谢这个 PR,设计上有几处很扎实:worker 只报事实、daemon 持状态并只认正面证据清除,「没看到」不等于「已切回」;以及把非 Fable 记录和 fork 继承记录保留在解析结果里只打标记而不是解析时丢弃——避免倒扫跨过最新记录去复活更老的 Fable 记录。这些都是踩过坑才有的写法。 下面一条建议合入前处理,其余为非阻断观察。 F1(建议合入前修):漂移到「第三个模型」会让提示提前消失规则 (c) 的清除判据是「服务模型 ≠ 这不是推演,是本机真实 transcript 里发生过的( 已确认整个文件 后果是:会话此刻仍在 opus-4-8(既不是 Fable 也不是 fallbackModel),提示却已消失——退回到这个 PR 想解决的「用户不知道后续的活是别的模型干的」原状。 我们倾向认为这是 bug 而非产品取向:commit message「清除只认切回证据」与合入的文档注释都写明清除需要「切回」证据,而漂到第三个模型不是切回 Fable,规则 (c) 与自述意图不一致。 建议修法:判据从「≠ fallbackModel」改成「服务模型是 Fable 模型」,复用解析层同一谓词: if (serving && isFableModelId(serving)) {
next = undefined;
}(评审中也考虑过 现有 568 项测试没有覆盖这一情形——只测了「等于 fallbackModel(保留)」和「等于 originalModel(清除)」两种,建议补一条第三模型漂移的回归。 F2(非阻断):source-lock 钉住了闸的形状,钉不住它的位置把 daemon 里 实际风险很低:worker 侧 F3(非阻断,仅供参考):
|
F1:清除规则(c)原为「服务模型 ≠ fallbackModel ⟹ 清除」,但会话漂移到 第三个非 Fable 模型(真实 transcript be6da26c:opus-5 跨 resume 静默变 opus-4-8,无新切换记录)也满足该条件,提示被提前清除——而会话仍不在 Fable 上。改为「服务模型是 Fable ⟹ 清除」,复用解析层同一 isFableModelId 判据;相较 == originalModel 还能正确处理手动切到另一个 Fable 变体的情况。 F2:daemon 闸的 source-lock 只锁形状不锁位置,把闸搬到状态写入之后 48 项全绿。补 indexOf 相对顺序断言(闸 < merge < 写状态),变异验证 能抓住。 测试:新增 3 条 F1 回归(merge 级 + tracker→merge 全链路);4 个 fallback 测试文件 132/132;卡片/transcript 回归 7 文件 360/360;全量 unit 净回归 0 (9 项 root 只读前置基线红 + 2 项并行 flake,隔离复跑绿)。
|
补充说明:维护者已确认合入,我按评审结论把 F1/F2 的修补直接追加到了本分支( 由于 PR 勾选了「允许维护者修改」,这次是快进追加一个新 commit,你原有的 10 个 commit 未被改动、也没有 force-push, 追加内容:
在最新主干上复验: 感谢贡献 🙏 如果你对第 1 点的口径有不同看法,欢迎直接说,可以再调整。 |
deepcoldy
left a comment
There was a problem hiding this comment.
双审收敛,建议合入。
评审要点已在前两条评论中说明。F1(会话漂移到第三个模型时提示被提前清除)与 F2(source-lock 只锁形状不锁位置)的修补已追加到本分支 dc7fd4ec5。
在最新主干 278ac46a8 上复验:10 个 commit 零冲突,tsc 0 error、bun run build 通过;fallback 测试 132/132、主干新增改动的回归面 171/171、卡片与 transcript 回归 468/468。变异验证两枪均转红(还原 F1 判据 → 3 红;把守卫搬到状态写入之后 → 1 红),确认修补真承重。
感谢贡献。
|
🚀 Released in v3.19.1 |
改了什么
Claude Code 在会话中自动切换模型(Fable 安全管控触发的降级、模型不可用兜底、额度确认切换)时,botmux 现在会在本会话那张流式卡片的最底部贴一行黄色小字提示,例如:
⚠️ 安全管控降级:Fable 5.1 → Opus 4.8(cyber)⚠️ 模型不可用,本轮切换:Fable 5.1 → Opus 4.8(overloaded)⚠️ 额度限制已切换:Fable 5.1 → Opus 4.8提示随每一轮卡片刷新持续显示,只有识别到会话已切回(降级之后的主线程回复来自其它模型)、或出现更新的切换记录、或换到了另一条 Claude 会话时才消失。不发单独消息,不自动切回。
同时修了一个旧问题:Claude 会话的卡片用量行里模型名一直取启动配置,降级后显示的是错的;现在改为取回复记录里的实际服务模型(
ds.activeModel)。为什么
Fable 的安全分类器命中后,Claude Code 会把整个会话切到 Opus 4.8 并停留在那里直到手动
/model,飞书侧此前完全看不出来。本机会话记录里 7 月以来有 16 次这样的静默降级,用户在飞书上并不知道后续的活是别的模型干的。实现方式
type=system,subtype为model_refusal_fallback/model_fallback/model_consent_fallback),以及type=assistant记录的message.model。src/worker.ts+src/services/claude-transcript.ts):新的 session 级切换记录、当前回复的服务模型、绑定的 Claude 会话 id。所有喂bridgeQueue的 transcript drain 路径(主路径、fingerprint/rotation 切换、pid 跟随切换、secondary 补读)都经过同一个观测入口,按路径的会话 id 分流;冷启动/换会话时从文件尾部有界回扫(4MB)补种。src/core/worker-pool.tsmergeModelFallbackObservation):只在三种正面证据下清除——会话 id 不符、更新的记录不是 Fable 降级(含 fork 复制过来的neutralizedByFork记录)、切换之后的回复模型 ≠ 降级模型。空回扫、worker 换代、daemon 重启都不会清除。状态随persistStreamCardState持久化到Session.modelFallback。cliId = claude-code的会话生效;只有原模型是 Fable 的记录才产生提示;scope=local(子 agent 局部回退)忽略。src/im/lark/card-builder.ts、md-card.ts):卡片最后一个元素,text_size: x-small,<font color='yellow'>;模型名转友好名(claude-fable-5-1[1m]→Fable 5.1,日期后缀不会被当成次版本号);原因去控制字符、单行、限长 24。影响面评估
src/types.ts(ModelFallbackState、WorkerToDaemon新增model_fallback消息)、src/core/types.ts(DaemonSession.modelFallback)、src/core/worker-pool.ts(新 IPC case、换代清理、用量快照)、src/core/session-manager.ts(持久化脏检查与 restore 回填)、src/im/lark/card-builder.ts/md-card.ts(流式卡片)、src/i18n/zh.ts/en.ts。bridgeIngest及其同族 drain)上,worker 与 daemon 两侧都加了claude-code门;Codex / TRAE / 其它 CLI 的 bridge 代码未动,跑了 codex / traex / hermes / existing-app-server 的 bridge wiring 与 codex-active-runtime-wiring 测试确认无回归。实际测试验证
在本分支(基于
origin/mastere7c8cf8)上:新增 4 个测试文件覆盖:记录解析与 Fable 门、tracker 的去重/切回/会话绑定、尾部回扫、daemon 合并规则(保留/清除/会话切换/activeModel)、卡片渲染(三种文案 zh/en、位置、字号颜色、清除后元素数)、以及 worker 各 drain 路径接入观测入口的源码锁。
卡片示意
卡片底部(用量行与按钮行之后)新增一行:
用真实降级状态渲染出的卡片已在飞书里预览确认过样式;生产链路上的端到端(真实 Fable 安全管控触发)尚未在线上 daemon 验证,建议合并后第一次真触发时看一眼。
已知限制
/model切回后,要等下一条回复出来提示才消失(Claude 只在回复记录里留模型信息,/model本身只有一行人类可读的 local_command 记录)。suppressRecoveryCard)沿用所有运行时补丁共用的抑制,状态已落盘,下一次活动刷新卡片。