diff --git a/docs/design/auto-compaction-threshold-redesign.md b/docs/design/auto-compaction-threshold-redesign.md
new file mode 100644
index 00000000000..24caf0b1c18
--- /dev/null
+++ b/docs/design/auto-compaction-threshold-redesign.md
@@ -0,0 +1,428 @@
+# Auto-Compaction Threshold Redesign
+
+**Status:** Draft · 2026-05-14
+
+## 背景
+
+当前 qwen-code 的自动压缩仅使用单一比例阈值 `COMPRESSION_TOKEN_THRESHOLD = 0.7`(`chatCompressionService.ts:33`),所有窗口大小共用同一比例。对比 claude-code 的「绝对 token 梯子」(autoCompact.ts:62-65),qwen-code 存在三个具体问题:
+
+1. **大窗口下预留过多**:1M 模型 70% 阈值在 700K 触发,剩余 300K 远超摘要 + 输出实际所需的 ~33K
+2. **失败 1 次永久锁**:`hasFailedCompressionAttempt = true` 之后整个 session 不再尝试 auto-compact(geminiChat.ts:504),比 claude-code 的「连续 3 次熔断」更严苛
+3. **tip 系统与 auto 阈值脱钩**:`tipRegistry.ts` 里的三条 `context-*` tip 使用固定的 50/80/95 百分比,与 auto-compact 阈值(70%)完全独立。这意味着在「auto 正常工作」的主路径上 80% / 95% tip 极少触发,而在「auto 失败 / 反应式兜底」的边缘路径上又缺乏与阈值对齐的语义
+4. **压缩调用本身没有输出预算控制**:[chatCompressionService.ts:374-376](packages/core/src/services/chatCompressionService.ts:374) 显式开启 `thinkingConfig.includeThoughts = true`(注释:「Compression quality drives every subsequent main turn」),同时 sideQuery 调用未设 `maxOutputTokens` 上限。代码注释([:436-437](packages/core/src/services/chatCompressionService.ts:436))也承认 `compressionOutputTokenCount may include non-persisted tokens (thoughts)`。在压缩接近窗口顶时,总输出可能膨胀,使 buffer 预留缺乏可预测上限。
更糟糕的是跨 provider 行为不一致:Anthropic 的 thinking budget 与 max_tokens 完全独立;OpenAI 的 reasoning tokens 不受 max_completion_tokens 限制;Gemini 的行为又因模型版本而异。这意味着「单靠加 maxOutputTokens 就能控制总输出」在 qwen-code 这种多 provider 项目里不成立
+
+5. **阈值判断使用的 `lastPromptTokenCount` 系统性下偏。** [geminiChat.ts:1217-1232](packages/core/src/core/geminiChat.ts:1217) 表明这个数来自上一轮 API response 的 `usageMetadata.totalTokenCount`。两个 gap:(a) 不包含本轮即将加入的 user message,每次 cheap-gate 判断都比真实 prompt 小一段;(b) 首轮初始值是 0,`--continue` 恢复巨大 session / sub-agent 继承大量历史时第一次 send 永远绕过所有阈值。对比 claude-code 的 `tokenCountWithEstimation`([query.ts:638](src/query.ts:638))走「最后一条 assistant API usage + 之后新增 message 估算」的双轨制能闭合这两个 gap
+
+## 设计目标
+
+- 引入「比例 + 绝对」混合阈值,让大窗口模型由绝对值接管,小窗口仍走比例兜底
+- 新增 warn / hard 两层(auto 保留为主触发点),形成三层梯子
+- 把 tip 系统重写为跟随新阈值的触发条件
+- 失败处理从「1 次永久锁」升级为「3 次熔断 + 自动恢复」
+- **压缩调用关闭 thinking 并加 `maxOutputTokens` 上限**:与 claude-code 对齐,让总输出受单一参数约束、buffer 预算可预测;接受压缩质量可能下降的代价
+- **加 token 估算补偿**:消除 `lastPromptTokenCount` 的「滞后一轮」和「首轮为 0」两个系统性下偏,让阈值判断更贴近真实 prompt 大小
+- 删除 settings 里的 `contextPercentageThreshold` 配置入口(内部 PCT 常量保留)
+- **不引入** env 覆盖通道、**不**新增显式 enabled 开关
+
+## 三层阈值梯子
+
+```
+ window (raw context window)
+ │
+ │ ← SUMMARY_RESERVE = 20K
+ ▼
+ effectiveWindow
+ │
+ │ ← HARD_BUFFER = 3K
+ ▼
+ hard_threshold = effectiveWindow - 3K
+ │
+ │ ← (AUTOCOMPACT_BUFFER - HARD_BUFFER) = 10K
+ ▼
+auto_threshold = max(PCT * window, effectiveWindow - AUTOCOMPACT_BUFFER)
+ │
+ │ ← WARN_BUFFER = 20K
+ ▼
+warn_threshold = max((PCT - WARN_OFFSET) * window, auto_threshold - WARN_BUFFER)
+ │
+ ▼
+ 0
+```
+
+### 三层语义
+
+| 层 | 触发条件 | 行为 |
+| -------- | ------------------------------ | -------------------------------------------------------- |
+| **warn** | `tokenCount >= warn_threshold` | UI 提示「距自动压缩还剩 X tokens」,不改变 send 行为 |
+| **auto** | `tokenCount >= auto_threshold` | 在 send 前 `tryCompress(force=false)`,正常压缩流程 |
+| **hard** | `tokenCount >= hard_threshold` | 在 send 前 `tryCompress(force=true)`,重置失败锁强制压缩 |
+
+`hard` 层等同于把现有 reactive overflow(geminiChat.ts:711)的兜底逻辑提前到 send 前,避免一次失败的 oversized request round-trip。
+
+## 内部常量
+
+```ts
+// chatCompressionService.ts
+const DEFAULT_PCT = 0.7; // auto 比例兜底
+const WARN_PCT_OFFSET = 0.1; // warn 比例 = PCT - WARN_OFFSET = 0.6
+const COMPACT_MAX_OUTPUT_TOKENS = 20_000; // 压缩 sideQuery 输出硬上限(thinking + summary 合计)
+const SUMMARY_RESERVE = 20_000; // 阈值梯子从窗口顶减去的输出预留 = maxOutput
+const AUTOCOMPACT_BUFFER = 13_000; // auto 与 effectiveWindow 间距
+const WARN_BUFFER = 20_000; // warn 与 auto 间距
+const HARD_BUFFER = 3_000; // hard 与 effectiveWindow 间距
+const MAX_CONSECUTIVE_FAILURES = 3; // 失败熔断阈值
+```
+
+数值来源:全部沿用 claude-code 的实测值([autoCompact.ts:30,62-65](src/services/compact/autoCompact.ts:30))。
+
+`SUMMARY_RESERVE = COMPACT_MAX_OUTPUT_TOKENS` 是关键关系:模型受 `maxOutputTokens` 硬限制约束,输出不可能超出 20K,因此 reserve 不需要额外 safety margin。`thinking + summary` 是合并预算(Gemini SDK / 多数 provider 的 `maxOutputTokens` 语义),模型自行在两者间分配。
+
+## 计算函数
+
+```ts
+export interface CompactionThresholds {
+ warn: number;
+ auto: number;
+ hard: number; // 当 hard < auto 时等于 auto(小窗口退化)
+ effectiveWindow: number;
+}
+
+export function computeThresholds(window: number): CompactionThresholds {
+ const effectiveWindow = window - SUMMARY_RESERVE;
+
+ const absAuto = effectiveWindow - AUTOCOMPACT_BUFFER;
+ const auto = Math.max(DEFAULT_PCT * window, absAuto);
+
+ const absWarn = auto - WARN_BUFFER;
+ const warn = Math.max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, absWarn);
+
+ const rawHard = effectiveWindow - HARD_BUFFER;
+ const hard = Math.max(rawHard, auto); // 小窗口下退化为 auto
+
+ return { warn, auto, hard, effectiveWindow };
+}
+```
+
+### 实测数据
+
+| 窗口 | warn | auto | hard | 备注 |
+| ---- | ----------- | ----------- | ------------ | ------------------------------- |
+| 32K | 19.2K (pct) | 22.4K (pct) | 22.4K (退化) | 比例兜底 |
+| 64K | 38.4K (pct) | 44.8K (pct) | 44.8K (退化) | 比例兜底 |
+| 128K | 76.8K (pct) | 95K (abs) | 105K (abs) | 混合(warn=pct, auto/hard=abs) |
+| 200K | 147K (abs) | 167K (abs) | 177K (abs) | 绝对接管 |
+| 256K | 203K (abs) | 223K (abs) | 233K (abs) | 绝对接管 |
+| 1M | 947K (abs) | 967K (abs) | 977K (abs) | 全绝对 |
+
+`(pct)` 表示该层由比例公式决定,`(abs)` 表示由绝对值公式决定。
+
+## 用户配置
+
+### ChatCompressionSettings 变更
+
+```ts
+// packages/core/src/config/config.ts:217
+export interface ChatCompressionSettings {
+ /** 保留(与本设计无关,由 compactionInputSlimming 使用) */
+ imageTokenEstimate?: number;
+}
+```
+
+**删除:** `contextPercentageThreshold` 字段。理由:
+
+1. 新公式下,对主流窗口(>= 128K)该字段几乎无影响——绝对值接管
+2. 小窗口下用户配置反而可能让阈值"更早"压缩,与节省 token 直觉相反
+3. claude-code 没有暴露此字段,无类似的用户面配置先例
+
+### Breaking change 处理
+
+**用户面:** 启动时 `Config` 加载发现 `chatCompression.contextPercentageThreshold` 存在:
+
+- 写入 stderr 一行警告:`"chatCompression.contextPercentageThreshold has been removed and is now controlled by built-in thresholds."`
+- **不**报错、**不**阻塞启动
+- 字段值被忽略
+
+**SDK 面(R5.4):** `CompressOptions` 的 `hasFailedCompressionAttempt: boolean` 字段重命名为 `consecutiveFailures: number`。两点差异:
+
+| | 旧字段 | 新字段 |
+| ---- | ------------------------------ | -------------------------------------------------------------------- |
+| 名称 | `hasFailedCompressionAttempt` | `consecutiveFailures` |
+| 类型 | `boolean` | `number` |
+| 语义 | `true` = 永久禁用 auto-compact | `>= MAX_CONSECUTIVE_FAILURES`(默认 3)= 暂时禁用直到 force 成功重置 |
+
+仓库内只有 `GeminiChat.tryCompress` 一个内部消费方,所以内部 migration 风险低;但 `@qwen-code/qwen-code-core` 是 published package、`CompressOptions` 在 d.ts 里可见,下游 SDK 直接调 `service.compress({ ..., hasFailedCompressionAttempt: true })` 的代码会拿到 TS 编译错误。**迁移指引:** 把 `true` 改为 `MAX_CONSECUTIVE_FAILURES`(或任意 >= 3 的整数),`false` 改为 `0`。如果调用方维护自己的失败计数,直接传入即可。
+
+## Token 估算补偿
+
+qwen-code 的 `lastPromptTokenCount` 来自上一轮 API response 的 `usageMetadata.totalTokenCount`([geminiChat.ts:1217-1232](packages/core/src/core/geminiChat.ts:1217))。这导致:
+
+1. **滞后一轮**:cheap-gate 用 `lastPromptTokenCount` 判断,但本次 send 实际 prompt = 它 + 本轮 user message。少算的部分可能让阈值判断 false-negative
+2. **首轮为 0**:初始值是 0,第一次 send 时无论历史多大都不会触发任何阈值(含 `--continue` 恢复 / sub-agent 继承场景)
+
+引入轻量本地估算函数 `estimatePromptTokens`,在 send 前 cheap-gate / hard 判断时补足这两段缺失:
+
+```ts
+// chatCompressionService.ts(或新文件 packages/core/src/services/tokenEstimation.ts)
+
+const BYTES_PER_TOKEN = 4; // 通用 char/4 估算(claude-code 同此)
+const BYTES_PER_TOKEN_JSON = 2; // JSON / tool_call input 更密集
+
+/**
+ * 估算一组 Content 的 token 数,用于补偿 API usage metadata 的滞后。
+ * 对 image / document 复用现有 imageTokenEstimate(默认 1600)。
+ */
+export function estimateContentTokens(
+ contents: Content[],
+ imageTokenEstimate = DEFAULT_IMAGE_TOKEN_ESTIMATE,
+): number {
+ // 复用 estimateContentChars(compactionInputSlimming.ts),再除以 bytesPerToken
+ // 内部对 functionCall / functionResponse 用 BYTES_PER_TOKEN_JSON
+ // ...
+}
+
+/**
+ * cheap-gate 与 hard 判断的统一入口。
+ * 主路径:lastPromptTokenCount 准 + 本轮 user message 估算
+ * 首轮路径:full history 估算
+ */
+export function estimatePromptTokens(
+ history: Content[],
+ userMessage: Content,
+ lastPromptTokenCount: number,
+): number {
+ if (lastPromptTokenCount > 0) {
+ return lastPromptTokenCount + estimateContentTokens([userMessage]);
+ }
+ return estimateContentTokens([...history, userMessage]);
+}
+```
+
+应用位置:
+
+- `chatCompressionService.compress()` 的 cheap-gate:把 `originalTokenCount` 来源换成 `estimatePromptTokens(history, userMessage, lastPromptTokenCount)`
+- `geminiChat.sendMessageStream` 入口的 hard 判断(见下一节)
+
+**估算只用于提前触发,不用于「跳过触发」。** 因为 char/4 是粗略下界估计,作为 false-positive 一侧是安全的(宁可早一点压),作为 false-negative 则不可靠。
+
+## 触发链路改动
+
+### chatCompressionService.ts
+
+1. **导出 `computeThresholds`**,供 cheap-gate / UI / 命令复用
+2. **`compress()` cheap-gate** (line 221-249):
+ ```ts
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES && !force) {
+ return NOOP;
+ }
+ const { auto } = computeThresholds(contextLimit);
+ const effectiveTokens = estimatePromptTokens(
+ curatedHistory,
+ userMessage,
+ originalTokenCount,
+ );
+ if (!force && effectiveTokens < auto) return NOOP;
+ ```
+3. **`compress()` 的 runSideQuery 调用** (line 356-380):关闭 thinking + 加 `maxOutputTokens`:
+
+ ```ts
+ const summaryResult = await runSideQuery(config, {
+ // ...
+ config: {
+ thinkingConfig: { includeThoughts: false }, // 关闭 thinking(与 claude-code 一致)
+ maxOutputTokens: COMPACT_MAX_OUTPUT_TOKENS, // 硬上限 20K
+ },
+ // ...
+ });
+ ```
+
+ 或者直接删掉 `thinkingConfig` 让 `runSideQuery` 默认值([sideQuery.ts:118](packages/core/src/utils/sideQuery.ts:118) 默认 `includeThoughts: false`)接管。
+
+ 关 thinking 后,`maxOutputTokens` 直接约束总输出(不存在 thinking 单独 budget 的问题),`SUMMARY_RESERVE = maxOutput = 20K` 是干净的硬关系。
+
+ 同时更新 [chatCompressionService.ts:374-376](packages/core/src/services/chatCompressionService.ts:374) 的注释,从「Compression quality drives every subsequent main turn — keep reasoning on」改为说明「为保证跨 provider 可预测的输出上限,与 claude-code 设计对齐」。
+
+ token math 一段([:436-437](packages/core/src/services/chatCompressionService.ts:436))的 "may include non-persisted tokens (thoughts)" 注释也可以同步清理
+
+### geminiChat.ts: `sendMessageStream` 入口(line 562)
+
+```ts
+// 替换前:tryCompress(force=false)
+// 替换后:用估算 token 判断是否触发 hard,决定 force 标志
+
+const { hard } = computeThresholds(contextLimit);
+const effectiveTokens = estimatePromptTokens(
+ this.getHistory(true),
+ createUserContent(params.message),
+ this.lastPromptTokenCount,
+);
+const shouldForceFromHard = effectiveTokens >= hard;
+
+if (shouldForceFromHard) {
+ // 重置熔断器,等同 force compress
+ this.consecutiveFailures = 0;
+}
+
+compressionInfo = await this.tryCompress(
+ prompt_id,
+ model,
+ shouldForceFromHard,
+ params.config?.abortSignal,
+);
+```
+
+### 失败处理升级 (`geminiChat.ts:504-510`)
+
+```ts
+// 替换前
+hasFailedCompressionAttempt: boolean;
+
+// 替换后
+consecutiveFailures: number; // 默认 0
+
+// 失败分支
+} else if (isCompressionFailureStatus(info.compressionStatus)) {
+ if (!force) {
+ this.consecutiveFailures += 1;
+ }
+}
+
+// 成功分支
+this.consecutiveFailures = 0;
+```
+
+`force=true` 调用失败不计入计数(保持现有 reactive / manual 不"占额"的语义)。
+
+## UI 改动
+
+### tipRegistry.ts 重写三条 context-\* tip
+
+三层阈值正好与三条 tip 一一对应。映射关系(按 token 数从低到高):
+
+| Tip ID | 当前条件 | 新条件 | 文案变化 |
+| ------------------ | --------------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------------------------------- |
+| `compress-intro` | `pct >= 50 && < 80 && sessionPromptCount > 5` | `tokenCount >= warn && tokenCount < auto && sessionPromptCount > 5` | 保持不变 |
+| `context-high` | `pct >= 80 && < 95` | `tokenCount >= auto && tokenCount < hard` | 保持不变 |
+| `context-critical` | `pct >= 95` | `tokenCount >= hard` | 加一句「Auto-compact will force on next send.」反映新 hard 层行为 |
+
+**对触发频率的影响:**
+
+- 主路径(auto 正常工作):`tokenCount` 跨越 auto 后立即触发压缩,下一轮 tokenCount 回落,所以 `context-high` 仅在「触发到压缩生效之间」短暂可见
+- 边缘路径(auto 失败 / 熔断 / reactive 来不及):`tokenCount` 持续上涨,会依次穿过 warn → auto → hard 触发三条 tip,跟用户视角的"上下文越来越紧"一致
+- `context-critical` 触发时 hard 层已经在 send 前 force compress(spec 触发链路改动一节),所以这条 tip 实际上是「post-rescue 告知」而非「pre-rescue 警告」,文案补一句说明
+
+`TipContext` 接口增加:
+
+```ts
+export interface TipContext {
+ lastPromptTokenCount: number;
+ contextWindowSize: number;
+ sessionPromptCount: number;
+ sessionCount: number;
+ platform: string;
+ // 新增:让 isRelevant 函数能拿到阈值。
+ // computeThresholds 在调用方算好后注入,避免 tipRegistry 直接依赖 core。
+ thresholds?: CompactionThresholds;
+}
+```
+
+`AppContainer.tsx:1150` 构造 `TipContext` 时同步注入。
+
+### /context 命令同步 (`contextCommand.ts:177-183`)
+
+```ts
+// 替换硬编码 (1 - threshold) * contextWindowSize
+const { warn, auto, hard, effectiveWindow } =
+ computeThresholds(contextWindowSize);
+
+// 显示四行:
+// Effective window: 180K (window − 20K reserve)
+// Warn threshold: 147K (...)
+// Auto threshold: 167K ← 当前位置
+// Hard threshold: 177K
+// 标记当前 token count 落在哪个 tier
+```
+
+### Footer 持续提示(可选 follow-up)
+
+本 spec 不强制实现 footer 持续提示,理由:
+
+- 现有 tip 系统已经能在 history 里给出提示
+- Footer 持续提示需要改 ink 渲染、增加重绘频率
+- 可作为本 spec 后置 follow-up(独立 PR)
+
+如果后续要做,建议触发条件 `tokenCount >= warn && tokenCount < auto`,超过 auto 后隐藏(压缩已开始)。
+
+## 测试覆盖
+
+### 单元测试(chatCompressionService.test.ts)
+
+- `computeThresholds(32K)` → 比例兜底分支(warn/auto 均 pct,hard 退化)
+- `computeThresholds(128K)` → 混合分支(warn=pct,auto=abs,hard=abs)
+- `computeThresholds(200K)` → 绝对接管分支(warn/auto/hard 均 abs)
+- `computeThresholds(1M)` → 全绝对分支
+- `computeThresholds(window=10K)` → 极小窗口(绝对值全负),公式不崩
+- 三层阈值始终满足 `warn <= auto <= hard`
+- max() 公式在边界点(pct \* window == abs)稳定
+
+### 单元测试(tokenEstimation.test.ts)
+
+- `estimateContentTokens` 对纯文本 / json / functionCall / functionResponse / image / document 分别走对应 bytesPerToken
+- `estimatePromptTokens` 在 `lastPromptTokenCount > 0` 时走「主路径」,等于 0 时走「首轮路径」
+- 大 user message 在 cheap-gate 阶段被加上去后能跨越 auto 阈值
+- 估算与真实 API usage 的偏差在 ±30% 以内(用真实历史样本回归)
+
+### 集成测试(geminiChat.test.ts / chatCompressionService.test.ts)
+
+- 3 次连续失败后 cheap-gate NOOP;下一次 force 后恢复
+- 单次失败不再永久锁
+- 估算 token 跨越 hard 后 send 自动 force compress
+- 压缩 sideQuery 调用 `maxOutputTokens = COMPACT_MAX_OUTPUT_TOKENS` 正确透传到 `runSideQuery`,`thinkingConfig.includeThoughts` 为 `false`(或被 sideQuery 默认值接管)
+- **首轮覆盖**:构造一个 `lastPromptTokenCount = 0` 但 history 巨大的 chat(模拟 `--continue` 恢复),首次 send 时 auto 阈值能被估算路径触发
+
+### 兼容性测试
+
+- 设置 `contextPercentageThreshold = 0.5` 启动 → stderr 警告 + 字段被忽略,行为以内部 PCT 常量为准
+
+### Tip 系统测试(tipRegistry.test.ts)
+
+- 三条 context-\* tip 在跨越 warn/auto/hard 时正确触发,且区间不重叠
+- 主路径下 auto 阈值触发压缩后 `context-high` 不持续可见
+- 边缘路径(熔断 + token 继续涨)下三条 tip 依次触发
+- TipContext 缺 `thresholds` 时(fallback)行为合理
+
+## 实施分阶段
+
+| Phase | 内容 | 独立性 |
+| ----- | -------------------------------------------------------------------------------------------- | ------------------ |
+| 1 | 内部常量 + `computeThresholds` + cheap-gate 改动(不含估算补偿) | 可独立合并 |
+| 2 | 失败处理升级(1 → 3 熔断) | 可独立合并 |
+| 3 | hard 层 force compress 提前 | 依赖 P1 + P7 |
+| 4 | 配置面变更 + breaking change 警告 | 依赖 P1 |
+| 5 | UI(tip 重写 + /context) | 依赖 P1 |
+| 6 | 压缩 sideQuery 关 thinking + 加 `maxOutputTokens` 上限 | 独立可先于 P1 落地 |
+| 7 | Token 估算补偿(`estimateContentTokens` + `estimatePromptTokens`,应用到 cheap-gate / hard) | 独立可与 P1 并行 |
+
+每个 Phase 可独立 PR。建议合并顺序 **P6 → P7 → P1 → P2 → P4 → P3 → P5**:先给压缩调用打上 `maxOutputTokens` 上限(让 buffer 假设可信);再加估算补偿(让 token 数判断更可靠);再把阈值基础设施落地;再做失败熔断、配置面变更;最后才打开 hard 层主动救场(这时已有可靠的 token 数 + 熔断器)。每个 PR 都能独立验证、独立回滚。
+
+## 风险与注意事项
+
+1. **关 thinking 可能影响摘要质量。** 原作者注释 "Compression quality drives every subsequent main turn — keep reasoning on" 表达过对此的担忧。本 spec 的判断是「可预测的 token 上限」优先于「最大化质量」,但落地后需要观察 telemetry 里 `compression_input_token_count` / `compression_output_token_count` 的分布,以及主对话在压缩后的质量变化(用户反馈、`COMPRESSION_FAILED_*` 状态率)。如果质量下降明显,再考虑回退到 thinking 开启 + provider-specific thinkingBudget 控制。
+
+2. **`maxOutputTokens` 触顶可能导致 summary 被截断。** 关 thinking 后,20K 直接限制 summary 主体;claude-code 实测 p99.99 ≈ 17K,留 ~3K 安全冗余。但 qwen-code 的压缩 prompt 与 claude-code 不同,分布需要观测。建议在压缩失败分支([chatCompressionService.ts:464-491](packages/core/src/services/chatCompressionService.ts:464))追加「检测到 finish_reason = MAX_TOKENS」的 NOOP 路径,避免持久化半截 summary。
+
+3. **跨 provider 的 maxOutputTokens 映射差异。** OpenAI compat (dashscope) → `max_tokens`、Anthropic → `max_tokens`、Gemini SDK → `maxOutputTokens`。当前 qwen-code 已有这层映射([contentGenerator.ts:94](packages/core/src/core/contentGenerator.ts:94) 等),需要在 P6 实现时验证 sideQuery 路径上 `maxOutputTokens` 字段确实贯穿到所有 provider 的请求体。
+
+4. **Token 估算是粗略下界,不应反向用作"跳过触发"的依据。** `char/4` 与各 provider 真实 tokenizer 偏差可能 ±30%。本 spec 只用估算来「让阈值更早触发」(false-positive 方向,宁可早压不可晚压)。所有「降低 token 计数 / 跳过压缩」的代码路径仍应使用 `lastPromptTokenCount`(API 权威值)。
+
+5. **估算函数与现有 `estimateContentChars` 的关系。** [compactionInputSlimming.ts](packages/core/src/services/compactionInputSlimming.ts) 已经有 `estimateContentChars`(用于压缩 split point 计算),新增的 `estimateContentTokens` 应复用它(除以 bytesPerToken)而非新写一套,避免两套估算口径出现分歧。
+
+## 不在本 spec 范围
+
+- Env 变量覆盖通道(D 方案):维持「配置面最小」原则
+- Footer 常驻可视化:留作 follow-up
+- 摘要 prompt 改进、`MIN_COMPRESSION_FRACTION` 调整:与阈值设计正交
+
+## 开放问题(等 review)
+
+1. **breaking change 强度**:警告 + 忽略字段 vs 启动报错。当前选警告,需要确认对企业部署/团队配置是否够友好
+2. **小窗口(32K)下 hard 与 auto 退化为同一值**:用户视角是否需要在 `/context` 明示「该窗口下 hard 已退化」
diff --git a/docs/plans/2026-05-14-auto-compaction-threshold-redesign.md b/docs/plans/2026-05-14-auto-compaction-threshold-redesign.md
new file mode 100644
index 00000000000..41efc45d78e
--- /dev/null
+++ b/docs/plans/2026-05-14-auto-compaction-threshold-redesign.md
@@ -0,0 +1,1752 @@
+# Auto-Compaction Threshold Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 把 qwen-code 自动压缩的单层比例阈值(70%)升级为「比例 + 绝对」混合的三层阈值梯子(warn / auto / hard),同时给压缩调用本身打上 `maxOutputTokens` 上限、关闭 thinking、引入失败熔断、修复 `lastPromptTokenCount` 的滞后/首轮缺口、清理用户配置面。
+
+**Architecture:**
+
+- `chatCompressionService.ts` 新增 `computeThresholds(window)` 输出 `{ warn, auto, hard }`;cheap-gate 用 `auto`,`sendMessageStream` 入口加 hard 主动救场。
+- 新建 `tokenEstimation.ts` 提供本地 char/4 估算函数,补偿 `lastPromptTokenCount` 的「滞后一轮 + 首轮为 0」两个 gap。
+- 失败处理从 `hasFailedCompressionAttempt: boolean` 单次锁升级为 `consecutiveFailures: number` 三次熔断。
+- 压缩 sideQuery 调用关 thinking + 加 `maxOutputTokens: 20K`。
+- 删除 `chatCompression.contextPercentageThreshold` settings 字段,启动时遇旧配置 stderr 警告并忽略。
+- `tipRegistry.ts` 三条 context-\* tip 重写为跟随新阈值;`/context` 命令显示三层数值。
+
+**Tech Stack:** TypeScript, Vitest, `@google/genai`, 现有 `compactionInputSlimming` 估算工具。
+
+**合并顺序:** P6 → P7 → P1 → P2 → P4 → P3 → P5。每个 Task 都是单 PR 候选。
+
+---
+
+## 文件结构
+
+| 路径 | 操作 | 责任 |
+| ----------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------- |
+| `packages/core/src/services/tokenEstimation.ts` | 创建 | 字符级 token 估算 + `estimatePromptTokens` 入口 |
+| `packages/core/src/services/tokenEstimation.test.ts` | 创建 | 估算函数单元测试 |
+| `packages/core/src/services/chatCompressionService.ts` | 修改 | 新增常量 + `computeThresholds`;改 cheap-gate;关 thinking + maxOutput;改失败计数 |
+| `packages/core/src/services/chatCompressionService.test.ts` | 修改 | computeThresholds 单测 + cheap-gate / sideQuery config 断言 |
+| `packages/core/src/core/geminiChat.ts` | 修改 | `sendMessageStream` 入口加 hard 检查;`hasFailedCompressionAttempt` → `consecutiveFailures` |
+| `packages/core/src/core/geminiChat.test.ts` | 修改 | hard 触发 + 熔断器 + 首轮覆盖集成测试 |
+| `packages/core/src/config/config.ts` | 修改 | `ChatCompressionSettings` 删除 `contextPercentageThreshold`;启动 warning |
+| `packages/cli/src/services/tips/tipRegistry.ts` | 修改 | 三条 context-\* tip 改用阈值绝对比较;`TipContext` 加 `thresholds` |
+| `packages/cli/src/services/tips/tipRegistry.test.ts` | 创建/修改 | tip 触发区间测试 |
+| `packages/cli/src/ui/commands/contextCommand.ts` | 修改 | 显示新三层阈值 |
+| `packages/cli/src/ui/commands/contextCommand.test.ts` | 修改 | 输出快照 |
+| `packages/cli/src/ui/AppContainer.tsx` | 修改 | 构造 `TipContext` 时注入 `thresholds` |
+
+---
+
+## Phase P6 — 压缩 sideQuery 关 thinking + 加 maxOutputTokens
+
+第一个落地,让后续阈值假设可信。独立 PR。
+
+### Task 1: 改 chatCompressionService 的 sideQuery 调用
+
+**Files:**
+
+- Modify: `packages/core/src/services/chatCompressionService.ts:374-376`
+- Modify: `packages/core/src/services/chatCompressionService.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+在 `chatCompressionService.test.ts` 顶部 import 部分增加 spy 入口,并在合适的 describe 内加测试。`runSideQuery` 已经是模块导出,可以 spyOn:
+
+```ts
+import * as sideQueryModule from '../utils/sideQuery.js';
+
+describe('ChatCompressionService.compress sideQuery config', () => {
+ it('passes maxOutputTokens=20_000 and includeThoughts=false to runSideQuery', async () => {
+ const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'summary',
+ usage: {
+ promptTokenCount: 1000,
+ candidatesTokenCount: 500,
+ totalTokenCount: 1500,
+ },
+ } as any);
+
+ const service = new ChatCompressionService();
+ await service.compress(makeFakeChat(), {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ hasFailedCompressionAttempt: false,
+ originalTokenCount: 180_000,
+ });
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ const callArg = spy.mock.calls[0]![1];
+ expect(callArg.config?.thinkingConfig?.includeThoughts).toBe(false);
+ expect(callArg.config?.maxOutputTokens).toBe(20_000);
+ });
+});
+```
+
+`makeFakeChat` / `makeFakeConfig` 复用现有测试 helper(如果文件里已有,直接用;没有就 inline 一个最小桩)。
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts -t 'passes maxOutputTokens=20_000'
+```
+
+Expected: FAIL — 现在传入的是 `{ thinkingConfig: { includeThoughts: true } }`,且没有 `maxOutputTokens`。
+
+- [ ] **Step 3: Implement — 修改 chatCompressionService.ts**
+
+替换 [chatCompressionService.ts:374-376](packages/core/src/services/chatCompressionService.ts:374) 整段 `config:`:
+
+```ts
+const summaryResult = await runSideQuery(config, {
+ purpose: 'chat-compression',
+ model,
+ maxAttempts: 1,
+ systemInstruction: getCompressionPrompt(),
+ contents: [
+ ...slim.slimmedHistory,
+ {
+ role: 'user',
+ parts: [
+ {
+ text: 'First, reason in your scratchpad. Then, generate the .',
+ },
+ ],
+ },
+ ],
+ // Compression output is bounded by maxOutputTokens to guarantee a predictable
+ // reserve across providers (see docs/design/auto-compaction-threshold-redesign.md).
+ // Thinking is disabled because per-provider thinking-budget semantics are
+ // inconsistent (Anthropic/OpenAI count it separately, Gemini varies by model).
+ config: {
+ thinkingConfig: { includeThoughts: false },
+ maxOutputTokens: COMPACT_MAX_OUTPUT_TOKENS,
+ },
+ abortSignal: signal ?? new AbortController().signal,
+ promptId,
+});
+```
+
+在文件顶部常量区(紧跟 `TOOL_ROUND_RETAIN_COUNT` 之后)加:
+
+```ts
+/**
+ * Hard cap on the compression sideQuery output (summary text only, since
+ * thinking is disabled). Mirrors claude-code's MAX_OUTPUT_TOKENS_FOR_SUMMARY
+ * (autoCompact.ts:30) which is based on p99.99 of real compaction outputs.
+ */
+export const COMPACT_MAX_OUTPUT_TOKENS = 20_000;
+```
+
+同时清理 `compress()` 内 token math 段(约 line 436-437)那条 `"may include non-persisted tokens (thoughts)"` 注释 —— 现在不存在 thinking 输出了,把句子改成「compressionOutputTokenCount reflects the summary tokens only since thinking is disabled」。
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts
+```
+
+Expected: PASS(新测试 + 现有测试不应回归)
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+Expected: 无错误。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/services/chatCompressionService.ts packages/core/src/services/chatCompressionService.test.ts
+git commit -m "$(cat <<'EOF'
+feat(core): cap compression sideQuery output and disable thinking
+
+Add COMPACT_MAX_OUTPUT_TOKENS=20_000 and pass maxOutputTokens to the
+runSideQuery call, disable thinkingConfig.includeThoughts. Aligns with
+claude-code's autoCompact reserve so the downstream threshold ladder
+(P1/P3) can rely on a predictable upper bound on summary output across
+providers (Anthropic / OpenAI / Gemini handle thinking budgets
+inconsistently).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Phase P7 — Token 估算补偿
+
+修复 `lastPromptTokenCount` 的滞后/首轮缺口。3 个 Task。
+
+### Task 2: 新建 tokenEstimation.ts 单元
+
+**Files:**
+
+- Create: `packages/core/src/services/tokenEstimation.ts`
+- Create: `packages/core/src/services/tokenEstimation.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/core/src/services/tokenEstimation.test.ts`:
+
+```ts
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { Content } from '@google/genai';
+import {
+ estimateContentTokens,
+ estimatePromptTokens,
+} from './tokenEstimation.js';
+
+const textContent = (text: string): Content => ({
+ role: 'user',
+ parts: [{ text }],
+});
+
+describe('estimateContentTokens', () => {
+ it('returns 0 for empty array', () => {
+ expect(estimateContentTokens([])).toBe(0);
+ });
+
+ it('estimates plain text at ~chars/4', () => {
+ // "hello world" = 11 chars → ceil(11/4) = 3
+ expect(estimateContentTokens([textContent('hello world')])).toBe(3);
+ });
+
+ it('sums tokens across multiple messages', () => {
+ const a = textContent('aaaa'); // 4/4 = 1
+ const b = textContent('bbbbbbbb'); // 8/4 = 2
+ expect(estimateContentTokens([a, b])).toBe(3);
+ });
+
+ it('estimates inlineData via imageTokenEstimate', () => {
+ const c: Content = {
+ role: 'user',
+ parts: [{ inlineData: { mimeType: 'image/png', data: 'xxx' } }],
+ };
+ expect(estimateContentTokens([c], 1600)).toBe(1600);
+ });
+
+ it('estimates functionCall (json-dense) at ~chars/2', () => {
+ const c: Content = {
+ role: 'model',
+ parts: [{ functionCall: { name: 'foo', args: { a: 1, b: 2 } } }],
+ };
+ // estimateContentChars stringifies; the resulting JSON is short but the
+ // ratio (chars/2) should make this >= chars/4 path.
+ const result = estimateContentTokens([c]);
+ expect(result).toBeGreaterThan(0);
+ });
+});
+
+describe('estimatePromptTokens', () => {
+ const history: Content[] = [
+ textContent('older message a'),
+ textContent('older message b'),
+ ];
+ const user = textContent('current user message');
+
+ it('uses lastPromptTokenCount + user-message estimate when count > 0', () => {
+ const userEst = estimateContentTokens([user]);
+ expect(estimatePromptTokens(history, user, 5000)).toBe(5000 + userEst);
+ });
+
+ it('falls back to full estimate when lastPromptTokenCount is 0', () => {
+ const fullEst = estimateContentTokens([...history, user]);
+ expect(estimatePromptTokens(history, user, 0)).toBe(fullEst);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/tokenEstimation.test.ts
+```
+
+Expected: FAIL — `tokenEstimation.ts` 尚未创建。
+
+- [ ] **Step 3: Implement — 新建 tokenEstimation.ts**
+
+`packages/core/src/services/tokenEstimation.ts`:
+
+```ts
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Content } from '@google/genai';
+import {
+ DEFAULT_IMAGE_TOKEN_ESTIMATE,
+ estimateContentChars,
+} from './compactionInputSlimming.js';
+
+/**
+ * Average bytes-per-token for char-based token estimation.
+ * Matches claude-code's roughTokenCountEstimation default (tokens.ts).
+ */
+const BYTES_PER_TOKEN = 4;
+
+/**
+ * Estimate the token count of a list of Content objects via char/4.
+ *
+ * Reuses `estimateContentChars` so that inlineData / functionCall /
+ * functionResponse get the same treatment they receive when computing
+ * compression split points — keeping the two estimators in sync prevents
+ * the auto-compaction trigger and the splitter from disagreeing on size.
+ *
+ * Intended for the pre-send threshold gate only. Char/4 is a conservative
+ * lower bound (real tokenizers vary ±30%); using it to TRIGGER compaction
+ * earlier is safe (false-positive), using it to SKIP compaction is not.
+ */
+export function estimateContentTokens(
+ contents: Content[],
+ imageTokenEstimate: number = DEFAULT_IMAGE_TOKEN_ESTIMATE,
+): number {
+ let totalChars = 0;
+ for (const content of contents) {
+ totalChars += estimateContentChars(content, imageTokenEstimate);
+ }
+ return Math.ceil(totalChars / BYTES_PER_TOKEN);
+}
+
+/**
+ * Compute an effective prompt-token count for the auto-compaction gate.
+ *
+ * `lastPromptTokenCount` (from the previous turn's usage metadata) lacks
+ * two things: the current user message, and any initial value on the
+ * very first send. This helper closes both gaps via local estimation.
+ */
+export function estimatePromptTokens(
+ history: Content[],
+ userMessage: Content,
+ lastPromptTokenCount: number,
+ imageTokenEstimate: number = DEFAULT_IMAGE_TOKEN_ESTIMATE,
+): number {
+ if (lastPromptTokenCount > 0) {
+ return (
+ lastPromptTokenCount +
+ estimateContentTokens([userMessage], imageTokenEstimate)
+ );
+ }
+ return estimateContentTokens([...history, userMessage], imageTokenEstimate);
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/tokenEstimation.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/services/tokenEstimation.ts packages/core/src/services/tokenEstimation.test.ts
+git commit -m "$(cat <<'EOF'
+feat(core): add token estimation helper for compaction gate
+
+Introduce estimateContentTokens / estimatePromptTokens built on the
+existing estimateContentChars (compactionInputSlimming) divided by a
+char/4 ratio. Will replace raw lastPromptTokenCount usage at the cheap-
+gate and hard-threshold checks so the system can react to (a) the
+current user message and (b) the very first send (where the API-
+reported count is 0).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+### Task 3: 在 chatCompressionService cheap-gate 应用估算
+
+**Files:**
+
+- Modify: `packages/core/src/services/chatCompressionService.ts`
+- Modify: `packages/core/src/services/chatCompressionService.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+本 Task 在 P1 之前落地,所以使用**现有的** `threshold * contextLimit` 公式(70% \* 200K = 140K),只把 `originalTokenCount` 替换为 `estimatePromptTokens(...)`:
+
+```ts
+import * as sideQueryModule from '../utils/sideQuery.js';
+
+describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () => {
+ it('triggers compaction when API-reported tokens are below threshold but estimated tokens with the pending user message exceed it', async () => {
+ // 200K 窗口当前阈值 = 0.7 * 200K = 140K
+ // originalTokenCount = 135K(差 5K)
+ // user message 估算 ~10K → 145K,跨越 140K
+ const userMessage: Content = {
+ role: 'user',
+ parts: [{ text: 'x'.repeat(40_000) }], // 40K chars ≈ 10K tokens
+ };
+ const chat = makeFakeChat({ historyChars: 500_000 });
+
+ // Mock runSideQuery 让 compress 后续步骤不爆
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'x',
+ usage: {
+ promptTokenCount: 100,
+ candidatesTokenCount: 50,
+ totalTokenCount: 150,
+ },
+ } as any);
+
+ const result = await new ChatCompressionService().compress(chat, {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ hasFailedCompressionAttempt: false,
+ originalTokenCount: 135_000,
+ pendingUserMessage: userMessage,
+ });
+ expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP);
+ });
+
+ it('NOOPs when neither originalTokenCount nor estimated total reaches threshold', async () => {
+ const chat = makeFakeChat();
+ const result = await new ChatCompressionService().compress(chat, {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ hasFailedCompressionAttempt: false,
+ originalTokenCount: 80_000,
+ pendingUserMessage: {
+ role: 'user',
+ parts: [{ text: 'short' }],
+ },
+ });
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
+ });
+});
+```
+
+`makeFakeChat({ historyChars })` 是测试文件内 inline helper:构造 `GeminiChat` 替身,`getHistory()` 返回长度近似匹配 `historyChars` 的 Content 数组(如果文件已有 helper 则复用)。
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts -t 'cheap-gate uses estimated tokens'
+```
+
+Expected: FAIL — 当前 cheap-gate 只看 `originalTokenCount`,会判定 NOOP。
+
+- [ ] **Step 3: Implement — 改 compress() cheap-gate**
+
+修改 [chatCompressionService.ts:235-249](packages/core/src/services/chatCompressionService.ts:235) 这段:
+
+```ts
+// Don't compress if not forced and we are under the limit. This is the
+// steady-state path on every send; we want to exit before paying for the
+// full `getHistory(true)` clone below.
+if (!force) {
+ const contextLimit =
+ config.getContentGeneratorConfig()?.contextWindowSize ??
+ DEFAULT_TOKEN_LIMIT;
+ const pendingUserMessage = opts.pendingUserMessage;
+ const effectiveTokens = pendingUserMessage
+ ? estimatePromptTokens(
+ chat.getHistory(true),
+ pendingUserMessage,
+ originalTokenCount,
+ slimmingConfig.imageTokenEstimate,
+ )
+ : originalTokenCount;
+ if (effectiveTokens < threshold * contextLimit) {
+ return {
+ newHistory: null,
+ info: {
+ originalTokenCount,
+ newTokenCount: originalTokenCount,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ };
+ }
+}
+```
+
+`CompressOptions` 接口([:172-196](packages/core/src/services/chatCompressionService.ts:172))加新字段:
+
+```ts
+export interface CompressOptions {
+ // ... 现有字段 ...
+ /**
+ * Pending user message about to be sent. When present, the cheap-gate
+ * adds its estimated token count to `originalTokenCount` (which reflects
+ * only the prior turn's API usage) so the gate sees the real prompt size.
+ * Optional for backward compatibility with callers that don't have a
+ * user message in hand (e.g. manual /compress force=true paths).
+ */
+ pendingUserMessage?: Content;
+}
+```
+
+加 import:`import { estimatePromptTokens } from './tokenEstimation.js';`
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/services/chatCompressionService.ts packages/core/src/services/chatCompressionService.test.ts
+git commit -m "$(cat <<'EOF'
+feat(core): cheap-gate uses estimated tokens when user message is pending
+
+Add `pendingUserMessage` to CompressOptions and feed it through
+estimatePromptTokens at the auto-compaction cheap-gate. Closes the
+'lag by one turn' gap where the threshold check missed the user
+message about to be sent.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+### Task 4: 在 geminiChat sendMessageStream 入口透传 pendingUserMessage
+
+**Files:**
+
+- Modify: `packages/core/src/core/geminiChat.ts`
+- Modify: `packages/core/src/core/geminiChat.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/core/src/core/geminiChat.test.ts` 增加:
+
+```ts
+describe('sendMessageStream first-turn estimation', () => {
+ it('triggers auto-compaction on the very first send when inherited history is huge', async () => {
+ // 模拟 sub-agent 继承大历史 / --continue 场景:
+ // lastPromptTokenCount = 0,但 history 已经填到接近 auto 阈值
+ const chat = makeChatWithLargeInheritedHistory(/* ~150K chars worth */);
+ expect(chat.getLastPromptTokenCount()).toBe(0);
+
+ const mockGen = mockContentGeneratorWithUsage({
+ totalTokenCount: 80_000,
+ });
+ chat.setContentGenerator(mockGen);
+
+ const stream = await chat.sendMessageStream(
+ 'qwen-test',
+ { message: 'next user prompt' },
+ 'prompt-1',
+ );
+ // 收集 stream 的第一个事件,应是 COMPRESSED
+ const first = await stream.next();
+ expect(first.value?.type).toBe(StreamEventType.COMPRESSED);
+ });
+});
+```
+
+helper `makeChatWithLargeInheritedHistory` 在测试文件里 inline:构造一个 `GeminiChat`,`history` 装入 1500 个简单 user/model content,每条 100 chars,总 ~150K chars。
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/core/geminiChat.test.ts -t 'first-turn estimation'
+```
+
+Expected: FAIL — 当前 `tryCompress` 用的是 `lastPromptTokenCount = 0`,cheap-gate 判 NOOP。
+
+- [ ] **Step 3: Implement — 改 sendMessageStream 与 tryCompress**
+
+[geminiChat.ts:562](packages/core/src/core/geminiChat.ts:562) 改为:
+
+```ts
+compressionInfo = await this.tryCompress(
+ prompt_id,
+ model,
+ false,
+ params.config?.abortSignal,
+ {
+ pendingUserMessage: createUserContent(params.message),
+ },
+);
+```
+
+`tryCompress` 函数签名(约 [:460-478](packages/core/src/core/geminiChat.ts:460))的 `options` 接口 `TryCompressOptions` 加:
+
+```ts
+interface TryCompressOptions {
+ originalTokenCountOverride?: number;
+ trigger?: CompactTrigger;
+ pendingUserMessage?: Content; // ← 新增
+}
+```
+
+把 `pendingUserMessage` 透传给 `service.compress`:
+
+```ts
+const { newHistory, info } = await service.compress(this, {
+ // ... 现有字段 ...
+ pendingUserMessage: options?.pendingUserMessage,
+});
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/core/geminiChat.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/core/geminiChat.ts packages/core/src/core/geminiChat.test.ts
+git commit -m "$(cat <<'EOF'
+feat(core): pass pendingUserMessage from sendMessageStream to tryCompress
+
+Closes the 'first send after inherited history' gap where
+lastPromptTokenCount is 0 and the cheap-gate would always NOOP.
+estimatePromptTokens falls back to a full-history estimate in that
+case once the user message is provided.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Phase P1 — 三层阈值常量 + computeThresholds + cheap-gate
+
+### Task 5: 添加常量与 computeThresholds 函数
+
+**Files:**
+
+- Modify: `packages/core/src/services/chatCompressionService.ts`
+- Modify: `packages/core/src/services/chatCompressionService.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`chatCompressionService.test.ts` 增加:
+
+```ts
+import { computeThresholds } from './chatCompressionService.js';
+
+describe('computeThresholds', () => {
+ it('32K window — proportional fallback for all tiers, hard degrades to auto', () => {
+ const t = computeThresholds(32_000);
+ expect(t.warn).toBe(19_200); // 0.6 * 32K
+ expect(t.auto).toBe(22_400); // 0.7 * 32K
+ expect(t.hard).toBe(22_400); // max(window-23K=9K, auto=22.4K) = auto
+ expect(t.effectiveWindow).toBe(12_000);
+ });
+
+ it('128K window — mixed (warn=pct, auto/hard=abs)', () => {
+ const t = computeThresholds(128_000);
+ expect(t.warn).toBe(76_800); // 0.6 * 128K (pct wins: 76.8K vs auto-20K=75K)
+ expect(t.auto).toBe(95_000); // abs: window-33K (abs wins: 95K vs 0.7*128K=89.6K)
+ expect(t.hard).toBe(105_000); // abs: window-23K
+ expect(t.effectiveWindow).toBe(108_000);
+ });
+
+ it('200K window — absolute takes over all tiers', () => {
+ const t = computeThresholds(200_000);
+ expect(t.warn).toBe(147_000); // abs: auto-20K (abs wins: 147K vs 0.6*200K=120K)
+ expect(t.auto).toBe(167_000); // abs: 200K-33K
+ expect(t.hard).toBe(177_000); // abs: 200K-23K
+ });
+
+ it('1M window — fully absolute', () => {
+ const t = computeThresholds(1_000_000);
+ expect(t.warn).toBe(947_000);
+ expect(t.auto).toBe(967_000);
+ expect(t.hard).toBe(977_000);
+ });
+
+ it('extreme small window (10K) does not crash; returns sane values', () => {
+ const t = computeThresholds(10_000);
+ expect(t.warn).toBeGreaterThan(0);
+ expect(t.auto).toBeGreaterThan(0);
+ expect(t.warn).toBeLessThanOrEqual(t.auto);
+ expect(t.auto).toBeLessThanOrEqual(t.hard);
+ });
+
+ it('thresholds always satisfy warn <= auto <= hard', () => {
+ for (const w of [32_000, 64_000, 128_000, 200_000, 256_000, 1_000_000]) {
+ const t = computeThresholds(w);
+ expect(t.warn).toBeLessThanOrEqual(t.auto);
+ expect(t.auto).toBeLessThanOrEqual(t.hard);
+ }
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts -t 'computeThresholds'
+```
+
+Expected: FAIL — `computeThresholds` 不存在。
+
+- [ ] **Step 3: Implement — 加常量与函数**
+
+在 [chatCompressionService.ts](packages/core/src/services/chatCompressionService.ts) 文件常量区(紧跟 `COMPACT_MAX_OUTPUT_TOKENS`)加:
+
+```ts
+/**
+ * Default proportional auto-compaction threshold (legacy semantics
+ * preserved as a small-window fallback / safety net).
+ */
+export const DEFAULT_PCT = 0.7;
+
+/**
+ * Warn-tier proportional offset: warn-pct = PCT - WARN_PCT_OFFSET (= 0.6).
+ */
+export const WARN_PCT_OFFSET = 0.1;
+
+/**
+ * Token budget reserved for compression output. Matches COMPACT_MAX_OUTPUT_TOKENS
+ * because thinking is disabled (see Task 1) so maxOutputTokens is the hard
+ * ceiling on summary output.
+ */
+export const SUMMARY_RESERVE = COMPACT_MAX_OUTPUT_TOKENS; // 20_000
+
+/** Distance between auto threshold and effectiveWindow. */
+export const AUTOCOMPACT_BUFFER = 13_000;
+
+/** Distance between warn threshold and auto threshold. */
+export const WARN_BUFFER = 20_000;
+
+/** Distance between hard threshold and effectiveWindow (claude-code MANUAL_COMPACT_BUFFER). */
+export const HARD_BUFFER = 3_000;
+
+/** Auto-compaction consecutive-failure circuit breaker. */
+export const MAX_CONSECUTIVE_FAILURES = 3;
+
+export interface CompactionThresholds {
+ /** Token count at which UI warn tier triggers. */
+ warn: number;
+ /** Token count at which auto-compaction triggers. */
+ auto: number;
+ /** Token count at which auto-compaction is forced (resets failure counter). */
+ hard: number;
+ /** Window minus SUMMARY_RESERVE; the budget available for input + summary. */
+ effectiveWindow: number;
+}
+
+/**
+ * Compute the three-tier threshold ladder for a given context window.
+ *
+ * Each tier is `max(proportional, absolute)`:
+ * auto = max(PCT * window, effectiveWindow - AUTOCOMPACT_BUFFER)
+ * warn = max((PCT - WARN_OFFSET) * window, auto - WARN_BUFFER)
+ * hard = max(effectiveWindow - HARD_BUFFER, auto) // hard degrades to auto for tiny windows
+ *
+ * Small windows (where the absolute branch goes negative) automatically fall
+ * back to the proportional branch. Large windows are dominated by the absolute
+ * branch, capping wasted reservation to ~33K instead of 30% of the window.
+ */
+export function computeThresholds(window: number): CompactionThresholds {
+ const effectiveWindow = window - SUMMARY_RESERVE;
+
+ const absAuto = effectiveWindow - AUTOCOMPACT_BUFFER;
+ const auto = Math.max(DEFAULT_PCT * window, absAuto);
+
+ const absWarn = auto - WARN_BUFFER;
+ const warn = Math.max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, absWarn);
+
+ const rawHard = effectiveWindow - HARD_BUFFER;
+ const hard = Math.max(rawHard, auto);
+
+ return { warn, auto, hard, effectiveWindow };
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/services/chatCompressionService.ts packages/core/src/services/chatCompressionService.test.ts
+git commit -m "$(cat <<'EOF'
+feat(core): add computeThresholds for three-tier compaction ladder
+
+Introduces warn/auto/hard thresholds combining proportional fallback
+(small windows) with absolute reservation (large windows). Matches the
+formula in docs/design/auto-compaction-threshold-redesign.md. Pure
+function with full coverage across 32K/128K/200K/1M/extreme-small
+windows.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+### Task 6: cheap-gate 切换到 computeThresholds.auto
+
+**Files:**
+
+- Modify: `packages/core/src/services/chatCompressionService.ts`
+- Modify: `packages/core/src/services/chatCompressionService.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe('compress cheap-gate uses computeThresholds.auto', () => {
+ it('on a 200K window with originalTokenCount=160K, NOOP (below auto=167K)', async () => {
+ const chat = makeFakeChat();
+ const result = await new ChatCompressionService().compress(chat, {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ hasFailedCompressionAttempt: false,
+ originalTokenCount: 160_000,
+ });
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
+ });
+
+ it('on a 200K window with originalTokenCount=168K, proceeds past gate', async () => {
+ // 168K > 167K (auto),cheap-gate 放行,进入 curatedHistory 阶段
+ const chat = makeFakeChat({ historyChars: 500_000 });
+ const result = await new ChatCompressionService().compress(chat, {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ hasFailedCompressionAttempt: false,
+ originalTokenCount: 168_000,
+ });
+ // 实际结果取决于 mock 出来的 sideQuery;只验证不是被 cheap-gate 拦下的早期 NOOP
+ expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts -t 'cheap-gate uses computeThresholds'
+```
+
+Expected: FAIL — 当前阈值是 `threshold * contextLimit = 0.7 * 200K = 140K`,160K 已经超过 140K 直接 cheap-gate 放行(不符断言①);168K 同理。
+
+- [ ] **Step 3: Implement — 切换 cheap-gate 公式**
+
+修改 [chatCompressionService.ts:235-249](packages/core/src/services/chatCompressionService.ts:235) 那段 `if (!force) { ... }` 块:
+
+```ts
+if (!force) {
+ const contextLimit =
+ config.getContentGeneratorConfig()?.contextWindowSize ??
+ DEFAULT_TOKEN_LIMIT;
+ const { auto } = computeThresholds(contextLimit);
+ const pendingUserMessage = opts.pendingUserMessage;
+ const effectiveTokens = pendingUserMessage
+ ? estimatePromptTokens(
+ chat.getHistory(true),
+ pendingUserMessage,
+ originalTokenCount,
+ slimmingConfig.imageTokenEstimate,
+ )
+ : originalTokenCount;
+ if (effectiveTokens < auto) {
+ return {
+ newHistory: null,
+ info: {
+ originalTokenCount,
+ newTokenCount: originalTokenCount,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ };
+ }
+}
+```
+
+同时删除 [chatCompressionService.ts:214-217](packages/core/src/services/chatCompressionService.ts:214) 那段 `const threshold = chatCompressionSettings?.contextPercentageThreshold ?? COMPRESSION_TOKEN_THRESHOLD;`,因为 `threshold` 现在不再被 cheap-gate 使用。同时去掉 line 221 那个 `threshold <= 0` 分支(隐式禁用语义,详细在 P4 处理)。
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/services/chatCompressionService.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/services/chatCompressionService.ts packages/core/src/services/chatCompressionService.test.ts
+git commit -m "$(cat <<'EOF'
+refactor(core): cheap-gate uses computeThresholds.auto
+
+Replace the legacy `threshold * contextLimit` formula with
+computeThresholds.auto, which combines proportional fallback with
+absolute reservation. On large windows (>=128K) the gate now triggers
+later than 70% but reserves a fixed ~33K, freeing tens of thousands of
+context tokens that the old formula wasted.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Phase P2 — 失败处理升级(1 次锁 → 3 次熔断)
+
+### Task 7: hasFailedCompressionAttempt → consecutiveFailures
+
+**Files:**
+
+- Modify: `packages/core/src/core/geminiChat.ts`
+- Modify: `packages/core/src/services/chatCompressionService.ts`
+- Modify: `packages/core/src/core/geminiChat.test.ts`
+- Modify: `packages/core/src/services/chatCompressionService.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`geminiChat.test.ts`:
+
+```ts
+describe('compression failure circuit breaker', () => {
+ it('tolerates 2 consecutive failures, NOOPs the third', async () => {
+ const chat = makeChatWithMockedFailingCompression();
+ // 触发 3 次连续失败:
+ await chat.sendMessageStream('m', { message: 'a' }, 'p1'); // attempt 1 fails
+ await chat.sendMessageStream('m', { message: 'b' }, 'p2'); // attempt 2 fails
+ const events = await collectEvents(
+ await chat.sendMessageStream('m', { message: 'c' }, 'p3'), // attempt 3 should NOOP
+ );
+ expect(
+ events.find((e) => e.type === StreamEventType.COMPRESSED),
+ ).toBeUndefined();
+ // 验证 service.compress 第 3 次根本没被调用(熔断器 NOOP 在 cheap-gate)
+ expect(getCompressCallCount()).toBe(2);
+ });
+
+ it('resets counter on a successful force compress', async () => {
+ const chat = makeChatWithMockedFailingCompression();
+ await chat.sendMessageStream('m', { message: 'a' }, 'p1'); // fail
+ await chat.sendMessageStream('m', { message: 'b' }, 'p2'); // fail
+ // 用户手动 /compress
+ await chat.tryCompress('p3', 'm', /* force */ true);
+ // 现在熔断器应该已重置
+ await chat.sendMessageStream('m', { message: 'c' }, 'p4');
+ expect(getCompressCallCount()).toBeGreaterThan(3);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/core/geminiChat.test.ts -t 'circuit breaker'
+```
+
+Expected: FAIL — 当前一次失败就永久锁,第 2 次 send 已经被 cheap-gate NOOP,第 3 次也 NOOP,但断言 ② 期望力 force 之后能恢复且 sendMessageStream 走得到 compress。
+
+- [ ] **Step 3: Implement —替换字段**
+
+[geminiChat.ts](packages/core/src/core/geminiChat.ts) 内部字段(grep `hasFailedCompressionAttempt`):
+
+```ts
+// 替换前
+private hasFailedCompressionAttempt = false;
+
+// 替换后
+private consecutiveFailures = 0;
+```
+
+[geminiChat.ts:467-478](packages/core/src/core/geminiChat.ts:467) 的 `tryCompress` 函数传给 `service.compress` 的字段:
+
+```ts
+const { newHistory, info } = await service.compress(this, {
+ promptId,
+ force,
+ model,
+ config: this.config,
+ consecutiveFailures: this.consecutiveFailures, // ← 取代 hasFailedCompressionAttempt
+ originalTokenCount:
+ options?.originalTokenCountOverride ?? this.lastPromptTokenCount,
+ pendingUserMessage: options?.pendingUserMessage,
+ trigger: options?.trigger,
+ signal,
+});
+```
+
+[geminiChat.ts:503-510](packages/core/src/core/geminiChat.ts:503) 失败/成功分支:
+
+```ts
+if (info.compressionStatus === CompressionStatus.COMPRESSED && newHistory) {
+ // ... 现有逻辑 ...
+ this.setHistory(newHistory);
+ this.config.getFileReadCache().clear();
+ this.lastPromptTokenCount = info.newTokenCount;
+ this.telemetryService?.setLastPromptTokenCount(info.newTokenCount);
+ this.consecutiveFailures = 0; // ← 取代 hasFailedCompressionAttempt = false
+} else if (isCompressionFailureStatus(info.compressionStatus)) {
+ if (!force) {
+ this.consecutiveFailures += 1; // ← 取代 hasFailedCompressionAttempt = true
+ }
+}
+```
+
+[chatCompressionService.ts](packages/core/src/services/chatCompressionService.ts) 的 `CompressOptions` 接口:
+
+```ts
+export interface CompressOptions {
+ // ... 现有字段 ...
+ /**
+ * Number of consecutive auto-compaction failures for this chat. When
+ * it reaches MAX_CONSECUTIVE_FAILURES, the gate stops trying until a
+ * successful force=true call resets it.
+ */
+ consecutiveFailures: number;
+ // 删除 hasFailedCompressionAttempt
+}
+```
+
+`compress()` 函数内 [:221](packages/core/src/services/chatCompressionService.ts:221) 那段 cheap-gate 检查:
+
+```ts
+// Cheap gates first — these don't need the curated history.
+if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES && !force) {
+ return {
+ newHistory: null,
+ info: {
+ originalTokenCount: 0,
+ newTokenCount: 0,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ };
+}
+```
+
+更新解构 `const { ... } = opts;` 把 `hasFailedCompressionAttempt` 替换成 `consecutiveFailures`。
+
+`chatCompressionService.test.ts` 中所有传 `hasFailedCompressionAttempt: false/true` 的地方改为 `consecutiveFailures: 0` / `consecutiveFailures: MAX_CONSECUTIVE_FAILURES`,逐个修正测试期望。
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/core/geminiChat.test.ts packages/core/src/services/chatCompressionService.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/core/geminiChat.ts packages/core/src/services/chatCompressionService.ts packages/core/src/core/geminiChat.test.ts packages/core/src/services/chatCompressionService.test.ts
+git commit -m "$(cat <<'EOF'
+refactor(core): replace hasFailedCompressionAttempt with circuit breaker
+
+Switches from a one-shot permanent lock to a three-strike circuit
+breaker (MAX_CONSECUTIVE_FAILURES=3). Successful force compress
+(manual /compress, reactive overflow, or hard-tier rescue) resets the
+counter. Aligns with claude-code's design and unblocks recovery from
+transient failures (rate limits, transient model errors) that
+previously disabled auto-compaction for the rest of the session.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Phase P4 — 配置面:删除 contextPercentageThreshold + breaking-change 警告
+
+### Task 8: 删除字段 + 启动 warning
+
+**Files:**
+
+- Modify: `packages/core/src/config/config.ts`
+- Modify: `packages/cli/src/config/settingsSchema.ts`(如果有引用)
+- Modify: `packages/core/src/services/chatCompressionService.ts`
+- Modify: `packages/core/src/services/chatCompressionService.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/core/src/config/config.test.ts`(如果不存在则创建):
+
+```ts
+import { describe, it, expect, vi } from 'vitest';
+
+describe('Config — chatCompression.contextPercentageThreshold deprecation', () => {
+ it('logs a stderr warning when the deprecated field is set', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ new Config({
+ // ... minimal required Config params ...
+ chatCompression: { contextPercentageThreshold: 0.5 } as any,
+ });
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'chatCompression.contextPercentageThreshold has been removed',
+ ),
+ );
+ warnSpy.mockRestore();
+ });
+
+ it('does not warn when the deprecated field is absent', () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ new Config({
+ // ... minimal params, no chatCompression.contextPercentageThreshold ...
+ });
+ expect(warnSpy).not.toHaveBeenCalledWith(
+ expect.stringContaining('chatCompression.contextPercentageThreshold'),
+ );
+ warnSpy.mockRestore();
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/config/config.test.ts
+```
+
+Expected: FAIL — Config 当前完全接受这个字段,无 warning。
+
+- [ ] **Step 3: Implement — 改 ChatCompressionSettings + Config 构造函数**
+
+[config.ts:217-227](packages/core/src/config/config.ts:217):
+
+```ts
+export interface ChatCompressionSettings {
+ /**
+ * Estimated tokens for a single inline image / document part when
+ * apportioning chars across history in `findCompressSplitPoint`.
+ * Also used as the placeholder budget when stripping inline media
+ * out of the side-query compaction prompt. Default 1600.
+ * Env override: `QWEN_IMAGE_TOKEN_ESTIMATE`.
+ */
+ imageTokenEstimate?: number;
+}
+```
+
+(删除 `contextPercentageThreshold` 字段。)
+
+[config.ts](packages/core/src/config/config.ts) 找到 Config 构造函数中处理 `params.chatCompression` 的位置(约 line 933),在赋值前加:
+
+```ts
+if (
+ params.chatCompression &&
+ typeof (params.chatCompression as Record)
+ .contextPercentageThreshold !== 'undefined'
+) {
+ console.warn(
+ '[qwen-code] chatCompression.contextPercentageThreshold has been removed ' +
+ 'and is now controlled by built-in thresholds. Setting will be ignored.',
+ );
+}
+this.chatCompression = params.chatCompression;
+```
+
+`chatCompressionService.ts` 同时清理:[:214-217](packages/core/src/services/chatCompressionService.ts:214) 那段已经在 Task 6 删除,再检查文件里有没有残留 `chatCompressionSettings?.contextPercentageThreshold` 或导出的常量 `COMPRESSION_TOKEN_THRESHOLD`:
+
+- 如果 `COMPRESSION_TOKEN_THRESHOLD` 已经无任何引用,删除该常量。
+- 如果还有引用(比如 telemetry 或 doc),改为引用 `DEFAULT_PCT`。
+
+cli/config/settingsSchema.ts 不需要改 —— `chatCompression` 仍然是 `type: 'object'`,里面没有 schema 字段([settingsSchema.ts:1020-1028](packages/cli/src/config/settingsSchema.ts:1020))。如果 schema 内部有对 `contextPercentageThreshold` 的引用,删除。
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core
+npm test --workspace=packages/cli
+```
+
+Expected: PASS(包括既有压缩相关测试)
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/config/config.ts packages/core/src/config/config.test.ts packages/core/src/services/chatCompressionService.ts packages/core/src/services/chatCompressionService.test.ts
+git commit -m "$(cat <<'EOF'
+refactor(core)!: remove chatCompression.contextPercentageThreshold setting
+
+The proportional threshold is now an internal constant (DEFAULT_PCT) and
+the auto-compaction threshold is computed from a mixed proportional /
+absolute formula (computeThresholds). User-facing tuning of the bare
+percentage no longer maps to meaningful behavior on large-window models.
+
+Existing settings.json files containing the field will log a one-line
+stderr warning on startup; the field is otherwise ignored.
+
+BREAKING CHANGE: chatCompression.contextPercentageThreshold is removed.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Phase P3 — hard 层主动救场
+
+### Task 9: sendMessageStream 入口加 hard 检查 + force compress
+
+**Files:**
+
+- Modify: `packages/core/src/core/geminiChat.ts`
+- Modify: `packages/core/src/core/geminiChat.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe('sendMessageStream hard-tier rescue', () => {
+ it('triggers force compress when estimated tokens cross hard threshold', async () => {
+ // 构造 200K 窗口:hard = 177K
+ const chat = makeChatWithLastPromptTokenCount(176_000);
+ // 本轮 user message 估算 + 176K 越过 177K
+ const userMessage = makeBigUserMessage(/* ~3K tokens */);
+ const stream = await chat.sendMessageStream(
+ 'm',
+ { message: userMessage },
+ 'p',
+ );
+ const first = await stream.next();
+ expect(first.value?.type).toBe(StreamEventType.COMPRESSED);
+ expect(getLastCompressCallForce()).toBe(true);
+ });
+
+ it('hard rescue resets consecutiveFailures before forcing', async () => {
+ const chat = makeChatWithLastPromptTokenCount(176_000);
+ // 先制造 3 次失败,使 consecutiveFailures = 3
+ setMockedCompressionToFail(3);
+ await chat.sendMessageStream('m', { message: 'a' }, 'p1');
+ await chat.sendMessageStream('m', { message: 'b' }, 'p2');
+ await chat.sendMessageStream('m', { message: 'c' }, 'p3');
+ expect(chat.getConsecutiveFailures()).toBe(3);
+ // 第 4 次:token 跨越 hard,hard rescue 重置熔断器并 force=true
+ setMockedCompressionToSucceed();
+ await chat.sendMessageStream('m', { message: 'd' }, 'p4');
+ expect(getLastCompressCallForce()).toBe(true);
+ expect(chat.getConsecutiveFailures()).toBe(0);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/core/geminiChat.test.ts -t 'hard-tier rescue'
+```
+
+Expected: FAIL — sendMessageStream 当前永远以 `force=false` 调 tryCompress。
+
+- [ ] **Step 3: Implement —在 sendMessageStream 入口加 hard 判断**
+
+[geminiChat.ts:560-567](packages/core/src/core/geminiChat.ts:560):
+
+```ts
+// Hard-tier rescue: if pending prompt is large enough to risk overflow,
+// force compress before the send and reset the failure counter so a
+// session already in circuit-breaker NOOP can recover. This proactively
+// covers what reactive overflow (line ~711) would otherwise catch
+// after a wasted round-trip.
+const contextLimit =
+ this.config.getContentGeneratorConfig()?.contextWindowSize ??
+ DEFAULT_TOKEN_LIMIT;
+const { hard } = computeThresholds(contextLimit);
+const pendingUserMessage = createUserContent(params.message);
+const effectiveTokens = estimatePromptTokens(
+ this.getHistory(true),
+ pendingUserMessage,
+ this.lastPromptTokenCount,
+);
+const shouldForceFromHard = effectiveTokens >= hard;
+if (shouldForceFromHard) {
+ this.consecutiveFailures = 0;
+}
+
+compressionInfo = await this.tryCompress(
+ prompt_id,
+ model,
+ shouldForceFromHard,
+ params.config?.abortSignal,
+ { pendingUserMessage },
+);
+```
+
+注意:`createUserContent` 在 sendMessageStream 内部本来在 [:569](packages/core/src/core/geminiChat.ts:569) 调一次;现在我们提前调,所以 [:569](packages/core/src/core/geminiChat.ts:569) 那行 `const userContent = createUserContent(params.message);` 可以删除/替换为 `const userContent = pendingUserMessage;`。
+
+加 import:`import { computeThresholds } from '../services/chatCompressionService.js';`
+加 import:`import { estimatePromptTokens } from '../services/tokenEstimation.js';`
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/core -- --run packages/core/src/core/geminiChat.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck --workspace=packages/core
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/core/src/core/geminiChat.ts packages/core/src/core/geminiChat.test.ts
+git commit -m "$(cat <<'EOF'
+feat(core): hard-tier rescue forces compaction before oversized send
+
+When estimated tokens cross computeThresholds.hard, sendMessageStream
+now resets the consecutive-failure counter and calls tryCompress with
+force=true. This pulls reactive overflow recovery forward to before
+the send, saving one wasted round-trip and unblocking sessions whose
+circuit breaker had latched off.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Phase P5 — UI 改动(tip 重写 + /context 显示)
+
+### Task 10: tipRegistry 重写三条 context-\* tip
+
+**Files:**
+
+- Modify: `packages/cli/src/services/tips/tipRegistry.ts`
+- Modify: `packages/cli/src/services/tips/tipRegistry.test.ts`(如不存在则创建)
+- Modify: `packages/cli/src/ui/AppContainer.tsx`
+
+- [ ] **Step 1: Write the failing test**
+
+`packages/cli/src/services/tips/tipRegistry.test.ts`:
+
+```ts
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { tipRegistry, type TipContext } from './tipRegistry.js';
+
+const baseCtx: TipContext = {
+ lastPromptTokenCount: 0,
+ contextWindowSize: 200_000,
+ sessionPromptCount: 10,
+ sessionCount: 1,
+ platform: 'darwin',
+ thresholds: {
+ warn: 147_000,
+ auto: 167_000,
+ hard: 177_000,
+ effectiveWindow: 180_000,
+ },
+};
+
+function tipById(id: string) {
+ return tipRegistry.find((t) => t.id === id)!;
+}
+
+describe('context-* tip thresholds align with computeThresholds', () => {
+ it('compress-intro fires between warn and auto', () => {
+ const t = tipById('compress-intro');
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 100_000 })).toBe(
+ false,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 150_000 })).toBe(
+ true,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 168_000 })).toBe(
+ false,
+ );
+ });
+
+ it('context-high fires between auto and hard', () => {
+ const t = tipById('context-high');
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 150_000 })).toBe(
+ false,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 170_000 })).toBe(
+ true,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 178_000 })).toBe(
+ false,
+ );
+ });
+
+ it('context-critical fires at or above hard', () => {
+ const t = tipById('context-critical');
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 170_000 })).toBe(
+ false,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 178_000 })).toBe(
+ true,
+ );
+ });
+
+ it('falls back gracefully when thresholds undefined (legacy callers)', () => {
+ const ctx = { ...baseCtx, thresholds: undefined };
+ // 三条 tip 在缺 thresholds 时应该都不触发(不能比较)
+ expect(tipById('compress-intro').isRelevant(ctx)).toBe(false);
+ expect(tipById('context-high').isRelevant(ctx)).toBe(false);
+ expect(tipById('context-critical').isRelevant(ctx)).toBe(false);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/cli -- --run packages/cli/src/services/tips/tipRegistry.test.ts
+```
+
+Expected: FAIL — `TipContext` 没有 `thresholds` 字段;三条 tip 仍按 50/80/95 百分比触发。
+
+- [ ] **Step 3: Implement — 改 tipRegistry**
+
+[tipRegistry.ts:15-21](packages/cli/src/services/tips/tipRegistry.ts:15):
+
+```ts
+import type { CompactionThresholds } from '@qwen-code/qwen-code-core';
+import { DEFAULT_TOKEN_LIMIT } from '@qwen-code/qwen-code-core';
+
+export type TipTrigger = 'startup' | 'post-response';
+
+export interface TipContext {
+ lastPromptTokenCount: number;
+ contextWindowSize: number;
+ sessionPromptCount: number;
+ sessionCount: number;
+ platform: string;
+ /**
+ * Three-tier auto-compaction thresholds, computed by callers.
+ * Optional for backward compat; tip checks return false when missing.
+ */
+ thresholds?: CompactionThresholds;
+}
+```
+
+`getContextUsagePercent` 保留(其他 startup tip 可能用到),但 context-\* tips 不再依赖它。
+
+替换 [tipRegistry.ts:37-69](packages/cli/src/services/tips/tipRegistry.ts:37) 三条 tip 的 `isRelevant`:
+
+```ts
+export const tipRegistry: ContextualTip[] = [
+ // --- Post-response contextual tips (priority: higher = more urgent) ---
+ {
+ id: 'context-critical',
+ content:
+ 'Context near hard limit — auto-compact will force on next send. Consider /clear if you want to start fresh.',
+ trigger: 'post-response',
+ isRelevant: (ctx) =>
+ ctx.thresholds !== undefined &&
+ ctx.lastPromptTokenCount >= ctx.thresholds.hard,
+ cooldownPrompts: 3,
+ priority: 100,
+ },
+ {
+ id: 'context-high',
+ content: 'Context is getting full. Use /compress to free up space.',
+ trigger: 'post-response',
+ isRelevant: (ctx) =>
+ ctx.thresholds !== undefined &&
+ ctx.lastPromptTokenCount >= ctx.thresholds.auto &&
+ ctx.lastPromptTokenCount < ctx.thresholds.hard,
+ cooldownPrompts: 5,
+ priority: 90,
+ },
+ {
+ id: 'compress-intro',
+ content: 'Long conversation? /compress summarizes history to free context.',
+ trigger: 'post-response',
+ isRelevant: (ctx) =>
+ ctx.thresholds !== undefined &&
+ ctx.lastPromptTokenCount >= ctx.thresholds.warn &&
+ ctx.lastPromptTokenCount < ctx.thresholds.auto &&
+ ctx.sessionPromptCount > 5,
+ cooldownPrompts: 10,
+ priority: 50,
+ },
+
+ // --- Startup tips --- ← 保持不变
+ // ... 后面 startup tips 不动 ...
+```
+
+`packages/cli/src/ui/AppContainer.tsx:1150` 那一带(已知是 contextual-tips 构造点),改为:
+
+```tsx
+// pseudo — 具体取决于现有代码
+const thresholds = computeThresholds(contextWindowSize);
+const tipCtx: TipContext = {
+ lastPromptTokenCount,
+ contextWindowSize,
+ sessionPromptCount,
+ sessionCount,
+ platform: process.platform,
+ thresholds,
+};
+```
+
+加 import 到 AppContainer.tsx:
+
+```tsx
+import { computeThresholds } from '@qwen-code/qwen-code-core';
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/cli -- --run packages/cli/src/services/tips/tipRegistry.test.ts
+npm test --workspace=packages/cli
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/cli/src/services/tips/tipRegistry.ts packages/cli/src/services/tips/tipRegistry.test.ts packages/cli/src/ui/AppContainer.tsx
+git commit -m "$(cat <<'EOF'
+feat(cli): align context-* tips with new compaction thresholds
+
+The three context-usage tips now compare tokenCount against the
+warn/auto/hard ladder from computeThresholds instead of fixed 50/80/95
+percentages. compress-intro fires between warn and auto, context-high
+between auto and hard, context-critical at or above hard. Threshold
+data is injected into TipContext from the AppContainer.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+### Task 11: /context 命令显示三层阈值
+
+**Files:**
+
+- Modify: `packages/cli/src/ui/commands/contextCommand.ts`
+- Modify: `packages/cli/src/ui/commands/contextCommand.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+```ts
+describe('/context shows three-tier thresholds', () => {
+ it('renders warn/auto/hard with current tier marker', () => {
+ const result = renderContextCommand({
+ contextWindowSize: 200_000,
+ lastPromptTokenCount: 150_000, // 在 warn 与 auto 之间
+ });
+ expect(result).toMatch(/Warn threshold:\s+147[,.]?000/);
+ expect(result).toMatch(/Auto threshold:\s+167[,.]?000/);
+ expect(result).toMatch(/Hard threshold:\s+177[,.]?000/);
+ expect(result).toMatch(/current tier:\s+warn/i);
+ });
+
+ it('correctly identifies "below warn" tier when tokens are low', () => {
+ const result = renderContextCommand({
+ contextWindowSize: 200_000,
+ lastPromptTokenCount: 50_000,
+ });
+ expect(result).toMatch(/current tier:\s+(safe|below warn|normal)/i);
+ });
+});
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+```bash
+npm test --workspace=packages/cli -- --run packages/cli/src/ui/commands/contextCommand.test.ts -t 'three-tier'
+```
+
+Expected: FAIL — 当前 [contextCommand.ts:177-183](packages/cli/src/ui/commands/contextCommand.ts:177) 用的是 `(1 - threshold) * contextWindowSize` 公式,只显示单个 "autocompactBuffer" 数。
+
+- [ ] **Step 3: Implement — 改 contextCommand 输出**
+
+替换 [contextCommand.ts:177-183](packages/cli/src/ui/commands/contextCommand.ts:177) 那段:
+
+```ts
+import { computeThresholds } from '@qwen-code/qwen-code-core';
+
+// ... 在 buildContextSummary 或类似入口里:
+const thresholds = computeThresholds(contextWindowSize);
+const { warn, auto, hard, effectiveWindow } = thresholds;
+
+function currentTier(tokens: number): string {
+ if (tokens >= hard) return 'hard (force compress imminent)';
+ if (tokens >= auto) return 'auto (compaction in progress / just ran)';
+ if (tokens >= warn) return 'warn';
+ return 'safe';
+}
+
+// 在格式化输出部分追加:
+const lines = [
+ // ... 现有输出 ...
+ `Effective window: ${formatNum(effectiveWindow)} (window − 20K reserve)`,
+ `Warn threshold: ${formatNum(warn)}`,
+ `Auto threshold: ${formatNum(auto)}`,
+ `Hard threshold: ${formatNum(hard)}`,
+ `Current tier: ${currentTier(lastPromptTokenCount)}`,
+];
+```
+
+注:`formatNum` 是现有项目里的 `.toLocaleString()` 等;如未在文件内则 inline 一个 `(n: number) => n.toLocaleString('en-US')`。
+
+同时**删除**原来计算 `autocompactBuffer` 的代码([:180-183](packages/cli/src/ui/commands/contextCommand.ts:180))和对 `compressionThreshold` 的使用 —— 现在直接看 `auto`。
+
+- [ ] **Step 4: Run test to verify it passes**
+
+```bash
+npm test --workspace=packages/cli -- --run packages/cli/src/ui/commands/contextCommand.test.ts
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Typecheck + lint**
+
+```bash
+npm run typecheck
+npm run lint
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add packages/cli/src/ui/commands/contextCommand.ts packages/cli/src/ui/commands/contextCommand.test.ts
+git commit -m "$(cat <<'EOF'
+feat(cli): /context shows three-tier thresholds and current tier
+
+Replace the legacy single-buffer display with effective window + warn /
+auto / hard threshold lines and a "current tier" label so users can see
+exactly where in the ladder the session sits.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## 验收(最终全量回归)
+
+落地所有 task 后,最后跑一遍全量校验:
+
+- [ ] **Step 1: 全量测试**
+
+```bash
+npm test
+```
+
+Expected: 全部 workspace 测试通过。
+
+- [ ] **Step 2: 全量 typecheck**
+
+```bash
+npm run typecheck
+```
+
+- [ ] **Step 3: 全量 lint**
+
+```bash
+npm run lint
+```
+
+- [ ] **Step 4: 手动 smoke**
+
+启动 CLI,执行:
+
+1. `/context` —— 看新三层显示是否合理
+2. 跑一个会触发压缩的对话(可用 200K 窗口模型把 prompt 灌到 170K+)
+3. 设置 `chatCompression.contextPercentageThreshold = 0.5` 启动 —— 看 stderr 是否打印 deprecation 警告
+4. 用 `--continue` 恢复一个 huge session,首次 send 时压缩是否被首轮估算路径触发
+
+- [ ] **Step 5: PR 描述统一脚本(可选)**
+
+如果 PR 是分批提交的,每个 PR 描述里链接 [docs/design/auto-compaction-threshold-redesign.md](docs/design/auto-compaction-threshold-redesign.md) 并标注 Phase / Task。
diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md
index f6b71a07668..f31af9c4218 100644
--- a/docs/users/configuration/settings.md
+++ b/docs/users/configuration/settings.md
@@ -144,7 +144,9 @@ Settings are organized into categories. Most settings should be placed within th
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `splitToolMedia` (set `true` for strict OpenAI-compatible servers like LM Studio that reject non-text content on `role: "tool"` messages — splits media into a follow-up user message), `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
-| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
+| `model.chatCompression.contextPercentageThreshold` | number | **REMOVED.** Auto-compaction now uses a three-tier threshold ladder (warn / auto / hard) computed internally from the model's context window via the `computeThresholds()` function — no longer user-configurable. Setting this field in `settings.json` is silently ignored, and a one-line deprecation warning is emitted to stderr at startup. To disable auto-compaction entirely, use `model.chatCompression.disabled: true` (manual `/compress` and reactive overflow recovery still work). (See PR #4168 for the redesign rationale.) | `N/A` |
+| `model.chatCompression.disabled` | boolean | When `true`, suppresses the proactive cheap-gate and hard-tier rescue paths so the chat retains full uncompressed history. Manual `/compress` (user-initiated) and reactive overflow recovery (API-layer last-ditch safety net) still run. Replaces the removed `contextPercentageThreshold: 0` escape hatch for compliance / debugging / audit-trail sessions. The first NOOP from this gate emits a once-per-process warn so operators can distinguish "user disabled" from "system broken". | `false` |
+| `model.chatCompression.imageTokenEstimate` | number | Estimated tokens for a single inline image / document part when apportioning chars across history in `findCompressSplitPoint` and as the placeholder budget when stripping inline media out of the side-query compaction prompt. Env override: `QWEN_IMAGE_TOKEN_ESTIMATE`. Setting to `0` makes images invisible to the token estimator — only do so if you understand that compression decisions in image-heavy sessions will then ignore image weight. | `1600` |
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
| `model.skipLoopDetection` | boolean | Disables loop detection checks. Loop detection prevents infinite loops in AI responses but can generate false positives that interrupt legitimate workflows. Enable this option if you experience frequent false positive loop detection interruptions. | `false` |
| `model.skipStartupContext` | boolean | Skips sending the startup workspace context (environment summary and acknowledgement) at the beginning of each session. Enable this if you prefer to provide context manually or want to save tokens on startup. | `false` |
diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js
index fb4ff837bae..dd68595b253 100644
--- a/packages/cli/src/i18n/locales/en.js
+++ b/packages/cli/src/i18n/locales/en.js
@@ -1777,6 +1777,20 @@ export default {
Used: 'Used',
Free: 'Free',
'Autocompact buffer': 'Autocompact buffer',
+ // Compaction-threshold section in /context output. Added in PR #4168
+ // alongside the three-tier ladder; ensure these keys appear in every
+ // locale to avoid mixed-language renders.
+ 'Compaction thresholds': 'Compaction thresholds',
+ 'Effective window': 'Effective window',
+ 'Warn threshold': 'Warn threshold',
+ 'Auto threshold': 'Auto threshold',
+ 'Hard threshold': 'Hard threshold',
+ 'window − {{reserve}} reserve': 'window − {{reserve}} reserve',
+ 'Current tier': 'Current tier',
+ safe: 'safe',
+ warn: 'warn',
+ auto: 'auto',
+ hard: 'hard',
'Usage by category': 'Usage by category',
'System prompt': 'System prompt',
'Built-in tools': 'Built-in tools',
diff --git a/packages/cli/src/services/tips/index.ts b/packages/cli/src/services/tips/index.ts
index aac01be57c8..e0429bb264f 100644
--- a/packages/cli/src/services/tips/index.ts
+++ b/packages/cli/src/services/tips/index.ts
@@ -10,7 +10,6 @@ export { TipHistory } from './tipHistory.js';
export { selectTip } from './tipScheduler.js';
export {
tipRegistry,
- getContextUsagePercent,
type ContextualTip,
type TipContext,
type TipTrigger,
diff --git a/packages/cli/src/services/tips/tipRegistry.test.ts b/packages/cli/src/services/tips/tipRegistry.test.ts
new file mode 100644
index 00000000000..efc003b9c7d
--- /dev/null
+++ b/packages/cli/src/services/tips/tipRegistry.test.ts
@@ -0,0 +1,135 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { tipRegistry, type TipContext } from './tipRegistry.js';
+
+const baseCtx: TipContext = {
+ lastPromptTokenCount: 0,
+ contextWindowSize: 200_000,
+ sessionPromptCount: 10,
+ sessionCount: 1,
+ platform: 'darwin',
+ thresholds: {
+ warn: 147_000,
+ auto: 167_000,
+ hard: 177_000,
+ effectiveWindow: 180_000,
+ },
+};
+
+function tipById(id: string) {
+ return tipRegistry.find((t) => t.id === id)!;
+}
+
+describe('context-* tip thresholds align with computeThresholds', () => {
+ it('compress-intro fires between warn and auto', () => {
+ const t = tipById('compress-intro');
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 100_000 })).toBe(
+ false,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 150_000 })).toBe(
+ true,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 168_000 })).toBe(
+ false,
+ );
+ });
+
+ it('context-high fires between auto and hard', () => {
+ const t = tipById('context-high');
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 150_000 })).toBe(
+ false,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 170_000 })).toBe(
+ true,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 178_000 })).toBe(
+ false,
+ );
+ });
+
+ it('context-critical fires at or above hard', () => {
+ const t = tipById('context-critical');
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 170_000 })).toBe(
+ false,
+ );
+ expect(t.isRelevant({ ...baseCtx, lastPromptTokenCount: 178_000 })).toBe(
+ true,
+ );
+ });
+
+ it('context-high covers the small-window collapse case (R11.2: hard === auto)', () => {
+ // R9.5 gated context-critical on `hard > auto` to avoid claiming
+ // "near hard limit" when there's no distinct hard tier. That
+ // created a coverage gap on small windows: context-high's band
+ // `[auto, hard)` is the empty set when hard === auto, so users at
+ // the auto threshold got no tip at all. R11.2: context-high must
+ // fire on `>= auto` when hard === auto (treating it as "everything
+ // above auto" — there's no distinct hard tier to delimit).
+ const t = tipById('context-high');
+ const collapsedCtx = {
+ ...baseCtx,
+ thresholds: {
+ effectiveWindow: 32_000,
+ warn: 18_000,
+ auto: 22_400,
+ hard: 22_400, // collapsed
+ },
+ lastPromptTokenCount: 25_000, // above the collapsed threshold
+ };
+ expect(t.isRelevant(collapsedCtx)).toBe(true);
+ });
+
+ it('context-critical suppresses when hard === auto (R9.5 small-window collapse)', () => {
+ // On small windows (e.g. 32K) computeThresholds collapses
+ // hard to equal auto. The critical band [hard, ∞) starts at the
+ // auto threshold; firing the tip there would claim "near hard
+ // limit" when there is no distinct hard limit. R9.5: gate on
+ // `hard > auto` like `currentTier` does. The `context-high` tip
+ // in band `[auto, hard)` already covers small windows.
+ const t = tipById('context-critical');
+ const collapsedCtx = {
+ ...baseCtx,
+ thresholds: {
+ effectiveWindow: 32_000,
+ warn: 18_000,
+ auto: 22_400,
+ hard: 22_400, // collapsed to equal auto
+ },
+ lastPromptTokenCount: 25_000, // above the collapsed threshold
+ };
+ expect(t.isRelevant(collapsedCtx)).toBe(false);
+ });
+
+ it('falls back gracefully when thresholds undefined (legacy callers)', () => {
+ const ctx = { ...baseCtx, thresholds: undefined };
+ // All three context-* tips return false when thresholds are missing
+ // (the comparison would be unsafe without them).
+ expect(tipById('compress-intro').isRelevant(ctx)).toBe(false);
+ expect(tipById('context-high').isRelevant(ctx)).toBe(false);
+ expect(tipById('context-critical').isRelevant(ctx)).toBe(false);
+ });
+
+ it('compress-intro additionally gates on sessionPromptCount > 5', () => {
+ const t = tipById('compress-intro');
+ // Above warn, below auto, but session is too new.
+ expect(
+ t.isRelevant({
+ ...baseCtx,
+ lastPromptTokenCount: 150_000,
+ sessionPromptCount: 3,
+ }),
+ ).toBe(false);
+ expect(
+ t.isRelevant({
+ ...baseCtx,
+ lastPromptTokenCount: 150_000,
+ sessionPromptCount: 6,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/cli/src/services/tips/tipRegistry.ts b/packages/cli/src/services/tips/tipRegistry.ts
index cb655783b2f..852b82ac061 100644
--- a/packages/cli/src/services/tips/tipRegistry.ts
+++ b/packages/cli/src/services/tips/tipRegistry.ts
@@ -8,7 +8,7 @@
* Contextual tip registry — defines tips, their conditions, and display rules.
*/
-import { DEFAULT_TOKEN_LIMIT } from '@qwen-code/qwen-code-core';
+import { type CompactionThresholds } from '@qwen-code/qwen-code-core';
export type TipTrigger = 'startup' | 'post-response';
@@ -18,6 +18,12 @@ export interface TipContext {
sessionPromptCount: number;
sessionCount: number;
platform: string;
+ /**
+ * Three-tier auto-compaction thresholds, computed by callers via
+ * `computeThresholds(contextWindowSize)`. Optional for backward compat;
+ * context-* tip checks return false when missing.
+ */
+ thresholds?: CompactionThresholds;
}
export interface ContextualTip {
@@ -29,19 +35,31 @@ export interface ContextualTip {
priority: number;
}
-export function getContextUsagePercent(ctx: TipContext): number {
- const windowSize = ctx.contextWindowSize || DEFAULT_TOKEN_LIMIT;
- return (ctx.lastPromptTokenCount / windowSize) * 100;
-}
-
export const tipRegistry: ContextualTip[] = [
// --- Post-response contextual tips (priority: higher = more urgent) ---
{
id: 'context-critical',
- content:
- 'Context is almost full! Run /compress now or start /new to continue.',
+ // R6.8 / R7.10: tip fires post-response. We don't know from this
+ // call site whether (a) hard-tier rescue ran successfully and
+ // shrank the context, (b) it ran but failed/NOOP'd, or (c) it was
+ // suppressed because `hardRescueFailureCount` hit
+ // `MAX_CONSECUTIVE_FAILURES`. The earlier wording ("auto-compact
+ // was forced on this turn") was wrong in case (c); the still
+ // earlier ("will force on next send") was wrong in case (a).
+ // Neutral, actionable wording is correct across all three.
+ content: 'Context near hard limit. Run /compress or /clear to free space.',
trigger: 'post-response',
- isRelevant: (ctx) => getContextUsagePercent(ctx) >= 95,
+ // R9.5: gate on `hard > auto` mirroring `currentTier` in
+ // contextCommand.ts. On small windows (e.g. 32K) `computeThresholds`
+ // collapses `hard` to equal `auto`, leaving the critical band
+ // degenerate — without this guard the tip fires at the auto
+ // threshold while claiming "near hard limit" when there is no
+ // distinct hard limit. The `context-high` tip in the band
+ // `[auto, hard)` already covers small windows.
+ isRelevant: (ctx) =>
+ ctx.thresholds !== undefined &&
+ ctx.thresholds.hard > ctx.thresholds.auto &&
+ ctx.lastPromptTokenCount >= ctx.thresholds.hard,
cooldownPrompts: 3,
priority: 100,
},
@@ -49,10 +67,16 @@ export const tipRegistry: ContextualTip[] = [
id: 'context-high',
content: 'Context is getting full. Use /compress to free up space.',
trigger: 'post-response',
- isRelevant: (ctx) => {
- const pct = getContextUsagePercent(ctx);
- return pct >= 80 && pct < 95;
- },
+ // R11.2: when `hard === auto` (small windows ≤ ~77K, including
+ // 32K / 64K), the `[auto, hard)` band collapses to empty —
+ // context-critical is suppressed by R9.5's guard and the user
+ // would otherwise get no tip at all. Accept everything `>= auto`
+ // in that case: there's no distinct hard tier above to delimit.
+ isRelevant: (ctx) =>
+ ctx.thresholds !== undefined &&
+ ctx.lastPromptTokenCount >= ctx.thresholds.auto &&
+ (ctx.thresholds.hard === ctx.thresholds.auto ||
+ ctx.lastPromptTokenCount < ctx.thresholds.hard),
cooldownPrompts: 5,
priority: 90,
},
@@ -60,10 +84,11 @@ export const tipRegistry: ContextualTip[] = [
id: 'compress-intro',
content: 'Long conversation? /compress summarizes history to free context.',
trigger: 'post-response',
- isRelevant: (ctx) => {
- const pct = getContextUsagePercent(ctx);
- return pct >= 50 && pct < 80 && ctx.sessionPromptCount > 5;
- },
+ isRelevant: (ctx) =>
+ ctx.thresholds !== undefined &&
+ ctx.lastPromptTokenCount >= ctx.thresholds.warn &&
+ ctx.lastPromptTokenCount < ctx.thresholds.auto &&
+ ctx.sessionPromptCount > 5,
cooldownPrompts: 10,
priority: 50,
},
diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts
index 99d0d746939..7400fca1302 100644
--- a/packages/cli/src/ui/commands/contextCommand.test.ts
+++ b/packages/cli/src/ui/commands/contextCommand.test.ts
@@ -6,28 +6,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Config } from '@qwen-code/qwen-code-core';
-import { collectContextData } from './contextCommand.js';
+import {
+ collectContextData,
+ formatContextUsageText,
+} from './contextCommand.js';
// uiTelemetryService is consumed inside collectContextData via the
// re-export from core; mock it here so the function returns deterministic
-// numbers without needing a real session.
+// numbers without needing a real session. The mock fns live inside
+// vi.hoisted so they are available when vi.mock's factory runs (vi.mock
+// is hoisted above module-level const declarations).
+const { mockGetLastPromptTokenCount, mockGetLastCachedContentTokenCount } =
+ vi.hoisted(() => ({
+ mockGetLastPromptTokenCount: vi.fn().mockReturnValue(0),
+ mockGetLastCachedContentTokenCount: vi.fn().mockReturnValue(0),
+ }));
+
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const original =
await importOriginal();
return {
...original,
uiTelemetryService: {
- getLastPromptTokenCount: vi.fn().mockReturnValue(0),
- getLastCachedContentTokenCount: vi.fn().mockReturnValue(0),
+ getLastPromptTokenCount: mockGetLastPromptTokenCount,
+ getLastCachedContentTokenCount: mockGetLastCachedContentTokenCount,
},
};
});
+function makeMockConfig(contextWindowSize = 32_000): Config {
+ return {
+ getModel: vi.fn().mockReturnValue('test-model'),
+ getContentGeneratorConfig: vi.fn().mockReturnValue({
+ contextWindowSize,
+ }),
+ getToolRegistry: vi.fn().mockReturnValue({
+ getAllTools: vi.fn().mockReturnValue([]),
+ getFunctionDeclarations: vi.fn().mockReturnValue([]),
+ }),
+ getUserMemory: vi.fn().mockReturnValue(''),
+ getSkillManager: vi.fn().mockReturnValue({
+ listSkills: vi.fn().mockResolvedValue([]),
+ }),
+ getChatCompression: vi.fn().mockReturnValue(undefined),
+ } as unknown as Config;
+}
+
describe('collectContextData (contextCommand)', () => {
let getFunctionDeclarationsSpy: ReturnType;
let mockConfig: Config;
beforeEach(() => {
+ mockGetLastPromptTokenCount.mockReturnValue(0);
+ mockGetLastCachedContentTokenCount.mockReturnValue(0);
getFunctionDeclarationsSpy = vi.fn().mockReturnValue([]);
mockConfig = {
getModel: vi.fn().mockReturnValue('test-model'),
@@ -62,3 +93,72 @@ describe('collectContextData (contextCommand)', () => {
});
});
});
+
+describe('/context shows three-tier thresholds', () => {
+ beforeEach(() => {
+ mockGetLastPromptTokenCount.mockReturnValue(0);
+ mockGetLastCachedContentTokenCount.mockReturnValue(0);
+ });
+
+ it('renders warn/auto/hard with the warn-tier marker when usage sits between warn and auto', async () => {
+ // 200K window. computeThresholds(200K) = {
+ // warn: 147,000, auto: 167,000, hard: 177,000, effectiveWindow: 180,000
+ // }
+ // lastPromptTokenCount = 150K → between warn and auto → tier = warn.
+ mockGetLastPromptTokenCount.mockReturnValue(150_000);
+ const data = await collectContextData(makeMockConfig(200_000), false);
+ const text = formatContextUsageText(data);
+
+ expect(text).toMatch(/Effective window:\s+180,000/);
+ expect(text).toMatch(/Warn threshold:\s+147,000/);
+ expect(text).toMatch(/Auto threshold:\s+167,000/);
+ expect(text).toMatch(/Hard threshold:\s+177,000/);
+ expect(text).toMatch(/Current tier:\s+warn/);
+ expect(data.breakdown.currentTier).toBe('warn');
+ expect(data.breakdown.thresholds).toEqual({
+ effectiveWindow: 180_000,
+ warn: 147_000,
+ auto: 167_000,
+ hard: 177_000,
+ });
+ });
+
+ it('classifies usage below the warn threshold as the safe tier', async () => {
+ mockGetLastPromptTokenCount.mockReturnValue(50_000);
+ const data = await collectContextData(makeMockConfig(200_000), false);
+ const text = formatContextUsageText(data);
+
+ expect(text).toMatch(/Current tier:\s+safe/);
+ expect(data.breakdown.currentTier).toBe('safe');
+ });
+
+ it('classifies usage at or above the hard threshold as the hard tier', async () => {
+ mockGetLastPromptTokenCount.mockReturnValue(180_000);
+ const data = await collectContextData(makeMockConfig(200_000), false);
+ expect(data.breakdown.currentTier).toBe('hard');
+ });
+
+ it('classifies usage between auto and hard as the auto tier', async () => {
+ // 200K window — between 167K (auto) and 177K (hard) → tier = auto.
+ mockGetLastPromptTokenCount.mockReturnValue(170_000);
+ const data = await collectContextData(makeMockConfig(200_000), false);
+ expect(data.breakdown.currentTier).toBe('auto');
+ const text = formatContextUsageText(data);
+ expect(text).toMatch(/Current tier:\s+auto/);
+ });
+
+ it('treats no-API-data sessions as safe and omits the threshold section from text', async () => {
+ // lastPromptTokenCount = 0 → collectContextData uses the estimated branch:
+ // currentTier should be `safe` regardless of overhead size, and
+ // formatContextUsageText must NOT emit the "Compaction thresholds" section
+ // because the estimated path renders a different layout.
+ mockGetLastPromptTokenCount.mockReturnValue(0);
+ const data = await collectContextData(makeMockConfig(200_000), false);
+ expect(data.breakdown.currentTier).toBe('safe');
+ // Thresholds are still computed and exposed on the breakdown for downstream
+ // consumers, even though the text layout suppresses them.
+ expect(data.breakdown.thresholds.auto).toBe(167_000);
+ const text = formatContextUsageText(data);
+ expect(text).not.toMatch(/Compaction thresholds/);
+ });
+});
diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts
index a58fc596815..e4651751cf5 100644
--- a/packages/cli/src/ui/commands/contextCommand.ts
+++ b/packages/cli/src/ui/commands/contextCommand.ts
@@ -13,6 +13,7 @@ import {
MessageType,
type HistoryItemContextUsage,
type ContextCategoryBreakdown,
+ type ContextTier,
type ContextToolDetail,
type ContextMemoryDetail,
type ContextSkillDetail,
@@ -24,14 +25,34 @@ import {
DEFAULT_TOKEN_LIMIT,
ToolNames,
buildSkillLlmContent,
+ computeThresholds,
+ type CompactionThresholds,
+ SUMMARY_RESERVE,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
/**
- * Default compression token threshold (triggers compression at 70% usage).
- * The autocompact buffer is (1 - threshold) * contextWindowSize.
+ * Classify a token count against the three-tier compaction ladder. Mirrors
+ * the gating logic in `chatCompressionService` / `geminiChat` so the
+ * `/context` output's "current tier" label reflects exactly which tier the
+ * runtime would treat the session as sitting in.
*/
-const DEFAULT_COMPRESSION_THRESHOLD = 0.7;
+function currentTier(
+ tokens: number,
+ thresholds: CompactionThresholds,
+): ContextTier {
+ // R6.12: for small windows `computeThresholds` collapses hard to auto
+ // (when `rawHard < auto`). Checking `>= hard` first would then make
+ // the 'auto' tier unreachable — small-window users would jump straight
+ // from 'warn' to 'hard'. Only return 'hard' when there's a meaningful
+ // gap above auto, so 'auto' is reachable when the two collapse.
+ if (thresholds.hard > thresholds.auto && tokens >= thresholds.hard) {
+ return 'hard';
+ }
+ if (tokens >= thresholds.auto) return 'auto';
+ if (tokens >= thresholds.warn) return 'warn';
+ return 'safe';
+}
/**
* Estimate token count for a string using a character-based heuristic.
@@ -174,13 +195,16 @@ export async function collectContextData(
const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens;
- const compressionThreshold =
- config.getChatCompression()?.contextPercentageThreshold ??
- DEFAULT_COMPRESSION_THRESHOLD;
- const autocompactBuffer =
- compressionThreshold > 0
- ? Math.round((1 - compressionThreshold) * contextWindowSize)
- : 0;
+ const thresholds = computeThresholds(contextWindowSize);
+ // Keep the `(window - auto)` buffer for the legacy three-segment progress
+ // bar in ContextUsage.tsx — it visualizes the headroom between the auto
+ // threshold and the window edge, which is exactly `contextWindowSize -
+ // thresholds.auto`. New consumers should read `breakdown.thresholds`
+ // directly.
+ const autocompactBuffer = Math.max(
+ 0,
+ Math.round(contextWindowSize - thresholds.auto),
+ );
const rawOverhead =
systemPromptTokens +
@@ -287,6 +311,33 @@ export async function collectContextData(
: skills;
}
+ // Tier classification: prefer the API-reported total when available.
+ // When no API call has happened yet (first /context, --continue resume,
+ // sub-agent inheritance), classify against `rawOverhead` so a session
+ // dominated by system prompt / skills / MCP tools doesn't silently show
+ // "safe". (R2.2)
+ //
+ // SCOPE GAP (R5.1): `rawOverhead` excludes `messagesTokens` — the actual
+ // chat history. A `--continue` restore with 100K of historical messages
+ // (but small overhead) will still display "safe" here, even though the
+ // cheap-gate inside chatCompressionService will trigger compression on
+ // the very next send (it uses `estimatePromptTokens(history, ...)` which
+ // walks the real history). This is a UI/runtime divergence — for a
+ // single render — that resolves the moment any send happens.
+ //
+ // TODO (R6.11): plumb the chat history into collectContextData for
+ // same-source-of-truth as the cheap-gate. Implementation sketch:
+ // 1. Make `estimatePromptTokens.userMessage` optional (today it's
+ // required because every send-path caller has a real message).
+ // 2. Add a `chat?: GeminiChat` parameter to collectContextData,
+ // passed from the UI layer that already holds the active chat.
+ // 3. Here, when `chat` is available, call
+ // `estimatePromptTokens(chat.getHistory(true), undefined, 0,
+ // imageTokenEstimate)` to get the actual size of inherited history.
+ // Deferred because step (1) is a non-trivial signature change across
+ // tokenEstimation.ts + tests + every caller.
+ const tierTokens = isEstimated ? rawOverhead : apiTotalTokens;
+
const breakdown: ContextCategoryBreakdown = {
systemPrompt: displaySystemPrompt,
builtinTools: displayBuiltinTools,
@@ -296,6 +347,13 @@ export async function collectContextData(
messages: messagesTokens,
freeSpace,
autocompactBuffer,
+ thresholds: {
+ effectiveWindow: thresholds.effectiveWindow,
+ warn: thresholds.warn,
+ auto: thresholds.auto,
+ hard: thresholds.hard,
+ },
+ currentTier: currentTier(tierTokens, thresholds),
};
return {
@@ -340,6 +398,11 @@ function fmtCategoryRow(
return `${leftPart}${' '.repeat(dots)}${right}`;
}
+/** Locale-grouped integer (e.g. 147000 -> "147,000"). */
+function formatNum(n: number): string {
+ return Math.round(n).toLocaleString('en-US');
+}
+
/**
* Convert a HistoryItemContextUsage to a human-readable text string,
* mirroring the layout of the interactive ContextUsage component.
@@ -375,17 +438,34 @@ export function formatContextUsageText(data: HistoryItemContextUsage): string {
`Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`,
);
lines.push('');
- lines.push(fmtCategoryRow('Used', totalTokens, contextWindowSize));
- lines.push(fmtCategoryRow('Free', breakdown.freeSpace, contextWindowSize));
+ lines.push(fmtCategoryRow(t('Used'), totalTokens, contextWindowSize));
+ lines.push(
+ fmtCategoryRow(t('Free'), breakdown.freeSpace, contextWindowSize),
+ );
+ lines.push('');
+ lines.push(`**${t('Compaction thresholds')}**`);
+ // R6.13: i18n the labels + derive the reserve hint from the
+ // SUMMARY_RESERVE constant so it doesn't go stale if the constant
+ // changes. Numbers stay locale-formatted via formatNum.
+ const reserveK = `${Math.round(SUMMARY_RESERVE / 1000)}K`;
+ lines.push(
+ ` ${t('Effective window')}: ${formatNum(breakdown.thresholds.effectiveWindow)} (${t('window − {{reserve}} reserve', { reserve: reserveK })})`,
+ );
+ lines.push(
+ ` ${t('Warn threshold')}: ${formatNum(breakdown.thresholds.warn)}`,
+ );
+ lines.push(
+ ` ${t('Auto threshold')}: ${formatNum(breakdown.thresholds.auto)}`,
+ );
lines.push(
- fmtCategoryRow(
- 'Autocompact buffer',
- breakdown.autocompactBuffer,
- contextWindowSize,
- ),
+ ` ${t('Hard threshold')}: ${formatNum(breakdown.thresholds.hard)}`,
);
+ // R11.8: wrap the tier value in t() so non-English locales don't
+ // see a mixed-language render (English tier name + translated
+ // label). Same pattern in ContextUsage.tsx render.
+ lines.push(` ${t('Current tier')}: ${t(breakdown.currentTier)}`);
lines.push('');
- lines.push('**Usage by category**');
+ lines.push(`**${t('Usage by category')}**`);
}
lines.push(
diff --git a/packages/cli/src/ui/components/Tips.test.ts b/packages/cli/src/ui/components/Tips.test.ts
index 9a93d7d2f0b..418b6ab901a 100644
--- a/packages/cli/src/ui/components/Tips.test.ts
+++ b/packages/cli/src/ui/components/Tips.test.ts
@@ -40,6 +40,14 @@ function createContext(overrides: Partial = {}): TipContext {
sessionPromptCount: 0,
sessionCount: 1,
platform: 'linux',
+ // Matches computeThresholds(1_000_000) — kept inline so this test stays
+ // hermetic to the registry's tier logic rather than re-deriving constants.
+ thresholds: {
+ warn: 947_000,
+ auto: 967_000,
+ hard: 977_000,
+ effectiveWindow: 980_000,
+ },
...overrides,
};
}
@@ -59,7 +67,8 @@ describe('selectTip', () => {
it('returns context-high tip when context usage is high', () => {
const ctx = createContext({
- lastPromptTokenCount: 850_000,
+ // Between auto (967K) and hard (977K) — context-high band.
+ lastPromptTokenCount: 970_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
@@ -71,7 +80,8 @@ describe('selectTip', () => {
it('returns context-critical tip when context usage is critical', () => {
const ctx = createContext({
- lastPromptTokenCount: 960_000,
+ // At/above hard (977K) — context-critical band.
+ lastPromptTokenCount: 980_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
@@ -83,7 +93,8 @@ describe('selectTip', () => {
it('returns compress-intro tip when context is moderate and session is long', () => {
const ctx = createContext({
- lastPromptTokenCount: 550_000,
+ // Between warn (947K) and auto (967K) — compress-intro band.
+ lastPromptTokenCount: 955_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
@@ -106,7 +117,7 @@ describe('selectTip', () => {
it('respects cooldown — does not re-show same tip within cooldown period', () => {
const ctx = createContext({
- lastPromptTokenCount: 850_000,
+ lastPromptTokenCount: 970_000,
contextWindowSize: 1_000_000,
sessionPromptCount: 10,
});
diff --git a/packages/cli/src/ui/components/views/ContextUsage.test.tsx b/packages/cli/src/ui/components/views/ContextUsage.test.tsx
new file mode 100644
index 00000000000..6a40e17566d
--- /dev/null
+++ b/packages/cli/src/ui/components/views/ContextUsage.test.tsx
@@ -0,0 +1,135 @@
+/**
+ * @license
+ * Copyright 2025 Qwen
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from 'ink-testing-library';
+import { ContextUsage } from './ContextUsage.js';
+import type {
+ ContextCategoryBreakdown,
+ ContextThresholds,
+ ContextTier,
+} from '../../types.js';
+
+afterEach(() => {
+ cleanup();
+});
+
+const thresholds: ContextThresholds = {
+ effectiveWindow: 108_000,
+ warn: 76_800,
+ auto: 95_000,
+ hard: 105_000,
+};
+
+function makeBreakdown(
+ currentTier: ContextTier,
+ overrides: Partial = {},
+): ContextCategoryBreakdown {
+ return {
+ systemPrompt: 5000,
+ builtinTools: 8000,
+ mcpTools: 0,
+ memoryFiles: 200,
+ skills: 1000,
+ messages: 0,
+ freeSpace: 80_000,
+ autocompactBuffer: 33_000,
+ thresholds,
+ currentTier,
+ ...overrides,
+ };
+}
+
+describe('ContextUsage — CompactionThresholds section (review #4168 R1.6)', () => {
+ it('renders the new three-tier section with all four threshold rows', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('Compaction thresholds');
+ expect(frame).toContain('Effective window');
+ expect(frame).toContain('Warn threshold');
+ expect(frame).toContain('Auto threshold');
+ expect(frame).toContain('Hard threshold');
+ expect(frame).toContain('Current tier');
+ });
+
+ it('shows safe tier without any ▶ marker', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame() ?? '';
+ // safe tier → no ▶ marker on any threshold row
+ expect(frame).not.toContain('▶');
+ // The literal word "safe" appears as the Current tier value
+ expect(frame).toMatch(/Current tier[\s\S]*safe/);
+ });
+
+ it('places ▶ on the warn row when currentTier === warn', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame() ?? '';
+ expect(frame).toContain('▶');
+ // The ▶ should appear on the Warn-threshold line and nowhere else.
+ const lines = frame.split('\n');
+ const warnLine = lines.find((l) => l.includes('Warn threshold')) ?? '';
+ expect(warnLine).toContain('▶');
+ const autoLine = lines.find((l) => l.includes('Auto threshold')) ?? '';
+ expect(autoLine).not.toContain('▶');
+ const hardLine = lines.find((l) => l.includes('Hard threshold')) ?? '';
+ expect(hardLine).not.toContain('▶');
+ });
+
+ it('places ▶ on the hard row when currentTier === hard', () => {
+ const { lastFrame } = render(
+ ,
+ );
+ const frame = lastFrame() ?? '';
+ const lines = frame.split('\n');
+ const hardLine = lines.find((l) => l.includes('Hard threshold')) ?? '';
+ expect(hardLine).toContain('▶');
+ // Current tier reads `hard`
+ expect(frame).toMatch(/Current tier[\s\S]*hard/);
+ });
+});
diff --git a/packages/cli/src/ui/components/views/ContextUsage.tsx b/packages/cli/src/ui/components/views/ContextUsage.tsx
index fefe9095649..bcc9885707a 100644
--- a/packages/cli/src/ui/components/views/ContextUsage.tsx
+++ b/packages/cli/src/ui/components/views/ContextUsage.tsx
@@ -9,9 +9,11 @@ import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type {
ContextCategoryBreakdown,
- ContextToolDetail,
ContextMemoryDetail,
ContextSkillDetail,
+ ContextThresholds,
+ ContextTier,
+ ContextToolDetail,
} from '../../types.js';
import { t } from '../../../i18n/index.js';
@@ -140,6 +142,102 @@ const CategoryRow: React.FC<{
);
};
+/**
+ * A row inside the "Compaction thresholds" section: label + token count, with
+ * a left-edge marker when the current usage has crossed this tier.
+ */
+const ThresholdRow: React.FC<{
+ label: string;
+ tokens: number;
+ isCurrent?: boolean;
+}> = ({ label, tokens, isCurrent }) => {
+ const tokenStr = `${formatTokens(tokens)} ${t('tokens')}`;
+ return (
+
+
+
+ {isCurrent ? '▶' : ' '}
+
+
+
+ {label}
+
+
+ {tokenStr}
+
+
+ );
+};
+
+/**
+ * Color associated with each compaction tier — green for safe, escalating to
+ * red for hard. Keep these aligned with how `theme.status.*` is used elsewhere
+ * so the tier badge feels native to the existing design.
+ */
+function tierColor(tier: ContextTier): string {
+ switch (tier) {
+ case 'safe':
+ return theme.status.success;
+ case 'warn':
+ return theme.status.warning;
+ case 'auto':
+ return theme.status.warning;
+ case 'hard':
+ return theme.status.error;
+ default:
+ return theme.text.secondary;
+ }
+}
+
+/**
+ * Renders the three-tier compaction threshold ladder (warn / auto / hard) with
+ * the effective window and a current-tier marker. Source of the data is
+ * `breakdown.thresholds` + `breakdown.currentTier`, which the context command
+ * derives from `computeThresholds()` in core.
+ */
+const CompactionThresholds: React.FC<{
+ thresholds: ContextThresholds;
+ currentTier: ContextTier;
+}> = ({ thresholds, currentTier }) => (
+
+
+ {t('Compaction thresholds')}
+
+
+
+
+
+
+
+
+
+
+ {t('Current tier')}
+
+
+
+ {t(currentTier)}
+
+
+
+
+);
+
/**
* A detail row for individual items (MCP tools, memory files, skills).
*/
@@ -348,6 +446,15 @@ export const ContextUsage: React.FC = ({
/>
)}
+ {/* Three-tier compaction thresholds — visible even when isEstimated so
+ the user can see the auto-compact landscape before any API call. */}
+ {breakdown.thresholds && breakdown.currentTier && (
+
+ )}
+
{showDetails ? (
<>
{/* Built-in tools detail */}
diff --git a/packages/cli/src/ui/hooks/useContextualTips.ts b/packages/cli/src/ui/hooks/useContextualTips.ts
index ecdd706ea26..743d6f4945c 100644
--- a/packages/cli/src/ui/hooks/useContextualTips.ts
+++ b/packages/cli/src/ui/hooks/useContextualTips.ts
@@ -10,7 +10,11 @@
*/
import { useEffect, useRef } from 'react';
-import { type Config, DEFAULT_TOKEN_LIMIT } from '@qwen-code/qwen-code-core';
+import {
+ type Config,
+ DEFAULT_TOKEN_LIMIT,
+ computeThresholds,
+} from '@qwen-code/qwen-code-core';
import {
StreamingState,
MessageType,
@@ -81,6 +85,7 @@ export function useContextualTips({
sessionPromptCount,
sessionCount: tipHistory.sessionCount,
platform: process.platform,
+ thresholds: computeThresholds(contextWindowSize),
};
const tip = selectTip('post-response', tipContext, tipRegistry, tipHistory);
diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts
index a39771cb2ed..ab8bc6a8851 100644
--- a/packages/cli/src/ui/types.ts
+++ b/packages/cli/src/ui/types.ts
@@ -5,6 +5,7 @@
*/
import type {
+ CompactionThresholds,
CompressionStatus,
MCPServerConfig,
ThoughtSummary,
@@ -342,6 +343,15 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
// --- Context Usage types ---
+export type ContextTier = 'safe' | 'warn' | 'auto' | 'hard';
+
+/**
+ * Re-export of core's `CompactionThresholds` to keep the CLI's display
+ * layer and core's compaction layer on the exact same shape. (R6.4: a
+ * locally-duplicated shape silently drifts if core's fields change.)
+ */
+export type ContextThresholds = CompactionThresholds;
+
export interface ContextCategoryBreakdown {
systemPrompt: number;
builtinTools: number;
@@ -350,7 +360,20 @@ export interface ContextCategoryBreakdown {
skills: number;
messages: number;
freeSpace: number;
+ /**
+ * Distance from the auto-compaction threshold to the window edge.
+ * Derived from `thresholds.auto` (= `contextWindowSize - auto`); retained
+ * so the legacy three-segment progress bar in `ContextUsage.tsx` keeps
+ * working without a separate code path.
+ */
autocompactBuffer: number;
+ /** Three-tier ladder used by auto-compaction (warn / auto / hard) plus the effective window. */
+ thresholds: ContextThresholds;
+ /**
+ * Which tier the current usage sits in. `safe` is below `warn`; `warn` /
+ * `auto` / `hard` mean `totalTokens` has crossed the corresponding tier.
+ */
+ currentTier: ContextTier;
}
export interface ContextToolDetail {
diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts
index 8a668cfe581..b5e29ffe194 100644
--- a/packages/core/src/config/config.test.ts
+++ b/packages/core/src/config/config.test.ts
@@ -6,7 +6,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
-import type { ConfigParameters, SandboxConfig } from './config.js';
+import type {
+ ChatCompressionSettings,
+ ConfigParameters,
+ SandboxConfig,
+} from './config.js';
import {
Config,
ApprovalMode,
@@ -3320,4 +3324,55 @@ describe('Model Switching and Config Updates', () => {
);
});
});
+
+ describe('chatCompression.contextPercentageThreshold deprecation', () => {
+ // The proportional-threshold knob `contextPercentageThreshold` was
+ // removed in the auto-compaction threshold redesign (Task 8) — the
+ // value is now derived from `computeThresholds(...)` in the
+ // ChatCompressionService and is no longer user-tunable. Existing
+ // settings.json files that still set the field should keep working
+ // but get a one-time stderr warning so users know to remove it.
+ let warnSpy: ReturnType;
+
+ beforeEach(() => {
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('logs a stderr warning when the deprecated field is set', () => {
+ new Config({
+ ...baseParams,
+ chatCompression: {
+ contextPercentageThreshold: 0.5,
+ } as ChatCompressionSettings,
+ });
+ expect(warnSpy).toHaveBeenCalledWith(
+ expect.stringContaining(
+ 'chatCompression.contextPercentageThreshold has been removed',
+ ),
+ );
+ });
+
+ it('does not warn when chatCompression is absent', () => {
+ new Config({ ...baseParams });
+ const warnCalls = warnSpy.mock.calls.map((c) => String(c[0]));
+ expect(
+ warnCalls.some((m) => m.includes('contextPercentageThreshold')),
+ ).toBe(false);
+ });
+
+ it('does not warn when chatCompression is set without the deprecated field', () => {
+ new Config({
+ ...baseParams,
+ chatCompression: { imageTokenEstimate: 1600 },
+ });
+ const warnCalls = warnSpy.mock.calls.map((c) => String(c[0]));
+ expect(
+ warnCalls.some((m) => m.includes('contextPercentageThreshold')),
+ ).toBe(false);
+ });
+ });
});
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index 8379276606a..606f0eb5997 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -268,7 +268,6 @@ export interface BugCommandSettings {
}
export interface ChatCompressionSettings {
- contextPercentageThreshold?: number;
/**
* Estimated tokens for a single inline image / document part when
* apportioning chars across history in `findCompressSplitPoint`.
@@ -277,6 +276,16 @@ export interface ChatCompressionSettings {
* Env override: `QWEN_IMAGE_TOKEN_ESTIMATE`.
*/
imageTokenEstimate?: number;
+ /**
+ * When `true`, auto-compaction (proactive + hard-tier rescue) is
+ * disabled entirely. The chat keeps growing without bound until the
+ * API rejects an oversized prompt, at which point reactive overflow
+ * recovery still runs as a last-ditch safety net. Use for
+ * compliance / debugging / audit-trail sessions that need
+ * uncompressed history. Replaces the removed
+ * `contextPercentageThreshold: 0` escape hatch. (review #4168 R11.4)
+ */
+ disabled?: boolean;
}
/**
@@ -1037,6 +1046,24 @@ export class Config {
this.loadMemoryFromIncludeDirectories =
params.loadMemoryFromIncludeDirectories ?? false;
this.importFormat = params.importFormat ?? 'tree';
+ // Auto-compaction threshold moved to built-in constants (computeThresholds
+ // in chatCompressionService.ts). The old `contextPercentageThreshold`
+ // field is deprecated; if present in user settings, emit a one-time
+ // warning and ignore the value.
+ if (
+ params.chatCompression &&
+ typeof (params.chatCompression as Record)[
+ 'contextPercentageThreshold'
+ ] !== 'undefined'
+ ) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ '[qwen-code] chatCompression.contextPercentageThreshold has been removed ' +
+ 'and is now controlled by built-in thresholds. Setting will be ignored. ' +
+ 'To disable auto-compaction entirely, use chatCompression.disabled: true ' +
+ 'in your settings (manual /compress and reactive overflow still work).',
+ );
+ }
this.chatCompression = params.chatCompression;
this.interactive = params.interactive ?? false;
this.trustedFolder = params.trustedFolder;
diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts
index 5bd844056cd..a0a159211da 100644
--- a/packages/core/src/core/client.test.ts
+++ b/packages/core/src/core/client.test.ts
@@ -1867,7 +1867,7 @@ describe('Gemini Client (client.ts)', () => {
// tryCompressChat is now a thin wrapper around GeminiChat.tryCompress.
// The compression logic itself is exercised in chatCompressionService.test.ts
// (token math, threshold checks, hook firing) and geminiChat.test.ts (history
- // mutation, recording, hasFailedCompressionAttempt). The tests below cover
+ // mutation, recording, consecutiveFailures circuit breaker). The tests below cover
// only what the wrapper itself adds: argument forwarding and the IDE-context
// flag flip.
describe('tryCompressChat (delegation)', () => {
diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts
index 7f2d514ce02..02182c28a7e 100644
--- a/packages/core/src/core/client.ts
+++ b/packages/core/src/core/client.ts
@@ -46,10 +46,7 @@ import {
} from './turn.js';
// Services
-import {
- COMPRESSION_PRESERVE_THRESHOLD,
- COMPRESSION_TOKEN_THRESHOLD,
-} from '../services/chatCompressionService.js';
+import { COMPRESSION_PRESERVE_THRESHOLD } from '../services/chatCompressionService.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { CommitAttributionService } from '../services/commitAttribution.js';
@@ -2040,5 +2037,4 @@ export class GeminiClient {
export const TEST_ONLY = {
COMPRESSION_PRESERVE_THRESHOLD,
- COMPRESSION_TOKEN_THRESHOLD,
};
diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts
index fd25e8a220f..0981873518b 100644
--- a/packages/core/src/core/geminiChat.test.ts
+++ b/packages/core/src/core/geminiChat.test.ts
@@ -24,7 +24,10 @@ import type { Config } from '../config/config.js';
import { setSimulate429 } from '../utils/testUtils.js';
import { uiTelemetryService } from '../telemetry/uiTelemetry.js';
import { CompressionStatus, type ChatCompressionInfo } from './turn.js';
-import { ChatCompressionService } from '../services/chatCompressionService.js';
+import {
+ ChatCompressionService,
+ MAX_CONSECUTIVE_FAILURES,
+} from '../services/chatCompressionService.js';
import { SessionStartSource } from '../hooks/types.js';
const { mockGetHeapStatistics } = vi.hoisted(() => ({
@@ -87,6 +90,10 @@ const { mockLogContentRetry, mockLogContentRetryFailure } = vi.hoisted(() => ({
vi.mock('../telemetry/loggers.js', () => ({
logContentRetry: mockLogContentRetry,
logContentRetryFailure: mockLogContentRetryFailure,
+ // Real ChatCompressionService.compress() calls logChatCompression on
+ // every attempt; the R3.4 integration test exercises that path, so the
+ // mock has to expose it (no-op).
+ logChatCompression: vi.fn(),
}));
vi.mock('../telemetry/uiTelemetry.js', () => ({
@@ -139,6 +146,7 @@ describe('GeminiChat', async () => {
getTool: vi.fn(),
}),
getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator),
+ getBaseLlmClient: vi.fn().mockReturnValue(undefined),
getChatCompression: vi.fn().mockReturnValue(undefined),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi
@@ -1223,7 +1231,17 @@ describe('GeminiChat', async () => {
compressionStatus: CompressionStatus.NOOP,
},
});
- vi.spyOn(chat, 'getHistory').mockImplementationOnce(() => {
+ // sendMessageStream now calls getHistory(true) twice: once during the
+ // hard-tier rescue check (before compression) and once after
+ // compression to build requestContents. We want the post-compression
+ // call to throw — let the first pass through to the real impl, then
+ // explode on the second.
+ const realGetHistory = chat.getHistory.bind(chat);
+ const getHistorySpy = vi.spyOn(chat, 'getHistory');
+ getHistorySpy.mockImplementationOnce((curated?: boolean) =>
+ realGetHistory(curated),
+ );
+ getHistorySpy.mockImplementationOnce(() => {
throw new Error('history setup failed');
});
@@ -1336,13 +1354,123 @@ describe('GeminiChat', async () => {
).toBe(200);
});
- it('clears hasFailedCompressionAttempt after a forced successful compression', async () => {
+ it('forwards the pending user message to the compression cheap-gate', async () => {
+ // The cheap-gate inside ChatCompressionService.compress uses
+ // estimatePromptTokens(history, pendingUserMessage, lastPromptTokenCount)
+ // so the very first send after inherited history (where
+ // lastPromptTokenCount === 0) can still trigger compaction. This test
+ // pins the wiring: sendMessageStream MUST pass the user message it just
+ // built through to tryCompress -> service.compress.
+ expect(chat.getLastPromptTokenCount()).toBe(0);
+
+ const compressedHistory: Content[] = [
+ { role: 'user', parts: [{ text: 'summary' }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ ];
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValueOnce({
+ newHistory: compressedHistory,
+ info: {
+ originalTokenCount: 150_000,
+ newTokenCount: 40_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ makeStreamResponse('answer'),
+ );
+
+ const userMessageText = 'next user prompt';
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: userMessageText },
+ 'prompt-id-first-turn',
+ );
+ // The first event in the stream should be COMPRESSED because the
+ // cheap-gate, fed the pending user message, can now size the prompt.
+ const first = await stream.next();
+ expect(first.done).toBe(false);
+ expect(first.value?.type).toBe(StreamEventType.COMPRESSED);
+
+ // Drain the rest so the send-lock releases cleanly.
+ for await (const _ of stream) {
+ /* consume */
+ }
+
+ expect(compressSpy).toHaveBeenCalledTimes(1);
+ const passedOpts = compressSpy.mock.calls[0][1];
+ // sendMessageStream's contract: compute effectiveTokens upstream
+ // and forward via precomputedEffectiveTokens.
+ expect(passedOpts.precomputedEffectiveTokens).toBeTypeOf('number');
+ });
+
+ it('triggers compaction end-to-end through the real ChatCompressionService when lastPromptTokenCount === 0 and inherited history is large (R3.4)', async () => {
+ // Reviewer R3.4: the "forwards the pending user message" test above
+ // mocks the service entirely, so the real cheap-gate (the actual
+ // estimatePromptTokens fallback branch when lastPromptTokenCount===0)
+ // never runs. Exercise the full chain here:
+ // sendMessageStream → tryCompress → service.compress (REAL) →
+ // cheap-gate (real estimate via getHistory + userMessage) →
+ // splitter (real) → runSideQuery (mocked at baseLlmClient) →
+ // persistence.
+ const largeChars = 'x'.repeat(400_000); // ~100K estimated tokens
+ const inheritedHistory: Content[] = [
+ { role: 'user', parts: [{ text: largeChars }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ { role: 'user', parts: [{ text: 'follow up' }] },
+ { role: 'model', parts: [{ text: 'response' }] },
+ ];
+ chat.setHistory(inheritedHistory);
+ expect(chat.getLastPromptTokenCount()).toBe(0);
+
+ // Default DEFAULT_TOKEN_LIMIT = 128K → auto ≈ 95K. 100K estimate
+ // crosses, so cheap-gate must let compaction proceed.
+ const generateText = vi.fn().mockResolvedValue({
+ text: 'compressed',
+ usage: {
+ promptTokenCount: 99_000,
+ candidatesTokenCount: 1500,
+ totalTokenCount: 100_500,
+ },
+ });
+ vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({
+ generateText,
+ } as unknown as ReturnType);
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ makeStreamResponse('done'),
+ );
+
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'follow-up after restore' },
+ 'prompt-r3-4',
+ );
+ const events: StreamEvent[] = [];
+ for await (const event of stream) {
+ events.push(event);
+ }
+
+ const compressed = events.find(
+ (e) => e.type === StreamEventType.COMPRESSED,
+ );
+ expect(compressed).toBeDefined();
+ expect(
+ (compressed as { type: StreamEventType; info: ChatCompressionInfo })
+ .info.compressionStatus,
+ ).toBe(CompressionStatus.COMPRESSED);
+ // Real runSideQuery was hit (proves the cheap-gate didn't short-circuit
+ // and the splitter produced a non-empty historyToCompress).
+ expect(generateText).toHaveBeenCalled();
+ });
+
+ it('clears consecutiveFailures after a forced successful compression', async () => {
const compressSpy = vi.spyOn(
ChatCompressionService.prototype,
'compress',
);
- // Step 1: auto-compression fails — latch is set on the chat.
+ // Step 1: auto-compression fails — counter increments on the chat.
compressSpy.mockResolvedValueOnce({
newHistory: null,
info: {
@@ -1363,14 +1491,12 @@ describe('GeminiChat', async () => {
for await (const _ of stream1) {
/* consume */
}
- // Latch passed to service was false on this attempt; service marks it
- // failed and tryCompress flips the chat's flag to true.
- expect(compressSpy.mock.calls[0][1].hasFailedCompressionAttempt).toBe(
- false,
- );
+ // Counter passed to service was 0 on this attempt; the failure branch
+ // in tryCompress then increments it to 1.
+ expect(compressSpy.mock.calls[0][1].consecutiveFailures).toBe(0);
- // Step 2: a forced /compress succeeds. After this, the latch must
- // be cleared so future auto-compressions are not suppressed.
+ // Step 2: a forced /compress succeeds. After this, the counter must
+ // be reset so future auto-compressions are not suppressed.
compressSpy.mockResolvedValueOnce({
newHistory: [
{ role: 'user', parts: [{ text: 'summary' }] },
@@ -1383,13 +1509,12 @@ describe('GeminiChat', async () => {
},
});
await chat.tryCompress('prompt-latch-force', 'test-model', true);
- // tryCompress was called with force=true, so the service got latch=true
- // (the gate is `hasFailedCompressionAttempt && !force`, force overrides).
- expect(compressSpy.mock.calls[1][1].hasFailedCompressionAttempt).toBe(
- true,
- );
+ // tryCompress was called with force=true, so the service got
+ // consecutiveFailures=1 (carried from step 1's increment); force
+ // bypasses the breaker, but the counter was still forwarded as-is.
+ expect(compressSpy.mock.calls[1][1].consecutiveFailures).toBe(1);
- // Step 3: next auto-compression sees the cleared latch.
+ // Step 3: next auto-compression sees the reset counter.
compressSpy.mockResolvedValueOnce({
newHistory: null,
info: {
@@ -1409,9 +1534,7 @@ describe('GeminiChat', async () => {
for await (const _ of stream2) {
/* consume */
}
- expect(compressSpy.mock.calls[2][1].hasFailedCompressionAttempt).toBe(
- false,
- );
+ expect(compressSpy.mock.calls[2][1].consecutiveFailures).toBe(0);
});
it('reactively compresses and retries once after a context overflow error', async () => {
@@ -1775,82 +1898,876 @@ describe('GeminiChat', async () => {
/* consume */
}
})(),
- ).rejects.toThrow(overflow);
+ ).rejects.toThrow(overflow);
+
+ const nextStream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'next' },
+ 'prompt-id-after-reactive-failed-latch',
+ );
+ for await (const _ of nextStream) {
+ /* consume */
+ }
+
+ expect(compressSpy).toHaveBeenCalledTimes(3);
+ // Reactive compression is force=true, so tryCompress's own failure
+ // branch doesn't increment the counter (force=true skips it). The
+ // reactive overflow handler bumps the counter by 1 so a transient
+ // network error doesn't permanently latch the breaker; only
+ // MAX_CONSECUTIVE_FAILURES repeated reactive failures will. (R1.2)
+ expect(compressSpy.mock.calls[2][1].consecutiveFailures).toBe(1);
+ });
+
+ it('releases the send-lock when reactive compression throws', async () => {
+ const overflow = new Error(
+ 'prompt is too long: 135000 tokens > 128000 maximum',
+ );
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 0,
+ newTokenCount: 0,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ })
+ .mockRejectedValueOnce(new Error('compression failed'))
+ .mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 0,
+ newTokenCount: 0,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream)
+ .mockRejectedValueOnce(overflow)
+ .mockResolvedValueOnce(makeStreamResponse('next request ok'));
+
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'latest' },
+ 'prompt-id-reactive-throws',
+ );
+ await expect(
+ (async () => {
+ for await (const _ of stream) {
+ /* consume */
+ }
+ })(),
+ ).rejects.toThrow(overflow);
+
+ const nextStream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'next' },
+ 'prompt-id-after-reactive-throws',
+ );
+ const events: StreamEvent[] = [];
+ for await (const event of nextStream) {
+ events.push(event);
+ }
+
+ expect(compressSpy).toHaveBeenCalledTimes(3);
+ expect(
+ events.some(
+ (event) =>
+ event.type === StreamEventType.CHUNK &&
+ event.value.candidates?.[0]?.content?.parts?.[0]?.text ===
+ 'next request ok',
+ ),
+ ).toBe(true);
+ });
+ });
+
+ // Task 9 (P3): the hard-tier rescue pulls reactive overflow recovery
+ // forward to BEFORE the API call. When the estimated prompt size already
+ // crosses `computeThresholds(window).hard`, sendMessageStream must:
+ // 1) reset consecutiveFailures (so a latched circuit breaker can recover)
+ // 2) call tryCompress with force=true (so MAX_CONSECUTIVE_FAILURES does
+ // not gate the only attempt that can save the next round-trip).
+ describe('sendMessageStream hard-tier rescue', () => {
+ function makeStreamResponse(text = 'ok') {
+ return (async function* () {
+ yield {
+ candidates: [
+ {
+ content: { parts: [{ text }], role: 'model' },
+ finishReason: 'STOP',
+ index: 0,
+ safetyRatings: [],
+ },
+ ],
+ text: () => text,
+ } as unknown as GenerateContentResponse;
+ })();
+ }
+
+ /**
+ * Default 200K window in our mocks; computeThresholds:
+ * effectiveWindow = 200K - 20K (SUMMARY_RESERVE) = 180K
+ * hard = max(180K - 3K, auto) = 177K
+ * So lastPromptTokenCount=176K + a small user message tips over 177K.
+ */
+ beforeEach(() => {
+ vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
+ authType: AuthType.USE_GEMINI,
+ model: 'test-model',
+ contextWindowSize: 200_000,
+ });
+ });
+
+ it('forces compaction with force=true when estimated tokens cross hard threshold', async () => {
+ const compressedHistory: Content[] = [
+ { role: 'user', parts: [{ text: 'summary' }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ ];
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValueOnce({
+ newHistory: compressedHistory,
+ info: {
+ originalTokenCount: 176_000,
+ newTokenCount: 40_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ makeStreamResponse('after rescue'),
+ );
+
+ // Seed lastPromptTokenCount JUST under the 177K hard threshold; the
+ // pending user message adds a handful of estimate-tokens that pushes
+ // effective >= 177K, so the rescue must trigger.
+ chat.setLastPromptTokenCount(176_999);
+
+ const userMessage = 'this is the next user message';
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: userMessage },
+ 'prompt-id-hard-rescue-forces',
+ );
+ for await (const _ of stream) {
+ /* consume */
+ }
+
+ expect(compressSpy).toHaveBeenCalledTimes(1);
+ const passedOpts = compressSpy.mock.calls[0][1];
+ expect(passedOpts.force).toBe(true);
+ // Pin the estimation-reuse perf optimization: sendMessageStream
+ // computes effectiveTokens once and forwards via
+ // precomputedEffectiveTokens. A regression that drops this field
+ // back to undefined would be otherwise invisible.
+ expect(passedOpts.precomputedEffectiveTokens).toBeTypeOf('number');
+ expect(passedOpts.precomputedEffectiveTokens).toBeGreaterThanOrEqual(
+ 177_000,
+ );
+ });
+
+ it('resets consecutiveFailures before forcing when hard threshold crossed', async () => {
+ // Pre-latch the breaker by failing the unforced cheap-gate
+ // MAX_CONSECUTIVE_FAILURES times below the hard threshold.
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+
+ // The latching sends never touch the hard tier; lastPromptTokenCount is
+ // small enough that effective < hard, so force stays false on each.
+ compressSpy.mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 100_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async () => makeStreamResponse(),
+ );
+ chat.setLastPromptTokenCount(50_000);
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: `latch-${i}` },
+ `prompt-latch-${i}`,
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ expect(compressSpy.mock.calls[i][1].force).toBe(false);
+ }
+ // The counter is now at MAX_CONSECUTIVE_FAILURES (latched).
+ expect(compressSpy.mock.calls.at(-1)![1].consecutiveFailures).toBe(
+ MAX_CONSECUTIVE_FAILURES - 1,
+ );
+
+ // Now bump lastPromptTokenCount into hard tier and send again. The
+ // hard-tier rescue must reset the counter and force=true on the call —
+ // not short-circuit on the latched breaker.
+ compressSpy.mockClear();
+ compressSpy.mockResolvedValueOnce({
+ newHistory: [
+ { role: 'user', parts: [{ text: 'summary' }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ ],
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 40_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ });
+ chat.setLastPromptTokenCount(176_999);
+ const rescueStream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'rescue me' },
+ 'prompt-hard-rescue-reset',
+ );
+ for await (const _ of rescueStream) {
+ /* consume */
+ }
+
+ expect(compressSpy).toHaveBeenCalledTimes(1);
+ // Counter forwarded to the service must be 0 (reset before the call),
+ // not MAX_CONSECUTIVE_FAILURES (which would gate the cheap-gate).
+ expect(compressSpy.mock.calls[0][1].consecutiveFailures).toBe(0);
+ expect(compressSpy.mock.calls[0][1].force).toBe(true);
+ });
+
+ it('does not force when tokens are below hard threshold (normal auto path)', async () => {
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 0,
+ newTokenCount: 0,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ makeStreamResponse(),
+ );
+
+ // Well below 177K hard threshold — normal auto path.
+ chat.setLastPromptTokenCount(50_000);
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'small message' },
+ 'prompt-id-hard-rescue-below',
+ );
+ for await (const _ of stream) {
+ /* consume */
+ }
+
+ expect(compressSpy).toHaveBeenCalledTimes(1);
+ expect(compressSpy.mock.calls[0][1].force).toBe(false);
+ });
+
+ it('suppresses hard-rescue entirely when chatCompression.disabled is true (R12.5)', async () => {
+ // R12.5: R11.4 added a source-level gate that skips hard-rescue
+ // when the user has explicitly disabled auto-compaction. The
+ // service-level gate handles the proactive cheap-gate (force=false);
+ // the hard-rescue path uses force=true to bypass the breaker, so
+ // it would otherwise sidestep the service gate. The R11.4 commit
+ // didn't add a regression-guard for this branch. A future
+ // refactor removing the source-level gate would silently let
+ // compliance/audit-mode sessions get force-compressed via rescue.
+ vi.mocked(mockConfig.getChatCompression).mockReturnValue({
+ disabled: true,
+ });
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ makeStreamResponse(),
+ );
+
+ // Set lastPromptTokenCount above the 177K hard threshold so the
+ // rescue WOULD fire pre-R11.4. Verify it doesn't fire force=true.
+ chat.setLastPromptTokenCount(176_999);
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'should-not-rescue' },
+ 'prompt-id-r12-5',
+ );
+ for await (const _ of stream) {
+ /* consume */
+ }
+
+ // The rescue must NOT call tryCompress with force=true. The
+ // proactive cheap-gate may still be called (gated separately at
+ // the service layer), but if it does it must be force=false.
+ const forcedCalls = compressSpy.mock.calls.filter((c) => c[1].force);
+ expect(forcedCalls).toHaveLength(0);
+ });
+
+ // R7.11: hardRescueFailureCount budget — three branches the previous
+ // round left untested. Without these, regressions to the counter
+ // accounting (the "every failure-shape strikes the budget" guarantee
+ // from R7.2 + R7.3) would silently disable the rescue's bound.
+ it('stops firing the rescue after MAX_CONSECUTIVE_FAILURES rescue failures (R7.11)', async () => {
+ // Each send crosses the hard threshold, but compression always
+ // fails. After MAX rescue strikes, subsequent sends should NOT
+ // pass force=true any longer — the budget is exhausted and the
+ // chat falls back to the normal cheap-gate path (which will be a
+ // NOOP because consecutiveFailures has been reset and the gate
+ // re-evaluates from there).
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 178_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async () => makeStreamResponse(),
+ );
+
+ chat.setLastPromptTokenCount(176_999);
+ // Burn the rescue budget.
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: `burn-${i}` },
+ `prompt-burn-${i}`,
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ expect(compressSpy.mock.calls[i][1].force).toBe(true);
+ }
+
+ // Next send still crosses hard, but the rescue must be suppressed.
+ chat.setLastPromptTokenCount(176_999);
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'after-budget' },
+ 'prompt-after-budget',
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ const lastCallIdx = compressSpy.mock.calls.length - 1;
+ // After the budget is exhausted, the only remaining defence is
+ // reactive overflow — sendMessageStream MUST NOT pass force=true
+ // any longer.
+ expect(compressSpy.mock.calls[lastCallIdx][1].force).toBe(false);
+ });
+
+ it('does NOT exhaust the rescue budget on NOOPs — the pessimistic strike is refunded (R9.2)', async () => {
+ // R9.2: NOOP from a forced rescue means the history slice was
+ // too small to split this turn — not evidence that the
+ // compression mechanism is broken. The pessimistic pre-call
+ // increment is refunded in the post-call NOOP branch so a
+ // session whose first few turns happen to be too small does NOT
+ // permanently disable hard-rescue. Verify by running MAX+2
+ // consecutive NOOPs and asserting force=true on every call
+ // (budget never exhausts).
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 178_000,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async () => makeStreamResponse(),
+ );
+
+ chat.setLastPromptTokenCount(176_999);
+ // MAX_CONSECUTIVE_FAILURES + 2 sends — far past the strike
+ // budget. With R9.2's refund, NONE of these should fall back to
+ // force=false; the rescue keeps firing because NOOPs cost nothing.
+ const TOTAL_SENDS = MAX_CONSECUTIVE_FAILURES + 2;
+ for (let i = 0; i < TOTAL_SENDS; i++) {
+ chat.setLastPromptTokenCount(176_999);
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: `noop-${i}` },
+ `prompt-noop-${i}`,
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ expect(compressSpy.mock.calls[i][1].force).toBe(true);
+ }
+ });
+
+ it('counts a thrown exception from the forced rescue against the budget (R7.11 / R7.2)', async () => {
+ // R7.2: before pessimistic accounting, a throw inside tryCompress
+ // skipped both the consecutiveFailures and hardRescueFailureCount
+ // increments — every subsequent send re-fired the doomed rescue.
+ // Verify the budget exhausts after MAX consecutive throws.
+ let throwCount = 0;
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockImplementation(async () => {
+ // Pessimistic: the throw must still strike the budget. We
+ // count throws so the test can also assert how many actually
+ // ran.
+ if (throwCount < MAX_CONSECUTIVE_FAILURES) {
+ throwCount += 1;
+ throw new Error(`simulated provider 5xx #${throwCount}`);
+ }
+ // After the budget is exhausted, sendMessageStream should
+ // pass force=false; we make this a NOOP so the send can
+ // complete cleanly.
+ return {
+ newHistory: null,
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 178_000,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ };
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async () => makeStreamResponse(),
+ );
+ chat.setLastPromptTokenCount(176_999);
+
+ // The first MAX sends throw. sendMessageStream re-throws, so we
+ // catch and continue.
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ await expect(
+ chat.sendMessageStream(
+ 'test-model',
+ { message: `throw-${i}` },
+ `prompt-throw-${i}`,
+ ),
+ ).rejects.toThrow(/simulated provider 5xx/);
+ expect(compressSpy.mock.calls[i][1].force).toBe(true);
+ }
+ // Next send must NOT fire force=true — budget exhausted purely
+ // via throws.
+ chat.setLastPromptTokenCount(176_999);
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'after-throw-budget' },
+ 'prompt-after-throw-budget',
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ const lastCallIdx = compressSpy.mock.calls.length - 1;
+ expect(compressSpy.mock.calls[lastCallIdx][1].force).toBe(false);
+ });
+
+ it('reactive overflow catch block increments consecutiveFailures on thrown exceptions (R7.11 / R6.7)', async () => {
+ // The status-based reactive failure path was tested in R6.7's
+ // initial commit. This pins the OTHER half: if reactive
+ // compression throws (network 5xx, abort, etc.), the catch block
+ // must still bump consecutiveFailures so the proactive cheap-gate
+ // breaker eventually trips.
+ const compressSpy = vi
+ .spyOn(ChatCompressionService.prototype, 'compress')
+ .mockResolvedValueOnce({
+ // First call: the cheap-gate NOOPs (below hard threshold), so
+ // we reach the API call.
+ newHistory: null,
+ info: {
+ originalTokenCount: 50_000,
+ newTokenCount: 50_000,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ })
+ // Second call: reactive overflow recovery — make it throw.
+ .mockRejectedValueOnce(new Error('reactive 5xx'));
+
+ // Make generateContentStream throw a context-overflow-shaped error
+ // so the reactive recovery branch is exercised.
+ const overflowError = Object.assign(
+ new Error('context length exceeded'),
+ {
+ status: 400,
+ },
+ );
+ vi.mocked(mockContentGenerator.generateContentStream).mockRejectedValue(
+ overflowError,
+ );
+
+ chat.setLastPromptTokenCount(50_000);
+ // The send will fail (reactive recovery threw); we just need the
+ // counter to have ticked.
+ await expect(
+ chat
+ .sendMessageStream(
+ 'test-model',
+ { message: 'trigger-reactive' },
+ 'prompt-reactive-throw',
+ )
+ .then(async (s) => {
+ for await (const _ of s) {
+ /* consume */
+ }
+ }),
+ ).rejects.toThrow();
+
+ // Now send again under conditions that would invoke the cheap-gate
+ // breaker. consecutiveFailures must be at least 1 after the
+ // throw above.
+ compressSpy.mockClear();
+ compressSpy.mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 60_000,
+ newTokenCount: 60_000,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ });
+ vi.mocked(
+ mockContentGenerator.generateContentStream,
+ ).mockResolvedValueOnce(makeStreamResponse());
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'next' },
+ 'prompt-next-after-reactive-throw',
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ expect(compressSpy).toHaveBeenCalledTimes(1);
+ expect(
+ compressSpy.mock.calls[0][1].consecutiveFailures,
+ ).toBeGreaterThanOrEqual(1);
+ });
+
+ it('hard-rescue clears breakerWarningEmitted alongside consecutiveFailures reset (R9.4)', async () => {
+ // R9.4: when hard-rescue resets consecutiveFailures = 0, it must
+ // ALSO clear breakerWarningEmitted. Otherwise a sequence of
+ // "breaker trip → warn emitted → flag latched → hard-rescue
+ // resets counter → breaker trips again → silent (flag still
+ // true)" leaves the second trip undiagnosed.
+ //
+ // Direct field-peek regression test: drive the counter to MAX,
+ // call tryCompress with NOOP to force the breaker warn path,
+ // then trigger a hard-rescue (force=true at hard threshold) and
+ // assert the flag is cleared.
+ type ChatInternals = {
+ consecutiveFailures: number;
+ breakerWarningEmitted: boolean;
+ hardRescueFailureCount: number;
+ };
+ const internals = chat as unknown as ChatInternals;
+
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+
+ // 1. Pre-trip the breaker: counter = MAX, warn-flag latched.
+ internals.consecutiveFailures = MAX_CONSECUTIVE_FAILURES;
+ internals.breakerWarningEmitted = true;
+
+ // 2. Stage a hard-rescue scenario: lastPromptTokenCount >= hard,
+ // rescue must fire. Mock returns COMPRESSED so we focus on
+ // the pre-call reset path (R9.4 is the reset; COMPRESSED also
+ // triggers the post-call reset, but R9.4's contract is the
+ // PRE-call clear specifically).
+ compressSpy.mockResolvedValueOnce({
+ newHistory: [
+ { role: 'user', parts: [{ text: 'summary' }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ ],
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 40_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ makeStreamResponse(),
+ );
+ chat.setLastPromptTokenCount(176_999);
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'cross-hard' },
+ 'prompt-r9-4',
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ // 3. Both counters cleared, flag cleared. (consecutiveFailures
+ // reset in the pre-call hard-rescue path; flag reset there
+ // too — that's the R9.4 fix.)
+ expect(internals.consecutiveFailures).toBe(0);
+ expect(internals.breakerWarningEmitted).toBe(false);
+ });
+
+ it('coerces NaN candidatesTokenCount from usageMetadata to 0 (R11.1)', async () => {
+ // R11.1: `??` only coalesces null/undefined; NaN passes through.
+ // If a provider returns NaN, the stored field becomes NaN; next
+ // turn's `estimatePromptTokens` returns NaN; `NaN >= hard` is
+ // always false → hard-rescue never fires → API overflows. Guard
+ // with Number.isFinite so any non-finite value (NaN / Infinity)
+ // coerces to 0.
+ type ChatInternals = { lastCandidatesTokenCount: number };
+ const internals = chat as unknown as ChatInternals;
+
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ (async function* () {
+ yield {
+ candidates: [
+ {
+ content: { parts: [{ text: 'ok' }], role: 'model' },
+ finishReason: 'STOP',
+ index: 0,
+ safetyRatings: [],
+ },
+ ],
+ usageMetadata: {
+ promptTokenCount: 10_000,
+ candidatesTokenCount: NaN, // hostile provider payload
+ totalTokenCount: 10_000,
+ },
+ text: () => 'ok',
+ } as unknown as GenerateContentResponse;
+ })(),
+ );
- const nextStream = await chat.sendMessageStream(
+ const stream = await chat.sendMessageStream(
'test-model',
- { message: 'next' },
- 'prompt-id-after-reactive-failed-latch',
+ { message: 'test' },
+ 'prompt-id-r11-1-nan',
);
- for await (const _ of nextStream) {
+ for await (const _ of stream) {
/* consume */
}
- expect(compressSpy).toHaveBeenCalledTimes(3);
- expect(compressSpy.mock.calls[2][1].hasFailedCompressionAttempt).toBe(
- true,
- );
+ // Without the Number.isFinite guard, this would be NaN.
+ expect(internals.lastCandidatesTokenCount).toBe(0);
+ expect(Number.isFinite(internals.lastCandidatesTokenCount)).toBe(true);
});
- it('releases the send-lock when reactive compression throws', async () => {
- const overflow = new Error(
- 'prompt is too long: 135000 tokens > 128000 maximum',
- );
+ it('rejects non-finite / negative values from EVERY API token-count capture site (R12.1 sibling sweep)', async () => {
+ // R12.1: R11.1 added a Number.isFinite guard to lastCandidatesTokenCount
+ // but left lastPromptTokenCount (assigned 3 lines above) accepting
+ // anything truthy. The Phase-3 audit matrix for "API value capture"
+ // surfaces 3 sites in total: promptTokenCount, candidatesTokenCount,
+ // and cachedContentTokenCount. All three need the same uniform
+ // coerce-to-0-on-non-finite-or-negative guard.
+ //
+ // Drive each pathological value (Infinity, NaN, negative) through
+ // promptTokenCount and assert the stored field is 0. Negative is
+ // important: `Number.isFinite(-1)` is true, so the guard must also
+ // include a `>= 0` check.
+ type ChatInternals = {
+ lastPromptTokenCount: number;
+ lastCandidatesTokenCount: number;
+ };
+ const internals = chat as unknown as ChatInternals;
+
+ for (const bad of [Number.POSITIVE_INFINITY, Number.NaN, -1, -1e9]) {
+ // Reset
+ internals.lastPromptTokenCount = 5000;
+ internals.lastCandidatesTokenCount = 500;
+
+ vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue(
+ (async function* () {
+ yield {
+ candidates: [
+ {
+ content: { parts: [{ text: 'ok' }], role: 'model' },
+ finishReason: 'STOP',
+ index: 0,
+ safetyRatings: [],
+ },
+ ],
+ usageMetadata: {
+ promptTokenCount: bad,
+ candidatesTokenCount: bad,
+ totalTokenCount: bad,
+ },
+ text: () => 'ok',
+ } as unknown as GenerateContentResponse;
+ })(),
+ );
+
+ const stream = await chat.sendMessageStream(
+ 'test-model',
+ { message: `r12-1-${String(bad)}` },
+ `prompt-r12-1-${String(bad)}`,
+ );
+ for await (const _ of stream) {
+ /* consume */
+ }
+
+ // ALL capture sites must coerce. A `Number.isFinite`-only guard
+ // would let `-1` poison the prompt count.
+ expect(Number.isFinite(internals.lastPromptTokenCount)).toBe(true);
+ expect(internals.lastPromptTokenCount).toBeGreaterThanOrEqual(0);
+ expect(Number.isFinite(internals.lastCandidatesTokenCount)).toBe(true);
+ expect(internals.lastCandidatesTokenCount).toBeGreaterThanOrEqual(0);
+ }
+ });
+
+ it('budget-exhausted warn fires once per exhaustion, not on every send (R8.4)', async () => {
+ // Symmetric with R7.9's `breakerWarningEmitted`: once the rescue
+ // budget exhausts and the chat stays over hard, the warn must
+ // throttle to a single emission. Without the throttle, a session
+ // stuck above the threshold spams the log at one warn per send.
const compressSpy = vi
.spyOn(ChatCompressionService.prototype, 'compress')
- .mockResolvedValueOnce({
- newHistory: null,
- info: {
- originalTokenCount: 0,
- newTokenCount: 0,
- compressionStatus: CompressionStatus.NOOP,
- },
- })
- .mockRejectedValueOnce(new Error('compression failed'))
- .mockResolvedValueOnce({
+ .mockResolvedValue({
newHistory: null,
info: {
- originalTokenCount: 0,
- newTokenCount: 0,
- compressionStatus: CompressionStatus.NOOP,
+ originalTokenCount: 178_000,
+ newTokenCount: 178_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
},
});
- vi.mocked(mockContentGenerator.generateContentStream)
- .mockRejectedValueOnce(overflow)
- .mockResolvedValueOnce(makeStreamResponse('next request ok'));
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async () => makeStreamResponse(),
+ );
+ // Hook the chat's debug logger so we can count warn emissions.
+ const warnSpy = vi.fn();
+ // The debugLogger is module-level; spy on the warn method that
+ // sendMessageStream actually uses by intercepting via the logger
+ // module import in the test bootstrap. Since we can't easily
+ // replace it here, we use a different observable: count
+ // generateContentStream invocations after the budget exhausts
+ // (every send proceeds normally), and we assert the warn-emitted
+ // flag's effect indirectly by verifying that the suite of sends
+ // does not error out and the rescue is suppressed exactly once
+ // per send post-exhaustion.
+ void warnSpy;
+
+ chat.setLastPromptTokenCount(176_999);
+ // Burn the budget.
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: `burn-${i}` },
+ `prompt-burn-${i}`,
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ }
+ // Now send 5 more times — each crosses hard and finds budget
+ // exhausted. Pre-R8.4 the warn fired 5 times; post-R8.4 it fires
+ // once total. Direct observation requires a logger spy, but the
+ // mechanism is identical to R7.9's `breakerWarningEmitted`: the
+ // flag must be on GeminiChat and cleared on COMPRESSED success.
+ // This test pins the *behavior* (no crash, sends complete) and
+ // the implementation test below pins the flag-reset semantics.
+ for (let i = 0; i < 5; i++) {
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: `post-burn-${i}` },
+ `prompt-post-burn-${i}`,
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ }
+ // Total compress calls: MAX (rescue) + 5 (proactive cheap-gate
+ // attempts; consecutiveFailures was reset by hard-rescue triggers
+ // so the breaker hasn't latched).
+ expect(compressSpy).toHaveBeenCalledTimes(MAX_CONSECUTIVE_FAILURES + 5);
+ // The last 5 must all be force=false (budget exhausted).
+ for (let i = 0; i < 5; i++) {
+ const callIdx = MAX_CONSECUTIVE_FAILURES + i;
+ expect(compressSpy.mock.calls[callIdx][1].force).toBe(false);
+ }
+ });
- const stream = await chat.sendMessageStream(
- 'test-model',
- { message: 'latest' },
- 'prompt-id-reactive-throws',
+ it('restores hard-rescue budget after a successful compression refunds the strikes (R8.5)', async () => {
+ // After the rescue budget exhausts, a subsequent COMPRESSED
+ // success (from any path — reactive overflow, manual /compress,
+ // or eventually-passing rescue retry) must reset
+ // `hardRescueFailureCount` to 0 so the rescue can fire again on
+ // future hard-tier crossings. Without this, a chat that ever
+ // exhausted its budget is permanently demoted to reactive-only.
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
);
- await expect(
- (async () => {
- for await (const _ of stream) {
- /* consume */
- }
- })(),
- ).rejects.toThrow(overflow);
- const nextStream = await chat.sendMessageStream(
+ // Burn the budget with failures.
+ compressSpy.mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 178_000,
+ compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
+ },
+ });
+ vi.mocked(mockContentGenerator.generateContentStream).mockImplementation(
+ async () => makeStreamResponse(),
+ );
+ chat.setLastPromptTokenCount(176_999);
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ const s = await chat.sendMessageStream(
+ 'test-model',
+ { message: `burn-${i}` },
+ `prompt-burn-${i}`,
+ );
+ for await (const _ of s) {
+ /* consume */
+ }
+ }
+ // Confirm budget exhausted: next call would be force=false.
+ // Now stage a successful COMPRESSED outcome and trigger compaction
+ // via a normal cheap-gate send (consecutiveFailures was reset by
+ // the rescue triggers so the proactive path is open).
+ compressSpy.mockClear();
+ compressSpy.mockResolvedValueOnce({
+ newHistory: [
+ { role: 'user', parts: [{ text: 'summary' }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ ],
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 40_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ });
+ const s = await chat.sendMessageStream(
'test-model',
- { message: 'next' },
- 'prompt-id-after-reactive-throws',
+ { message: 'recover' },
+ 'prompt-recover',
);
- const events: StreamEvent[] = [];
- for await (const event of nextStream) {
- events.push(event);
+ for await (const _ of s) {
+ /* consume */
}
-
- expect(compressSpy).toHaveBeenCalledTimes(3);
- expect(
- events.some(
- (event) =>
- event.type === StreamEventType.CHUNK &&
- event.value.candidates?.[0]?.content?.parts?.[0]?.text ===
- 'next request ok',
- ),
- ).toBe(true);
+ // After the COMPRESSED call, the budget should be refunded.
+ // Verify by setting up another hard-threshold crossing and
+ // confirming force=true gets passed (rescue is re-armed).
+ compressSpy.mockClear();
+ compressSpy.mockResolvedValueOnce({
+ newHistory: [
+ { role: 'user', parts: [{ text: 'summary' }] },
+ { role: 'model', parts: [{ text: 'ack' }] },
+ ],
+ info: {
+ originalTokenCount: 178_000,
+ newTokenCount: 40_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ });
+ chat.setLastPromptTokenCount(176_999);
+ const s2 = await chat.sendMessageStream(
+ 'test-model',
+ { message: 'cross-hard-again' },
+ 'prompt-cross-hard-again',
+ );
+ for await (const _ of s2) {
+ /* consume */
+ }
+ expect(compressSpy).toHaveBeenCalledTimes(1);
+ expect(compressSpy.mock.calls[0][1].force).toBe(true);
});
});
@@ -3562,9 +4479,9 @@ describe('GeminiChat', async () => {
});
// Compression logic is tested in chatCompressionService.test.ts; this
- // suite covers per-chat state on GeminiChat: hasFailedCompressionAttempt
- // stickiness, token-count mutation, history replacement, and conditional
- // telemetry mirroring.
+ // suite covers per-chat state on GeminiChat: consecutiveFailures
+ // circuit breaker, token-count mutation, history replacement, and
+ // conditional telemetry mirroring.
describe('tryCompress (per-chat state)', () => {
const userMsg = (text: string) => ({
role: 'user' as const,
@@ -3659,7 +4576,7 @@ describe('GeminiChat', async () => {
expect(uiTelemetryService.setLastPromptTokenCount).not.toHaveBeenCalled();
});
- it('marks hasFailedCompressionAttempt and suppresses subsequent unforced auto-compactions', async () => {
+ it('increments consecutiveFailures and forwards it to subsequent unforced auto-compactions', async () => {
const compressSpy = mockCompressionService('failed-inflated');
const first = await chat.tryCompress('p1', 'm1');
@@ -3669,9 +4586,10 @@ describe('GeminiChat', async () => {
expect(compressSpy).toHaveBeenCalledTimes(1);
// The next unforced call should reach the service with
- // hasFailedCompressionAttempt=true; the service's threshold check then
- // returns NOOP. The important thing here is that GeminiChat actually
- // forwards the sticky flag.
+ // consecutiveFailures=1 (incremented after the first failure). The
+ // important thing here is that GeminiChat actually forwards the
+ // updated counter — the service's own threshold logic is tested
+ // separately in chatCompressionService.test.ts.
compressSpy.mockClear();
compressSpy.mockResolvedValue({
newHistory: null,
@@ -3683,9 +4601,7 @@ describe('GeminiChat', async () => {
});
await chat.tryCompress('p2', 'm1');
expect(compressSpy).toHaveBeenCalledTimes(1);
- expect(compressSpy.mock.calls[0][1].hasFailedCompressionAttempt).toBe(
- true,
- );
+ expect(compressSpy.mock.calls[0][1].consecutiveFailures).toBe(1);
});
it('forwards force=true to the compression service', async () => {
@@ -3748,9 +4664,7 @@ describe('GeminiChat', async () => {
await chat.tryCompress('p2', 'm1');
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);
- expect(compressSpy.mock.calls[0][1].hasFailedCompressionAttempt).toBe(
- false,
- );
+ expect(compressSpy.mock.calls[0][1].consecutiveFailures).toBe(0);
});
it('backs off repeated heap-pressure bypasses after a heap-triggered failure', async () => {
@@ -3822,4 +4736,178 @@ describe('GeminiChat', async () => {
expect(compressSpy.mock.calls[0][1].bypassTokenThreshold).toBe(false);
});
});
+
+ // The circuit breaker is the three-strike replacement for the old
+ // single-shot hasFailedCompressionAttempt lock. After
+ // MAX_CONSECUTIVE_FAILURES failures the chat stops trying to auto-compact
+ // until a successful force compress (or any successful compress) resets
+ // the counter.
+ describe('compression failure circuit breaker', () => {
+ const userMsg = (text: string) => ({
+ role: 'user' as const,
+ parts: [{ text }],
+ });
+ const modelMsg = (text: string) => ({
+ role: 'model' as const,
+ parts: [{ text }],
+ });
+
+ it('tolerates MAX_CONSECUTIVE_FAILURES - 1 failures and increments the counter each time', async () => {
+ // Mock the service to "fail" every call (the chat's counter increments
+ // each time). After (MAX - 1) failures, the next tryCompress should
+ // still call the service. The actual NOOP-at-threshold gating is the
+ // service's job (and verified separately) — here we just observe that
+ // GeminiChat keeps forwarding the incremented counter.
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+ compressSpy.mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 100_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ },
+ });
+ chat.setHistory([userMsg('a'), modelMsg('b'), userMsg('c')]);
+
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ await chat.tryCompress(`p${i}`, 'm1');
+ // The i-th call sees consecutiveFailures = i (counter pre-increment).
+ expect(compressSpy.mock.calls[i][1].consecutiveFailures).toBe(i);
+ }
+ // After MAX_CONSECUTIVE_FAILURES failures, the breaker is tripped.
+ // The next call will still be made by GeminiChat (it does not
+ // short-circuit on its side), but the service's cheap-gate will NOOP.
+ expect(compressSpy).toHaveBeenCalledTimes(MAX_CONSECUTIVE_FAILURES);
+ await chat.tryCompress('p-last', 'm1');
+ expect(
+ compressSpy.mock.calls[MAX_CONSECUTIVE_FAILURES][1].consecutiveFailures,
+ ).toBe(MAX_CONSECUTIVE_FAILURES);
+ });
+
+ it('does not increment the counter on forced-call failures', async () => {
+ // Forced compressions (manual /compress, reactive overflow) bypass
+ // the breaker AND must not count toward it. Otherwise a flaky
+ // manual /compress would burn the breaker for auto-compaction.
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+ compressSpy.mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 100_000,
+ compressionStatus: CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
+ },
+ });
+ for (let i = 0; i < 5; i++) {
+ await chat.tryCompress(`p-force-${i}`, 'm1', true);
+ }
+ // After 5 forced failures, an unforced call must still see counter=0.
+ compressSpy.mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 0,
+ newTokenCount: 0,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ });
+ await chat.tryCompress('p-unforced', 'm1');
+ const lastCall = compressSpy.mock.calls.at(-1);
+ expect(lastCall![1].consecutiveFailures).toBe(0);
+ });
+
+ it('resets the counter to 0 on a successful (forced) compress', async () => {
+ // After two failures, a successful force compress should reset the
+ // counter — the next unforced send tries again with consecutiveFailures=0.
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+ compressSpy
+ .mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 100_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT,
+ },
+ })
+ .mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 100_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
+ },
+ })
+ .mockResolvedValueOnce({
+ newHistory: [userMsg('summary'), modelMsg('ack')],
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 30_000,
+ compressionStatus: CompressionStatus.COMPRESSED,
+ },
+ })
+ .mockResolvedValueOnce({
+ newHistory: null,
+ info: {
+ originalTokenCount: 0,
+ newTokenCount: 0,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ });
+
+ // Two failures → counter is 2.
+ await chat.tryCompress('p1', 'm1');
+ await chat.tryCompress('p2', 'm1');
+ expect(compressSpy.mock.calls[1][1].consecutiveFailures).toBe(1);
+
+ // Forced successful compress → counter resets to 0.
+ await chat.tryCompress('p-force', 'm1', true);
+ expect(compressSpy.mock.calls[2][1].consecutiveFailures).toBe(2);
+
+ // Next unforced call: counter is back to 0.
+ await chat.tryCompress('p3', 'm1');
+ expect(compressSpy.mock.calls[3][1].consecutiveFailures).toBe(0);
+ });
+
+ it('counts COMPRESSION_FAILED_OUTPUT_TRUNCATED toward the breaker (R6.15)', async () => {
+ // The truncation guard (R1.1 + R5.2b) returns OUTPUT_TRUNCATED on
+ // cap overruns. isCompressionFailureStatus() includes it so the
+ // breaker should trip after MAX_CONSECUTIVE_FAILURES — verify
+ // explicitly so a future enum reshuffle that drops it from the
+ // failure-status list is caught.
+ const compressSpy = vi.spyOn(
+ ChatCompressionService.prototype,
+ 'compress',
+ );
+ compressSpy.mockResolvedValue({
+ newHistory: null,
+ info: {
+ originalTokenCount: 100_000,
+ newTokenCount: 100_000,
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED,
+ },
+ });
+ chat.setHistory([userMsg('a'), modelMsg('b'), userMsg('c')]);
+
+ for (let i = 0; i < MAX_CONSECUTIVE_FAILURES; i++) {
+ await chat.tryCompress(`p-trunc-${i}`, 'm1');
+ }
+ // Counter should have reached MAX after these truncation-status
+ // failures, just like INFLATED and EMPTY_SUMMARY do.
+ await chat.tryCompress('p-trunc-after', 'm1');
+ expect(
+ compressSpy.mock.calls[MAX_CONSECUTIVE_FAILURES][1].consecutiveFailures,
+ ).toBe(MAX_CONSECUTIVE_FAILURES);
+ });
+ });
});
diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts
index c2fc71bbea6..36e8d47bdce 100644
--- a/packages/core/src/core/geminiChat.ts
+++ b/packages/core/src/core/geminiChat.ts
@@ -45,8 +45,12 @@ import {
import { type ChatRecordingService } from '../services/chatRecordingService.js';
import {
ChatCompressionService,
+ computeThresholds,
+ MAX_CONSECUTIVE_FAILURES,
type CompactTrigger,
} from '../services/chatCompressionService.js';
+import { resolveSlimmingConfig } from '../services/compactionInputSlimming.js';
+import { estimatePromptTokens } from '../services/tokenEstimation.js';
import {
ContentRetryEvent,
ContentRetryFailureEvent,
@@ -98,11 +102,27 @@ export function redactStructuredOutputArgsForRecording(
};
}
+/**
+ * Coerce a provider-supplied numeric usage field to a safe non-negative
+ * number. Drops NaN, Infinity, non-numbers, and negatives to 0 — every
+ * downstream consumer of these fields (cheap-gate threshold compare,
+ * cold-vs-steady-state estimator branch, hard-rescue trigger) assumes
+ * a finite non-negative count. A poisoned value silently disables the
+ * gate (`tokens >= NaN` is false) or fires it permanently
+ * (`Infinity >= hard`). (R11.1 / R12.1)
+ */
+function coerceUsageCount(value: unknown): number {
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
+ ? value
+ : 0;
+}
+
function isCompressionFailureStatus(status: CompressionStatus): boolean {
return (
status === CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT ||
status === CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY ||
- status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR
+ status === CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR ||
+ status === CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED
);
}
@@ -144,6 +164,12 @@ interface ContentRetryOptions {
interface TryCompressOptions {
originalTokenCountOverride?: number;
trigger?: CompactTrigger;
+ /**
+ * Pre-computed `estimatePromptTokens` value from the caller. When set,
+ * the cheap-gate uses this instead of recomputing — avoids a second
+ * `getHistory(true)` clone per send. (review #4168 R1.3 / R1.4)
+ */
+ precomputedEffectiveTokens?: number;
}
const INVALID_CONTENT_RETRY_OPTIONS: ContentRetryOptions = {
@@ -434,12 +460,88 @@ export class GeminiChat {
private lastPromptTokenCount = 0;
/**
- * Per-chat sticky flag. After an unforced compression attempt fails (empty
- * summary or inflated token count), automatic compaction is suppressed
- * for the remainder of this chat to avoid burning compression API calls
- * in a loop. Manual `/compress` still works (it passes `force=true`).
+ * Previous turn's `candidatesTokenCount` (the model's output). Captured
+ * alongside `lastPromptTokenCount` in the streaming usage-metadata
+ * handler. The cheap-gate / hard-rescue threshold check adds this to
+ * the prompt-size estimate so the next send accounts for the model
+ * response that's been appended to `history` since the previous API
+ * call. Without it the estimate is systematically one response short
+ * (typically 500–5000 tokens), which matters when the hard tier sits
+ * only HARD_BUFFER (~3K) from the window edge — the rescue would fire
+ * late and the API call would overflow. (review #4168 R10.1)
+ *
+ * Reset to 0 whenever `lastPromptTokenCount` is updated from anything
+ * other than a fresh API response — i.e. after successful compression
+ * (the new compressed history doesn't have a "previous response" to
+ * count) or when seeded via `setLastPromptTokenCount` for inherited
+ * histories.
+ */
+ private lastCandidatesTokenCount = 0;
+
+ /**
+ * Number of consecutive auto-compaction failures for this chat. The
+ * cheap-gate NOOPs once this reaches MAX_CONSECUTIVE_FAILURES (default 3)
+ * until a successful compress (forced or not) resets it to 0. Replaces the
+ * single-shot hasFailedCompressionAttempt lock that previously disabled
+ * auto-compaction for the rest of the session on any failure.
+ *
+ * SEMANTICS (R5.3 / R6.3): this counter tracks the cheap-gate path's
+ * health. Hard-tier rescue has its own bound (`hardRescueFailureCount`,
+ * below) because its trigger condition (token cross hard threshold) and
+ * failure-mode (model can't compress further) are different from a
+ * regular proactive compression failure. Detail:
+ * - Auto-compaction failures (cheap-gate path): increment by 1.
+ * - Manual `/compress` failures: skipped (`force=true` → `!force`
+ * guard in the failure branch).
+ * - Hard-tier rescue failures: skipped (force=true); see
+ * `hardRescueFailureCount` for that path's retry budget.
+ * - Reactive overflow failures: explicitly +1 (also force=true, but
+ * the post-call site bumps the counter — see ~L984). Status-based
+ * and thrown failures both increment now (R6.7).
+ */
+ private consecutiveFailures = 0;
+
+ /**
+ * Consecutive hard-tier rescue failures for this chat. Hard rescue is
+ * force=true so `tryCompress`'s normal `!force` guard skips the
+ * `consecutiveFailures` increment, and the rescue itself resets that
+ * counter to 0 to let force-compress proceed past the cheap-gate
+ * breaker. Without a dedicated counter, repeated hard-rescue failures
+ * would burn one compaction API call per send forever.
+ *
+ * Bounded by MAX_CONSECUTIVE_FAILURES: after that many consecutive
+ * rescue failures, `sendMessageStream` stops gating on hard threshold
+ * and lets reactive overflow take over as the next layer of defence.
+ * Any successful compression (rescue or otherwise) resets it to 0.
+ *
+ * Accounting: incremented **pessimistically** — before calling
+ * `tryCompress` from the hard-rescue path — and only refunded on a
+ * `COMPRESSED` outcome (handled inside `tryCompress` alongside the
+ * `consecutiveFailures` reset). This guarantees the strike sticks for
+ * every non-success shape uniformly: thrown exceptions (post-call
+ * site unreachable), NOOP returns (history not compressible), and
+ * failure statuses.
+ */
+ private hardRescueFailureCount = 0;
+
+ /**
+ * Throttle flag for the breaker-tripped warning. Without throttling
+ * the warn fires on every `compress()` call after the breaker
+ * latches, drowning the actually-actionable signal in noise. We emit
+ * once when the breaker first trips and clear the flag whenever
+ * `consecutiveFailures` resets to 0 (success or manual `/compress`).
+ * (R7.9)
+ */
+ private breakerWarningEmitted = false;
+
+ /**
+ * Throttle flag for the hard-rescue-budget-exhausted warning.
+ * Symmetric with `breakerWarningEmitted`: once the rescue budget
+ * exhausts and the chat stays over the hard threshold, this warn
+ * would fire on every send without a throttle. Cleared in the
+ * COMPRESSED success branch alongside the counter resets. (R8.4)
*/
- private hasFailedCompressionAttempt = false;
+ private budgetExhaustedWarningEmitted = false;
/**
* Heap-pressure compaction is process-wide pressure applied per chat. If one
@@ -491,6 +593,12 @@ export class GeminiChat {
*/
setLastPromptTokenCount(count: number): void {
this.lastPromptTokenCount = count;
+ // R10.1: external seeding (inherited history, post-compression) has
+ // no "previous turn's response" to anchor on — the history slice
+ // either was just rewritten (compression) or was carried over from
+ // a parent chat with its own API response history already in place.
+ // Reset to 0; the next real API response will set the live value.
+ this.lastCandidatesTokenCount = 0;
}
/**
@@ -536,16 +644,37 @@ export class GeminiChat {
);
}
+ // R7.9: warn-once on the first send after the breaker trips so an
+ // oncall sees the symptom without the log spamming every send for
+ // the rest of the session. The flag clears in the COMPRESSED branch
+ // below alongside `consecutiveFailures = 0`. Mirrors the service's
+ // own gate check so the log line matches the NOOP it predicts.
+ if (
+ this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES &&
+ !force &&
+ !bypassTokenThreshold &&
+ !this.breakerWarningEmitted
+ ) {
+ debugLogger.warn(
+ `[chat-compression] breaker tripped: consecutiveFailures=` +
+ `${this.consecutiveFailures} >= MAX=${MAX_CONSECUTIVE_FAILURES}; ` +
+ `skipping auto-compaction. Use /compress to force a recovery. ` +
+ `(This message is logged once per trip.)`,
+ );
+ this.breakerWarningEmitted = true;
+ }
+
const service = new ChatCompressionService();
const { newHistory, info } = await service.compress(this, {
promptId,
force,
model,
config: this.config,
- hasFailedCompressionAttempt: this.hasFailedCompressionAttempt,
+ consecutiveFailures: this.consecutiveFailures,
originalTokenCount:
options?.originalTokenCountOverride ?? this.lastPromptTokenCount,
bypassTokenThreshold,
+ precomputedEffectiveTokens: options?.precomputedEffectiveTokens,
trigger: options?.trigger,
signal,
});
@@ -567,27 +696,54 @@ export class GeminiChat {
debugLogger.debug('[FILE_READ_CACHE] clear after auto tryCompress');
this.config.getFileReadCache().clear();
this.lastPromptTokenCount = info.newTokenCount;
+ // R10.1: compression rewrote `history` — the previous turn's
+ // response is gone from the new history slice (or absorbed into
+ // the snapshot envelope, which is already counted in
+ // `info.newTokenCount`). Reset so the next steady-state estimate
+ // doesn't double-count.
+ this.lastCandidatesTokenCount = 0;
// Mirror to the global singleton only when wired (main session).
// Subagents pass `telemetryService=undefined` to keep their context
// usage out of the main agent's UI counters.
this.telemetryService?.setLastPromptTokenCount(info.newTokenCount);
- // Re-enable auto-compaction so a forced /compress recovers a chat
- // that an earlier auto-attempt latched off.
- this.hasFailedCompressionAttempt = false;
+ // Reset the consecutive-failure counter on success so a forced /compress
+ // (or any successful compaction) recovers a chat whose breaker had
+ // tripped. Also clear the heap-pressure cooldown — pressure has eased
+ // enough that compaction worked. (R6.3: hardRescueFailureCount also
+ // resets on any compression success, not just hard-rescue success.)
+ this.consecutiveFailures = 0;
+ this.hardRescueFailureCount = 0;
+ // R7.9 / R8.4: clear log throttles so a subsequent trip /
+ // exhaustion emits its first-of-cycle warn rather than being
+ // silently swallowed by a stale flag.
+ this.breakerWarningEmitted = false;
+ this.budgetExhaustedWarningEmitted = false;
this.heapPressureCompressionCooldownUntil = 0;
} else if (bypassTokenThreshold) {
- // If heap-pressure compaction cannot reduce history (NOOP or failure),
- // avoid repeatedly cloning history and/or paying side-query latency while
- // the process-wide pressure remains high.
+ // Heap-pressure compaction failed: skip touching the failure counter
+ // (it tracks token-threshold compaction health, not memory pressure)
+ // and start a short cooldown so we don't burn API calls / clones while
+ // pressure remains high.
this.heapPressureCompressionCooldownUntil =
Date.now() + HEAP_PRESSURE_COMPRESSION_COOLDOWN_MS;
} else if (isCompressionFailureStatus(info.compressionStatus)) {
- // Track failed attempts (only mark as failed if not forced) so we
- // stop spending compression-API calls on a chat that can't shrink.
- // Heap-pressure attempts are a safety net, not evidence that normal
- // token-threshold compaction should be latched off for this chat.
+ // Track failed attempts (only count if not forced) so we stop spending
+ // compression-API calls on a chat that can't shrink after
+ // MAX_CONSECUTIVE_FAILURES strikes in a row.
if (!force) {
- this.hasFailedCompressionAttempt = true;
+ this.consecutiveFailures += 1;
+ // R11.3: per-strike visibility. Pre-R11.3, the FIRST log line
+ // for proactive failures was the breaker-trip warn-once on
+ // strike 3 — strikes 1 and 2 produced nothing. An oncall
+ // investigating "auto-compaction stopped" couldn't see WHICH
+ // status (EMPTY_SUMMARY vs OUTPUT_TRUNCATED vs INFLATED vs
+ // TOKEN_COUNT_ERROR) drove the trip without source diving.
+ // Info-level: compaction failures are rare in normal
+ // operation, so this isn't happy-path noise.
+ debugLogger.info(
+ `[chat-compression] auto-compaction failed: status=${info.compressionStatus}, ` +
+ `strike=${this.consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}`,
+ );
}
}
@@ -689,14 +845,151 @@ export class GeminiChat {
// resolves it) has not run yet. Any setup error before returning the
// generator must release the lock or subsequent sends will block forever
// at `await this.sendPromise`.
+ // Build the user content BEFORE compression so the cheap-gate can size
+ // the upcoming prompt — closes the "first send after inherited history"
+ // gap where `lastPromptTokenCount === 0` and the gate would otherwise
+ // see only the stale prior-turn count (0).
+ const userContent = createUserContent(params.message);
+
+ // Hard-tier rescue: when the estimated prompt size is at or above the
+ // hard threshold (effectiveWindow - HARD_BUFFER), force compaction in
+ // this send instead of waiting for the API to reject the request as too
+ // large. This also resets the consecutive-failure counter so a session
+ // that previously latched the breaker can recover — hard implies the
+ // next API call would very likely overflow without compaction.
+ //
+ // We compute `effectiveTokens` ONCE here and pass it through to
+ // tryCompress → service.compress so the cheap-gate doesn't redo the
+ // estimation (which involves another `getHistory(true)` clone). This
+ // reuse also fixes a per-config-knob inconsistency: previously the
+ // hard-tier rescue used the default imageTokenEstimate while the
+ // cheap-gate inside tryCompress used the user's resolved value.
+ // (review #4168 R1.3 + R1.4)
+ const contextLimit =
+ this.config.getContentGeneratorConfig()?.contextWindowSize ??
+ DEFAULT_TOKEN_LIMIT;
+ const { hard } = computeThresholds(contextLimit);
+ const imageTokenEstimate = resolveSlimmingConfig(
+ this.config.getChatCompression(),
+ ).imageTokenEstimate;
+ // When lastPromptTokenCount > 0, estimatePromptTokens uses the
+ // API-authoritative count + the previous turn's response (added
+ // to history since that API call returned) + a tiny estimate of
+ // just the new user message — it does NOT touch the history at
+ // all in that branch, so skip the costly `getHistory(true)` clone
+ // on the steady-state path. (R10.1 added the candidates term.)
+ const effectiveTokens = estimatePromptTokens(
+ this.lastPromptTokenCount > 0 ? [] : this.getHistory(true),
+ userContent,
+ this.lastPromptTokenCount,
+ imageTokenEstimate,
+ this.lastCandidatesTokenCount,
+ );
+ // Bound hard-rescue retries with pessimistic accounting. Without
+ // a bound, a chat whose history can't shrink (model consistently
+ // produces unusable summaries, network broken, history too small
+ // to split, etc.) would fire hard-rescue on every send forever —
+ // force=true skips the regular consecutiveFailures increment,
+ // and the rescue's own pre-call reset wipes any state proactive
+ // compaction may have accumulated.
+ //
+ // Pessimistic pattern: increment the rescue strike BEFORE calling
+ // tryCompress, and only refund on COMPRESSED success. Covers
+ // every failure-shape uniformly:
+ // - throw (provider 5xx / abort) → strike kept (post-call unreachable)
+ // - NOOP (history too small to split) → strike kept
+ // - failure status → strike kept
+ // - COMPRESSED → strike refunded
+ // R11.4: if the user has explicitly disabled auto-compaction,
+ // hard-rescue (which is the AUTOMATIC overflow protector, not
+ // manual /compress) is suppressed too — reactive overflow at the
+ // API layer remains the last-ditch safety net. The service-level
+ // disable gate catches the proactive cheap-gate (force=false);
+ // hard-rescue uses force=true to bypass the breaker, so it would
+ // otherwise sidestep that gate. Skip at the source.
+ const autoCompactionDisabled =
+ this.config.getChatCompression()?.disabled === true;
+ const wantHardRescue = !autoCompactionDisabled && effectiveTokens >= hard;
+ const shouldForceFromHard =
+ wantHardRescue &&
+ this.hardRescueFailureCount < MAX_CONSECUTIVE_FAILURES;
+ if (shouldForceFromHard) {
+ // Mutate counters BEFORE the call so unreachable post-call
+ // paths can't desync state.
+ debugLogger.info(
+ `[compaction] hard-tier rescue: effectiveTokens=${effectiveTokens} >= hard=${hard}, ` +
+ `forcing compaction (consecutiveFailures ${this.consecutiveFailures} → 0, ` +
+ `hardRescueFailureCount ${this.hardRescueFailureCount} → ${this.hardRescueFailureCount + 1})`,
+ );
+ this.consecutiveFailures = 0;
+ // R9.4: clear the breaker warn-once flag whenever
+ // consecutiveFailures resets to 0 — otherwise a stale `true`
+ // from a previous trip silences the warn on subsequent trips
+ // in the same session ("breaker tripped → hard-rescue resets
+ // counter → flag stays true → next trip is silent"). Both
+ // resets (this one and the COMPRESSED success branch in
+ // `tryCompress`) must clear the flag.
+ this.breakerWarningEmitted = false;
+ this.hardRescueFailureCount += 1;
+ } else if (wantHardRescue && !this.budgetExhaustedWarningEmitted) {
+ // Rescue suppressed because the budget is exhausted. R8.4:
+ // throttle to once per exhaustion (cleared on COMPRESSED
+ // recovery) so an oncall debugging "why isn't hard-rescue
+ // firing" still gets the signal, but a session stuck above
+ // hard doesn't spam the log on every send.
+ debugLogger.warn(
+ `[compaction] hard-tier rescue skipped: budget exhausted ` +
+ `(hardRescueFailureCount=${this.hardRescueFailureCount}/${MAX_CONSECUTIVE_FAILURES}). ` +
+ `Reactive overflow is the remaining safety net; run /compress to recover. ` +
+ `(This message is logged once per exhaustion.)`,
+ );
+ this.budgetExhaustedWarningEmitted = true;
+ }
+
compressionInfo = await this.tryCompress(
prompt_id,
model,
- false,
+ shouldForceFromHard,
params.config?.abortSignal,
+ {
+ precomputedEffectiveTokens: effectiveTokens,
+ },
);
- const userContent = createUserContent(params.message);
+ // Post-call accounting + diagnostics. The pre-call pessimistic
+ // increment makes every non-success shape (throw / failure /
+ // NOOP) cost a strike by default; the COMPRESSED success path in
+ // `tryCompress` refunds via `hardRescueFailureCount = 0`. The
+ // R9.2 refinement below additionally REFUNDS NOOPs here:
+ // force=true bypasses the cheap-gate, so a NOOP from this path
+ // means the history slice was too small to split this turn
+ // (curated empty / MIN_COMPRESSION_FRACTION / no compressible
+ // slice). That is not evidence the rescue mechanism is broken;
+ // it can become compressible again as the user continues. Keep
+ // the strike only for genuine failure statuses and throws.
+ if (shouldForceFromHard) {
+ if (isCompressionFailureStatus(compressionInfo.compressionStatus)) {
+ debugLogger.warn(
+ `[compaction] hard-tier rescue failed: status=${compressionInfo.compressionStatus}, ` +
+ `hardRescueFailureCount=${this.hardRescueFailureCount}/${MAX_CONSECUTIVE_FAILURES}`,
+ );
+ } else if (
+ compressionInfo.compressionStatus === CompressionStatus.NOOP
+ ) {
+ // R9.2: refund — NOOP is a "nothing to do this turn", not a
+ // failure of the compression mechanism. Without the refund,
+ // a session whose first few turns happen to be too small to
+ // compress would permanently disable the rescue.
+ this.hardRescueFailureCount = Math.max(
+ 0,
+ this.hardRescueFailureCount - 1,
+ );
+ debugLogger.debug(
+ `[compaction] hard-tier rescue NOOP (history not compressible) — strike refunded; ` +
+ `hardRescueFailureCount=${this.hardRescueFailureCount}/${MAX_CONSECUTIVE_FAILURES}`,
+ );
+ }
+ }
// Add user content to history ONCE before any attempts.
this.history.push(userContent);
@@ -891,7 +1184,15 @@ export class GeminiChat {
if (
isCompressionFailureStatus(reactiveInfo.compressionStatus)
) {
- self.hasFailedCompressionAttempt = true;
+ // Reactive compression is force=true so tryCompress's
+ // failure branch did not increment the counter. Count it
+ // explicitly as one strike — a single transient error
+ // (network blip, model 5xx) should not permanently latch
+ // the breaker; only repeated reactive failures should.
+ // Hard-tier rescue (sendMessageStream) resets the counter
+ // when token usage crosses the hard threshold, which is
+ // the intended recovery path. (review #4168 R1.2)
+ self.consecutiveFailures += 1;
}
} catch (compressionError) {
if (
@@ -904,6 +1205,12 @@ export class GeminiChat {
'Reactive compression failed.',
compressionError,
);
+ // Thrown exceptions (network errors, model 5xx, timeouts)
+ // also count as a reactive failure — without this increment
+ // a consistently throwing reactive path would never trip
+ // the breaker. Mirrors the status-based increment above
+ // for consistent fail accounting. (R6.7)
+ self.consecutiveFailures += 1;
}
} else {
debugLogger.warn(
@@ -1368,22 +1675,40 @@ export class GeminiChat {
// Collect token usage for consolidated recording
if (chunk.usageMetadata) {
usageMetadata = chunk.usageMetadata;
+ // R12.1: every API-supplied numeric usage field goes through
+ // `coerceUsageCount`. Pre-R12.1 the prompt-count site relied on
+ // truthy-check only (`promptTokenCount || totalTokenCount`),
+ // which accepts negative numbers and Infinity; R11.1's
+ // `Number.isFinite` on candidates accepted negative values too
+ // (Number.isFinite(-1) is true). Single helper enforces the
+ // (finite ∧ >= 0) contract uniformly across all three sites.
+ //
// Context usage tracks prompt size; output isn't in history yet.
- const lastPromptTokenCount =
- usageMetadata.promptTokenCount || usageMetadata.totalTokenCount;
- if (lastPromptTokenCount) {
+ const promptCount =
+ coerceUsageCount(usageMetadata.promptTokenCount) ||
+ coerceUsageCount(usageMetadata.totalTokenCount);
+ if (promptCount) {
// Always update the per-chat counter so this chat (including
// subagents) can make its own compaction decisions.
- this.lastPromptTokenCount = lastPromptTokenCount;
+ this.lastPromptTokenCount = promptCount;
+ // R10.1: also capture the model's response size — it gets
+ // appended to history immediately after this handler runs,
+ // and the next turn's prompt-size estimate needs to add it
+ // back since `lastPromptTokenCount` only reflects the input
+ // sent on THIS turn.
+ this.lastCandidatesTokenCount = coerceUsageCount(
+ usageMetadata.candidatesTokenCount,
+ );
// Mirror to the global telemetry only when wired — subagents
// pass `telemetryService=undefined` to keep their context usage
// out of the main session's UI counters.
- this.telemetryService?.setLastPromptTokenCount(lastPromptTokenCount);
+ this.telemetryService?.setLastPromptTokenCount(promptCount);
}
- if (usageMetadata.cachedContentTokenCount && this.telemetryService) {
- this.telemetryService.setLastCachedContentTokenCount(
- usageMetadata.cachedContentTokenCount,
- );
+ const cachedCount = coerceUsageCount(
+ usageMetadata.cachedContentTokenCount,
+ );
+ if (cachedCount && this.telemetryService) {
+ this.telemetryService.setLastCachedContentTokenCount(cachedCount);
}
}
diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts
index f0978fdece0..88d376849ef 100644
--- a/packages/core/src/core/prompts.ts
+++ b/packages/core/src/core/prompts.ts
@@ -439,12 +439,24 @@ Examples of the kind of risky actions that warrant user confirmation:
When you encounter an obstacle, do not use destructive actions as a shortcut to simply make it go away. For instance, try to identify root causes and fix underlying issues rather than bypassing safety checks (e.g. --no-verify). If you discover unexpected state like unfamiliar files, branches, or configuration, investigate before deleting or overwriting, as it may represent the user's in-progress work. For example, typically resolve merge conflicts rather than discarding changes; similarly, if a lock file exists, investigate what process holds it rather than deleting it. In short: only take risky actions carefully, and when in doubt, ask before acting. Follow both the spirit and letter of these instructions - measure twice, cut once.`;
}
+/**
+ * Tag name of the XML envelope the compression model emits and that
+ * `ChatCompressionService` extracts as the persisted summary. Single
+ * source of truth — both the prompt template (this file) and the
+ * extraction regex (`chatCompressionService.ts`) reference this
+ * constant, so a rename is type-safe instead of a silent failure
+ * mode where the model still emits the old tag and the regex never
+ * matches. (review #4168 R9.3)
+ */
+export const COMPRESSION_SNAPSHOT_TAG = 'state_snapshot';
+
/**
* Provides the system prompt for the history compression process.
* This prompt instructs the model to act as a specialized state manager,
* think in a scratchpad, and produce a structured XML summary.
*/
export function getCompressionPrompt(): string {
+ const T = COMPRESSION_SNAPSHOT_TAG;
return `
You are the component that summarizes internal chat history into a given structure.
@@ -452,11 +464,11 @@ When the conversation history grows too large, you will be invoked to distill th
First, you will think through the entire history in a private . Review the user's overall goal, the agent's actions, tool outputs, file modifications, and any unresolved questions. Identify every piece of information that is essential for future actions.
-After your reasoning is complete, generate the final XML object. Be incredibly dense with information. Omit any irrelevant conversational filler.
+After your reasoning is complete, generate the final <${T}> XML object. Be incredibly dense with information. Omit any irrelevant conversational filler.
The structure MUST be as follows:
-
+<${T}>
@@ -468,7 +480,7 @@ The structure MUST be as follows:
- Build Command: \`npm run build\`
- Testing: Tests are run with \`npm test\`. Test files must end in \`.test.ts\`.
- API Endpoint: The primary API endpoint is \`https://api.example.com/v2\`.
-
+
-->
@@ -500,7 +512,7 @@ The structure MUST be as follows:
4. [TODO] Update tests to reflect the API change.
-->
-
+${T}>
`.trim();
}
diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts
index 8847120a843..4ee39936ea7 100644
--- a/packages/core/src/core/turn.ts
+++ b/packages/core/src/core/turn.ts
@@ -171,6 +171,17 @@ export enum CompressionStatus {
/** The compression was not necessary and no action was taken */
NOOP,
+
+ /**
+ * The compression succeeded but the summary output hit
+ * COMPACT_MAX_OUTPUT_TOKENS, suggesting truncation. Distinct from
+ * `EMPTY_SUMMARY` so telemetry can separate prompt-quality failures
+ * (empty / nonsensical summary) from capacity failures (output cap
+ * hit, may need a higher cap or finer-grained splitter).
+ * `isCompressionFailureStatus` treats this as a failure so it counts
+ * toward the per-chat circuit breaker. (R5.2)
+ */
+ COMPRESSION_FAILED_OUTPUT_TRUNCATED,
}
export interface ChatCompressionInfo {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1efcf64a331..cd0cab73ed2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -131,6 +131,11 @@ export type { ToolSearchTool, ToolSearchParams } from './tools/tool-search.js';
// Services
// ============================================================================
+export {
+ computeThresholds,
+ SUMMARY_RESERVE,
+ type CompactionThresholds,
+} from './services/chatCompressionService.js';
export * from './services/chatRecordingService.js';
export * from './services/cronScheduler.js';
export * from './services/fileDiscoveryService.js';
diff --git a/packages/core/src/services/chatCompressionService.test.ts b/packages/core/src/services/chatCompressionService.test.ts
index 3aa349863ec..fd79339b881 100644
--- a/packages/core/src/services/chatCompressionService.test.ts
+++ b/packages/core/src/services/chatCompressionService.test.ts
@@ -7,7 +7,9 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ChatCompressionService,
+ computeThresholds,
findCompressSplitPoint,
+ MAX_CONSECUTIVE_FAILURES,
TOOL_ROUND_RETAIN_COUNT,
} from './chatCompressionService.js';
import type { Content } from '@google/genai';
@@ -18,6 +20,7 @@ import type { GeminiChat } from '../core/geminiChat.js';
import type { Config } from '../config/config.js';
import type { BaseLlmClient } from '../core/baseLlmClient.js';
import { PreCompactTrigger, PostCompactTrigger } from '../hooks/types.js';
+import * as sideQueryModule from '../utils/sideQuery.js';
vi.mock('../telemetry/uiTelemetry.js');
vi.mock('../core/tokenLimits.js');
@@ -420,14 +423,14 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(result.newHistory).toBeNull();
});
- it('should return NOOP if previously failed and not forced', async () => {
+ it('should return NOOP when consecutiveFailures has hit the breaker and not forced', async () => {
vi.mocked(mockChat.getHistory).mockReturnValue([
{ role: 'user', parts: [{ text: 'hi' }] },
]);
@@ -436,11 +439,78 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: true,
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(result.newHistory).toBeNull();
+ // R9.1: breaker NOOP echoes the caller's originalTokenCount — a
+ // regression to `0` would corrupt telemetry on trip events
+ // (dashboards would show 0-token sessions). (R7.6 contract pin.)
+ expect(result.info.originalTokenCount).toBe(
+ uiTelemetryService.getLastPromptTokenCount(),
+ );
+ expect(result.info.newTokenCount).toBe(
+ uiTelemetryService.getLastPromptTokenCount(),
+ );
+ });
+
+ it('falls through when consecutiveFailures is below the breaker threshold', async () => {
+ // Below MAX_CONSECUTIVE_FAILURES, the cheap-gate must NOT NOOP on the
+ // failure counter alone — it should fall through. Use force=true to
+ // bypass the token-threshold check too, then prove we reached the
+ // post-cheap-gate path by observing chat.getHistory(true) being called.
+ vi.mocked(mockChat.getHistory).mockReturnValue([
+ { role: 'user', parts: [{ text: 'hi' }] },
+ ]);
+
+ await service.compress(mockChat, {
+ promptId: mockPromptId,
+ // force=true so the only thing that could NOOP us up front is the
+ // circuit-breaker. At MAX-1, the breaker must NOT trip.
+ force: true,
+ model: mockModel,
+ config: mockConfig,
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES - 1,
+ originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
+ });
+ // Reaching the curated-history clone is the proof we got past the
+ // cheap-gate. The service calls chat.getHistory(true) once it falls
+ // through — if the breaker had tripped, it would have returned the
+ // cheap-gate NOOP without ever touching the history clone.
+ expect(mockChat.getHistory).toHaveBeenCalledWith(true);
+ });
+
+ it('trips the circuit breaker only when consecutiveFailures has reached MAX_CONSECUTIVE_FAILURES', async () => {
+ vi.mocked(mockChat.getHistory).mockReturnValue([
+ { role: 'user', parts: [{ text: 'hi' }] },
+ ]);
+ // At exactly MAX (unforced) -> NOOP at cheap-gate.
+ const tripped = await service.compress(mockChat, {
+ promptId: mockPromptId,
+ force: false,
+ model: mockModel,
+ config: mockConfig,
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
+ originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
+ });
+ expect(tripped.info.compressionStatus).toBe(CompressionStatus.NOOP);
+
+ // force=true bypasses the breaker even when tripped.
+ vi.mocked(mockChat.getHistory).mockClear();
+ vi.mocked(mockChat.getHistory).mockReturnValue([
+ { role: 'user', parts: [{ text: 'hi' }] },
+ ]);
+ await service.compress(mockChat, {
+ promptId: mockPromptId,
+ force: true,
+ model: mockModel,
+ config: mockConfig,
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
+ originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
+ });
+ // Force bypasses the cheap-gate; service reaches the curated-history clone.
+ expect(mockChat.getHistory).toHaveBeenCalledWith(true);
});
it('should return NOOP if under token threshold and not forced', async () => {
@@ -456,7 +526,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
@@ -478,7 +548,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -495,7 +565,7 @@ describe('ChatCompressionService', () => {
bypassTokenThreshold: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -504,7 +574,7 @@ describe('ChatCompressionService', () => {
expect(mockGenerateContent).toHaveBeenCalled();
});
- it('should bypass the failed-attempt latch when heap pressure requests compaction', async () => {
+ it('should bypass the consecutive-failure breaker when heap pressure requests compaction', async () => {
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
@@ -519,7 +589,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -536,7 +606,9 @@ describe('ChatCompressionService', () => {
bypassTokenThreshold: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: true,
+ // Breaker is tripped (consecutiveFailures >= MAX) but heap-pressure
+ // bypass must override the latch so the memory safety net still fires.
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -545,18 +617,44 @@ describe('ChatCompressionService', () => {
expect(mockGenerateContent).toHaveBeenCalled();
});
- it('should return NOOP when contextPercentageThreshold is 0', async () => {
+ it('silently ignores the deprecated chatCompression.contextPercentageThreshold = 0 (no longer disables compaction)', async () => {
+ // Pre-PR #4168, setting contextPercentageThreshold = 0 short-circuited
+ // compress() at the cheap-gate (NOOP). The field was removed from
+ // ChatCompressionSettings as part of the redesign; leftover values
+ // in stale settings.json must be ignored without suppressing the gate.
+ // Drive the non-force path with originalTokenCount above auto so the
+ // gate would have to actively pass, and verify the side-query fires.
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
- vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
+ 100_000,
+ );
+ // The deprecated field is no longer in ChatCompressionSettings; cast so
+ // we can simulate a leftover value coming from a stale settings.json.
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
contextPercentageThreshold: 0,
- });
+ } as unknown as ReturnType);
+ // 128K window → auto ≈ 95K; originalTokenCount 100K crosses.
+ vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
+ model: 'gemini-pro',
+ contextWindowSize: 128_000,
+ } as unknown as ReturnType);
- const mockGenerateContent = vi.fn();
+ const mockGenerateContent = vi.fn().mockResolvedValue({
+ text: 'Summary',
+ usage: {
+ // Realistic compression usage so the inflation guard doesn't fire:
+ // newTokens = max(0, 100000 - (99000 - 1000) + 1500) = 3500 → COMPRESSED
+ promptTokenCount: 99_000,
+ candidatesTokenCount: 1500,
+ totalTokenCount: 100_500,
+ },
+ });
vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({
generateText: mockGenerateContent,
} as unknown as BaseLlmClient);
@@ -566,68 +664,104 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
- expect(result.info).toMatchObject({
- compressionStatus: CompressionStatus.NOOP,
- originalTokenCount: 0,
- newTokenCount: 0,
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ expect(mockGenerateContent).toHaveBeenCalled();
+ });
+
+ it('honors chatCompression.disabled and NOOPs the cheap-gate without firing the side query (R11.4)', async () => {
+ // R11.4: re-adds the disable escape hatch removed alongside
+ // contextPercentageThreshold. Users with compliance / debugging /
+ // audit-trail needs set `chatCompression.disabled: true` to keep
+ // full uncompressed history. Reactive overflow still runs at the
+ // API layer as the safety net; only the proactive + hard-rescue
+ // paths skip. Force / heap-pressure bypass still take effect
+ // (manual /compress and process-wide memory pressure remain).
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ vi.mocked(mockChat.getHistory).mockReturnValue(history);
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
+ 100_000,
+ );
+ vi.mocked(mockConfig.getChatCompression).mockReturnValue({
+ disabled: true,
});
- expect(mockGenerateContent).not.toHaveBeenCalled();
- expect(tokenLimit).not.toHaveBeenCalled();
+ vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
+ model: 'gemini-pro',
+ contextWindowSize: 128_000,
+ } as unknown as ReturnType);
- const forcedResult = await service.compress(mockChat, {
+ const mockGenerateContent = vi.fn();
+ vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({
+ generateText: mockGenerateContent,
+ } as unknown as BaseLlmClient);
+
+ const result = await service.compress(mockChat, {
promptId: mockPromptId,
- force: true,
+ force: false, // proactive path
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
- expect(forcedResult.info).toMatchObject({
- compressionStatus: CompressionStatus.NOOP,
- originalTokenCount: 0,
- newTokenCount: 0,
- });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
expect(mockGenerateContent).not.toHaveBeenCalled();
- expect(tokenLimit).not.toHaveBeenCalled();
});
- it('should return NOOP when contextPercentageThreshold is 0 even with token threshold bypass', async () => {
+ it('chatCompression.disabled still allows force=true (manual /compress) to proceed (R11.4)', async () => {
+ // The disable knob is for the AUTOMATIC paths only. Manual
+ // /compress (force=true) and heap-pressure bypass remain
+ // active — the user has explicitly asked, or the process is at
+ // memory risk.
const history: Content[] = [
{ role: 'user', parts: [{ text: 'msg1' }] },
{ role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
];
vi.mocked(mockChat.getHistory).mockReturnValue(history);
- vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(800);
+ vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
+ 100_000,
+ );
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
- contextPercentageThreshold: 0,
+ disabled: true,
});
+ vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
+ model: 'gemini-pro',
+ contextWindowSize: 128_000,
+ } as unknown as ReturnType);
- const mockGenerateContent = vi.fn();
+ const mockGenerateContent = vi.fn().mockResolvedValue({
+ text: 'Manual',
+ usage: {
+ promptTokenCount: 99_000,
+ candidatesTokenCount: 1500,
+ totalTokenCount: 100_500,
+ },
+ });
vi.mocked(mockConfig.getBaseLlmClient).mockReturnValue({
generateText: mockGenerateContent,
} as unknown as BaseLlmClient);
const result = await service.compress(mockChat, {
promptId: mockPromptId,
- force: false,
- bypassTokenThreshold: true,
+ force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
- expect(result.info).toMatchObject({
- compressionStatus: CompressionStatus.NOOP,
- originalTokenCount: 0,
- newTokenCount: 0,
- });
- expect(mockGenerateContent).not.toHaveBeenCalled();
- expect(tokenLimit).not.toHaveBeenCalled();
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ expect(mockGenerateContent).toHaveBeenCalled();
});
it('should return NOOP when historyToCompress is below MIN_COMPRESSION_FRACTION of total', async () => {
@@ -662,7 +796,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -687,7 +821,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
// newTokenCount = 800 - (1600 - 1000) + 50 = 800 - 600 + 50 = 250 <= 800 (success)
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -703,14 +837,16 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
expect(result.info.newTokenCount).toBe(250); // 800 - (1600 - 1000) + 50
expect(result.newHistory).not.toBeNull();
- expect(result.newHistory![0].parts![0].text).toBe('Summary');
+ expect(result.newHistory![0].parts![0].text).toBe(
+ 'Summary',
+ );
expect(mockGenerateContent).toHaveBeenCalled();
expect(mockGetHookSystem).toHaveBeenCalled();
});
@@ -728,7 +864,7 @@ describe('ChatCompressionService', () => {
// newTokenCount = 100 - (1100 - 1000) + 50 = 100 - 100 + 50 = 50 <= 100 (success)
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -745,7 +881,7 @@ describe('ChatCompressionService', () => {
// forced
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -765,7 +901,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -781,7 +917,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -801,7 +937,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateText = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -817,7 +953,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
signal: abortController.signal,
});
@@ -850,7 +986,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateText = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 200,
candidatesTokenCount: 50,
@@ -866,7 +1002,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -892,7 +1028,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateText = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -908,19 +1044,21 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
- // Compression quality depends on thinkingConfig.includeThoughts being on
- // and maxAttempts being short (best-effort); a future refactor that drops
- // any of these would silently regress quality without this assertion.
+ // Thinking is intentionally disabled (per-provider budget semantics are
+ // inconsistent) and the output is hard-capped by COMPACT_MAX_OUTPUT_TOKENS
+ // so subsequent threshold math has a predictable reserve. maxAttempts=1
+ // keeps the call best-effort (next turn re-triggers on failure).
expect(mockGenerateText).toHaveBeenCalledWith(
expect.objectContaining({
model: mockModel,
maxAttempts: 1,
config: expect.objectContaining({
- thinkingConfig: { includeThoughts: true },
+ thinkingConfig: { includeThoughts: false },
+ maxOutputTokens: 20_000,
}),
}),
);
@@ -936,7 +1074,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1,
candidatesTokenCount: 20,
@@ -952,7 +1090,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -977,7 +1115,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
// No usage -> keep original token count
usage: undefined,
});
@@ -990,7 +1128,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1024,7 +1162,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1058,7 +1196,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1078,7 +1216,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1,
candidatesTokenCount: 20,
@@ -1094,7 +1232,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1119,7 +1257,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1135,7 +1273,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1171,7 +1309,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -1188,7 +1326,7 @@ describe('ChatCompressionService', () => {
// force = true -> Manual trigger
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1216,7 +1354,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1233,7 +1371,7 @@ describe('ChatCompressionService', () => {
// force = false -> Auto trigger
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1252,30 +1390,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
- originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
- });
-
- expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
- expect(mockFirePreCompactEvent).not.toHaveBeenCalled();
- });
-
- it('should not fire PreCompact hook when threshold is 0', async () => {
- const history: Content[] = [
- { role: 'user', parts: [{ text: 'msg1' }] },
- { role: 'model', parts: [{ text: 'msg2' }] },
- ];
- vi.mocked(mockChat.getHistory).mockReturnValue(history);
- vi.mocked(mockConfig.getChatCompression).mockReturnValue({
- contextPercentageThreshold: 0,
- });
-
- const result = await service.compress(mockChat, {
- promptId: mockPromptId,
- force: true,
- model: mockModel,
- config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1299,7 +1414,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1328,7 +1443,7 @@ describe('ChatCompressionService', () => {
);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1344,7 +1459,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1376,7 +1491,7 @@ describe('ChatCompressionService', () => {
});
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1392,7 +1507,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1418,7 +1533,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1434,7 +1549,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1473,7 +1588,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -1490,13 +1605,13 @@ describe('ChatCompressionService', () => {
// force = true -> Manual trigger
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
expect(mockFirePostCompactEvent).toHaveBeenCalledWith(
PostCompactTrigger.Manual,
- 'Summary',
+ 'Summary',
undefined,
);
});
@@ -1518,7 +1633,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Auto Summary',
+ text: 'Auto Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1535,13 +1650,13 @@ describe('ChatCompressionService', () => {
// force = false -> Auto trigger
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
expect(mockFirePostCompactEvent).toHaveBeenCalledWith(
PostCompactTrigger.Auto,
- 'Auto Summary',
+ 'Auto Summary',
undefined,
);
});
@@ -1576,7 +1691,7 @@ describe('ChatCompressionService', () => {
force: true,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1607,7 +1722,7 @@ describe('ChatCompressionService', () => {
);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1623,7 +1738,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1658,7 +1773,7 @@ describe('ChatCompressionService', () => {
});
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1674,7 +1789,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1701,7 +1816,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary',
+ text: 'Summary',
usage: {
promptTokenCount: 1600,
candidatesTokenCount: 50,
@@ -1717,7 +1832,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1780,7 +1895,7 @@ describe('ChatCompressionService', () => {
vi.mocked(tokenLimit).mockReturnValue(1000);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'Summary of all work done',
+ text: 'Summary of all work done',
usage: {
promptTokenCount: 1100,
candidatesTokenCount: 50,
@@ -1797,7 +1912,7 @@ describe('ChatCompressionService', () => {
// force=true (manual /compress)
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1854,7 +1969,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'state snapshot summary',
+ text: 'state snapshot summary',
usage: {
promptTokenCount: 2000,
candidatesTokenCount: 50,
@@ -1870,7 +1985,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1946,7 +2061,7 @@ describe('ChatCompressionService', () => {
} as unknown as ReturnType);
const mockGenerateContent = vi.fn().mockResolvedValue({
- text: 'state snapshot summary',
+ text: 'state snapshot summary',
usage: {
promptTokenCount: 60_000,
candidatesTokenCount: 200,
@@ -1962,7 +2077,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -1974,7 +2089,9 @@ describe('ChatCompressionService', () => {
// [summary_user, summary_ack_model, continuation_bridge_user, ...keep]
// where keep starts with the retained model+functionCall.
expect(newHistory[0].role).toBe('user');
- expect(newHistory[0].parts?.[0].text).toBe('state snapshot summary');
+ expect(newHistory[0].parts?.[0].text).toBe(
+ 'state snapshot summary',
+ );
expect(newHistory[1].role).toBe('model');
expect(newHistory[2].role).toBe('user');
expect(newHistory[2].parts?.[0].text).toMatch(/Continue/);
@@ -2033,7 +2150,7 @@ describe('ChatCompressionService', () => {
force: false,
model: mockModel,
config: mockConfig,
- hasFailedCompressionAttempt: false,
+ consecutiveFailures: 0,
originalTokenCount: uiTelemetryService.getLastPromptTokenCount(),
});
@@ -2042,3 +2159,898 @@ describe('ChatCompressionService', () => {
});
});
});
+
+describe('ChatCompressionService.compress sideQuery config', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('passes maxOutputTokens=20_000 and includeThoughts=false to runSideQuery', async () => {
+ const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'summary',
+ usage: {
+ promptTokenCount: 1000,
+ candidatesTokenCount: 500,
+ totalTokenCount: 1500,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const service = new ChatCompressionService();
+ await service.compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(spy).toHaveBeenCalledTimes(1);
+ const callArg = spy.mock.calls[0]![1] as {
+ config?: {
+ thinkingConfig?: { includeThoughts?: boolean };
+ maxOutputTokens?: number;
+ };
+ };
+ expect(callArg.config?.thinkingConfig?.includeThoughts).toBe(false);
+ expect(callArg.config?.maxOutputTokens).toBe(20_000);
+ });
+
+ it('returns FAILED_OUTPUT_TRUNCATED when the summary output hits the cap with no complete snapshot envelope (likely mid-snapshot truncation)', async () => {
+ // R1.1 made the breaker tick; R5.2 split the status; R7.8 reverted
+ // R6.2's `>` back to `>=`; R9.7 gates the guard on `!snapshotMatch`
+ // so the cap-hit-but-envelope-complete case is preserved (scratchpad
+ // ate the budget, snapshot is still valid). For the guard to fire
+ // we need a raw output that hit the cap AND has no closing tag —
+ // the actual shape of mid-snapshot truncation.
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'verbose reasoning\npartial summary mid-content — no closing tag',
+ usage: {
+ promptTokenCount: 50_000,
+ candidatesTokenCount: 20_001, // over the cap
+ totalTokenCount: 70_001,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const warn = vi.fn();
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn, debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(
+ CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED,
+ );
+ expect(result.newHistory).toBeNull();
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('COMPACT_MAX_OUTPUT_TOKENS'),
+ );
+ });
+
+ it('treats output at exactly COMPACT_MAX_OUTPUT_TOKENS as truncated when the snapshot envelope is incomplete (R8.2 / R7.8 / R9.7 exact-cap boundary)', async () => {
+ // Combined R7.8 (revert `>` to `>=`) + R9.7 (gate on `!snapshotMatch`)
+ // semantics: at the cap AND with an incomplete snapshot envelope,
+ // we treat as truncated. This test pins R7.8 against future `>` and
+ // R9.7's no-closing-tag requirement against future "any cap-hit
+ // truncates" regressions.
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'reasoning\npartial content with no closing tag',
+ usage: {
+ promptTokenCount: 50_000,
+ candidatesTokenCount: 20_000, // exactly at cap
+ totalTokenCount: 70_000,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(
+ CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED,
+ );
+ });
+
+ it('preserves the snapshot when output hits the cap but a complete envelope was extracted (R9.7)', async () => {
+ // R9.7: when the closing tag is present, the model finished its
+ // snapshot before the cap was reached — the scratchpad ate the
+ // budget, not the snapshot. Dropping the result would throw away a
+ // valid summary. R8.7's scaling keeps newTokenCount honest about
+ // what's actually persisted; the truncation guard only fires now
+ // when the snapshot envelope is incomplete (no closing tag).
+ const VERBOSE_SCRATCHPAD =
+ '' + 'reasoning '.repeat(2000) + '';
+ const COMPLETE_SNAPSHOT =
+ 'Valid concise summary content';
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: `${VERBOSE_SCRATCHPAD}\n${COMPLETE_SNAPSHOT}`,
+ usage: {
+ promptTokenCount: 175_000,
+ candidatesTokenCount: 20_000, // at cap, but envelope is complete
+ totalTokenCount: 195_000,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ expect(result.newHistory).not.toBeNull();
+ expect(result.newHistory![0].parts![0].text).toBe(COMPLETE_SNAPSHOT);
+ });
+
+ it('persists only the envelope, stripping pre/post scratchpad content (R8.3a / R7.1 data-retention)', async () => {
+ // R7.1's data-retention fix: with `includeThoughts: false` the
+ // model emits its reasoning as plain text alongside
+ // . Persisting the concatenation would leak
+ // sensitive tool output that the model quoted to reason about.
+ // Verify the persisted summary is the snapshot envelope ONLY.
+ const SCRATCHPAD =
+ 'secret API_KEY=sk-xxxYYY in tool output';
+ const SNAPSHOT = 'Clean summary';
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: `${SCRATCHPAD}\n${SNAPSHOT}`,
+ usage: {
+ // Realistic compression side-query: input ≈ originalTokenCount
+ // (most of the history) + ~1000 prompt overhead. Without this
+ // the `originalTokenCount - (input - 1000) + output` formula
+ // makes the new count > original and trips the inflation guard.
+ promptTokenCount: 175_000,
+ candidatesTokenCount: 500,
+ totalTokenCount: 175_500,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ expect(result.newHistory).not.toBeNull();
+ const persisted = result.newHistory![0].parts![0].text!;
+ // Snapshot envelope persists exactly; scratchpad content nowhere
+ // in the persisted history.
+ expect(persisted).toBe(SNAPSHOT);
+ expect(persisted).not.toContain('API_KEY');
+ expect(persisted).not.toContain('scratchpad');
+ expect(persisted).not.toContain('sk-xxxYYY');
+ });
+
+ it('extracts the real snapshot when the scratchpad literally mentions (R8.6 / R7.1 regex anchor)', async () => {
+ // Reviewer R8.6: the compression prompt instructs the model to
+ // "generate the ", so the scratchpad is plausibly
+ // going to mention the tag literally. A non-greedy match from the
+ // first occurrence would capture the scratchpad's mention through
+ // to the real closing tag — bypassing the data-retention fix.
+ // The regex must anchor on the LAST opening tag so the captured
+ // envelope is always the real snapshot, never the mention.
+ const RAW =
+ 'I need to generate a of this conversation now. ' +
+ 'Reasoning: API_KEY=sk-xxx was mentioned in the chat.\n' +
+ 'Real summary content here';
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: RAW,
+ usage: {
+ promptTokenCount: 175_000,
+ candidatesTokenCount: 500,
+ totalTokenCount: 175_500,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ const persisted = result.newHistory![0].parts![0].text!;
+ expect(persisted).toBe(
+ 'Real summary content here',
+ );
+ expect(persisted).not.toContain('API_KEY');
+ expect(persisted).not.toContain('Reasoning:');
+ });
+
+ it('warns and surfaces EMPTY_SUMMARY when model output is non-empty but lacks tags (R8.1 / R8.3b format-violation)', async () => {
+ // Format violation: the model produced text but didn't follow the
+ // envelope contract. The persisted summary becomes
+ // empty (regex no-match) and we surface EMPTY_SUMMARY — but pre-R8.1
+ // this branch was silent, making it indistinguishable from a model
+ // that genuinely returned nothing. A warn log with a content slice
+ // is the actionable diagnostic an oncall needs.
+ const FORMAT_VIOLATION_RAW =
+ 'Sure, here is the summary: The user asked X and Y happened. ' +
+ 'No tags emitted at all.';
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: FORMAT_VIOLATION_RAW,
+ usage: {
+ promptTokenCount: 1000,
+ candidatesTokenCount: 500,
+ totalTokenCount: 1500,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const warn = vi.fn();
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn, debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(
+ CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
+ );
+ // Warn must fire with a content fingerprint so oncall can identify
+ // "format violation" vs "genuinely empty model output".
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('state_snapshot'),
+ );
+ });
+
+ it('breaker-tripped NOOP returns the caller originalTokenCount, not zero (R8.3c / R7.6 telemetry)', async () => {
+ // R7.6 changed the breaker-tripped NOOP from returning
+ // `{ originalTokenCount: 0, newTokenCount: 0 }` to forwarding the
+ // caller's count so telemetry/dashboards aren't misled by a zero on
+ // the trip event. Pin that contract.
+ const ORIGINAL = 175_000;
+ const mockChat = {
+ getHistory: vi
+ .fn()
+ .mockReturnValue([{ role: 'user', parts: [{ text: 'msg' }] }]),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: MAX_CONSECUTIVE_FAILURES, // breaker tripped
+ originalTokenCount: ORIGINAL,
+ });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
+ expect(result.info.originalTokenCount).toBe(ORIGINAL);
+ expect(result.info.newTokenCount).toBe(ORIGINAL);
+ });
+
+ it('newTokenCount accounts for only the persisted snapshot, not the discarded scratchpad (R8.7)', async () => {
+ // Pre-R8.7: newTokenCount used compressionOutputTokenCount from the
+ // API, which counts the full model output (scratchpad + snapshot).
+ // The persisted history only contains the snapshot, so the inflated
+ // count made the next cheap-gate trigger compaction earlier than
+ // necessary. Fix: scale by summary/raw character ratio so the
+ // bookkeeping reflects what we actually keep.
+ //
+ // Mock raw output where scratchpad is ~3x the snapshot in chars,
+ // and assert newTokenCount tracks the snapshot share — not the full
+ // candidatesTokenCount.
+ const SCRATCHPAD = '' + 'x'.repeat(3000) + '';
+ const SNAPSHOT =
+ '' + 'y'.repeat(1000) + '';
+ const RAW = `${SCRATCHPAD}\n${SNAPSHOT}`;
+
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: RAW,
+ usage: {
+ promptTokenCount: 175_000, // realistic — slimmed history + prompt
+ candidatesTokenCount: 1024, // full output (scratchpad + snapshot)
+ totalTokenCount: 176_024,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ // The persisted summary is SNAPSHOT (~1016 chars). The raw was
+ // ~4030 chars. The scaled "snapshot share" of the 1024-token
+ // candidatesTokenCount is approximately 1024 * 1016/4030 ≈ 258.
+ // Pre-R8.7, the code used the full 1024 verbatim. Asserting the
+ // count is *materially* smaller pins the scaling behaviour without
+ // hard-coding the exact value (different scaling strategies are
+ // acceptable; the contract is "smaller than the raw API count").
+ const apiOutputTokens = 1024;
+ const persistedOutputTokens =
+ result.info.newTokenCount - 180_000 + (175_000 - 1000); // invert the formula
+ expect(persistedOutputTokens).toBeGreaterThan(0);
+ expect(persistedOutputTokens).toBeLessThan(apiOutputTokens * 0.5); // snapshot is ~25% of raw
+ });
+
+ it('newTokenCount has a char-based floor preventing severe undercount on verbose scratchpads (R12.3)', async () => {
+ // R12.3: R8.7's pure-scaling formula collapses to near-zero when
+ // the scratchpad/snapshot ratio is extreme. Example from reviewer:
+ // rawLen=200K, summary=5K, apiOutput=15K → scaled = 375 tokens.
+ // That makes the cheap-gate think the new history is much smaller
+ // than it actually is, suppressing the NEXT compression for many
+ // turns. Floor to at least chars/4 of the persisted summary so
+ // the bookkeeping cannot fall below a meaningful lower bound.
+ const VERBOSE_SCRATCHPAD =
+ '' + 'r'.repeat(200_000) + '';
+ const SMALL_SNAPSHOT =
+ '' + 's'.repeat(5_000) + '';
+ vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: `${VERBOSE_SCRATCHPAD}\n${SMALL_SNAPSHOT}`,
+ usage: {
+ promptTokenCount: 175_000,
+ candidatesTokenCount: 15_000, // ~50K scratchpad tokens + ~5K snapshot tokens
+ totalTokenCount: 190_000,
+ },
+ } as never);
+
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ { role: 'user', parts: [{ text: 'msg3' }] },
+ { role: 'model', parts: [{ text: 'msg4' }] },
+ ];
+ const mockChat = {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ const mockConfig = {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: 200_000 }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn(), info: vi.fn() }),
+ } as unknown as Config;
+
+ const result = await new ChatCompressionService().compress(mockChat, {
+ promptId: 'p',
+ force: true,
+ model: 'qwen-test',
+ config: mockConfig,
+ consecutiveFailures: 0,
+ originalTokenCount: 180_000,
+ });
+
+ expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
+ // The persisted summary is ~5025 chars → char-based estimate is
+ // ~5025/4 = 1257 tokens. The pure scaling formula would give
+ // 15000 * (5025/205033) ≈ 367 (the bug). With the floor, the
+ // bookkeeping must be at least the char-based estimate.
+ const persistedOutputTokens =
+ result.info.newTokenCount - 180_000 + (175_000 - 1000);
+ expect(persistedOutputTokens).toBeGreaterThanOrEqual(1000);
+ });
+});
+
+describe('ChatCompressionService.compress cheap-gate uses estimated tokens', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ // Inline helpers (Task 3): the existing file uses per-block inline
+ // mockChat/mockConfig rather than shared factories, so we follow that
+ // pattern here. getHistory(true) returns a non-empty array so the cheap-
+ // gate flow can reach the spy when the threshold is crossed.
+ function makeFakeChat(): GeminiChat {
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ ];
+ return {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ }
+
+ function makeFakeConfig(opts: { contextWindowSize: number }): Config {
+ return {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: opts.contextWindowSize }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+ }
+
+ it('triggers compaction when precomputedEffectiveTokens crosses the auto threshold even though originalTokenCount is below it', async () => {
+ // 200K window, computeThresholds(200K).auto = 167K
+ // originalTokenCount = 160K (under by 7K), but caller's precomputed
+ // estimate factors in the pending user message → 170K, crosses 167K.
+ // R6.14 collapsed the "estimate-inside-the-service" branch; callers
+ // pass `precomputedEffectiveTokens` upstream now. (R7.5 removed the
+ // now-dead `pendingUserMessage` field; only the precomputed value
+ // remains in the contract.)
+
+ const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'x',
+ usage: {
+ promptTokenCount: 100,
+ candidatesTokenCount: 50,
+ totalTokenCount: 150,
+ },
+ } as never);
+
+ const result = await new ChatCompressionService().compress(makeFakeChat(), {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ consecutiveFailures: 0,
+ originalTokenCount: 160_000,
+ precomputedEffectiveTokens: 170_000,
+ });
+
+ // cheap-gate let it through (not NOOP), so spy was called
+ expect(spy).toHaveBeenCalled();
+ expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP);
+ });
+
+ it('NOOPs when neither precomputedEffectiveTokens nor originalTokenCount reaches threshold', async () => {
+ const spy = vi
+ .spyOn(sideQueryModule, 'runSideQuery')
+ .mockResolvedValue({ text: 's', usage: {} } as never);
+
+ const result = await new ChatCompressionService().compress(makeFakeChat(), {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ consecutiveFailures: 0,
+ originalTokenCount: 80_000,
+ precomputedEffectiveTokens: 80_010,
+ });
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
+ });
+
+ it('falls back to originalTokenCount when no precomputedEffectiveTokens is supplied (R6.14)', async () => {
+ // Direct callers (tests, future internal paths) without precomputed
+ // estimate use originalTokenCount as the gate input — the
+ // pendingUserMessage-only branch was removed because the service
+ // shouldn't double-clone history that the caller already paid for.
+ const spy = vi
+ .spyOn(sideQueryModule, 'runSideQuery')
+ .mockResolvedValue({ text: 's', usage: {} } as never);
+
+ const result = await new ChatCompressionService().compress(makeFakeChat(), {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ consecutiveFailures: 0,
+ originalTokenCount: 50_000, // below auto=167K → NOOP
+ });
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
+ });
+
+ it('precomputedEffectiveTokens path skips estimation work (R6.15)', async () => {
+ // R6.15: pin the perf optimization. When the caller supplies
+ // precomputedEffectiveTokens, the service must NOT recompute the
+ // estimate (no `getHistory(true)` clone). Verifies that the
+ // precomputed value alone drives the gate decision — even if
+ // originalTokenCount is way below the threshold.
+ const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'x',
+ usage: {
+ promptTokenCount: 99_000,
+ candidatesTokenCount: 1500,
+ totalTokenCount: 100_500,
+ },
+ } as never);
+
+ const result = await new ChatCompressionService().compress(makeFakeChat(), {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ consecutiveFailures: 0,
+ // Raw count low, but caller's precomputed estimate has already
+ // crossed auto=167K — the gate trusts the precomputed value.
+ originalTokenCount: 10_000,
+ precomputedEffectiveTokens: 180_000,
+ });
+
+ expect(spy).toHaveBeenCalled();
+ expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP);
+ });
+});
+
+describe('computeThresholds', () => {
+ it('32K window — proportional fallback for all tiers, hard degrades to auto', () => {
+ const t = computeThresholds(32_000);
+ expect(t.warn).toBe(19_200); // 0.6 * 32K
+ expect(t.auto).toBe(22_400); // 0.7 * 32K
+ expect(t.hard).toBe(22_400); // max(window-23K=9K, auto=22.4K) = auto
+ expect(t.effectiveWindow).toBe(12_000);
+ });
+
+ it('128K window — mixed (warn=pct, auto/hard=abs)', () => {
+ const t = computeThresholds(128_000);
+ expect(t.warn).toBe(76_800); // 0.6 * 128K (pct wins: 76.8K vs auto-20K=75K)
+ expect(t.auto).toBe(95_000); // abs: effectiveWindow-13K = 108-13 = 95K (abs wins: 95K vs 0.7*128K=89.6K)
+ expect(t.hard).toBe(105_000); // abs: effectiveWindow-3K = 108-3 = 105K
+ expect(t.effectiveWindow).toBe(108_000);
+ });
+
+ it('200K window — absolute takes over all tiers', () => {
+ const t = computeThresholds(200_000);
+ expect(t.warn).toBe(147_000); // abs: auto-20K (abs wins: 147K vs 0.6*200K=120K)
+ expect(t.auto).toBe(167_000); // abs: effectiveWindow-13K = 180-13 = 167K
+ expect(t.hard).toBe(177_000); // abs: effectiveWindow-3K = 180-3 = 177K
+ });
+
+ it('1M window — fully absolute', () => {
+ const t = computeThresholds(1_000_000);
+ expect(t.warn).toBe(947_000);
+ expect(t.auto).toBe(967_000);
+ expect(t.hard).toBe(977_000);
+ });
+
+ it('extreme small window (10K) does not crash; returns sane values', () => {
+ const t = computeThresholds(10_000);
+ expect(t.warn).toBeGreaterThan(0);
+ expect(t.auto).toBeGreaterThan(0);
+ expect(t.warn).toBeLessThanOrEqual(t.auto);
+ expect(t.auto).toBeLessThanOrEqual(t.hard);
+ });
+
+ it('thresholds always satisfy warn <= auto <= hard', () => {
+ for (const w of [32_000, 64_000, 128_000, 200_000, 256_000, 1_000_000]) {
+ const t = computeThresholds(w);
+ expect(t.warn).toBeLessThanOrEqual(t.auto);
+ expect(t.auto).toBeLessThanOrEqual(t.hard);
+ }
+ });
+
+ it('returns all-Infinity for non-finite or non-positive windows (R12.2)', () => {
+ // R12.2: an OpenAI-compat proxy returning `"context_window": null`
+ // would surface as `contextWindowSize: NaN`. Pre-R12.2,
+ // computeThresholds propagated NaN to all four fields → every
+ // downstream comparison (`tokens < NaN`) returned false → the
+ // ENTIRE three-tier gate (proactive + warn tip + hard rescue)
+ // silently disabled. Return Infinity for the threshold tiers so
+ // gate comparisons cleanly fall through to NOOP-equivalent paths
+ // rather than poisoning arithmetic.
+ for (const bad of [Number.NaN, 0, -1, -100_000, Number.NEGATIVE_INFINITY]) {
+ const t = computeThresholds(bad);
+ expect(t.warn).toBe(Number.POSITIVE_INFINITY);
+ expect(t.auto).toBe(Number.POSITIVE_INFINITY);
+ expect(t.hard).toBe(Number.POSITIVE_INFINITY);
+ expect(t.effectiveWindow).toBe(0);
+ }
+ });
+});
+
+describe('ChatCompressionService.compress cheap-gate uses computeThresholds.auto', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function makeFakeChat(): GeminiChat {
+ const history: Content[] = [
+ { role: 'user', parts: [{ text: 'msg1' }] },
+ { role: 'model', parts: [{ text: 'msg2' }] },
+ ];
+ return {
+ getHistory: vi.fn().mockReturnValue(history),
+ } as unknown as GeminiChat;
+ }
+
+ function makeFakeConfig(opts: { contextWindowSize: number }): Config {
+ return {
+ getChatCompression: vi.fn(),
+ getBaseLlmClient: vi.fn(),
+ getContentGeneratorConfig: vi
+ .fn()
+ .mockReturnValue({ contextWindowSize: opts.contextWindowSize }),
+ getHookSystem: vi.fn().mockReturnValue({
+ fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
+ firePreCompactEvent: vi.fn().mockResolvedValue(undefined),
+ firePostCompactEvent: vi.fn().mockResolvedValue(undefined),
+ }),
+ getModel: () => 'test-model',
+ getApprovalMode: () => 'default',
+ getDebugLogger: () => ({ warn: vi.fn(), debug: vi.fn() }),
+ } as unknown as Config;
+ }
+
+ it('on a 200K window with originalTokenCount=160K, NOOPs (below auto=167K)', async () => {
+ const spy = vi
+ .spyOn(sideQueryModule, 'runSideQuery')
+ .mockResolvedValue({ text: 's', usage: {} } as never);
+
+ const result = await new ChatCompressionService().compress(makeFakeChat(), {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ consecutiveFailures: 0,
+ originalTokenCount: 160_000,
+ });
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(result.info.compressionStatus).toBe(CompressionStatus.NOOP);
+ });
+
+ it('on a 200K window with originalTokenCount=168K, falls through cheap-gate (above auto=167K)', async () => {
+ const spy = vi.spyOn(sideQueryModule, 'runSideQuery').mockResolvedValue({
+ text: 'summary',
+ usage: {
+ promptTokenCount: 1000,
+ candidatesTokenCount: 500,
+ totalTokenCount: 1500,
+ },
+ } as never);
+
+ const result = await new ChatCompressionService().compress(makeFakeChat(), {
+ promptId: 'p',
+ force: false,
+ model: 'qwen-test',
+ config: makeFakeConfig({ contextWindowSize: 200_000 }),
+ consecutiveFailures: 0,
+ originalTokenCount: 168_000,
+ });
+
+ // 168K > 167K (computeThresholds(200K).auto), cheap-gate lets through
+ expect(spy).toHaveBeenCalled();
+ expect(result.info.compressionStatus).not.toBe(CompressionStatus.NOOP);
+ });
+});
diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts
index f704ee10fed..8715ab1c60f 100644
--- a/packages/core/src/services/chatCompressionService.ts
+++ b/packages/core/src/services/chatCompressionService.ts
@@ -9,7 +9,10 @@ import type { Config } from '../config/config.js';
import type { GeminiChat } from '../core/geminiChat.js';
import { type ChatCompressionInfo, CompressionStatus } from '../core/turn.js';
import { DEFAULT_TOKEN_LIMIT } from '../core/tokenLimits.js';
-import { getCompressionPrompt } from '../core/prompts.js';
+import {
+ COMPRESSION_SNAPSHOT_TAG,
+ getCompressionPrompt,
+} from '../core/prompts.js';
import { runSideQuery } from '../utils/sideQuery.js';
import { logChatCompression } from '../telemetry/loggers.js';
import { makeChatCompressionEvent } from '../telemetry/types.js';
@@ -20,12 +23,7 @@ import {
resolveSlimmingConfig,
slimCompactionInput,
} from './compactionInputSlimming.js';
-
-/**
- * Threshold for compression token count as a fraction of the model's token limit.
- * If the chat history exceeds this threshold, it will be compressed.
- */
-export const COMPRESSION_TOKEN_THRESHOLD = 0.7;
+import { estimateContentTokens } from './tokenEstimation.js';
/**
* The fraction of the latest chat history to keep. A value of 0.3
@@ -50,6 +48,153 @@ export const MIN_COMPRESSION_FRACTION = 0.05;
*/
export const TOOL_ROUND_RETAIN_COUNT = 2;
+/**
+ * Hard cap on the compression sideQuery output (summary text only, since
+ * thinking is disabled). Mirrors claude-code's MAX_OUTPUT_TOKENS_FOR_SUMMARY
+ * (autoCompact.ts:30) which is based on p99.99 of real compaction outputs.
+ */
+export const COMPACT_MAX_OUTPUT_TOKENS = 20_000;
+
+/**
+ * Default proportional auto-compaction threshold. Used as a small-window
+ * fallback / safety net inside computeThresholds — when the window is so
+ * small that the absolute branch becomes degenerate, the proportional
+ * branch keeps the trigger usable.
+ */
+export const DEFAULT_PCT = 0.7;
+
+/**
+ * Offset from DEFAULT_PCT used to position the warn tier proportionally
+ * (warn-pct = 0.7 - 0.1 = 0.6). Three-tier ladder makes warn fire
+ * meaningfully before auto on small windows where the absolute formula
+ * would otherwise compress warn flush against auto.
+ */
+export const WARN_PCT_OFFSET = 0.1;
+
+/**
+ * Token budget reserved from the window for compression output. Matches
+ * COMPACT_MAX_OUTPUT_TOKENS because thinking is disabled (see Task 1) and
+ * maxOutputTokens is therefore the hard ceiling on total summary output.
+ */
+export const SUMMARY_RESERVE = COMPACT_MAX_OUTPUT_TOKENS; // 20_000
+
+/**
+ * Distance between auto threshold and effectiveWindow. Matches claude-code's
+ * AUTOCOMPACT_BUFFER_TOKENS (autoCompact.ts:62) — empirically chosen to leave
+ * headroom for the compaction sideQuery round-trip plus a few user-message
+ * turns before the window saturates.
+ */
+export const AUTOCOMPACT_BUFFER = 13_000;
+
+/**
+ * Distance between warn threshold and auto threshold. Matches claude-code's
+ * WARNING_THRESHOLD_BUFFER_TOKENS (autoCompact.ts:63) — sized so the warn
+ * tier fires a couple of turns before auto-compaction in practice.
+ */
+export const WARN_BUFFER = 20_000;
+
+/** Distance between hard threshold and effectiveWindow (matches claude-code's MANUAL_COMPACT_BUFFER). */
+export const HARD_BUFFER = 3_000;
+
+/**
+ * Auto-compaction consecutive-failure circuit breaker. After this many
+ * consecutive failures the cheap-gate NOOPs until a successful force
+ * compress resets the counter. Co-located here with other compaction-
+ * tuning constants; the counter state itself lives on GeminiChat.
+ */
+export const MAX_CONSECUTIVE_FAILURES = 3;
+
+/**
+ * Compiled extraction regex for the `` envelope.
+ * Hoisted to module scope because it depends only on the immutable
+ * `COMPRESSION_SNAPSHOT_TAG` — rebuilding via `new RegExp()` on every
+ * `compress()` call was waste and signaled (falsely) to readers that
+ * the pattern might vary per call. (R11.7)
+ *
+ * The greedy `[\s\S]*` prefix anchors on the LAST opening tag so a
+ * scratchpad that mentions the tag literally (the prompt instructs
+ * the model to "generate the ") doesn't pull
+ * scratchpad content into the captured envelope. See R8.6 for the
+ * design discussion.
+ */
+const SNAPSHOT_REGEX = new RegExp(
+ `[\\s\\S]*<${COMPRESSION_SNAPSHOT_TAG}>([\\s\\S]*?)${COMPRESSION_SNAPSHOT_TAG}>`,
+);
+
+/**
+ * Process-wide throttle for the "auto-compaction disabled" warn. Lives
+ * at module scope because `ChatCompressionService` is constructed per
+ * `tryCompress` call, so an instance flag would warn on every send
+ * for the whole session. Reset by tests via `resetDisabledWarningForTests`.
+ * (R12.4)
+ */
+let disabledWarningEmitted = false;
+
+/** Test-only: reset the process-wide R12.4 throttle. */
+export function resetDisabledWarningForTests(): void {
+ disabledWarningEmitted = false;
+}
+
+export interface CompactionThresholds {
+ /** Token count at which UI warn tier triggers. */
+ readonly warn: number;
+ /** Token count at which auto-compaction triggers. */
+ readonly auto: number;
+ /** Token count at which auto-compaction is forced (resets failure counter). */
+ readonly hard: number;
+ /** Window minus SUMMARY_RESERVE; the budget available for input + summary. */
+ readonly effectiveWindow: number;
+}
+
+/**
+ * Compute the three-tier threshold ladder for a given context window.
+ *
+ * Each tier is `max(proportional, absolute)`:
+ * auto = max(DEFAULT_PCT * window, effectiveWindow - AUTOCOMPACT_BUFFER)
+ * warn = max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, auto - WARN_BUFFER)
+ * hard = max(effectiveWindow - HARD_BUFFER, auto) // hard degrades to auto for tiny windows
+ *
+ * Small windows (where the absolute branch goes negative) automatically
+ * fall back to the proportional branch. Large windows are dominated by
+ * the absolute branch, capping wasted reservation to ~33K instead of 30%
+ * of the window.
+ *
+ * Pure function — no I/O, no shared state — safe to call repeatedly.
+ *
+ * R12.2: a non-finite or non-positive `window` (e.g. an OpenAI-compat
+ * proxy returning `"context_window": null` → NaN; a misconfigured
+ * provider returning 0; a negative override) propagates NaN to all
+ * four returned fields. Every downstream `tokens < NaN` / `tokens >=
+ * NaN` comparison evaluates to `false`, silently disabling the entire
+ * three-tier gate (proactive + warn tip + hard rescue). Return
+ * `Infinity` for the threshold tiers so gate comparisons fall through
+ * to the NOOP-equivalent path (`tokens < Infinity` always true), and
+ * `effectiveWindow: 0` so any "window minus auto" derivations clamp
+ * to zero rather than poisoning their own math.
+ */
+export function computeThresholds(window: number): CompactionThresholds {
+ if (!Number.isFinite(window) || window <= 0) {
+ return {
+ warn: Number.POSITIVE_INFINITY,
+ auto: Number.POSITIVE_INFINITY,
+ hard: Number.POSITIVE_INFINITY,
+ effectiveWindow: 0,
+ };
+ }
+ const effectiveWindow = window - SUMMARY_RESERVE;
+
+ const absAuto = effectiveWindow - AUTOCOMPACT_BUFFER;
+ const auto = Math.max(DEFAULT_PCT * window, absAuto);
+
+ const absWarn = auto - WARN_BUFFER;
+ const warn = Math.max((DEFAULT_PCT - WARN_PCT_OFFSET) * window, absWarn);
+
+ const rawHard = effectiveWindow - HARD_BUFFER;
+ const hard = Math.max(rawHard, auto);
+
+ return { warn, auto, hard, effectiveWindow };
+}
+
export type CompactTrigger = 'manual' | 'auto';
const hasFunctionCall = (content: Content | undefined): boolean =>
@@ -170,13 +315,16 @@ export interface CompressOptions {
model: string;
config: Config;
/**
- * Whether a previous unforced compression attempt failed for this chat.
- * Suppresses auto-compaction; manual `/compress` (force=true) overrides.
+ * Number of consecutive auto-compaction failures for this chat. When it reaches
+ * MAX_CONSECUTIVE_FAILURES, the cheap-gate stops trying until a successful
+ * force=true call resets it.
*/
- hasFailedCompressionAttempt: boolean;
+ consecutiveFailures: number;
/**
* Most recent prompt token count for this chat. Compared against
- * `threshold * contextWindowSize` for the auto-compaction gate. Callers
+ * `computeThresholds(contextWindowSize).auto` for the auto-compaction
+ * gate, optionally augmented by the pending user message's estimated
+ * token count via `estimatePromptTokens` (see Task 3 / Task 6). Callers
* source this from the per-chat counter (main session, subagents alike) —
* the service does not read or write any global telemetry.
*/
@@ -196,6 +344,17 @@ export interface CompressOptions {
*/
trigger?: CompactTrigger;
signal?: AbortSignal;
+ /**
+ * Pre-computed effective-token count from `estimatePromptTokens()`. When
+ * provided, the cheap-gate uses this directly and skips its own
+ * estimation pass (along with the accompanying `chat.getHistory(true)`
+ * clone). Callers that already computed this value upstream —
+ * primarily `sendMessageStream` for the hard-tier rescue — pass it
+ * through to avoid duplicate work. When omitted (manual `/compress`,
+ * heap-pressure bypass, direct service callers), the cheap-gate falls
+ * back to `originalTokenCount`. (review #4168 R1.3 / R1.4)
+ */
+ precomputedEffectiveTokens?: number;
}
export class ChatCompressionService {
@@ -208,7 +367,7 @@ export class ChatCompressionService {
force,
model,
config,
- hasFailedCompressionAttempt,
+ consecutiveFailures,
originalTokenCount,
bypassTokenThreshold = false,
trigger,
@@ -216,23 +375,68 @@ export class ChatCompressionService {
} = opts;
const compactTrigger = trigger ?? (force ? 'manual' : 'auto');
const chatCompressionSettings = config.getChatCompression();
- const threshold =
- chatCompressionSettings?.contextPercentageThreshold ??
- COMPRESSION_TOKEN_THRESHOLD;
const slimmingConfig = resolveSlimmingConfig(chatCompressionSettings);
+ // R11.4: explicit user disable wins over all internal state. The
+ // proactive cheap-gate and hard-rescue paths NOOP; the user-
+ // initiated path (`force=true` for manual /compress) and heap-
+ // pressure bypass remain active because the user has explicitly
+ // asked or the process is at memory risk. Reactive overflow at
+ // the API layer is still the last-ditch safety net. Replaces the
+ // removed `contextPercentageThreshold: 0` escape hatch.
+ //
+ // R12.4: log once per process when the disable knob trips a NOOP
+ // so an oncall investigating "auto-compaction isn't running" can
+ // distinguish "user explicitly disabled it" from "system broken".
+ // Symmetric with R7.9's `breakerWarningEmitted` / R8.4's
+ // `budgetExhaustedWarningEmitted` throttles. Module-level flag so
+ // the warn fires on the first NOOP and stays silent thereafter,
+ // even across new `ChatCompressionService` instances (the service
+ // is constructed per `tryCompress` call).
+ if (chatCompressionSettings?.disabled && !force && !bypassTokenThreshold) {
+ if (!disabledWarningEmitted) {
+ config
+ .getDebugLogger()
+ .warn(
+ '[chat-compression] auto-compaction is disabled via ' +
+ 'chatCompression.disabled=true; skipping proactive + ' +
+ 'hard-rescue paths. Manual /compress and reactive overflow ' +
+ 'still work. (This message is logged once per process.)',
+ );
+ disabledWarningEmitted = true;
+ }
+ return {
+ newHistory: null,
+ info: {
+ originalTokenCount,
+ newTokenCount: originalTokenCount,
+ compressionStatus: CompressionStatus.NOOP,
+ },
+ };
+ }
+
// Cheap gates first — these don't need the curated history. Heap-pressure
- // bypass must also bypass the failed-attempt latch, otherwise one failed
- // compression would disable this safety net for the rest of the chat.
+ // bypass must also bypass the consecutive-failure breaker, otherwise N
+ // failed compactions would disable this memory-pressure safety net for
+ // the rest of the chat.
+ //
+ // R7.9: this NOOP is silent at the service layer. The caller
+ // (`GeminiChat.tryCompress`) emits a warn-once log on the first send
+ // after the breaker trips, so observability is preserved without
+ // spamming on every subsequent send for the rest of the session.
+ // R7.6: return the caller's `originalTokenCount` rather than 0 so
+ // telemetry/dashboards see the real session token count on the trip
+ // event, not a misleading zero.
if (
- threshold <= 0 ||
- (hasFailedCompressionAttempt && !force && !bypassTokenThreshold)
+ consecutiveFailures >= MAX_CONSECUTIVE_FAILURES &&
+ !force &&
+ !bypassTokenThreshold
) {
return {
newHistory: null,
info: {
- originalTokenCount: 0,
- newTokenCount: 0,
+ originalTokenCount,
+ newTokenCount: originalTokenCount,
compressionStatus: CompressionStatus.NOOP,
},
};
@@ -245,7 +449,21 @@ export class ChatCompressionService {
const contextLimit =
config.getContentGeneratorConfig()?.contextWindowSize ??
DEFAULT_TOKEN_LIMIT;
- if (originalTokenCount < threshold * contextLimit) {
+ const { auto } = computeThresholds(contextLimit);
+ // Effective-token source: the only production caller of the auto-compact
+ // gate (`sendMessageStream` → hard-tier rescue) always passes
+ // `precomputedEffectiveTokens`. Manual /compress and heap-pressure
+ // bypass the gate entirely via `force` / `bypassTokenThreshold`. So in
+ // practice the precomputed branch is the only live one. We keep the
+ // fallback to `originalTokenCount` for direct service callers (tests,
+ // future call sites without a pending message) — but DO NOT take the
+ // "estimate here" path: that would double-clone the history (the
+ // caller already did it). The previous pendingUserMessage-only
+ // branch was unreachable in production; removing avoids the latent
+ // clone risk if a future caller forgot to precompute. (R6.14)
+ const effectiveTokens =
+ opts.precomputedEffectiveTokens ?? originalTokenCount;
+ if (effectiveTokens < auto) {
return {
newHistory: null,
info: {
@@ -375,20 +593,100 @@ export class ChatCompressionService {
role: 'user',
parts: [
{
- text: 'First, reason in your scratchpad. Then, generate the .',
+ text: `First, reason in your scratchpad. Then, generate the <${COMPRESSION_SNAPSHOT_TAG}>.`,
},
],
},
],
- // Compression quality drives every subsequent main turn — keep reasoning on.
+ // Compression output is bounded by maxOutputTokens to guarantee a predictable
+ // reserve across providers (see docs/design/auto-compaction-threshold-redesign.md).
+ // Thinking is disabled because per-provider thinking-budget semantics are
+ // inconsistent (Anthropic/OpenAI count it separately, Gemini varies by model).
config: {
- thinkingConfig: { includeThoughts: true },
+ thinkingConfig: { includeThoughts: false },
+ maxOutputTokens: COMPACT_MAX_OUTPUT_TOKENS,
},
abortSignal: signal ?? new AbortController().signal,
promptId,
});
- const summary = summaryResult.text;
- const isSummaryEmpty = !summary || summary.trim().length === 0;
+ // R7.1: extract just the `` XML envelope from the
+ // raw response. The compression prompt asks the model to reason in
+ // a private `` block first, then emit ``.
+ // With `includeThoughts: false`, the underlying provider doesn't
+ // tag the scratchpad as a thought part — it arrives as plain text
+ // alongside the snapshot, so `summaryResult.text` (from
+ // `getResponseText` filtering `!part.thought`) returns BOTH the
+ // scratchpad and the snapshot concatenated. Persisting the
+ // scratchpad as part of the chat's compressed memory is a data
+ // retention regression: scratchpads can quote sensitive tool output
+ // (API keys, filesystem paths, fragments of private files) the
+ // model needed to reason about but should not survive the turn.
+ // Extracting just the snapshot envelope keeps memory limited to the
+ // structured fields the prompt actually requests.
+ //
+ // If the model failed to emit the tags at all (prompt drift / format
+ // violation), the regex returns no match and we fall through to the
+ // empty-summary branch — `COMPRESSION_FAILED_EMPTY_SUMMARY` ticks
+ // the breaker, the right signal for "model didn't follow format".
+ // The truncation guard below operates on the raw text (not the
+ // extracted summary) so its TRUNCATED telemetry stays distinct from
+ // EMPTY_SUMMARY when the cap is hit mid-snapshot.
+ const rawSummaryText = summaryResult.text;
+ const isRawEmpty = !rawSummaryText || rawSummaryText.trim().length === 0;
+ // R8.6: anchor on the LAST opening `` tag, not the
+ // first. The compression prompt instructs the model to "generate
+ // the ", so the scratchpad is plausibly going to
+ // mention the literal tag name. A non-greedy match from the first
+ // occurrence would capture scratchpad content through to the real
+ // closing tag — bypassing the data-retention fix entirely. Using
+ // a greedy prefix `[\s\S]*` forces the regex
+ // engine to find the LAST opening tag, then the non-greedy
+ // `[\s\S]*?` captures the smallest valid envelope.
+ // R9.3 / R11.7: regex hoisted to module scope (`SNAPSHOT_REGEX`)
+ // — it depends only on `COMPRESSION_SNAPSHOT_TAG` which is itself
+ // module-level and immutable, so rebuilding per `compress()` call
+ // was waste. Hoisting also signals to readers that the pattern is
+ // a fixed contract, not parameterised.
+ const snapshotMatch = rawSummaryText?.match(SNAPSHOT_REGEX);
+ const summary = snapshotMatch
+ ? `<${COMPRESSION_SNAPSHOT_TAG}>${snapshotMatch[1]}${COMPRESSION_SNAPSHOT_TAG}>`
+ : '';
+ const isSummaryEmpty = summary.trim().length === 0;
+ if (
+ !isSummaryEmpty &&
+ rawSummaryText &&
+ rawSummaryText.length > summary.length
+ ) {
+ config
+ .getDebugLogger()
+ .debug(
+ `[chat-compression] stripped ${rawSummaryText.length - summary.length} chars ` +
+ `of pre/post-snapshot text (likely scratchpad) before persisting summary`,
+ );
+ }
+ // R8.1: format violation is the surprising case — model produced
+ // text but didn't follow the contract. Without a
+ // distinguishing log, this is indistinguishable from a model that
+ // genuinely returned nothing (which warrants different operator
+ // action: prompt vs. provider).
+ //
+ // R11.6: log the length only. Earlier versions included
+ // `rawSummaryText.slice(0, 200)` for diagnostic context, but the
+ // scratchpad's most sensitive content (API keys, paths quoted from
+ // tool output) often appears in the FIRST 200 chars of the model's
+ // reasoning — exactly the window that slice captured. The length
+ // alone distinguishes "model returned nothing" from "model
+ // returned content but no tags", which is the
+ // operationally actionable distinction; the actual content can be
+ // recovered from provider-side logging if needed.
+ if (!isRawEmpty && isSummaryEmpty) {
+ config
+ .getDebugLogger()
+ .warn(
+ `[chat-compression] model output (${rawSummaryText!.length} chars) ` +
+ `contained no <${COMPRESSION_SNAPSHOT_TAG}> tags — treating as empty summary.`,
+ );
+ }
const compressionUsageMetadata = summaryResult.usage;
const compressionInputTokenCount =
compressionUsageMetadata?.promptTokenCount;
@@ -405,6 +703,67 @@ export class ChatCompressionService {
);
}
+ // Defensive guard: if the side-query hit COMPACT_MAX_OUTPUT_TOKENS, the
+ // summary is likely truncated mid-content and unsafe to persist. Drop it
+ // and surface as a failure so the consecutive-failure breaker counts it —
+ // if the model consistently produces max-length summaries we want to stop
+ // trying after MAX_CONSECUTIVE_FAILURES strikes rather than burn an API
+ // call on every send. Reactive overflow still catches the catastrophic
+ // case. See docs/design/auto-compaction-threshold-redesign.md risk #2.
+ //
+ // TODO(finish_reason): the current cap check is a heuristic. The proper
+ // signal is `finish_reason === 'length'` (OpenAI) / `MAX_TOKENS`
+ // (Gemini), but `runSideQuery` doesn't surface it today. Plumb it
+ // through and tighten this guard when that's available.
+ //
+ // `>=` (not `>`): the API hard-caps output at `COMPACT_MAX_OUTPUT_TOKENS`,
+ // so `>` could never fire. `>=` catches the case that matters
+ // (output landed at the cap → almost certainly truncated). The
+ // false-positive window (a legitimate summary that hits exactly
+ // 20K) is extraordinarily narrow — p99 of real summaries is ~17K
+ // per claude-code data — and the COMPRESSION_FAILED_OUTPUT_TRUNCATED
+ // breaker bounds the worst case to 3 strikes before NOOP. The
+ // proper fix lives in the TODO(finish_reason) plumbing above.
+ //
+ // R9.7: gate the truncation guard on `!snapshotMatch`. If the
+ // extraction succeeded (closing tag is present), the model finished
+ // its snapshot before the cap was reached — the cap was consumed
+ // by the scratchpad, which we discard anyway. Dropping the snapshot
+ // in that case would throw away a valid result. R8.7's scaling
+ // keeps newTokenCount honest when only the scratchpad inflates the
+ // raw output. The truncation guard now only fires when the model
+ // hit the cap AND didn't emit a complete envelope — strong evidence
+ // of mid-snapshot truncation.
+ if (
+ !isRawEmpty &&
+ !snapshotMatch &&
+ typeof compressionOutputTokenCount === 'number' &&
+ compressionOutputTokenCount >= COMPACT_MAX_OUTPUT_TOKENS
+ ) {
+ config
+ .getDebugLogger()
+ .warn(
+ `[chat-compression] summary output reached the ` +
+ `COMPACT_MAX_OUTPUT_TOKENS cap (${COMPACT_MAX_OUTPUT_TOKENS}); ` +
+ `dropping potentially-truncated result. This counts as a ` +
+ `compression failure for the per-chat circuit breaker.`,
+ );
+ return {
+ newHistory: null,
+ info: {
+ originalTokenCount,
+ newTokenCount: originalTokenCount,
+ // Distinct from EMPTY_SUMMARY so telemetry / logs can tell a
+ // prompt-quality failure (empty summary → tune prompt / splitter)
+ // apart from a capacity failure (output cap hit → raise cap or
+ // shrink splitter input). isCompressionFailureStatus() treats both
+ // as failures so the persistence behaviour is unchanged. (R5.2)
+ compressionStatus:
+ CompressionStatus.COMPRESSION_FAILED_OUTPUT_TRUNCATED,
+ },
+ };
+ }
+
let newTokenCount = originalTokenCount;
let extraHistory: Content[] = [];
let canCalculateNewTokenCount = false;
@@ -441,9 +800,24 @@ export class ChatCompressionService {
// Best-effort token math using *only* model-reported token counts.
//
// Note: compressionInputTokenCount includes the compression prompt and
- // the extra "reason in your scratchpad" instruction(approx. 1000 tokens), and
- // compressionOutputTokenCount may include non-persisted tokens (thoughts).
- // We accept these inaccuracies to avoid local token estimation.
+ // the extra "reason in your scratchpad" instruction(approx. 1000 tokens).
+ //
+ // R8.7: compressionOutputTokenCount counts the FULL model output
+ // (scratchpad + snapshot), but R7.1 only persists the snapshot
+ // envelope. Using the raw API count inflates newTokenCount by
+ // the scratchpad's share, which makes the next cheap-gate fire
+ // earlier than it should. Scale by the char ratio so the
+ // bookkeeping reflects what we actually kept.
+ //
+ // R12.3: pure scaling collapses to near-zero on extreme
+ // scratchpad/snapshot ratios (e.g. 200K scratchpad + 5K snapshot
+ // with 15K API tokens scales to ~375). The undercount makes the
+ // next cheap-gate think the new history is tiny, suppressing
+ // compression for many turns until reactive overflow recovers.
+ // Floor by `estimateContentTokens` on the persisted summary so
+ // the bookkeeping has a meaningful lower bound — `Math.max(api-
+ // scaled, char-based)` keeps API tokenizer fidelity when the
+ // scratchpad is reasonable and clamps when it isn't.
if (
typeof compressionInputTokenCount === 'number' &&
compressionInputTokenCount > 0 &&
@@ -451,11 +825,29 @@ export class ChatCompressionService {
compressionOutputTokenCount > 0
) {
canCalculateNewTokenCount = true;
+ const rawLen = rawSummaryText ? rawSummaryText.length : summary.length;
+ const scaledOutputTokens =
+ rawLen > 0
+ ? Math.max(
+ 1,
+ Math.round(
+ compressionOutputTokenCount * (summary.length / rawLen),
+ ),
+ )
+ : compressionOutputTokenCount;
+ const charBasedFloor = estimateContentTokens(
+ [{ role: 'user', parts: [{ text: summary }] }],
+ slimmingConfig.imageTokenEstimate,
+ );
+ const persistedOutputTokens = Math.max(
+ scaledOutputTokens,
+ charBasedFloor,
+ );
newTokenCount = Math.max(
0,
originalTokenCount -
(compressionInputTokenCount - 1000) +
- compressionOutputTokenCount,
+ persistedOutputTokens,
);
}
}
diff --git a/packages/core/src/services/compactionInputSlimming.ts b/packages/core/src/services/compactionInputSlimming.ts
index 7f0fb9f8ddd..9a51e6e02b7 100644
--- a/packages/core/src/services/compactionInputSlimming.ts
+++ b/packages/core/src/services/compactionInputSlimming.ts
@@ -20,7 +20,15 @@ import type { ChatCompressionSettings } from '../config/config.js';
export const DEFAULT_IMAGE_TOKEN_ESTIMATE = 1600;
-const TOKEN_TO_CHAR_RATIO = 4;
+/**
+ * Average chars-per-token ratio for the char-based token estimator. This
+ * constant is the single source of truth: `tokenEstimation.ts` re-exports
+ * it as `CHARS_PER_TOKEN` and the auto-compaction threshold gate divides
+ * by it. Keeping a single declaration eliminates the silent-drift risk
+ * the two-copy pattern carried (one file's update would have desynced
+ * splitter sizing from gate sizing without any compiler signal). (R7.4)
+ */
+export const TOKEN_TO_CHAR_RATIO = 4;
const DEFAULT_MIME = 'application/octet-stream';
/**
diff --git a/packages/core/src/services/tokenEstimation.test.ts b/packages/core/src/services/tokenEstimation.test.ts
new file mode 100644
index 00000000000..27a5bc22967
--- /dev/null
+++ b/packages/core/src/services/tokenEstimation.test.ts
@@ -0,0 +1,115 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { Content } from '@google/genai';
+import {
+ estimateContentTokens,
+ estimatePromptTokens,
+} from './tokenEstimation.js';
+
+const textContent = (text: string): Content => ({
+ role: 'user',
+ parts: [{ text }],
+});
+
+describe('estimateContentTokens', () => {
+ it('returns 0 for empty array', () => {
+ expect(estimateContentTokens([])).toBe(0);
+ });
+
+ it('estimates plain text at ~chars/4', () => {
+ // "hello world" = 11 chars → ceil(11/4) = 3
+ expect(estimateContentTokens([textContent('hello world')])).toBe(3);
+ });
+
+ it('sums tokens across multiple messages', () => {
+ const a = textContent('aaaa'); // 4/4 = 1
+ const b = textContent('bbbbbbbb'); // 8/4 = 2
+ expect(estimateContentTokens([a, b])).toBe(3);
+ });
+
+ it('estimates inlineData via imageTokenEstimate', () => {
+ const c: Content = {
+ role: 'user',
+ parts: [{ inlineData: { mimeType: 'image/png', data: 'xxx' } }],
+ };
+ // estimateContentChars uses imageTokenEstimate * TOKEN_TO_CHAR_RATIO (4)
+ // for inlineData, so estimateContentTokens divides back by 4 → 1600
+ expect(estimateContentTokens([c], 1600)).toBe(1600);
+ });
+
+ it('estimates functionCall (json-dense) contributes some positive count', () => {
+ const c: Content = {
+ role: 'model',
+ parts: [{ functionCall: { name: 'foo', args: { a: 1, b: 2 } } }],
+ };
+ const result = estimateContentTokens([c]);
+ expect(result).toBeGreaterThan(0);
+ });
+
+ it('estimates functionResponse (nested parts) contributes some positive count', () => {
+ // functionResponse takes a distinct branch in estimateContentChars
+ // (nested parts walk + json-stringify fallback). Tool-heavy
+ // conversations are where context grows fastest, so locking coverage
+ // here protects the trigger from undercounting. (review #4168 R3.5)
+ const c: Content = {
+ role: 'user',
+ parts: [
+ {
+ functionResponse: {
+ name: 'tool',
+ response: { result: 'data'.repeat(100) },
+ },
+ },
+ ],
+ };
+ const result = estimateContentTokens([c]);
+ expect(result).toBeGreaterThan(0);
+ });
+});
+
+describe('estimatePromptTokens', () => {
+ const history: Content[] = [
+ textContent('older message a'),
+ textContent('older message b'),
+ ];
+ const user = textContent('current user message');
+
+ it('uses lastPromptTokenCount + user-message estimate when count > 0', () => {
+ const userEst = estimateContentTokens([user]);
+ expect(estimatePromptTokens(history, user, 5000)).toBe(5000 + userEst);
+ });
+
+ it('falls back to full estimate when lastPromptTokenCount is 0', () => {
+ const fullEst = estimateContentTokens([...history, user]);
+ expect(estimatePromptTokens(history, user, 0)).toBe(fullEst);
+ });
+
+ it('adds lastCandidatesTokenCount in the steady-state branch (R10.1)', () => {
+ // R10.1: `lastPromptTokenCount` from the previous turn covers the
+ // input sent on that turn but NOT the model response that has since
+ // been appended to history. Without the candidates term, the
+ // estimate lags by one response (typically 500–5000 tokens), which
+ // matters when the hard tier sits only HARD_BUFFER (~3K) from the
+ // window edge — the rescue fires late and the API call overflows.
+ const userEst = estimateContentTokens([user]);
+ const lastPrompt = 5000;
+ const lastCandidates = 800;
+ expect(
+ estimatePromptTokens(history, user, lastPrompt, 1600, lastCandidates),
+ ).toBe(lastPrompt + lastCandidates + userEst);
+ });
+
+ it('defaults lastCandidatesTokenCount to 0 for backward-compatible callers', () => {
+ // The new param is optional with default 0 so existing callers
+ // (none-yet outside geminiChat) keep their pre-R10 behavior. The
+ // missing-response under-count is documented; the hard-rescue path
+ // upstream now plumbs the real value.
+ const userEst = estimateContentTokens([user]);
+ expect(estimatePromptTokens(history, user, 5000)).toBe(5000 + userEst);
+ });
+});
diff --git a/packages/core/src/services/tokenEstimation.ts b/packages/core/src/services/tokenEstimation.ts
new file mode 100644
index 00000000000..4c021c8b58a
--- /dev/null
+++ b/packages/core/src/services/tokenEstimation.ts
@@ -0,0 +1,98 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Content } from '@google/genai';
+import {
+ DEFAULT_IMAGE_TOKEN_ESTIMATE,
+ estimateContentChars,
+ TOKEN_TO_CHAR_RATIO,
+} from './compactionInputSlimming.js';
+
+/**
+ * Average characters-per-token for char-based token estimation. The inputs
+ * are character counts from `estimateContentChars` (i.e. `string.length`),
+ * not byte counts — for CJK / multi-byte text the byte/char ratio differs
+ * from 1, so a "bytes" name would mislead. (review #4168 R3.1)
+ *
+ * Re-exported from `compactionInputSlimming.ts`'s `TOKEN_TO_CHAR_RATIO`
+ * (the single declaration). Previously this file declared a duplicate
+ * `= 4` literal with the coupling enforced only by prose. If someone had
+ * changed one constant without the other, the splitter and the gate
+ * would disagree on content size — producing intermittent compression
+ * quality degradation extremely hard to trace. (R7.4)
+ */
+export const CHARS_PER_TOKEN = TOKEN_TO_CHAR_RATIO;
+
+/**
+ * Estimate the token count of a list of Content objects via char/4.
+ *
+ * Reuses `estimateContentChars` so that inlineData / functionCall /
+ * functionResponse get the same treatment they receive when computing
+ * compression split points — keeping the two estimators in sync prevents
+ * the auto-compaction trigger and the splitter from disagreeing on size.
+ *
+ * Intended for the pre-send threshold gate only. char/4 is a conservative
+ * lower bound (real tokenizers vary ±30%); using it to TRIGGER compaction
+ * earlier is safe (false-positive), using it to SKIP compaction is not.
+ */
+export function estimateContentTokens(
+ contents: Content[],
+ imageTokenEstimate: number = DEFAULT_IMAGE_TOKEN_ESTIMATE,
+): number {
+ let totalChars = 0;
+ for (const content of contents) {
+ totalChars += estimateContentChars(content, imageTokenEstimate);
+ }
+ return Math.ceil(totalChars / CHARS_PER_TOKEN);
+}
+
+/**
+ * Compute an effective prompt-token count for the auto-compaction gate.
+ *
+ * `lastPromptTokenCount` (from the previous turn's usage metadata) lacks
+ * three things: the current user message, the previous turn's MODEL
+ * RESPONSE that has since been appended to history, and any initial
+ * value on the very first send. This helper closes all three gaps via
+ * local estimation.
+ *
+ * R10.1: `lastCandidatesTokenCount` is the previous turn's
+ * `candidatesTokenCount` (model output) — captured alongside
+ * `lastPromptTokenCount` in the same usage-metadata handler. Without it
+ * the steady-state estimate lags by one response (typically 500–5000
+ * tokens) and the hard-tier rescue (which sits only HARD_BUFFER ≈ 3K
+ * from the window edge) fires late, costing a doomed API round-trip
+ * before reactive recovery catches the overflow.
+ *
+ * WARNING: like estimateContentTokens, this is a conservative lower
+ * bound. Use it to TRIGGER earlier, never to SKIP — the fallback path
+ * (lastPromptTokenCount === 0) returns a pure estimate with no API-
+ * authoritative anchor.
+ */
+export function estimatePromptTokens(
+ history: Content[],
+ userMessage: Content,
+ lastPromptTokenCount: number,
+ imageTokenEstimate: number = DEFAULT_IMAGE_TOKEN_ESTIMATE,
+ lastCandidatesTokenCount: number = 0,
+): number {
+ if (lastPromptTokenCount > 0) {
+ return (
+ lastPromptTokenCount +
+ lastCandidatesTokenCount +
+ estimateContentTokens([userMessage], imageTokenEstimate)
+ );
+ }
+ // First-send fallback (no API data yet): estimate from `history + userMessage`
+ // only. This MISSES the system prompt (~8-15K), tool definitions (~5K),
+ // skill content, and cache headers — typically ~15-20K of under-estimate.
+ // The reactive overflow handler is the safety net if the hard-tier rescue
+ // misses for that reason. See review #4168 R3.3.
+ //
+ // The cold-start branch does NOT add `lastCandidatesTokenCount` — by
+ // definition we have no prior API response when this branch runs, and
+ // any pre-existing model turns are walked via `history`.
+ return estimateContentTokens([...history, userMessage], imageTokenEstimate);
+}