---
url: /wave-agent/specs/core/message-compact.md
description: 自动与手动 `/compact` 上下文压缩：请求前触发、摘要 fork、熔断上限、压缩钩子与重载后从最后压缩边界重建
---

# 功能规格说明：消息压缩

**创建日期**：2026-01-22\
**更新日期**：2026-09-29

## 用户场景与测试 *（必填）*

### 用户故事：自动历史压缩（优先级：P1）

作为 AI 代理，当对话历史变得过长时，我希望自动总结较旧的消息，以便保持在模型的 token 限制内同时保持上下文。

**为什么是这个优先级**：对长时间运行的会话至关重要，以防止"超出上下文窗口"错误并降低成本。

**独立测试**：可以模拟 token 使用超过阈值并验证 `AIManager` 触发压缩周期并用摘要块替换旧消息。

**验收场景**：

1. **假设**估算的上下文 token 数达到自动压缩阈值，**当**发送下一个 API 请求前执行检查时，**则**代理必须识别要压缩的消息并在请求发出前触发压缩
2. **假设**消息被识别用于压缩，**当**总结完成时，**则**原始消息必须被 `compress` 块替换，后跟旧消息列表的最后 2 个 API 轮次
3. **假设**存在 `compress` 块，**当**向 API 发送消息时，**则**它必须被转换为 **user** 消息（匹配 Claude Code 的自动压缩行为）

***

### 用户故事：请求前自动压缩触发（对齐 Claude Code）（优先级：P1）

作为 AI 代理，我希望在发送 API 请求之前基于估算的上下文大小判断是否压缩，而不是在响应返回后才发现超限，以便超限请求永远不会真正发出。

**为什么是这个优先级**：当前实现是在每次 API 响应返回后检查该次响应的 `usage`，超限时才压缩——判断慢一拍，导致输入超过 `contextWindow` 的请求真实发出（可能直接撞上模型 context length / 413 错误）。对齐 Claude Code 的 proactive 机制：请求前用「上次响应的真实 usage + 其后新增消息的估算」预测本次请求的上下文大小，超过阈值先压缩再发。

**独立测试**：模拟一次巨大工具输出后，验证下一次请求发出前即触发压缩（而非等响应返回）；模拟 fork 路径（压缩、auto-memory 提取），验证不触发自动压缩；验证压缩完成后同一轮循环内不再重复触发。

**验收场景**：

1. **假设**存在上次 API 响应的 `usage`，**当**构造本次请求前执行上下文估算时，**则**估算值 = 上次响应的 `total_tokens` + 上次响应之后新增消息的字符估算（CJK 感知，与 `estimateTokens` 一致）
2. **假设**估算的上下文 token 数达到自动压缩阈值，**当**发送请求前检查时，**则**必须触发压缩，超阈值请求不得发出
3. **假设**估算的上下文 token 数未超过阈值，**当**发送请求前检查时，**则**请求正常发出，不触发压缩
4. **假设**agent 循环中一轮工具调用结束、下一轮请求将要发出，**当**发送请求前检查时，**则**每次请求前（包括工具调用后的后续轮次）都必须重新估算并检查
5. **假设**当前请求是压缩 fork 或 auto-memory 提取等 fork 路径，**当**发送请求前检查时，**则**必须跳过自动压缩判断（fork 继承完整历史，触发压缩会造成递归死锁）
6. **假设**压缩刚完成、同一轮 agent 循环继续，**当**发送请求前检查时，**则**不得基于压缩前的估算值再次触发压缩（压缩结果已保证在阈值内）
7. **假设**上次响应后的新消息估算与真实 token 数存在偏差，**当**请求真实发出后返回 413 / context length 错误时，**则**走既有错误处理路径（`invalid_request` + 友好错误消息），不引入新的响应后主动压缩检查

***

### 用户故事：压缩触发仅使用 total_tokens（去除缓存 token 双重计数）（优先级：P1）

作为 AI 代理，我希望自动压缩的判断只依据 `total_tokens`（已包含缓存命中 token），不再叠加 `cache_read_input_tokens` 和 `cache_creation_input_tokens`，以便上下文占用度量准确，压缩不会过早触发。

**为什么是这个优先级**：wave 走 OpenAI 兼容接口（OpenAI SDK + 网关），该格式下 `prompt_tokens = 缓存命中 + 未命中`、`total_tokens = prompt_tokens + completion_tokens`，缓存命中已计入 `total_tokens`（DeepSeek 官方文档明确 `prompt_tokens` 等于 `prompt_cache_hit_tokens + prompt_cache_miss_tokens`）。原实现把 `prompt_tokens_details.cached_tokens` 规范化后与 `total_tokens` 相加，造成缓存命中双重计数——缓存命中通常占上下文绝大部分，导致压缩过早触发（实际上下文远未到 `contextWindow` 就压缩）。该加法源自 Anthropic 原生格式（其 `input_tokens` 不含缓存字段），与 wave 实际使用的 OpenAI 兼容格式错配。

**独立测试**：构造含 `cache_read_input_tokens`（大值）和 `cache_creation_input_tokens` 的 usage，验证压缩判断仅使用 `total_tokens`，缓存字段不参与判断；验证 `latestTotalTokens` 展示语义同样仅使用 `total_tokens`（与压缩判断口径一致，缓存字段不参与展示）。

**验收场景**：

