mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
feat: 整合功能恢复与技能学习闭环(含 ECC v2.1 parity + Opus 4.7 接入 + prompt 工程优化)
主要变更: - 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 修复
This commit is contained in:
350
docs/bugs/cached-microcompact-issues.md
Normal file
350
docs/bugs/cached-microcompact-issues.md
Normal file
@@ -0,0 +1,350 @@
|
||||
# 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`:
|
||||
```typescript
|
||||
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`(调用方):
|
||||
```typescript
|
||||
const toolsToDelete = mod.getToolResultsToDelete(state)
|
||||
if (toolsToDelete.length > 0) {
|
||||
const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete)
|
||||
if (cacheEdits) {
|
||||
pendingCacheEdits = cacheEdits
|
||||
}
|
||||
// ← 缺失:没有将 toolsToDelete 标记为已删除
|
||||
}
|
||||
```
|
||||
|
||||
### 后果
|
||||
|
||||
1. **重复删除**:每次 API 调用都会重复返回相同的 tool ID 进行删除
|
||||
2. **统计失真**:`activeToolCount` 计算为 `state.toolOrder.length - state.deletedRefs.size`,但 `deletedRefs.size` 永远为 0
|
||||
3. **API 浪费**:重复的 `cache_edits` 请求增加请求体大小
|
||||
|
||||
### 测试文件如何掩盖此问题
|
||||
|
||||
`__tests__/cachedMicrocompact.test.ts:78-92`:
|
||||
```typescript
|
||||
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`:
|
||||
```typescript
|
||||
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` 中:
|
||||
```typescript
|
||||
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` 自动填充:
|
||||
|
||||
```typescript
|
||||
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` |
|
||||
|
||||
### 后果
|
||||
|
||||
1. **命名混淆**:同一个名字在不同上下文意味完全不同的东西
|
||||
2. **`claude.ts:1226` 读取不存在的字段**:
|
||||
```typescript
|
||||
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`:
|
||||
```typescript
|
||||
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`(本分支新增):
|
||||
```typescript
|
||||
// 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 &&`):
|
||||
```typescript
|
||||
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`(本分支新增):
|
||||
```typescript
|
||||
// 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`:
|
||||
```typescript
|
||||
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 文档精确限定支持的模型:
|
||||
```typescript
|
||||
export function isModelSupportedForCacheEditing(model: string): boolean {
|
||||
return /claude-(opus|sonnet)-4[-\d]/.test(model)
|
||||
}
|
||||
```
|
||||
|
||||
或者在上游明确支持的模型列表可用后,改为白名单匹配。
|
||||
|
||||
---
|
||||
|
||||
## 修复优先级
|
||||
|
||||
| 优先级 | 问题 | 状态 | 原因 |
|
||||
|--------|------|------|------|
|
||||
| P0 | 问题 1:`deletedRefs` 未填充 | **待修复** | 启用后立即导致重复删除的逻辑 Bug |
|
||||
| ~~P1~~ | ~~问题 3:beta header 为空~~ | **已修复** ✓ | 当前分支已包含三层防御 + 测试覆盖 |
|
||||
| P2 | 问题 2:同名函数冲突 | **待修复** | 增加维护混淆风险 |
|
||||
| P3 | 问题 4:feature flag 未注册 | **设计选择** | 问题 1 修复后可按需启用 |
|
||||
| P3 | 问题 5:正则过宽 | **待确认** | 低风险,待 API 文档确认 |
|
||||
|
||||
## 验证步骤
|
||||
|
||||
### 问题 1 修复后验证
|
||||
|
||||
```bash
|
||||
# 运行现有测试(应该在修复 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 修复后验证
|
||||
|
||||
```bash
|
||||
# 设置环境变量启用缓存编辑
|
||||
FEATURE_CACHED_MICROCOMPACT=1 CLAUDE_CACHED_MICROCOMPACT=1 bun run dev
|
||||
|
||||
# 观察 debug 日志中的 Cached MC gate 输出
|
||||
# 确认 headerAvailable=true(需要 beta header 有值)
|
||||
# 确认 cachedMCEnabled=true
|
||||
```
|
||||
|
||||
### 全流程验证
|
||||
|
||||
```bash
|
||||
# 完整测试
|
||||
bun test src/services/compact/__tests__/cachedMicrocompact.test.ts
|
||||
bun run typecheck
|
||||
bun run test:all
|
||||
```
|
||||
158
docs/bugs/context-management-analysis.md
Normal file
158
docs/bugs/context-management-analysis.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Context Management 双机制深度分析
|
||||
|
||||
## 概述
|
||||
|
||||
项目中存在两套上下文管理机制,它们**不是独立的平行系统**,而是不同层次的互补机制,可以同时注入到同一个 API 请求中。
|
||||
|
||||
## 两套机制对比
|
||||
|
||||
### cachedMicrocompact(`cache_edits` 机制)
|
||||
|
||||
- **文件**: `src/services/compact/cachedMicrocompact.ts` + `src/services/compact/microCompact.ts:276-286`
|
||||
- **运行阶段**: API 调用**之前**,在 `query.ts:457` 中通过 `microcompactMessages()` 执行
|
||||
- **注入方式**: 在 `addCacheBreakpoints()`(`claude.ts:3149-3298`)中嵌入消息体内部:
|
||||
- 给 tool_result 添加 `cache_reference: tool_use_id`(第 3253-3294 行)
|
||||
- 将 `cache_edits` block 插入用户消息(第 3228-3247 行)
|
||||
- 历史 pinned edits 重新插入原位置(第 3213-3225 行)
|
||||
- **核心价值**: **保留 prompt cache 前缀不失效**。通过 cache 层操作删除指定 tool result,不触发完整前缀重写
|
||||
- **触发条件**: 工具计数超阈值(默认 10 个,客户端维护 `CachedMCState`)
|
||||
- **状态管理**: 有状态——`registeredTools`、`deletedRefs`、`pinnedEdits`。后续请求必须重发历史删除
|
||||
- **适用场景**: **缓存热**(频繁交互,缓存 TTL 内)
|
||||
- **当前状态**: 未发布的内部 API,`CACHE_EDITING_BETA_HEADER = ''`,`CACHED_MICROCOMPACT` feature flag 未注册
|
||||
|
||||
### apiMicrocompact(`context_management` 公开 API)
|
||||
|
||||
- **文件**: `src/services/compact/apiMicrocompact.ts`
|
||||
- **运行阶段**: 构建 API 请求参数**时**,在 `claude.ts:1684` 的 `paramsFromContext` 内调用
|
||||
- **注入方式**: 作为顶层字段 `context_management: { edits: [...] }` 发送(`claude.ts:1775-1779`)
|
||||
- **核心价值**: **声明式策略配置**——告诉 API "超过 X token 时自动清理最旧的 tool result"
|
||||
- **触发条件**: Token 超阈值(服务端评估,默认 180K input tokens)
|
||||
- **状态管理**: 无状态——每次请求独立声明策略
|
||||
- **缓存行为**: **会失效 prompt cache 前缀**(Anthropic 文档:"Invalidates cached prompt prefixes when content is cleared")。需要 `clear_at_least` 参数确保清理量值得缓存失效代价
|
||||
- **适用场景**: **缓存冷或阈值兜底**(不在乎缓存失效)
|
||||
- **当前状态**: 已发布公开 API,使用 `context-management-2025-06-27` beta header(已在项目中定义)
|
||||
|
||||
## 调用时序
|
||||
|
||||
```
|
||||
用户发消息
|
||||
│
|
||||
├─ query.ts:457 → microcompactMessages()
|
||||
│ ├─ ① time-based MC(缓存冷时 content-clear,短路退出)
|
||||
│ └─ ② cachedMicrocompact(缓存热时 cache_edits,不修改消息内容)
|
||||
│ └→ 排队 pendingCacheEdits
|
||||
│
|
||||
└─ claude.ts:paramsFromContext()
|
||||
├─ 消费 pendingCacheEdits → consumedCacheEdits
|
||||
├─ getAPIContextManagement() → contextManagement
|
||||
└─ 构建请求体:
|
||||
├─ messages: addCacheBreakpoints(..., useCachedMC, consumedCacheEdits, pinnedEdits)
|
||||
│ └→ cache_reference + cache_edits 嵌入消息内部
|
||||
└─ context_management: contextManagement
|
||||
└→ 顶层字段,声明式策略
|
||||
```
|
||||
|
||||
**互斥关系**:
|
||||
- time-based MC 触发时**跳过** cachedMC(`microCompact.ts:264-266`:"Cached MC is skipped when this fires: editing assumes a warm cache")
|
||||
- cachedMC 和 apiMC **可以同时生效**——分别注入到消息内部和顶层字段
|
||||
|
||||
## 协作设计意图
|
||||
|
||||
两者的设计是**分层互补**:
|
||||
|
||||
1. **cachedMC(热缓存优化)**: 在缓存有效期内(~5 分钟),精细删除单个 tool result,**零缓存失效代价**。适合频繁交互的场景。
|
||||
2. **apiMC(阈值兜底)**: 当 input token 超过阈值时,由服务端批量清理。**代价是缓存失效**,但确保不会超限。
|
||||
3. **time-based MC(冷缓存兜底)**: 当空闲超时导致缓存过期时,客户端直接 content-clear 消息体,为重写缓存做准备。
|
||||
|
||||
## 当前门控限制
|
||||
|
||||
### cachedMicrocompact 门控
|
||||
|
||||
| 门控 | 位置 | 值 | 影响 |
|
||||
|------|------|-----|------|
|
||||
| `feature('CACHED_MICROCOMPACT')` | `microCompact.ts:276` | `false`(未注册) | 整条路径不可达 |
|
||||
| `CLAUDE_CACHED_MICROCOMPACT=1` | `cachedMicrocompact.ts:27` | 未设置 | 启用检查失败 |
|
||||
| `CACHE_EDITING_BETA_HEADER` | `betas.ts:50` | `''`(空) | API 层 `cachedMCEnabled=false` |
|
||||
|
||||
### apiMicrocompact 门控
|
||||
|
||||
| 门控 | 位置 | 值 | 影响 |
|
||||
|------|------|-----|------|
|
||||
| `USER_TYPE=ant` | `apiMicrocompact.ts:90` | 非 ant | tool clearing 不触发 |
|
||||
| `USE_API_CLEAR_TOOL_RESULTS=1` | `apiMicrocompact.ts:94` | 未设置 | tool result 清理不启用 |
|
||||
| `USE_API_CLEAR_TOOL_USES=1` | `apiMicrocompact.ts:97` | 未设置 | tool use 清理不启用 |
|
||||
| `CONTEXT_MANAGEMENT_BETA_HEADER` | `betas.ts:7` | `context-management-2025-06-27` | **已可用** ✓ |
|
||||
| `modelSupportsContextManagement()` | `betas.ts:282` | Opus 4.6+, Sonnet 4.6 = true | **已可用** ✓ |
|
||||
| `clear_thinking_20251015` | `apiMicrocompact.ts:82-87` | 有 thinking 时启用 | **已生效** ✓(所有用户) |
|
||||
|
||||
## 已知问题
|
||||
|
||||
### P0: cachedMicrocompact 的 `deletedRefs` 未填充
|
||||
|
||||
详见 `docs/bugs/cached-microcompact-issues.md` 问题 1。
|
||||
|
||||
### P1: 类型不安全的 `as any` 桥接
|
||||
|
||||
`claude.ts:1763-1764` 中 `consumedCacheEdits` 和 `consumedPinnedEdits` 通过 `as any` 传入 `addCacheBreakpoints`。`CacheEditsBlock.edits` 的类型是 `{ type: string; tool_use_id: string }`,而 `addCacheBreakpoints` 期望的是 `{ type: 'delete'; cache_reference: string }`。两者字段名不同(`tool_use_id` vs `cache_reference`),靠 `as any` 掩盖了类型不匹配。
|
||||
|
||||
### P2: 两机制同时存在时的 API 行为未定义
|
||||
|
||||
目前无文档说明 Anthropic API 如何处理 `cache_edits`(消息内嵌)和 `context_management`(顶层字段)同时存在的情况。可能存在未定义交互。
|
||||
|
||||
## 启用方案
|
||||
|
||||
### 方案 A: 仅启用 apiMicrocompact(推荐,可立即实施)
|
||||
|
||||
1. **移除 `USER_TYPE=ant` 门控**(`apiMicrocompact.ts:90`),改为环境变量或 settings 控制
|
||||
2. **默认启用 tool clearing**(移除 `USE_API_CLEAR_TOOL_RESULTS` env 检查,或设置默认值)
|
||||
3. Beta header 和 `context_management` 注入逻辑已就绪,无需额外改动
|
||||
|
||||
代价:缓存失效(每次清理触发缓存前缀重写),但对订阅用户来说这不是问题(按使用量计费,不按缓存写入计费)。
|
||||
|
||||
### 方案 B: 同时启用两者(需等 cache_edits API 可用)
|
||||
|
||||
1. 先完成方案 A
|
||||
2. 修复 `deletedRefs` bug
|
||||
3. 等 `CACHE_EDITING_BETA_HEADER` 有值后启用 cachedMC
|
||||
4. 两者共存:cachedMC 在缓存热时精细操作,apiMC 在超限时兜底
|
||||
|
||||
### 方案 C: 用 `CACHE_EDITING_BETA_HEADER = CONTEXT_MANAGEMENT_BETA_HEADER` 尝试
|
||||
|
||||
将 `CACHE_EDITING_BETA_HEADER` 设为 `'context-management-2025-06-27'`,测试 API 是否接受消息内嵌的 `cache_reference` + `cache_edits`。如果接受,说明两者确实共用同一个 beta header。
|
||||
|
||||
## API 实测验证(2026-04-21 OAuth 订阅账户)
|
||||
|
||||
1. `/v1/models` 确认 Opus 4.7/4.6/Sonnet 4.6 都支持 `context_management`,含三种策略:
|
||||
- `clear_tool_uses_20250919` ✓
|
||||
- `clear_thinking_20251015` ✓
|
||||
- `compact_20260112` ✓(服务端压缩,新发现)
|
||||
2. `context-management-2025-06-27` beta header 被 API 接受(`context_management` 字段不报错)
|
||||
3. `cache_edits` 内嵌机制未测试(需要 beta header 值)
|
||||
|
||||
## 2026-04-21 已实施的修复
|
||||
|
||||
### 解除 `USER_TYPE=ant` 门控
|
||||
|
||||
**`apiMicrocompact.ts:89-92`**:移除 `if (process.env.USER_TYPE !== 'ant')` 整个 early return block。`clear_tool_uses_20250919` 默认对所有用户启用,可通过 `USE_API_CLEAR_TOOL_RESULTS=0` 环境变量禁用。
|
||||
|
||||
**`betas.ts:277-289`**:移除 `antOptedIntoToolClearing` 变量中的 `process.env.USER_TYPE === 'ant'` 条件,改为 `modelSupportsContextManagement(model) || USE_API_CONTEXT_MANAGEMENT=1`。beta header 注入不再依赖 ant 身份。
|
||||
|
||||
### 验证结果
|
||||
|
||||
- tsc 零错误
|
||||
- compact 相关 35 tests 全部通过
|
||||
- beta header 17 tests 全部通过
|
||||
- 全量 3415 pass / 1 fail(deep link 无关测试)/ 268 files
|
||||
|
||||
## 参考文件
|
||||
|
||||
- [Anthropic Context Editing 文档](https://docs.anthropic.com/en/docs/build-with-claude/context-editing)
|
||||
- `src/services/compact/microCompact.ts` — 入口及时序(第 253-293 行)
|
||||
- `src/services/compact/cachedMicrocompact.ts` — cache_edits 实现
|
||||
- `src/services/compact/apiMicrocompact.ts` — context_management 实现
|
||||
- `src/services/api/claude.ts:1579-1583` — consumedCacheEdits/consumedPinnedEdits 准备
|
||||
- `src/services/api/claude.ts:1684-1688` — contextManagement 获取
|
||||
- `src/services/api/claude.ts:1726-1741` — useCachedMC 和 beta header 注入
|
||||
- `src/services/api/claude.ts:1756-1779` — 两者同时注入到请求体
|
||||
- `src/services/api/claude.ts:3149-3298` — addCacheBreakpoints 完整实现
|
||||
- `src/utils/betas.ts:277-289` — CONTEXT_MANAGEMENT_BETA_HEADER 注入条件
|
||||
158
docs/bugs/model-picker-1m-ghost-option.md
Normal file
158
docs/bugs/model-picker-1m-ghost-option.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Bug: ModelPicker 1M 选项 key 不匹配导致幽灵选项
|
||||
|
||||
## 问题描述
|
||||
|
||||
用户通过 `/model` 选择 "Opus 4.6 (1M context)" 后:
|
||||
1. `[1m]` 后缀被静默丢弃,实际存储的 model 是 `'claude-opus-4-6'`(无 1M)
|
||||
2. 命令输出显示 `Set model to Opus 4.6` 而非 `Opus 4.6 (1M context)`
|
||||
3. 再次执行 `/model` 时,选项列表从 4 个变成 5 个,多出一个 "Opus 4.6" 幽灵选项
|
||||
|
||||
## 影响范围
|
||||
|
||||
所有 value 中自带 `[1m]` 后缀的预定义选项都受影响:
|
||||
- `getOpus46_1MOption()` — value: `getModelStrings().opus46 + '[1m]'` → `'claude-opus-4-6[1m]'`
|
||||
- `getOpus47_1MOption()` — value: `'opus[1m]'`(firstParty)
|
||||
- `getSonnet46_1MOption()` — value: `'sonnet[1m]'`(firstParty)
|
||||
- `getMergedOpus1MOption()` — value: `'opus[1m]'`(firstParty)
|
||||
- 所有 3P provider 的 1M 变体
|
||||
|
||||
## 根因分析
|
||||
|
||||
### 涉及文件
|
||||
|
||||
| 文件 | 行号 | 角色 |
|
||||
|------|------|------|
|
||||
| `src/components/ModelPicker.tsx` | 87-89 | `marked1MValues` 初始化(存储 base value) |
|
||||
| `src/components/ModelPicker.tsx` | 91-102 | `handleToggle1M` — Space 键切换 1M 标记 |
|
||||
| `src/components/ModelPicker.tsx` | 205-243 | `handleSelect` — 提交选择时的 1M 判断逻辑 |
|
||||
| `src/utils/model/modelOptions.ts` | 565-601 | `getModelOptions()` — custom model 追加逻辑 |
|
||||
|
||||
### Bug 链条详解
|
||||
|
||||
#### 第 1 步:`marked1MValues` 的 key 格式
|
||||
|
||||
`ModelPicker.tsx:87-89`:
|
||||
```typescript
|
||||
const [marked1MValues, setMarked1MValues] = useState<Set<string>>(
|
||||
() => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : [])
|
||||
)
|
||||
```
|
||||
|
||||
初始化时,如果当前 model 带 `[1m]`,存入的是 **去掉 `[1m]` 的 base value**。
|
||||
例如:`initialValue = 'claude-opus-4-6[1m]'` → set 中存 `'claude-opus-4-6'`
|
||||
|
||||
`handleToggle1M`(第 91-102 行)也是对 `focusedValue`(即 option 的 value 字段)直接操作,添加/删除的是 option 的原始 value。
|
||||
|
||||
#### 第 2 步:`handleSelect` 中的 key 查找不匹配
|
||||
|
||||
`ModelPicker.tsx:239-241`:
|
||||
```typescript
|
||||
const wants1M = marked1MValues.has(value) // 用 option 的完整 value 查找
|
||||
const baseValue = value.replace(/\[1m\]/i, '') // 去掉 [1m]
|
||||
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue // 根据 wants1M 决定
|
||||
```
|
||||
|
||||
问题:`value` 是 select option 的原始 value,对于 `getOpus46_1MOption()` 来说就是 `'claude-opus-4-6[1m]'`。但 `marked1MValues` 中存的 key 是 `'claude-opus-4-6'`(不带 `[1m]`)。
|
||||
|
||||
`marked1MValues.has('claude-opus-4-6[1m]')` **永远返回 false**。
|
||||
|
||||
因此 `wants1M = false`,`finalValue = 'claude-opus-4-6'`,1M 后缀被丢弃。
|
||||
|
||||
#### 第 3 步:幽灵选项产生
|
||||
|
||||
下次打开 `/model` 时,`initial = 'claude-opus-4-6'`。
|
||||
|
||||
`modelOptions.ts` 的 `getModelOptions()` 第 565-601 行检查 `customModel`:
|
||||
- `customModel = 'claude-opus-4-6'`
|
||||
- 基础选项中没有 value 为 `'claude-opus-4-6'` 的(只有 `'claude-opus-4-6[1m]'`)
|
||||
- 第 590 行 `getKnownModelOption('claude-opus-4-6')` 返回一个新选项 `{ value: 'claude-opus-4-6', label: 'Opus 4.6', ... }`
|
||||
- 追加到列表 → **5 个选项**
|
||||
|
||||
最终列表:
|
||||
1. Default (recommended) — value: `null`
|
||||
2. Opus 4.7 (merged 1M) — value: `'opus[1m]'`
|
||||
3. Opus 4.6 (1M context) — value: `'claude-opus-4-6[1m]'`(原始预定义选项)
|
||||
4. Haiku — value: `'haiku'`
|
||||
5. **Opus 4.6** — value: `'claude-opus-4-6'`(幽灵选项,由 custom model 逻辑追加)
|
||||
|
||||
## 修复方案
|
||||
|
||||
### 方案 A:修复 `handleSelect` 中的 1M 判断逻辑(推荐)
|
||||
|
||||
在 `ModelPicker.tsx` 的 `handleSelect` 中,检查 1M 状态时应该用 base value 作为 key(与 `marked1MValues` 的存储格式一致),并且要考虑 option value 本身就带 `[1m]` 的情况。
|
||||
|
||||
**修改位置**:`src/components/ModelPicker.tsx` 第 239-241 行
|
||||
|
||||
**当前代码**:
|
||||
```typescript
|
||||
const wants1M = marked1MValues.has(value)
|
||||
const baseValue = value.replace(/\[1m\]/i, '')
|
||||
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
|
||||
```
|
||||
|
||||
**修复思路**:
|
||||
```typescript
|
||||
const baseValue = value.replace(/\[1m\]/i, '')
|
||||
const optionHas1M = has1mContext(value) // option 自带 [1m]?
|
||||
const userToggled1M = marked1MValues.has(baseValue) // 用 base value 查找
|
||||
// 如果 option 自带 1M 且用户没有主动关闭,或者用户主动开启了 1M
|
||||
const wants1M = optionHas1M ? !userToggled1M : userToggled1M // 注意:toggle 语义需反转
|
||||
// 实际上更简洁的方式:直接用 base value 查 set
|
||||
const wants1M = marked1MValues.has(baseValue)
|
||||
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
|
||||
```
|
||||
|
||||
但这需要同时修改 `handleToggle1M` 和 `marked1MValues` 的初始化逻辑,确保三者的 key 格式统一。
|
||||
|
||||
### 方案 B:统一 `marked1MValues` 的 key 格式
|
||||
|
||||
让 `marked1MValues` 始终存储 base value(当前已经是这样),同时修改 `handleSelect` 用 base value 查找,修改 `handleToggle1M` 也用 base value 操作。
|
||||
|
||||
**需要修改的位置**:
|
||||
|
||||
1. **`handleToggle1M`(第 91-102 行)** — 当前直接用 `focusedValue` 作为 key。如果 `focusedValue` 带 `[1m]`(如 `'claude-opus-4-6[1m]'`),存入的 key 会与初始化时的格式不一致。需要统一为 base value:
|
||||
```typescript
|
||||
const handleToggle1M = useCallback(() => {
|
||||
if (!focusedValue || focusedValue === NO_PREFERENCE) return
|
||||
const base = focusedValue.replace(/\[1m\]/i, '') // 统一用 base value
|
||||
setMarked1MValues(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(base)) {
|
||||
next.delete(base)
|
||||
} else {
|
||||
next.add(base)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [focusedValue])
|
||||
```
|
||||
|
||||
2. **`is1MMarked` 判断(第 157 行)** — 也需要用 base value 查找:
|
||||
```typescript
|
||||
const is1MMarked = focusedValue !== undefined
|
||||
&& focusedValue !== NO_PREFERENCE
|
||||
&& marked1MValues.has(focusedValue.replace(/\[1m\]/i, ''))
|
||||
```
|
||||
|
||||
3. **`handleSelect`(第 239 行)** — 用 base value 查找:
|
||||
```typescript
|
||||
const baseValue = value.replace(/\[1m\]/i, '')
|
||||
const wants1M = marked1MValues.has(baseValue)
|
||||
const finalValue = wants1M ? `${baseValue}[1m]` : baseValue
|
||||
```
|
||||
|
||||
### 方案 C:让预定义 1M 选项的 value 不带 `[1m]`
|
||||
|
||||
将 `getOpus46_1MOption()` 等函数的 value 改为不带 `[1m]` 的 base value,让 1M 完全由 `marked1MValues` toggle 控制。这是最彻底的方案但改动最大,需要同时修改 `modelOptions.ts` 中所有 `*_1MOption` 函数。
|
||||
|
||||
## 推荐方案
|
||||
|
||||
**方案 B**:统一 `marked1MValues` 的 key 格式为 base value,修改 3 个位置。改动最小、最精准,不影响选项列表的结构。
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. 选择 "Opus 4.6 (1M context)" → 确认输出为 `Set model to Opus 4.6 (1M context)`
|
||||
2. 再次 `/model` → 确认仍然是 4 个选项,无幽灵项
|
||||
3. 选择 "Opus 4.7 (1M context)" → 同样验证无幽灵项
|
||||
4. 手动 Space 切换 1M on/off → 确认 toggle 正常工作
|
||||
5. 对已带 `[1m]` 的选项按 Space 关闭 1M → 确认存储的值不带 `[1m]`
|
||||
Reference in New Issue
Block a user