feat: 拦截 /compact 与 /clear,让上下文压缩真正生效#263
Conversation
飞书侧直接发 /compact 无效的根因:normal task 链路会给消息层层加前缀 (发送者标签 [姜黎]:、<msg-time>、聊天历史、memory/bot 身份),把 /compact 挤出消息开头。Claude Code CLI 只在消息首字符为 / 时才识别 local slash command, 于是 /compact 被当成普通文本,模型只回一句话,77 万 token 上下文纹丝不动。 新增 compact() 方法:以**裸** "/compact" 为 prompt + resume 调 SDK,捕获 compact_boundary(pre/post tokens) 与 status(compact_result/error),返回 CompactResult。 实测 SDK 0.3.156:bare /compact 触发真实压缩,session_id 保持不变, 40K→1.4K tokens(↓97%)。classifyCompactOutcome 把 "Not enough messages to compact" 归为 noop(非错误)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
覆盖:裸 /compact + resume 调用、成功带 pre/post tokens、noop(消息过少)、 真实失败带 error、无 compact 信号时不静默成功。classifyCompactOutcome 各分支。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleSlashCommand 新增 /compact 块:取 thread/主 session 的 conversationId 作 resume 目标,busy 守卫,调 claudeExecutor.compact,成功后回存 session ID, 回复 token 压缩比。/clear 作为 /reset 别名(本地清 conversationId 即等效清空)。 /help 增列两命令。导出纯函数 formatCompactReply + formatTokenCount。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
formatTokenCount(紧凑 k 表示)与 formatCompactReply(成功带压缩比/纯成功/ noop/失败各分支)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reviewing PR #263 — 拦截 /compact 与 /clear
|
|
Claude finished @lishuceo's task in 5m 36s —— View job Review: PR #263 — 拦截 /compact 与 /clear
🔴 话题内
|
| if (result.success && result.sessionId) { | ||
| if (effectiveThreadId) { | ||
| sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); | ||
| } | ||
| sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); | ||
| } |
There was a problem hiding this comment.
🔴 Bug: 话题内 /compact 会用「话题会话 id」覆盖「主会话 id」,破坏 thread 隔离 (confidence: 85)
读取 resume 目标时是 if/else:话题内用 thread session,否则用主 session。但回存时 setThreadConversationId 是有条件的,setConversationId(主会话)却无条件执行。
当 effectiveThreadId 存在时:
conversationId来自 thread session(话题的会话),压缩后result.sessionId是话题的会话 id;- 第 1196 行把这个话题会话 id + 话题的
cwd写进了主会话(chatId:userId级别)。
后果:用户回到主聊天框(非话题)发消息时,主会话会 resume 到话题的会话、用错 cwd(话题工作区),跨话题串味,正是架构要防的隔离破坏。主会话自身的 conversationId 当时并未被压缩,本不该被改写。
修复:把主会话回存放进 else 分支,与读取逻辑对称:
| if (result.success && result.sessionId) { | |
| if (effectiveThreadId) { | |
| sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); | |
| } | |
| sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); | |
| } | |
| if (result.success && result.sessionId) { | |
| if (effectiveThreadId) { | |
| sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); | |
| } else { | |
| sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); | |
| } | |
| } |
| if (result.success && result.sessionId) { | ||
| if (effectiveThreadId) { | ||
| sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); | ||
| } | ||
| sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); | ||
| } |
There was a problem hiding this comment.
🔴 Bug: 话题内 /compact 会用「话题会话 id」覆盖「主会话 id」,破坏 thread 隔离 (confidence: 85)
读取 resume 目标时是 if/else:话题内用 thread session,否则用主 session。但回存时 setThreadConversationId 是有条件的,setConversationId(主会话)却无条件执行。
当 effectiveThreadId 存在时:
conversationId来自 thread session(话题的会话),压缩后result.sessionId是话题的会话 id;- 第 1196 行把这个话题会话 id + 话题的
cwd写进了主会话(chatId:userId级别)。
后果:用户回到主聊天框(非话题)发消息时,主会话会 resume 到话题的会话、用错 cwd(话题工作区),跨话题串味,正是架构要防的隔离破坏。主会话自身的 conversationId 当时并未被压缩,本不该被改写。
修复:把主会话回存放进 else 分支,与读取逻辑对称:
| if (result.success && result.sessionId) { | |
| if (effectiveThreadId) { | |
| sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); | |
| } | |
| sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); | |
| } | |
| if (result.success && result.sessionId) { | |
| if (effectiveThreadId) { | |
| sessionManager.setThreadConversationId(effectiveThreadId, result.sessionId, cwd, agentId); | |
| } else { | |
| sessionManager.setConversationId(chatId, userId, result.sessionId, cwd, agentId); | |
| } | |
| } |

背景
用户反馈在飞书直接发
/compact没有任何效果——上下文一直涨不下来,单次对话动辄十几刀。根因(已从运行日志确认)
抓到实际执行记录:用户发的
text: "/compact"(8 字符),但丢给 SDK 的promptLength是 283 字符,在 77.6 万 token 上下文里 resume,跑了一个普通对话轮次(输出 796 token 文字),上下文丝毫未减。三层原因:
/compact—— 本地命令清单只有/auth /t /status /reset /stop /edit /dev /fork /memory /config /help,/compact直接 fall-through 到普通任务。[姜黎]:、<msg-time>、聊天历史、memory/bot 身份,把/compact挤出消息开头。/时识别 local slash command —— 被挤到中间后,/compact被当成纯文本。改动
ClaudeExecutor.compact():以裸"/compact"+resume调 SDK,捕获compact_boundary(pre/post tokens) 与status(compact_result/error)。handleSlashCommand拦截/compact:取 thread/主 session 的 conversationId 作 resume 目标,busy 守卫,压缩后回存 session ID 并回复压缩比。/clear作为/reset别名;/help增列两命令。实测验证(SDK 0.3.156,真实 API)
写探针脚本跑了真实「建会话 → /compact」:
/compact确实被识别为 local 命令并执行(非文本)。status(compacting)→status(compact_result+error)→init→assistant→result。Not enough messages to compact(归为 noop,非错误)。测试
executor.test.ts:compact() 成功/noop/失败/无信号 + 断言裸/compact调用 + classifyCompactOutcome。compact-reply.test.ts:formatTokenCount / formatCompactReply 各分支。npx vitest run:1700 passed,唯一失败memory/quality.test.ts(embedding 语义检索环境依赖,已用 git stash 确认在干净 base 上同样失败,与本 PR 无关)。🤖 Generated with Claude Code