1. **假设**响应的 `usage` 包含 `total_tokens`、`cache_read_input_tokens`、`cache_creation_input_tokens`，**当**判断是否达到自动压缩阈值时，**则**只使用 `total_tokens`（OpenAI 兼容格式下已含缓存命中），缓存字段不得叠加
2. **假设**响应仅含 `cache_read_input_tokens`（缓存命中极大）而 `total_tokens` 远低于阈值，**当**判断是否触发压缩时，**则**不得触发压缩
3. **假设**`usage` 中 `total_tokens` 未超阈值但叠加缓存字段后超过，**当**判断是否触发压缩时，**则**不得触发压缩
4. **假设**`latestTotalTokens` 展示（CLI / webview 的 token 用量显示），**当**展示 token 使用量时，**则**同样只使用 `total_tokens`，缓存字段不叠加——与压缩触发判断口径一致，避免 UI 显示"100% context"而压缩迟迟不触发

***

### 用户故事：压缩熔断器（优先级：P2）

作为 AI 代理，当压缩反复失败时，我希望停止尝试压缩，以避免在不可恢复的上下文状态上浪费 API 调用。

**为什么是这个优先级**：当上下文损坏时防止级联故障和不必要的 API 成本。

**独立测试**：可以模拟 3 次连续压缩失败并验证第 4 次高 token 使用轮次完全跳过压缩。

**验收场景**：

1. **假设**压缩已连续失败 3 次，**当**token 使用再次超过阈值时，**则**必须跳过压缩并记录警告
2. **假设**压缩成功，**当**下一次压缩周期运行时，**则**连续失败计数器必须重置为 0

***

### 用户故事：压缩 fork 代理循环（优先级：P1）

作为 AI 代理，当触发压缩时，我希望在与主对话参数完全一致的 fork 代理循环中生成摘要，使压缩请求命中主对话缓存，且模型无法通过工具调用逃避输出文本摘要。

**为什么是这个优先级**：压缩请求包含整个对话历史，与主对话保持缓存前缀一致可大幅降低成本；在协议层拒绝工具调用是防止模型以工具调用代替文本摘要的结构性手段（弱模型在纯文本压缩调用中可能将工具调用语法混入正文）。对齐 Claude Code 的 fork 压缩路径。

**独立测试**：模拟压缩触发，检查 fork 请求的模型/系统提示/工具集/生成参数与主对话一致；模拟模型返回 tool_calls，验证工具未执行、拒绝结果被回灌、循环继续直至文本输出或轮次上限。

**验收场景**：

1. **假设**压缩被触发，**当**构造 fork 请求时，**则**必须使用主模型（非快速模型），且系统提示、工具集、生成参数与主对话请求完全一致，压缩指令作为末尾 user 消息追加，使命中主对话缓存前缀
2. **假设**fork 循环中模型返回 tool_calls，**当**处理该响应时，**则**任何工具都不得执行，必须向模型回灌"压缩期间不允许使用工具"的拒绝结果并继续循环
3. **假设**fork 循环达到最大轮次（3 轮）仍无文本输出，**当**循环结束时，**则**必须判定压缩失败并计入连续失败计数（最大轮次偏离 Claude Code 的 1 轮：deny 回路内自纠借助缓存命中成本极低）
4. **假设**构造 fork 请求，**当**设置生成参数时，**则**不得显式覆盖最大输出 token 数或 thinking 配置，必须与主对话保持一致（避免缓存键失配，对齐 Claude Code fork 路径）
5. **假设**压缩指令被构造，**当**作为 user 消息追加时，**则**必须以"仅输出文本、禁止调用任何工具、工具调用将被拒绝并导致任务失败"的强制声明开头，并在末尾重申该约束

***

### 用户故事：压缩摘要格式提取（优先级：P1）

作为 AI 代理，当压缩模型返回文本时，我希望按约定格式提取摘要正文，以便进入上下文的压缩结果干净、无分析草稿等包装内容。

**为什么是这个优先级**：压缩提示词要求模型在 `<analysis>` 标签中打草稿、在 `<summary>` 标签中输出正文；提取规则保证只有摘要正文进入上下文。行为严格对齐 Claude Code 的 `formatCompactSummary`，不引入其没有的内容级校验。

**独立测试**：模拟压缩模型返回带 `<analysis>`/`<summary>` 标签、不带标签、含连续空行等不同输出，验证进入上下文的摘要符合提取规则。

**验收场景**：

1. **假设**压缩模型输出包含 `<analysis>` 块和 `<summary>` 块，**当**摘要被应用时，**则**必须剥离 `<analysis>` 块，仅保留 `<summary>` 标签内的内容
2. **假设**压缩模型输出不包含 `<summary>` 标签，**当**摘要被应用时，**则**原始文本必须原样透传，不做格式拒绝或重试
3. **假设**压缩模型输出包含连续多个空行，**当**摘要被应用时，**则**必须压缩为单个空行并去除首尾空白

***

### 用户故事：压缩后上下文恢复（优先级：P2）

作为 AI 代理，在压缩替换对话历史后，我希望重要上下文被重新注入，以便我可以继续工作而不丢失对文件、目录、计划模式、技能和后台任务的追踪。

**为什么是这个优先级**：防止代理在压缩后丢失关键环境上下文。

**独立测试**：可以验证压缩后摘要包含最近文件读取、工作目录、计划模式状态、可用技能和后台任务状态的各个部分。

**验收场景**：

