主要变更: - Skill Learning 闭环系统 (9/9 AC) - Opus 4.7 模型层接入 + adaptive thinking - Prompt 工程优化 (64 审计测试) - Agent Teams 简化门控 (默认启用) - Windows Terminal 后端修复 (EncodedCommand/WT_SESSION) - TF-IDF 技能搜索精准化 (字段加权/CJK 优化) - Autonomy 系统 (/autonomy 命令) - ACP 协议完整实现 - mock.module 泄漏修复 (CI 全绿) - 152+ lint/type 修复
12 KiB
Bug: cachedMicrocompact 缓存编辑实现存在 5 个问题
背景
分支 chore/lint-cleanup 将 src/services/compact/cachedMicrocompact.ts 从全 stub(no-op)改为真实实现。该模块负责 Cached Microcompact(缓存编辑)功能:在对话过程中,通过 API 的 cache_edits 机制删除旧的 tool result,避免重新发送完整 prompt 前缀,从而节省 token 和成本。
当前因问题 3 和问题 4 的阻断,这些 Bug 在运行时不会触发。但一旦启用 feature flag,问题 1 会立即暴露。
问题 1:deletedRefs 从未被填充(关键 Bug)
严重级别:CRITICAL
问题描述
getToolResultsToDelete() 返回待删除的 tool ID 列表,但既不在函数内部,也不在调用方 cachedMicrocompactPath() 中将这些 ID 添加到 state.deletedRefs。
涉及文件
| 文件 | 行号 | 角色 |
|---|---|---|
src/services/compact/cachedMicrocompact.ts |
87-93 | getToolResultsToDelete — 返回待删除 ID,但不更新 deletedRefs |
src/services/compact/microCompact.ts |
332-339 | cachedMicrocompactPath — 调用 getToolResultsToDelete 后不更新 deletedRefs |
src/services/compact/__tests__/cachedMicrocompact.test.ts |
78-92 | 测试用例手动填充 deletedRefs,掩盖了生产代码中的缺失 |
当前代码
cachedMicrocompact.ts:87-93:
export function getToolResultsToDelete(state: CachedMCState): string[] {
const { triggerThreshold, keepRecent } = getCachedMCConfig()
const active = state.toolOrder.filter(id => !state.deletedRefs.has(id))
if (active.length <= triggerThreshold) return []
const toDelete = active.slice(0, active.length - keepRecent)
return toDelete
// ← 缺失:没有将 toDelete 添加到 state.deletedRefs
}
microCompact.ts:332-339(调用方):
const toolsToDelete = mod.getToolResultsToDelete(state)
if (toolsToDelete.length > 0) {
const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
if (cacheEdits) {
pendingCacheEdits = cacheEdits
}
// ← 缺失:没有将 toolsToDelete 标记为已删除
}
后果
- 重复删除:每次 API 调用都会重复返回相同的 tool ID 进行删除
- 统计失真:
activeToolCount计算为state.toolOrder.length - state.deletedRefs.size,但deletedRefs.size永远为 0 - API 浪费:重复的
cache_edits请求增加请求体大小
测试文件如何掩盖此问题
__tests__/cachedMicrocompact.test.ts:78-92:
test('already deleted tools are not suggested again', () => {
// ... 注册 12 个 tool
const first = getToolResultsToDelete(state)
// 测试手动模拟删除——生产代码中没有等价操作
for (const id of first) {
state.deletedRefs.add(id) // ← 只在测试中手动做了
}
const second = getToolResultsToDelete(state)
// 验证不会重复建议——但前提是 deletedRefs 被正确填充
})
修复方案
方案 A(推荐):在 getToolResultsToDelete 内部标记
cachedMicrocompact.ts:
export function getToolResultsToDelete(state: CachedMCState): string[] {
const { triggerThreshold, keepRecent } = getCachedMCConfig()
const active = state.toolOrder.filter(id => !state.deletedRefs.has(id))
if (active.length <= triggerThreshold) return []
const toDelete = active.slice(0, active.length - keepRecent)
// 标记为已删除,防止下次重复返回
for (const id of toDelete) {
state.deletedRefs.add(id)
}
return toDelete
}
方案 B:在调用方标记
microCompact.ts 的 cachedMicrocompactPath 中:
const toolsToDelete = mod.getToolResultsToDelete(state)
if (toolsToDelete.length > 0) {
// 标记已删除
for (const id of toolsToDelete) {
state.deletedRefs.add(id)
}
const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
// ...
}
推荐方案 A:将副作用收敛在模块内部,调用方不需要关心内部状态管理。
测试修复
现有测试的手动 deletedRefs.add 应该被删除,改为验证 getToolResultsToDelete 自动填充:
test('already deleted tools are not suggested again', () => {
for (let i = 0; i < 12; i++) {
registerToolResult(state, `tool-${i}`)
}
const first = getToolResultsToDelete(state)
// 不需要手动 add — getToolResultsToDelete 应该已经标记了
expect(first.length).toBeGreaterThan(0)
for (const id of first) {
expect(state.deletedRefs.has(id)).toBe(true)
}
const second = getToolResultsToDelete(state)
for (const id of first) {
expect(second).not.toContain(id)
}
})
问题 2:两个同名 getCachedMCConfig 导出,签名冲突
严重级别:MEDIUM
问题描述
两个不同文件导出同名函数 getCachedMCConfig,但类型签名和用途完全不同:
| 文件 | 返回类型 | 用途 | 调用方 |
|---|---|---|---|
cachedMCConfig.ts(stub) |
{ enabled?, systemPromptSuggestSummaries?, supportedModels?, [key: string]: unknown } → {} |
系统 prompt 配置 | prompts.ts:70 |
cachedMicrocompact.ts(新实现) |
{ triggerThreshold: 10, keepRecent: 5 } |
微压缩阈值配置 | claude.ts:1212、microCompact.ts:311 |
后果
- 命名混淆:同一个名字在不同上下文意味完全不同的东西
claude.ts:1226读取不存在的字段:const config = getCachedMCConfig() // 从 cachedMicrocompact.ts 导入 logForDebugging( `... supportedModels=${jsonStringify((config as Record<string, unknown>).supportedModels)}` // ^^^^^^^^^^^^^^^^ 新实现中不存在此字段,永远输出 undefined )
修复方案
将 cachedMicrocompact.ts 中的函数重命名为 getCachedMicrocompactConfig,或将 cachedMCConfig.ts 的重命名为 getCachedMCFeatureConfig,消除歧义。同步更新所有调用方。
问题 3:CACHE_EDITING_BETA_HEADER 为空字符串——当前分支已修复(三层防御)
严重级别:HIGH → 已修复(INFO)
原始问题
src/constants/betas.ts:50:
export const CACHE_EDITING_BETA_HEADER: string = '';
上游(origin/main)的代码中,cacheEditingHeaderLatched 为 true 时会无条件 push 空字符串到 betas 数组,导致 API 请求中出现无效的 anthropic-beta header(如 "a,b," 或 "a,,b"),触发 API 400 错误。
当前分支的三层修复
当前分支已包含完整的三层防御,通过 git diff origin/main HEAD -- src/services/api/claude.ts 可以确认:
第 1 层:cachedMCEnabled 入口增加 headerAvailable 检查
claude.ts:1218-1223(本分支新增):
// cachedMC requires a non-empty beta header; the CACHE_EDITING_BETA_HEADER
// constant is '' in this fork (upstream hasn't published the real value).
// Without it, cache_reference and cache_edits in the request body cause
// API 400: "tool_result.cache_reference: Extra inputs are not permitted".
const headerAvailable = !!cacheEditingBetaHeader
cachedMCEnabled = featureEnabled && modelSupported && headerAvailable
上游原始代码为:cachedMCEnabled = featureEnabled && modelSupported(无 header 检查)。
第 2 层:latch push 增加 truthy 检查
claude.ts:1731-1732(本分支新增 cacheEditingBetaHeader &&):
if (
cacheEditingHeaderLatched &&
cacheEditingBetaHeader && // ← 本分支新增:空字符串不 push
getAPIProvider() === 'firstParty' &&
options.querySource === 'repl_main_thread' &&
!betasParams.includes(cacheEditingBetaHeader)
) {
betasParams.push(cacheEditingBetaHeader)
}
上游原始代码缺少 cacheEditingBetaHeader && 这行,导致 latch 生效时空字符串被 push。
第 3 层:最终过滤(兜底防御)
claude.ts:1749-1753(本分支新增):
// Filter out any empty-string beta headers before sending.
// Constants like CACHE_EDITING_BETA_HEADER or AFK_MODE_BETA_HEADER
// can be '' when their feature gate is off; an empty string in the
// betas array produces an invalid anthropic-beta header (400 error).
const filteredBetas = betasParams.filter(Boolean)
lastRequestBetas = filteredBetas
上游原始代码直接 lastRequestBetas = betasParams,无过滤。
测试覆盖
src/services/api/__tests__/betaHeaders.test.ts 包含完整的验证:
| 测试 | 验证点 |
|---|---|
known potentially-empty constants are identified |
确认 CACHE_EDITING_BETA_HEADER === '',Boolean 检查为 false |
truthy check correctly gates empty beta headers |
模拟 truthy 检查阻止空 header push |
simulates full header pipeline with all fixes |
模拟三层防御完整管道,验证空 header 不泄漏 |
simulates the bug scenario WITHOUT fix |
重现修复前 bug:空字符串被 push → toString() 产生无效逗号 |
useBetas flag correctly handles empty-after-filter |
验证全部 betas 为空时 filter 后不发送 |
当前状态
此问题已完全修复,无需额外操作。 当 Anthropic 公开 cache editing 的 beta header 值后,只需更新 betas.ts:50 的常量值即可,三层防御逻辑无需改动。
问题 4:Feature Flag 未注册(当前为死代码)
严重级别:INFO
问题描述
CACHED_MICROCOMPACT 不在 build.ts 或 scripts/defines.ts 的 feature 列表中。
当前 build 默认 features(19 个):
BUDDY, TRANSCRIPT_CLASSIFIER, BRIDGE_MODE, AGENT_TRIGGERS_REMOTE,
CHICAGO_MCP, VOICE_MODE, SHOT_STATS, PROMPT_CACHE_BREAK_DETECTION,
TOKEN_BUDGET, AGENT_TRIGGERS, ULTRATHINK, BUILTIN_EXPLORE_PLAN_AGENTS,
LODESTONE, EXTRACT_MEMORIES, VERIFICATION_AGENT, KAIROS_BRIEF,
AWAY_SUMMARY, ULTRAPLAN, DAEMON
CACHED_MICROCOMPACT 不在其中。feature('CACHED_MICROCOMPACT') 在构建和 dev 模式下都返回 false。
后果
cachedMicrocompact.ts 的所有真实实现是不可达代码。cachedMicrocompactPath 永远不会被执行。
修复方案
这是设计选择而非 Bug。当问题 1 和问题 3 修复后,可以将 CACHED_MICROCOMPACT 添加到 build defines 的 P1 或 P2 列表中启用。
问题 5:isModelSupportedForCacheEditing 正则过于宽泛
严重级别:LOW
问题描述
cachedMicrocompact.ts:34:
export function isModelSupportedForCacheEditing(model: string): boolean {
return /claude-[a-z]+-4[-\d]/.test(model)
}
该正则匹配任何 Claude 4.x 模型,包括 claude-haiku-4-5。但 cache editing 是 API 层面的特殊功能,可能只有 Opus/Sonnet 支持,Haiku 未必支持。
后果
如果 Haiku 不支持 cache editing,在 Haiku 模型下启用此功能会导致 API 错误。
修复方案
根据 API 文档精确限定支持的模型:
export function isModelSupportedForCacheEditing(model: string): boolean {
return /claude-(opus|sonnet)-4[-\d]/.test(model)
}
或者在上游明确支持的模型列表可用后,改为白名单匹配。
修复优先级
| 优先级 | 问题 | 状态 | 原因 |
|---|---|---|---|
| P0 | 问题 1:deletedRefs 未填充 |
待修复 | 启用后立即导致重复删除的逻辑 Bug |
| 已修复 ✓ | 当前分支已包含三层防御 + 测试覆盖 | ||
| P2 | 问题 2:同名函数冲突 | 待修复 | 增加维护混淆风险 |
| P3 | 问题 4:feature flag 未注册 | 设计选择 | 问题 1 修复后可按需启用 |
| P3 | 问题 5:正则过宽 | 待确认 | 低风险,待 API 文档确认 |
验证步骤
问题 1 修复后验证
# 运行现有测试(应该在修复 getToolResultsToDelete 后仍然通过)
bun test src/services/compact/__tests__/cachedMicrocompact.test.ts
# 新增测试验证:getToolResultsToDelete 自动填充 deletedRefs
# 1. 注册 12 个 tool
# 2. 调用 getToolResultsToDelete → 返回 7 个
# 3. 验证 state.deletedRefs.size === 7
# 4. 再次调用 getToolResultsToDelete → 返回 0(因为 active 只剩 5 个,低于阈值 10)
问题 3 修复后验证
# 设置环境变量启用缓存编辑
FEATURE_CACHED_MICROCOMPACT=1 CLAUDE_CACHED_MICROCOMPACT=1 bun run dev
# 观察 debug 日志中的 Cached MC gate 输出
# 确认 headerAvailable=true(需要 beta header 有值)
# 确认 cachedMCEnabled=true
全流程验证
# 完整测试
bun test src/services/compact/__tests__/cachedMicrocompact.test.ts
bun run typecheck
bun run test:all