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:
unraid
2026-04-22 16:07:42 +08:00
parent 711927f01b
commit 95fece4b51
316 changed files with 39611 additions and 14298 deletions

View File

@@ -0,0 +1,350 @@
# Bug: cachedMicrocompact 缓存编辑实现存在 5 个问题
## 背景
分支 `chore/lint-cleanup``src/services/compact/cachedMicrocompact.ts` 从全 stubno-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` 的常量值即可,三层防御逻辑无需改动。
---
## 问题 4Feature Flag 未注册(当前为死代码)
### 严重级别INFO
### 问题描述
`CACHED_MICROCOMPACT` 不在 `build.ts` 或 `scripts/defines.ts` 的 feature 列表中。
当前 build 默认 features19 个):
```
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~~ | ~~问题 3beta header 为空~~ | **已修复** ✓ | 当前分支已包含三层防御 + 测试覆盖 |
| P2 | 问题 2同名函数冲突 | **待修复** | 增加维护混淆风险 |
| P3 | 问题 4feature 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
```

View 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 faildeep 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 注入条件

View 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]`