1. **假设**压缩产生摘要，**当**摘要被应用时，**则**它必须增强最近文件读取内容（最多 5 个文件，每个 5000 token）
2. **假设**压缩被应用，**当**上下文恢复运行时，**则**当前工作目录必须被包含
3. **假设**代理处于计划模式，**当**上下文恢复运行时，**则**计划文件路径和存在状态必须被包含
4. **假设**技能已被调用，**当**上下文恢复运行时，**则**必须重新注入该技能的正文与描述——正文取模型当时实际看到的那一份（已替换 `${WAVE_SKILL_DIR}` 等目录占位符、首行含 `Base directory for this skill: <绝对路径>`）；记录里拿不到该内容时按技能文件重新渲染，且同样完成同样的替换与首行——**不得**把 `${WAVE_SKILL_DIR}` 这类占位符原样注入，也**不得**注入未解析的 frontmatter 语法（如 `>-`）
5. **假设**后台任务正在运行，**当**上下文恢复运行时，**则**每个代理的描述和状态必须被列出

***

### 用户故事：压缩后计划模式提醒保留（优先级：P2）

作为在长时间会话中在计划模式下工作的用户，我希望在对话压缩后重新注入计划模式指令，以便代理即使在历史被摘要替换后仍保持其只读约束和工作流指导。

**为什么是这个优先级**：如果不重新注入，压缩会从对话历史中移除所有计划模式 `<system-reminder>` 消息。代理将不知道计划模式约束，可能尝试编辑文件或采取计划文件之外的操作。

**独立测试**：在计划模式下触发压缩，然后验证完整的计划模式 `<system-reminder>` 提醒在压缩后作为用户消息注入，且代理继续遵守只读约束。

**验收场景**：

1. **假设**代理处于计划模式且发生压缩，**当**压缩摘要替换对话历史时，**则**完整的计划模式 `<system-reminder>` 必须在下一个 API 请求中作为用户消息注入
2. **假设**代理处于计划模式且发生压缩，**当**计划文件存在时，**则**重新注入的计划模式提醒必须包含计划文件路径和存在状态
3. **假设**代理不在计划模式，**当**发生压缩时，**则**不注入计划模式提醒
4. **假设**代理处于计划模式且发生压缩，**当**重新注入的提醒是压缩后的第一个提醒时，**则**它必须是完整指令（非稀疏），因为所有先前的提醒已被压缩移除

***

### 用户故事：手动 `/compact` 命令（优先级：P2）

作为用户，我希望手动触发对话压缩并提供可选的自定义指令，以便我可以主动减少上下文使用并将摘要集中在对话的特定方面。

**为什么是这个优先级**：给予用户对上下文管理的控制，支持在自动压缩触发之前主动压缩，并允许自定义摘要重点。

**独立测试**：输入 `/compact` 或 `/compact focus on the bug fix discussion`，验证压缩发生且可选指令被应用于摘要。

**验收场景**：

1. **假设**对话中有消息，**当**用户输入 `/compact` 时，**则**对话历史必须立即被压缩，无论 token 使用情况
2. **假设**对话中有消息，**当**用户输入 `/compact focus on the API design` 时，**则**自定义指令必须被传递给压缩 API 调用并影响摘要
3. **假设**AI 响应正在进行中，**当**用户输入 `/compact` 时，**则**`/compact` 被忽略：AI 处理不被中止，对话历史保持不变；AI 空闲时 `/compact` 才执行压缩
4. **假设**压缩已在进行中，**当**用户再次输入 `/compact` 时，**则**第二次压缩必须被跳过（熔断器）
5. **假设**已有一段包含多条消息的对话，**当**`/compact` 压缩完成，**则** 三端（CLI / webview / 插件端）的界面**当前不被整表替换**：已渲染的旧消息留在屏幕上，只追加摘要块；列表**口径**收缩为「摘要 + 压缩保留的最后轮次」，这次收缩在下一次重载（重新绑定会话 / 恢复 / 重连 / CLI 的结构性刷新）时生效——见 `ui/message-rendering-system.md`「内存只装压缩后的上下文，更早的历史按页从转录取」

***

### 用户故事：压缩后旧消息在 UI 保留渲染（优先级：P1）

作为用户，我希望压缩（自动或手动）之后，压缩之前的消息仍然渲染在界面上，压缩摘要以块的形式追加在消息列表末尾，以便我能回看被压缩掉的历史对话，而不必依赖重新加载会话。

**为什么是这个优先级**：压缩发生时用户正看着这段对话，界面不该在那一刻闪一下或清空——已渲染的内容留在屏幕上、摘要块追加在末尾（参考物同样是 append-only：压缩只追加摘要块，从不整表替换）。注意这只约束**当前这次渲染**：列表的**数据口径**收敛为「最近一次压缩 + 其后消息」（见 `ui/message-rendering-system.md`「内存只装压缩后的上下文，更早的历史按页从转录取」），重载后屏幕从首屏一页重建，压缩前的历史要靠上翻从转录按页读回来；转录始终保留全量。

**独立测试**：模拟压缩发生，验证当前这次渲染里压缩前的旧消息仍在、压缩摘要块追加在列表末尾；验证发给 API 的上下文仍从最后压缩边界开始（压缩对模型上下文的效果不变）。宿主接线另有三端单测：向宿主注入 `compactBlockAdded`（带 `message`）后，断言宿主消息缓存已追加该块、且 webview 收到 `appendMessage`（VSCE 侧为对应转发回调）——这条断言是"摘要块在插件端不显示"缺陷的回归闸门。

