Appearance
功能规格说明:消息压缩
创建日期:2026-01-22
更新日期:2026-09-29
用户场景与测试 (必填)
用户故事:自动历史压缩(优先级:P1)
作为 AI 代理,当对话历史变得过长时,我希望自动总结较旧的消息,以便保持在模型的 token 限制内同时保持上下文。
为什么是这个优先级:对长时间运行的会话至关重要,以防止"超出上下文窗口"错误并降低成本。
独立测试:可以模拟 token 使用超过阈值并验证 AIManager 触发压缩周期并用摘要块替换旧消息。
验收场景:
- 假设估算的上下文 token 数达到自动压缩阈值,当发送下一个 API 请求前执行检查时,则代理必须识别要压缩的消息并在请求发出前触发压缩
- 假设消息被识别用于压缩,当总结完成时,则原始消息必须被
compress块替换,后跟旧消息列表的最后 2 个 API 轮次 - 假设存在
compress块,当向 API 发送消息时,则它必须被转换为 user 消息(匹配 Claude Code 的自动压缩行为)
用户故事:请求前自动压缩触发(对齐 Claude Code)(优先级:P1)
作为 AI 代理,我希望在发送 API 请求之前基于估算的上下文大小判断是否压缩,而不是在响应返回后才发现超限,以便超限请求永远不会真正发出。
为什么是这个优先级:当前实现是在每次 API 响应返回后检查该次响应的 usage,超限时才压缩——判断慢一拍,导致输入超过 contextWindow 的请求真实发出(可能直接撞上模型 context length / 413 错误)。对齐 Claude Code 的 proactive 机制:请求前用「上次响应的真实 usage + 其后新增消息的估算」预测本次请求的上下文大小,超过阈值先压缩再发。
独立测试:模拟一次巨大工具输出后,验证下一次请求发出前即触发压缩(而非等响应返回);模拟 fork 路径(压缩、auto-memory 提取),验证不触发自动压缩;验证压缩完成后同一轮循环内不再重复触发。
验收场景:
- 假设存在上次 API 响应的
usage,当构造本次请求前执行上下文估算时,则估算值 = 上次响应的total_tokens+ 上次响应之后新增消息的字符估算(CJK 感知,与estimateTokens一致) - 假设估算的上下文 token 数达到自动压缩阈值,当发送请求前检查时,则必须触发压缩,超阈值请求不得发出
- 假设估算的上下文 token 数未超过阈值,当发送请求前检查时,则请求正常发出,不触发压缩
- 假设agent 循环中一轮工具调用结束、下一轮请求将要发出,当发送请求前检查时,则每次请求前(包括工具调用后的后续轮次)都必须重新估算并检查
- 假设当前请求是压缩 fork 或 auto-memory 提取等 fork 路径,当发送请求前检查时,则必须跳过自动压缩判断(fork 继承完整历史,触发压缩会造成递归死锁)
- 假设压缩刚完成、同一轮 agent 循环继续,当发送请求前检查时,则不得基于压缩前的估算值再次触发压缩(压缩结果已保证在阈值内)
- 假设上次响应后的新消息估算与真实 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(与压缩判断口径一致,缓存字段不参与展示)。
验收场景:
- 假设响应的
usage包含total_tokens、cache_read_input_tokens、cache_creation_input_tokens,当判断是否达到自动压缩阈值时,则只使用total_tokens(OpenAI 兼容格式下已含缓存命中),缓存字段不得叠加 - 假设响应仅含
cache_read_input_tokens(缓存命中极大)而total_tokens远低于阈值,当判断是否触发压缩时,则不得触发压缩 - 假设
usage中total_tokens未超阈值但叠加缓存字段后超过,当判断是否触发压缩时,则不得触发压缩 - 假设
latestTotalTokens展示(CLI / webview 的 token 用量显示),当展示 token 使用量时,则同样只使用total_tokens,缓存字段不叠加——与压缩触发判断口径一致,避免 UI 显示"100% context"而压缩迟迟不触发
用户故事:压缩熔断器(优先级:P2)
作为 AI 代理,当压缩反复失败时,我希望停止尝试压缩,以避免在不可恢复的上下文状态上浪费 API 调用。
为什么是这个优先级:当上下文损坏时防止级联故障和不必要的 API 成本。
独立测试:可以模拟 3 次连续压缩失败并验证第 4 次高 token 使用轮次完全跳过压缩。
验收场景:
- 假设压缩已连续失败 3 次,当token 使用再次超过阈值时,则必须跳过压缩并记录警告
- 假设压缩成功,当下一次压缩周期运行时,则连续失败计数器必须重置为 0
用户故事:压缩 fork 代理循环(优先级:P1)
作为 AI 代理,当触发压缩时,我希望在与主对话参数完全一致的 fork 代理循环中生成摘要,使压缩请求命中主对话缓存,且模型无法通过工具调用逃避输出文本摘要。
为什么是这个优先级:压缩请求包含整个对话历史,与主对话保持缓存前缀一致可大幅降低成本;在协议层拒绝工具调用是防止模型以工具调用代替文本摘要的结构性手段(弱模型在纯文本压缩调用中可能将工具调用语法混入正文)。对齐 Claude Code 的 fork 压缩路径。
独立测试:模拟压缩触发,检查 fork 请求的模型/系统提示/工具集/生成参数与主对话一致;模拟模型返回 tool_calls,验证工具未执行、拒绝结果被回灌、循环继续直至文本输出或轮次上限。
验收场景:
- 假设压缩被触发,当构造 fork 请求时,则必须使用主模型(非快速模型),且系统提示、工具集、生成参数与主对话请求完全一致,压缩指令作为末尾 user 消息追加,使命中主对话缓存前缀
- 假设fork 循环中模型返回 tool_calls,当处理该响应时,则任何工具都不得执行,必须向模型回灌"压缩期间不允许使用工具"的拒绝结果并继续循环
- 假设fork 循环达到最大轮次(3 轮)仍无文本输出,当循环结束时,则必须判定压缩失败并计入连续失败计数(最大轮次偏离 Claude Code 的 1 轮:deny 回路内自纠借助缓存命中成本极低)
- 假设构造 fork 请求,当设置生成参数时,则不得显式覆盖最大输出 token 数或 thinking 配置,必须与主对话保持一致(避免缓存键失配,对齐 Claude Code fork 路径)
- 假设压缩指令被构造,当作为 user 消息追加时,则必须以"仅输出文本、禁止调用任何工具、工具调用将被拒绝并导致任务失败"的强制声明开头,并在末尾重申该约束
用户故事:压缩摘要格式提取(优先级:P1)
作为 AI 代理,当压缩模型返回文本时,我希望按约定格式提取摘要正文,以便进入上下文的压缩结果干净、无分析草稿等包装内容。
为什么是这个优先级:压缩提示词要求模型在 <analysis> 标签中打草稿、在 <summary> 标签中输出正文;提取规则保证只有摘要正文进入上下文。行为严格对齐 Claude Code 的 formatCompactSummary,不引入其没有的内容级校验。
独立测试:模拟压缩模型返回带 <analysis>/<summary> 标签、不带标签、含连续空行等不同输出,验证进入上下文的摘要符合提取规则。
验收场景:
- 假设压缩模型输出包含
<analysis>块和<summary>块,当摘要被应用时,则必须剥离<analysis>块,仅保留<summary>标签内的内容 - 假设压缩模型输出不包含
<summary>标签,当摘要被应用时,则原始文本必须原样透传,不做格式拒绝或重试 - 假设压缩模型输出包含连续多个空行,当摘要被应用时,则必须压缩为单个空行并去除首尾空白
用户故事:压缩后上下文恢复(优先级:P2)
作为 AI 代理,在压缩替换对话历史后,我希望重要上下文被重新注入,以便我可以继续工作而不丢失对文件、目录、计划模式、技能和后台任务的追踪。
为什么是这个优先级:防止代理在压缩后丢失关键环境上下文。
独立测试:可以验证压缩后摘要包含最近文件读取、工作目录、计划模式状态、可用技能和后台任务状态的各个部分。
验收场景:
- 假设压缩产生摘要,当摘要被应用时,则它必须增强最近文件读取内容(最多 5 个文件,每个 5000 token)
- 假设压缩被应用,当上下文恢复运行时,则当前工作目录必须被包含
- 假设代理处于计划模式,当上下文恢复运行时,则计划文件路径和存在状态必须被包含
- 假设技能已被调用,当上下文恢复运行时,则必须重新注入该技能的正文与描述——正文取模型当时实际看到的那一份(已替换
${WAVE_SKILL_DIR}等目录占位符、首行含Base directory for this skill: <绝对路径>);记录里拿不到该内容时按技能文件重新渲染,且同样完成同样的替换与首行——不得把${WAVE_SKILL_DIR}这类占位符原样注入,也不得注入未解析的 frontmatter 语法(如>-) - 假设后台任务正在运行,当上下文恢复运行时,则每个代理的描述和状态必须被列出
用户故事:压缩后计划模式提醒保留(优先级:P2)
作为在长时间会话中在计划模式下工作的用户,我希望在对话压缩后重新注入计划模式指令,以便代理即使在历史被摘要替换后仍保持其只读约束和工作流指导。
为什么是这个优先级:如果不重新注入,压缩会从对话历史中移除所有计划模式 <system-reminder> 消息。代理将不知道计划模式约束,可能尝试编辑文件或采取计划文件之外的操作。
独立测试:在计划模式下触发压缩,然后验证完整的计划模式 <system-reminder> 提醒在压缩后作为用户消息注入,且代理继续遵守只读约束。
验收场景:
- 假设代理处于计划模式且发生压缩,当压缩摘要替换对话历史时,则完整的计划模式
<system-reminder>必须在下一个 API 请求中作为用户消息注入 - 假设代理处于计划模式且发生压缩,当计划文件存在时,则重新注入的计划模式提醒必须包含计划文件路径和存在状态
- 假设代理不在计划模式,当发生压缩时,则不注入计划模式提醒
- 假设代理处于计划模式且发生压缩,当重新注入的提醒是压缩后的第一个提醒时,则它必须是完整指令(非稀疏),因为所有先前的提醒已被压缩移除
用户故事:手动 /compact 命令(优先级:P2)
作为用户,我希望手动触发对话压缩并提供可选的自定义指令,以便我可以主动减少上下文使用并将摘要集中在对话的特定方面。
为什么是这个优先级:给予用户对上下文管理的控制,支持在自动压缩触发之前主动压缩,并允许自定义摘要重点。
独立测试:输入 /compact 或 /compact focus on the bug fix discussion,验证压缩发生且可选指令被应用于摘要。
验收场景:
- 假设对话中有消息,当用户输入
/compact时,则对话历史必须立即被压缩,无论 token 使用情况 - 假设对话中有消息,当用户输入
/compact focus on the API design时,则自定义指令必须被传递给压缩 API 调用并影响摘要 - 假设AI 响应正在进行中,当用户输入
/compact时,则/compact被忽略:AI 处理不被中止,对话历史保持不变;AI 空闲时/compact才执行压缩 - 假设压缩已在进行中,当用户再次输入
/compact时,则第二次压缩必须被跳过(熔断器) - 假设已有一段包含多条消息的对话,当
/compact压缩完成,则 三端(CLI / webview / 插件端)的界面当前不被整表替换:已渲染的旧消息留在屏幕上,只追加摘要块;列表口径收缩为「摘要 + 压缩保留的最后轮次」,这次收缩在下一次重载(重新绑定会话 / 恢复 / 重连 / CLI 的结构性刷新)时生效——见ui/message-rendering-system.md「内存只装压缩后的上下文,更早的历史按页从转录取」
用户故事:压缩后旧消息在 UI 保留渲染(优先级:P1)
作为用户,我希望压缩(自动或手动)之后,压缩之前的消息仍然渲染在界面上,压缩摘要以块的形式追加在消息列表末尾,以便我能回看被压缩掉的历史对话,而不必依赖重新加载会话。
为什么是这个优先级:压缩发生时用户正看着这段对话,界面不该在那一刻闪一下或清空——已渲染的内容留在屏幕上、摘要块追加在末尾(参考物同样是 append-only:压缩只追加摘要块,从不整表替换)。注意这只约束当前这次渲染:列表的数据口径收敛为「最近一次压缩 + 其后消息」(见 ui/message-rendering-system.md「内存只装压缩后的上下文,更早的历史按页从转录取」),重载后屏幕从首屏一页重建,压缩前的历史要靠上翻从转录按页读回来;转录始终保留全量。
独立测试:模拟压缩发生,验证当前这次渲染里压缩前的旧消息仍在、压缩摘要块追加在列表末尾;验证发给 API 的上下文仍从最后压缩边界开始(压缩对模型上下文的效果不变)。宿主接线另有三端单测:向宿主注入 compactBlockAdded(带 message)后,断言宿主消息缓存已追加该块、且 webview 收到 appendMessage(VSCE 侧为对应转发回调)——这条断言是"摘要块在插件端不显示"缺陷的回归闸门。
验收场景:
- 假设自动压缩发生,当压缩完成时,则 界面已渲染的旧消息不被移除,压缩摘要块追加在消息列表末尾(三端一致);下一次重载时首屏按压缩后的上下文重建,压缩前的历史仍可上翻取回
- 假设手动
/compact发生,当压缩完成时,则 行为与自动压缩一致:旧消息保留在屏幕上、摘要块追加在列表末尾(首屏口径的收缩同样发生在下一次重载) - 假设压缩后对话继续,当新消息到达时,则新消息追加在压缩摘要块之后
- 假设同一会话在一次连续使用中发生多次压缩,当 UI 渲染时,则每次压缩的摘要块都追加在列表末尾(不整表替换),屏幕上按发生先后能看到多个摘要块;重载后首屏只从最后一个摘要块开始,更早的摘要块与压缩前的消息在上翻时按转录顺序出现(见
ui/message-rendering-system.md「内存只装压缩后的上下文,更早的历史按页从转录取」) - 假设压缩完成,当构造下一个 API 请求时,则上下文仍从最后压缩边界开始,压缩前的旧消息不进入请求(压缩对模型上下文的效果不变)
- 假设CLI 中压缩后的消息总数超过渲染上限(折叠 30 / 展开 10),当渲染时,则只渲染最近的上限条数;三端的列表同口径(摘要 + 压缩保留轮次),转录保留全量
- 假设压缩在任一插件端(VS Code 插件 / JetBrains 插件 / 桌面端)中完成,当写入阶段结束时,则摘要块必须不依赖重新加载会话就出现在消息列表末尾:宿主收到
compactBlockAdded(载荷含摘要块那条完整Message)后,把它追加进自己的消息缓存并经appendMessage下发给 webview——三端行为一致;宿主不得只刷新缓存而不下发(那会让摘要在 webview 上缺失直到下次webviewReady)
用户故事:加载历史会话从最后压缩边界重建(优先级:P1)
作为用户,我希望恢复历史会话时,模型上下文与界面列表都从最后一次压缩开始,而不是把压缩前的旧消息重新装回来,以便会话跑了几周、其中 Read / MCP 读过几十张截图之后,恢复仍然快且省内存——「之前发生过什么」由摘要块承载。
为什么是这个优先级:转录文件是 append-only(压缩时追加摘要块而非删除旧消息),所以「加载时能读到压缩前的消息」这件事本身是成立的;问题在代价——消息里的图片是 base64 形态,把它们重新构造成对象只是为了摆在列表里,而列表的上半部分是用户几乎不会再翻的旧历史。参考物同样把剪枝放在加载期,但剪枝只决定模型上下文与首屏,回看由转录分页承担——屏幕能翻多深与内存装多少是两件事。因此常驻口径统一为「最近一次压缩 + 其后消息」(见 ui/message-rendering-system.md「内存只装压缩后的上下文,更早的历史按页从转录取」),三端(CLI / webview / 插件端)一致;压缩前的消息由用户上翻时按页从转录读回,不进内存常驻。
独立测试:构造含压缩摘要块及其之前旧消息的转录文件,恢复会话后断言列表只含该摘要块及其后消息、断言 API 上下文仍从最后压缩边界开始、断言转录文件未被改写。
验收场景:
- 假设转录文件包含压缩摘要块及其之前的旧消息,当宿主恢复该会话时,则 首屏列表只渲染该摘要块及其后的消息;压缩前的旧消息留在转录里,用户上翻时按页读回并接在列表前面
- 假设宿主恢复的会话含多个压缩摘要块,当重建列表时,则 首屏只从最后一个摘要块开始,更早的摘要块不随首屏下发,但上翻到那里时按转录顺序出现
- 假设恢复会话后用户发送新消息,当构造 API 请求时,则上下文从最后压缩摘要块开始,压缩前的旧消息不进入请求
- 假设会话发生过压缩,当压缩写入转录时,则保留的最后 API 轮次不得重复写盘(它们压缩前已写入;对齐 Claude Code 的 dedup-skipped),转录中每条消息唯一
- 假设CLI 恢复超长会话(消息数超过渲染上限),当渲染时,则只渲染最近的上限条数,不崩溃且性能可接受;列表本身也只有「最近一次压缩 + 其后消息」,不把压缩前的历史读进内存(CLI 没有上翻手势,回看交给终端自身的滚动缓冲)
用户故事:压缩过程中的 CLI 流式尾部提示(优先级:P2)
作为 CLI 用户,我希望压缩(手动 /compact 或自动触发)进行期间,在 "✻ Compacting" 加载提示后看到当前流式传输文本的最后 30 个字符,以便我知道压缩摘要正在生成并能实时看到输出进度。
为什么是这个优先级:压缩 fork 以流式模式请求(为避免网关空闲超时),但此前流式增量被静默消费、不向界面透出;在加载提示后展示流式尾部(与主对话/btw 的流式尾部样式一致)能让用户区分"正在思考"与"已经卡住",无需等待压缩结束。
独立测试:模拟压缩 fork 的 onContentDelta/onReasoningDelta 回调,验证回调内容被转发给 CLI 加载提示;验证超过 30 字符时仅显示 … 前缀截断的最后 30 个字符。
验收场景:
- 假设压缩进行中(手动或自动触发)且模型尚无任何流式输出,则 CLI 在消息列表下方显示 "✻ Compacting",不显示流式尾部。
- 假设压缩进行中且模型已输出文本(普通文本或推理内容,推理经内容通道混合),则 CLI 在 "✻ Compacting" 后显示该文本的最后 30 个字符;文本不超过 30 字符时原样显示,超过 30 字符时以
…前缀截断(换行折叠为\n,与主对话流式尾部样式一致)。 - 假设压缩完成(
compactionStateChange为 false),则 加载提示与流式尾部一并消失,界面上已渲染的旧消息留在原处,压缩摘要块追加在列表末尾。
用户故事:PreCompact 和 PostCompact 钩子事件(优先级:P2)
作为开发者,我希望配置在对话压缩之前和之后运行的钩子,以便我可以自定义压缩行为并以编程方式响应压缩后的摘要。
为什么是这个优先级:支持自定义压缩过程并允许下游系统响应对话摘要,但对基本功能不是关键的。
独立测试:配置 PreCompact 和 PostCompact 钩子,通过 /compact 命令或自动压缩触发压缩,验证 PreCompact 钩子在压缩前执行,PostCompact 钩子在压缩后执行。
验收场景:
- 假设配置了 PreCompact 钩子,当压缩被触发时,则钩子必须在压缩 API 调用之前执行,如果提供了自定义指令则在 JSON 输入中接收
compact_instructions,其 stdout 必须作为额外指令合并 - 假设配置了 PostCompact 钩子,当压缩成功完成时,则钩子必须在压缩 API 调用之后执行并在 JSON 输入中接收包含 AI 生成摘要的
compact_summary - 假设PreCompact 钩子返回退出码 2,当钩子完成时,则错误必须显示给用户但压缩必须继续(非阻塞)
- 假设PostCompact 钩子返回退出码 2,当钩子完成时,则错误必须显示给用户但执行必须继续(非阻塞)
- 假设同时配置了 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 之上)。
验收场景:
- 假设会话的
contextWindow为 200000、最大输出 token 为 32000,当计算自动压缩阈值时,则必须先预留min(32000, 20000)=20000个 compaction summary 输出 token,再预留 13000 个自动压缩安全余量,阈值为 167000 - 假设最大输出 token 小于 20000,当计算自动压缩阈值时,则 compaction summary 预留必须使用实际最大输出 token,而不是固定扣除 20000;阈值为
contextWindow - maxOutputTokens - 13000 - 假设最大输出 token 大于 20000,当计算自动压缩阈值时,则 compaction summary 预留最多只能是 20000;阈值不得因普通请求的更大输出上限继续降低
- 假设模型配置的
contextWindow为其他值(包括按模型覆盖的值),当执行自动压缩判断时,则必须对该模型使用同样的「context window - summary reservation - 13000」公式,不得写死 200000 - 假设对话中已有带 usage 的响应,当请求即将发出、估算是否达到自动压缩阈值时,则必须以最近一次响应 usage 的
total_tokens为锚点,只叠加该响应之后新增消息的估算值;system prompt、工具名称与 schema、动态上下文已包含在该 usage 内,不得再把它们作为固定开销额外相加 - 假设usage 锚点不变而 system prompt 或工具 schema 明显变大(例如注入项目规则、开启更多工具),当执行自动压缩检查时,则估算值不得随之增大,也不得因此提前压缩;这类内容的变化只通过下一次真实 usage 反映
- 假设估算值达到或超过自动压缩阈值,当进入下一次模型请求前执行检查时,则必须先完成自动压缩,原始超阈值请求不得发出
- 假设估算值低于自动压缩阈值,当进入下一次模型请求前执行检查时,则不得仅因为消息历史接近
contextWindow、或因为 system prompt 与工具 schema 很大而提前压缩 - 假设当前请求属于 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,验证各端显示按新值重新计算。
验收场景:
- 假设
contextWindow=200000、最大输出 token 为 32000,当计算 context usage 百分比时,则所有展示必须使用200000 - min(32000, 20000) - 13000 = 167000作为分母 - 假设
latestTotalTokens=83500且阈值为 167000,当各端刷新 context usage 时,则必须显示 50%(按统一的四舍五入规则) - 假设
latestTotalTokens=167000,当各端刷新 context usage 时,则必须显示 100% - 假设
latestTotalTokens超过自动压缩阈值,当各端刷新 context usage 时,则百分比必须钳制为 100%,不得显示超过 100% - 假设模型或配置改变了
contextWindow或最大输出 token,当重新计算 context usage 时,则必须使用该模型当前生效的自动压缩阈值,不得继续使用 200000 或旧阈值 - 假设压缩完成并产生新的
latestTotalTokens,当宿主、webview、CLI 或远控刷新状态时,则必须按新的 token 数和当前自动压缩阈值重新计算,不能沿用压缩前百分比 - 假设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),并测量单条通知的字节数不随已输出长度增长。
验收场景:
- 假设对话中有消息,当用户在插件端或桌面端中输入
/compact时,则客户端必须通过 stdiocompact请求触发压缩,而非将文本作为普通消息发送给模型 - 假设用户输入
/compact focus on the API design,当请求发出时,则自定义指令必须作为customInstructions传递给compact请求并影响摘要 - 假设压缩开始(手动或自动触发),当子进程发送
compactionStateChange(isCompacting=true)时,则webview 必须在消息列表末尾的闪烁光标后显示"正在压缩对话"提示,并在压缩期间保持显示 - 假设压缩进行中且模型已输出文本,当子进程发送
compactionContentUpdate时,则载荷是纯增量片段(与assistantContentUpdated的chunk同族,口径见ui/stdio-transport.md「流式通知纯增量负载」),消费端按序拼接后显示拼接结果的最后 30 个字符(换行折叠为\n,超过 30 字符以…前缀截断,与 CLI/btw 的流式尾部样式一致);单条通知的字节数与已输出总长度无关;模型尚无输出时不显示尾部 - 假设压缩完成,当子进程发送
compactionStateChange(isCompacting=false)时,则webview 必须移除该提示(含流式尾部) - 假设压缩已在进行中,当用户再次输入
/compact时,则第二次压缩必须被跳过(复用压缩熔断器) - 假设压缩状态变化,当宿主收到
compactionStateChange通知时,则宿主不得显示原生通知弹窗,也不得向消息列表插入系统消息 - 假设桌面端处于多窗格(分屏)状态,当某个会话窗格触发压缩时,则压缩提示(含流式尾部)必须仅显示在该窗格中
- 假设桌面端某个会话正在压缩、已显示"正在压缩对话"提示,当用户切换到其它会话再切回该会话时,则该会话的压缩提示必须仍然显示——
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;对照组(会话未切换)验证行为与现状一致。
验收场景:
- 假设压缩正在进行,当用户在 webview 点击「历史对话」中的另一会话时,则切换被忽略(不发
restoreSession),与 streaming/后台任务的守卫行为一致 - 假设压缩正在进行,当用户点击「新建对话」按钮或输入
/clear时,则被忽略(不发clearChat),且「新建对话」按钮禁用 - 假设压缩结束(
compactionStateChange回落 false),当用户再点击历史会话或「新建对话」时,则恢复正常可用(守卫解除) - 假设压缩 LLM 调用挂起期间会话被切走(
restoreSession使 sessionId 变化),当压缩 LLM 调用返回时,则摘要块不得追加进切换后会话的内存消息列表,也不得写进其转录文件(放弃本次压缩写入) - 假设压缩 LLM 调用挂起期间会话被清空(
/clear产生新 sessionId),当压缩完成时,则同场景 4:摘要不得写进清空后的新会话 - 假设压缩因会话切换放弃写入,当压缩流程结束时,则压缩状态仍必须回落为 false(
compactionStateChange正常发出、webview 提示消失),且不向任何会话追加错误块、不触发onCompactBlockAdded - 假设压缩 LLM 调用失败且期间会话未被切换,当错误处理执行时,则错误块写回发起压缩的会话(现状不变)
- 假设压缩全程会话未切换且成功,当压缩完成时,则摘要块写回发起压缩的会话(现状不变)
边界情况
- 压缩期间会话切换(双层守卫):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 计数工具相当准确
- 工具结果时间戳在工具完成执行时准确设置