**验收场景**：

1. **假设**自动压缩发生，**当**压缩完成时，**则** 界面已渲染的旧消息不被移除，压缩摘要块追加在消息列表末尾（三端一致）；下一次重载时首屏按压缩后的上下文重建，压缩前的历史仍可上翻取回
2. **假设**手动 `/compact` 发生，**当**压缩完成时，**则** 行为与自动压缩一致：旧消息保留在屏幕上、摘要块追加在列表末尾（首屏口径的收缩同样发生在下一次重载）
3. **假设**压缩后对话继续，**当**新消息到达时，**则**新消息追加在压缩摘要块之后
4. **假设**同一会话在一次连续使用中发生多次压缩，**当** UI 渲染时，**则**每次压缩的摘要块都追加在列表末尾（不整表替换），屏幕上按发生先后能看到多个摘要块；重载后首屏只从最后一个摘要块开始，更早的摘要块与压缩前的消息在上翻时按转录顺序出现（见 `ui/message-rendering-system.md`「内存只装压缩后的上下文，更早的历史按页从转录取」）
5. **假设**压缩完成，**当**构造下一个 API 请求时，**则**上下文仍从最后压缩边界开始，压缩前的旧消息不进入请求（压缩对模型上下文的效果不变）
6. **假设**CLI 中压缩后的消息总数超过渲染上限（折叠 30 / 展开 10），**当**渲染时，**则**只渲染最近的上限条数；三端的列表同口径（摘要 + 压缩保留轮次），转录保留全量
7. **假设**压缩在任一插件端（VS Code 插件 / JetBrains 插件 / 桌面端）中完成，**当**写入阶段结束时，**则**摘要块必须**不依赖重新加载会话**就出现在消息列表末尾：宿主收到 `compactBlockAdded`（载荷含摘要块那条完整 `Message`）后，把它追加进自己的消息缓存并经 `appendMessage` 下发给 webview——三端行为一致；宿主不得只刷新缓存而不下发（那会让摘要在 webview 上缺失直到下次 `webviewReady`）

***

### 用户故事：加载历史会话从最后压缩边界重建（优先级：P1）

作为用户，我希望恢复历史会话时，模型上下文与界面列表都从**最后一次压缩**开始，而不是把压缩前的旧消息重新装回来，以便会话跑了几周、其中 Read / MCP 读过几十张截图之后，恢复仍然快且省内存——「之前发生过什么」由摘要块承载。

**为什么是这个优先级**：转录文件是 append-only（压缩时追加摘要块而非删除旧消息），所以「加载时能读到压缩前的消息」这件事本身是成立的；问题在代价——消息里的图片是 base64 形态，把它们重新构造成对象只是为了摆在列表里，而列表的上半部分是用户几乎不会再翻的旧历史。参考物同样把剪枝放在加载期，但**剪枝只决定模型上下文与首屏，回看由转录分页承担**——屏幕能翻多深与内存装多少是两件事。因此常驻口径统一为「最近一次压缩 + 其后消息」（见 `ui/message-rendering-system.md`「内存只装压缩后的上下文，更早的历史按页从转录取」），三端（CLI / webview / 插件端）一致；压缩前的消息由用户上翻时按页从转录读回，不进内存常驻。

**独立测试**：构造含压缩摘要块及其之前旧消息的转录文件，恢复会话后断言列表只含该摘要块及其后消息、断言 API 上下文仍从最后压缩边界开始、断言转录文件未被改写。

**验收场景**：

1. **假设**转录文件包含压缩摘要块及其之前的旧消息，**当**宿主恢复该会话时，**则** 首屏列表只渲染该摘要块及其后的消息；压缩前的旧消息留在转录里，用户上翻时按页读回并接在列表前面
2. **假设**宿主恢复的会话含多个压缩摘要块，**当**重建列表时，**则** 首屏只从最后一个摘要块开始，更早的摘要块不随首屏下发，但上翻到那里时按转录顺序出现
3. **假设**恢复会话后用户发送新消息，**当**构造 API 请求时，**则**上下文从最后压缩摘要块开始，压缩前的旧消息不进入请求
4. **假设**会话发生过压缩，**当**压缩写入转录时，**则**保留的最后 API 轮次不得重复写盘（它们压缩前已写入；对齐 Claude Code 的 dedup-skipped），转录中每条消息唯一
5. **假设**CLI 恢复超长会话（消息数超过渲染上限），**当**渲染时，**则**只渲染最近的上限条数，不崩溃且性能可接受；列表本身也只有「最近一次压缩 + 其后消息」，不把压缩前的历史读进内存（CLI 没有上翻手势，回看交给终端自身的滚动缓冲）

***

### 用户故事：压缩过程中的 CLI 流式尾部提示（优先级：P2）

作为 CLI 用户，我希望压缩（手动 `/compact` 或自动触发）进行期间，在 "✻ Compacting" 加载提示后看到当前流式传输文本的最后 30 个字符，以便我知道压缩摘要正在生成并能实时看到输出进度。

**为什么是这个优先级**：压缩 fork 以流式模式请求（为避免网关空闲超时），但此前流式增量被静默消费、不向界面透出；在加载提示后展示流式尾部（与主对话/btw 的流式尾部样式一致）能让用户区分"正在思考"与"已经卡住"，无需等待压缩结束。

**独立测试**：模拟压缩 fork 的 `onContentDelta`/`onReasoningDelta` 回调，验证回调内容被转发给 CLI 加载提示；验证超过 30 字符时仅显示 `…` 前缀截断的最后 30 个字符。

**验收场景**：

1. **假设**压缩进行中（手动或自动触发）且模型尚无任何流式输出，**则** CLI 在消息列表下方显示 "✻ Compacting"，不显示流式尾部。
2. **假设**压缩进行中且模型已输出文本（普通文本或推理内容，推理经内容通道混合），**则** CLI 在 "✻ Compacting" 后显示该文本的最后 30 个字符；文本不超过 30 字符时原样显示，超过 30 字符时以 `…` 前缀截断（换行折叠为 `\n`，与主对话流式尾部样式一致）。
3. **假设**压缩完成（`compactionStateChange` 为 false），**则** 加载提示与流式尾部一并消失，界面上已渲染的旧消息留在原处，压缩摘要块追加在列表末尾。

***

### 用户故事：PreCompact 和 PostCompact 钩子事件（优先级：P2）

作为开发者，我希望配置在对话压缩之前和之后运行的钩子，以便我可以自定义压缩行为并以编程方式响应压缩后的摘要。

**为什么是这个优先级**：支持自定义压缩过程并允许下游系统响应对话摘要，但对基本功能不是关键的。

**独立测试**：配置 PreCompact 和 PostCompact 钩子，通过 `/compact` 命令或自动压缩触发压缩，验证 PreCompact 钩子在压缩前执行，PostCompact 钩子在压缩后执行。

**验收场景**：

1. **假设**配置了 PreCompact 钩子，**当**压缩被触发时，**则**钩子必须在压缩 API 调用之前执行，如果提供了自定义指令则在 JSON 输入中接收 `compact_instructions`，其 stdout 必须作为额外指令合并
2. **假设**配置了 PostCompact 钩子，**当**压缩成功完成时，**则**钩子必须在压缩 API 调用之后执行并在 JSON 输入中接收包含 AI 生成摘要的 `compact_summary`
3. **假设**PreCompact 钩子返回退出码 2，**当**钩子完成时，**则**错误必须显示给用户但压缩必须继续（非阻塞）
4. **假设**PostCompact 钩子返回退出码 2，**当**钩子完成时，**则**错误必须显示给用户但执行必须继续（非阻塞）
5. **假设**同时配置了 PreCompact 和 PostCompact 钩子，**当**压缩被触发时，**则**PreCompact 必须先运行，然后压缩发生，然后 SessionStart 钩子运行，最后 PostCompact 运行

***

### 用户故事：与 Claude Code 对齐的自动压缩阈值（优先级：P1）

作为长时间运行会话的用户，我希望 Wave 在自动压缩前为压缩摘要输出和请求安全余量预留 token，并以「最近一次响应的真实用量 + 其后新增消息」估算触发时机，以便在达到模型上下文上限前稳定压缩，并与 Claude Code 的阈值与估算语义一致。

**为什么是这个优先级**：阈值口径与估算口径必须一起成立。阈值侧要为 compaction summary 与安全余量预留空间；估算侧必须以最近一次响应的 usage 为锚点——OpenAI 兼容口径的 `total_tokens` 已经包含 system prompt、工具 schema、动态注入上下文，只在锚点之上叠加其后新增消息。若再额外叠加一份固定开销，同一份内容被算两遍，压缩触发点会明显早于上下文用量指示器的 100%（默认配置下固定开销约 3 万 token，相当于把触发点从用量环 95% 以上前移到 81% 左右，用户看到的就是「环还远不到 100% 就压缩了」）。这两点是自动压缩可信度的基础。

**独立测试**：设置 `contextWindow=200000` 和默认最大输出 token `32000`，构造包含历史消息与 usage 锚点的请求，验证自动压缩阈值为 `200000 - min(32000, 20000) - 13000 = 167000`；再分别改变最大输出与上下文窗口，验证阈值同步变化；最后在锚点不变的前提下追加一份很大的 system prompt 与工具 schema，验证估算值不变（不得把固定开销叠加到 usage 之上）。

**验收场景**：

1. **假设**会话的 `contextWindow` 为 200000、最大输出 token 为 32000，**当**计算自动压缩阈值时，**则**必须先预留 `min(32000, 20000)=20000` 个 compaction summary 输出 token，再预留 13000 个自动压缩安全余量，阈值为 167000
2. **假设**最大输出 token 小于 20000，**当**计算自动压缩阈值时，**则** compaction summary 预留必须使用实际最大输出 token，而不是固定扣除 20000；阈值为 `contextWindow - maxOutputTokens - 13000`
3. **假设**最大输出 token 大于 20000，**当**计算自动压缩阈值时，**则** compaction summary 预留最多只能是 20000；阈值不得因普通请求的更大输出上限继续降低
4. **假设**模型配置的 `contextWindow` 为其他值（包括按模型覆盖的值），**当**执行自动压缩判断时，**则**必须对该模型使用同样的「context window - summary reservation - 13000」公式，不得写死 200000
5. **假设**对话中已有带 usage 的响应，**当**请求即将发出、估算是否达到自动压缩阈值时，**则**必须以最近一次响应 usage 的 `total_tokens` 为锚点，只叠加该响应之后新增消息的估算值；system prompt、工具名称与 schema、动态上下文已包含在该 usage 内，不得再把它们作为固定开销额外相加
6. **假设**usage 锚点不变而 system prompt 或工具 schema 明显变大（例如注入项目规则、开启更多工具），**当**执行自动压缩检查时，**则**估算值不得随之增大，也不得因此提前压缩；这类内容的变化只通过下一次真实 usage 反映
7. **假设**估算值达到或超过自动压缩阈值，**当**进入下一次模型请求前执行检查时，**则**必须先完成自动压缩，原始超阈值请求不得发出
8. **假设**估算值低于自动压缩阈值，**当**进入下一次模型请求前执行检查时，**则**不得仅因为消息历史接近 `contextWindow`、或因为 system prompt 与工具 schema 很大而提前压缩
9. **假设**当前请求属于 compaction fork 或 auto-memory 等 fork 路径，**当**执行阈值检查时，**则**必须跳过自动压缩，避免递归压缩；现有压缩熔断器、手动 `/compact` 和压缩后上下文恢复行为保持不变

***

### 用户故事：上下文用量显示与自动压缩阈值一致（优先级：P1）

作为用户，我希望 Wave 各端显示的 context usage 百分比都以自动压缩阈值为分母，以便看到的 100% 与实际触发自动压缩的时刻一致，而不是看到 200k input window 的比例却在 167k 已经压缩。

**为什么是这个优先级**：自动压缩阈值与用量指示器使用不同分母会造成明显的产品误导——默认配置下达到 167k 时用量仍只显示约 84%，但系统已经开始压缩。CLI、webview、宿主和远控必须共享同一口径。

**独立测试**：设置 `contextWindow=200000`、最大输出 token 为 32000，验证 Wave 各端在 `latestTotalTokens=167000` 时均显示 100%，在 83500 时均显示 50%，在超过 167000 时均保持 100%；压缩后更新 `latestTotalTokens`，验证各端显示按新值重新计算。

**验收场景**：

1. **假设**`contextWindow=200000`、最大输出 token 为 32000，**当**计算 context usage 百分比时，**则**所有展示必须使用 `200000 - min(32000, 20000) - 13000 = 167000` 作为分母
2. **假设**`latestTotalTokens=83500` 且阈值为 167000，**当**各端刷新 context usage 时，**则**必须显示 50%（按统一的四舍五入规则）
3. **假设**`latestTotalTokens=167000`，**当**各端刷新 context usage 时，**则**必须显示 100%
4. **假设**`latestTotalTokens` 超过自动压缩阈值，**当**各端刷新 context usage 时，**则**百分比必须钳制为 100%，不得显示超过 100%
5. **假设**模型或配置改变了 `contextWindow` 或最大输出 token，**当**重新计算 context usage 时，**则**必须使用该模型当前生效的自动压缩阈值，不得继续使用 200000 或旧阈值
6. **假设**压缩完成并产生新的 `latestTotalTokens`，**当**宿主、webview、CLI 或远控刷新状态时，**则**必须按新的 token 数和当前自动压缩阈值重新计算，不能沿用压缩前百分比
7. **假设**context usage 改为以自动压缩阈值为分母，**当**实际 API 请求和自动压缩判断执行时，**则**`contextWindow` 仍表示模型原始窗口配置值（输入与输出共享的总预算），不能把显示阈值当成窗口本身

***

### 用户故事：手动压缩与压缩状态内联提示（优先级：P2）

作为插件端（VS Code 扩展与 JetBrains 插件）与桌面端用户，我希望能在客户端中手动触发对话压缩（`/compact`），并在压缩过程中于消息列表末尾的闪烁光标后看到"正在压缩对话"提示，压缩完成后提示自动消失，以便我主动管理上下文并实时知晓压缩状态，且不被通知弹窗或插入的系统消息打扰。

**为什么是这个优先级**：插件端与桌面端以 stdio 子进程方式运行 Agent，无法直接调用 CLI 内部命令；若不做协议适配，`/compact` 会被当作普通消息发送给模型而失效。压缩可能耗时数秒，在共享 webview 的闪烁光标后内联展示状态提示，让三端行为一致，避免原生通知、聊天内系统消息等不同形式造成的体验割裂。VS Code 扩展、JetBrains 插件与桌面端共享同一 webview 包，三者行为必须一致。

**独立测试**：在三端分别输入 `/compact`，验证通过 stdio `compact` 请求触发压缩而非发送普通消息；模拟 `compactionStateChange` 通知，验证开始时 webview 在消息列表末尾的闪烁光标后显示压缩提示，完成时提示消失，且全程不出现原生通知弹窗，消息列表也不新增系统消息；连续推送若干 `compactionContentUpdate` 增量通知，验证消费端按序拼接后显示流式尾部（最后 30 字符、`…` 前缀截断、换行折叠为 `\n`），并测量单条通知的字节数不随已输出长度增长。

**验收场景**：

1. **假设**对话中有消息，**当**用户在插件端或桌面端中输入 `/compact` 时，**则**客户端必须通过 stdio `compact` 请求触发压缩，而非将文本作为普通消息发送给模型
2. **假设**用户输入 `/compact focus on the API design`，**当**请求发出时，**则**自定义指令必须作为 `customInstructions` 传递给 `compact` 请求并影响摘要
3. **假设**压缩开始（手动或自动触发），**当**子进程发送 `compactionStateChange`（`isCompacting=true`）时，**则**webview 必须在消息列表末尾的闪烁光标后显示"正在压缩对话"提示，并在压缩期间保持显示
4. **假设**压缩进行中且模型已输出文本，**当**子进程发送 `compactionContentUpdate` 时，**则**载荷是**纯增量片段**（与 `assistantContentUpdated` 的 `chunk` 同族，口径见 `ui/stdio-transport.md`「流式通知纯增量负载」），消费端按序拼接后显示拼接结果的最后 30 个字符（换行折叠为 `\n`，超过 30 字符以 `…` 前缀截断，与 CLI/btw 的流式尾部样式一致）；单条通知的字节数与已输出总长度无关；模型尚无输出时不显示尾部
5. **假设**压缩完成，**当**子进程发送 `compactionStateChange`（`isCompacting=false`）时，**则**webview 必须移除该提示（含流式尾部）
6. **假设**压缩已在进行中，**当**用户再次输入 `/compact` 时，**则**第二次压缩必须被跳过（复用压缩熔断器）
7. **假设**压缩状态变化，**当**宿主收到 `compactionStateChange` 通知时，**则**宿主不得显示原生通知弹窗，也不得向消息列表插入系统消息
8. **假设**桌面端处于多窗格（分屏）状态，**当**某个会话窗格触发压缩时，**则**压缩提示（含流式尾部）必须仅显示在该窗格中
9. **假设**桌面端某个会话正在压缩、已显示"正在压缩对话"提示，**当**用户切换到其它会话再切回该会话时，**则**该会话的压缩提示必须仍然显示——`setInitialState` 携带该会话的 `isCompacting` 重放，webview 不得因会话切换把它丢弃；切换到其它会话期间不得显示该提示，直至压缩完成（`compactionStateChange` 回落 false）提示才消失

***

### 用户故事：压缩期间的会话保护（切换禁用 + SDK 写入兜底）（优先级：P1）

作为用户，我希望压缩进行期间「历史对话」切换与「新建对话 / `/clear`」像 streaming/后台任务运行时一样被禁用或忽略，以便我不会在压缩途中离开当前会话；同时即使某条路径绕过了 UI 守卫（状态通知在途的竞态窗口、CLI TUI 的 `/clear`、未来新增的切换路径），压缩摘要也绝不写进别的会话。

**为什么是这个优先级**：压缩是一次耗时数秒的 LLM 调用（fork），期间插件端的「历史对话」切换（`restoreSession`）和 `/clear` 都不被禁止；而压缩完成后的写入阶段直接操作 messageManager 的**当前**状态——若会话已被切走，摘要块会被追加进切换后会话的内存消息列表，并持久化写进其转录文件（PM 缺陷 3478352534577408：会话 2 执行 `/compact`，切到会话 3 后「对话已压缩」块出现在会话 3 中）。这是数据污染级缺陷：污染目标会话的 UI、转录与后续轮次的模型上下文。守卫分两层：webview 层与 streaming/后台任务共用同一守卫位（用户拍板口径），SDK 写入层兜底防竞态与非 webview 路径——CLI TUI、VS Code、JetBrains 与桌面端共享同一 SDK 路径，兜底必须在写入点统一执行。

**独立测试**：UI 层——向 webview 推送 `compactionStateChange(true)` 后点击历史会话与「新建对话」，验证不发 `restoreSession`/`clearChat` 且按钮禁用，回落 false 后恢复；SDK 层——在压缩 LLM 调用挂起期间把 messageManager 切到另一会话（变更 sessionId），放行压缩完成，验证不追加摘要块、不写转录、不触发 `onCompactBlockAdded`，且压缩状态仍正常回落为 false；对照组（会话未切换）验证行为与现状一致。

**验收场景**：

1. **假设**压缩正在进行，**当**用户在 webview 点击「历史对话」中的另一会话时，**则**切换被忽略（不发 `restoreSession`），与 streaming/后台任务的守卫行为一致
2. **假设**压缩正在进行，**当**用户点击「新建对话」按钮或输入 `/clear` 时，**则**被忽略（不发 `clearChat`），且「新建对话」按钮禁用
3. **假设**压缩结束（`compactionStateChange` 回落 false），**当**用户再点击历史会话或「新建对话」时，**则**恢复正常可用（守卫解除）
4. **假设**压缩 LLM 调用挂起期间会话被切走（`restoreSession` 使 sessionId 变化），**当**压缩 LLM 调用返回时，**则**摘要块不得追加进切换后会话的内存消息列表，也不得写进其转录文件（放弃本次压缩写入）
5. **假设**压缩 LLM 调用挂起期间会话被清空（`/clear` 产生新 sessionId），**当**压缩完成时，**则**同场景 4：摘要不得写进清空后的新会话
6. **假设**压缩因会话切换放弃写入，**当**压缩流程结束时，**则**压缩状态仍必须回落为 false（`compactionStateChange` 正常发出、webview 提示消失），且不向任何会话追加错误块、不触发 `onCompactBlockAdded`
7. **假设**压缩 LLM 调用失败且期间会话未被切换，**当**错误处理执行时，**则**错误块写回发起压缩的会话（现状不变）
8. **假设**压缩全程会话未切换且成功，**当**压缩完成时，**则**摘要块写回发起压缩的会话（现状不变）

***

### 边界情况

* **压缩期间会话切换（双层守卫）**：webview 层——压缩进行中（`isCompacting`）时历史会话选择与「新建对话 / `/clear`」被忽略、「新建对话」按钮禁用，与 streaming/后台任务共用守卫位（仅 IDE 语义，桌面端并行会话模型不受影响）；SDK 写入层——压缩进入写入阶段前若 messageManager 的 sessionId 与发起压缩时不一致，则整个写入阶段（摘要块追加、压缩后上下文恢复、计划模式提醒注入、SessionStart/PostCompact 钩子、失败错误块）全部放弃；已消耗的 LLM 调用不回收，原会话保持未压缩状态（用户可再次手动压缩）。SDK 兜底覆盖 UI 守卫挡不住的路径：状态通知在途时点击的竞态窗口、CLI TUI 的 `/clear`、未来新增的切换路径
* **压缩状态随会话切换重放**：桌面端并行会话模型允许压缩进行中切换会话（侧栏切换不走 IDE 的同会话守卫）；压缩进行中切换会话时，切回的会话若仍在压缩，`setInitialState` 必须携带该会话的 `isCompacting`，webview 据此恢复压缩提示（`SET_INITIAL_STATE` 不得以「缺省=false」吞掉该字段）；切到其它未压缩会话时不得继承上一会话的提示。流式尾部（`compactionContentUpdate`）不随 `setInitialState` 重放，切回后仅显示基础提示，待下一个流式增量/压缩结束时同步
* **压缩尾部是一次性指示、没有补差通道**：`compactionContentUpdate` 只驱动「正在压缩」提示后的流式尾部，压缩结束时随 `compactionStateChange(false)` 一起清空（webview 清空拼接缓冲、CLI 清空 `compactionStream`）。因此丢失的增量不回补、也没有版本号或游标——下一段增量接着拼、最多尾部短暂缺字，重连 / 回前台 / 切会话后只有基础提示（与上一条同口径）。压缩结束后的摘要正文走 `compactBlockAdded` 的完整消息，不依赖这条流
* **递归压缩**：当压缩已包含摘要的历史时，整个历史（包括旧摘要）被新的延续摘要替换
* **图片处理**：图片必须在压缩 API 调用之前从消息中剥离以减少 token 使用
* **Token 限制边缘**：如果摘要本身太长（不太可能但有可能），系统应该优雅处理
* **API 轮次边界**：压缩绝对不能将 tool_use/tool_result 对分割在压缩边界两侧
* **fork 循环无文本输出**：达到最大轮次（3 轮）或 fork 请求抛异常（用户中止除外）即判定压缩失败，计入熔断器；压缩无 fallback 降级路径（快速模型稳定性不足，且 fork 失败多为上下文本身不可恢复）
* **模型将工具调用语法作为文本输出**：不做内容级拦截，原样透传（与 Claude Code 一致的已知限制——Claude Code 依靠 agent loop 在协议层拒绝真实工具调用，对文本中的乱码双方均不拦截）
* **自动压缩阈值**：阈值为 `contextWindow - min(maxOutputTokens, 20000) - 13000`；其中 `maxOutputTokens` 取当前会话实际生效的最大输出 token 配置，且阈值不得低于 0
* **固定请求开销不重复计入**：最近一次响应的 usage（OpenAI 兼容口径的 `total_tokens`）已经包含 system prompt、工具名称与 schema、动态注入上下文以及缓存命中，请求前估算以此为锚点、只叠加其后新增消息的估算值，禁止再把固定开销额外相加（同一份内容算两遍会让触发点远早于用量指示器的 100%）。固定内容的变化由下一次真实 usage 反映，估算侧不做补偿；无法精确 tokenizer 时允许对新增消息使用统一近似估算
* **无上次响应的 usage 锚点**：会话第一轮请求（或清空后）尚无真实 usage，请求前估算退化为纯字符估算（全部消息按 `estimateTokens` 计算），不得因缺失锚点而跳过检查，也不得用 system prompt / 工具 schema 的估算去补足缺失的固定开销（对齐 Claude Code：首轮超限由下面的「请求前估算偏差」兜底）
* **请求前估算偏差**：估算可能低于真实 token 数，真实超限由 413 / context length 错误路径兜底；不引入响应后主动压缩检查
* **压缩后估算基准**：压缩后内存列表只剩「摘要 + 保留轮次」，自动压缩的请求前估算必须基于「最后压缩边界之后的上下文消息」而非全量消息，否则压缩后估算仍超阈值导致压缩反复触发
* **多次压缩**：每个压缩摘要块保留在各自对应的压缩点，API 上下文从最后一个摘要块开始
* **转录写入去重**：压缩时转录只追加摘要块，保留的最后 API 轮次不重复写盘（压缩前已写入；对齐 Claude Code 的 dedup-skipped）；转录保持每条消息唯一，加载无需去重。旧版本产生的含重复消息转录不受支持，无需迁移清理
* **重启后上下文**：压缩时保留的最后 API 轮次仅在当次会话内存上下文中有效，重启恢复后上下文从最后压缩摘要块开始（对齐 Claude Code：kept 消息不写盘，恢复后仅 boundary + summary + 后续消息）；界面首屏同口径，也从最后压缩摘要块开始，压缩前的消息由用户上翻按页从转录读回（见 `ui/message-rendering-system.md`「内存只装压缩后的上下文，更早的历史按页从转录取」）

## 假设

* 用于总结的 AI 模型能够生成简洁准确的摘要
* 压缩 fork 请求与主对话请求前缀一致时可命中模型侧前缀缓存（多数模型服务默认开启服务端自动前缀缓存）
* token 计数工具相当准确
* 工具结果时间戳在工具完成执行时准确设置
