diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8a6c6d5e..f0a8d650 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -157,15 +157,15 @@ Core compression behavior. #### `compress.maxContextLimit` - **Type:** `number | \`${number}%\`` -- **Default:** `"55%"` +- **Default:** `"80%"` - **Status:** ACTIVE -- **Description:** Upper context usage threshold (as % of model context window or absolute tokens). When exceeded, ACP nudges the model to compress. Example: `"55%"` or `100000`. +- **Description:** Upper context usage threshold (as % of model context window or absolute tokens). When exceeded, ACP nudges the model to compress. Example: `"80%"` or `100000`. #### `compress.minContextLimit` - **Type:** `number | \`${number}%\`` -- **Default:** `"45%"` +- **Default:** `"80%"` - **Status:** ACTIVE -- **Description:** Lower context usage threshold. ACP stops nudging when usage drops below this level. +- **Description:** Lower context usage threshold for turn/iteration reminder nudges. ACP stops injecting those reminders when usage drops below this level (or when the limit cannot be resolved to a concrete value, e.g. a `"X%"` limit with an unknown model context window). Growth nudges are governed separately by `minNudgeContextPercent`. #### `compress.modelMaxLimits` - **Type:** `Record` @@ -187,9 +187,15 @@ Core compression behavior. #### `compress.minNudgeContextPercent` - **Type:** `number` -- **Default:** `15` +- **Default:** `5` +- **Status:** ACTIVE +- **Description:** Floor for growth-triggered nudges, as a percentage of the model context window: a growth nudge requires context usage at or above this percentage (in addition to the growth threshold). Over-max (`maxContextLimit`) and the 98% emergency-override nudges bypass the floor. If the model context window is unknown, the floor is unresolvable and growth nudges fall back to growth-only behavior. Turn/iteration reminder nudges are governed by `minContextLimit`, not this field. The default is deliberately low: with the default `nudgeGrowthTokens` (50K), a 5% floor stays inert for typical working cycles and only binds on very large (≥2M-class) windows — a higher default (e.g. 15%) would bind on ≥400K windows and shift every compress cycle's working range upward on large-window models. Set `0` to disable the floor entirely, or raise it (e.g. 15–30%) to keep growth nudges waiting until a larger share of the window is in use. Override it per model with `modelMinNudgeLimits`. + +#### `compress.modelMinNudgeLimits` +- **Type:** `Record` +- **Default:** `undefined` - **Status:** ACTIVE -- **Description:** Minimum context usage percentage before any nudges are shown. Below this, no nudges are injected. +- **Description:** Per-model override for the growth-nudge floor. Keyed by `provider/model` (same keying as `modelMaxLimits` / `modelMinLimits`). Values are absolute tokens or `"X%"` of that model's context window. Precedence: `modelMinNudgeLimits[provider/model]` → `minNudgeContextPercent` × model context → growth-only behavior when the model context is unknown. An absolute value applies even when the model context window is unknown; a `"X%"` value with an unknown model context falls through to the global percent. Example: `{"openai/gpt-5.6": 150000, "openrouter/z-ai/glm-5.3": "20%"}` #### `compress.nudgeGrowthTokens` - **Type:** `number` @@ -442,6 +448,10 @@ Post-compression quality evaluation. Runs after each compression to verify summa "modelMinLimits": { "gpt-4o": 60000, "claude-3.5-sonnet": "50%" + }, + "modelMinNudgeLimits": { + "openai/gpt-5.6": 150000, + "openrouter/z-ai/glm-5.3": "20%" } } } diff --git a/CONFIGURATION.zh-CN.md b/CONFIGURATION.zh-CN.md index 3fa5ad9e..dbab36d2 100644 --- a/CONFIGURATION.zh-CN.md +++ b/CONFIGURATION.zh-CN.md @@ -165,7 +165,7 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): - **类型:** `number | \`${number}%\`` - **默认值:** `"45%"` - **状态:** ACTIVE -- **说明:** 上下文使用率下限。使用率降至此值以下时,ACP 停止 nudge。 +- **说明:** turn/iteration 提醒 nudge 的上下文使用率下限。使用率降至此值以下时(或该限制无法解析为具体值时,例如模型上下文窗口未知时的 `"X%"` 限制),ACP 停止注入这些提醒。Growth nudge 由 `minNudgeContextPercent` 单独控制。 #### `compress.modelMaxLimits` - **类型:** `Record` @@ -187,9 +187,15 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): #### `compress.minNudgeContextPercent` - **类型:** `number` -- **默认值:** `15` +- **默认值:** `5` - **状态:** ACTIVE -- **说明:** 触发任何 nudge 的最低上下文使用率百分比。低于此值时不注入 nudge。 +- **说明:** growth 触发 nudge 的下限,以模型上下文窗口的百分比表示:growth nudge 要求上下文使用率达到或超过该百分比(此外还需满足增长阈值)。超过上限(`maxContextLimit`)和 98% 紧急覆盖 nudge 不受此下限约束。若模型上下文窗口未知,则下限无法解析,growth nudge 回退到仅增长行为。turn/iteration 提醒 nudge 由 `minContextLimit` 控制,而非此字段。默认值刻意设低:在默认 `nudgeGrowthTokens`(50K)下,5% 下限在典型工作循环中不生效,仅在超大(≥2M 级)窗口上才会约束——更高的默认值(如 15%)会在 ≥400K 窗口上生效,并使大窗口模型每个压缩循环的工作区间上移。设为 `0` 可完全禁用下限;调高(如 15–30%)可让 growth nudge 等待更大的窗口占用比例。可用 `modelMinNudgeLimits` 按模型覆盖。 + +#### `compress.modelMinNudgeLimits` +- **类型:** `Record` +- **默认值:** `undefined` +- **状态:** ACTIVE +- **说明:** 按模型覆盖 growth-nudge 下限。以 `provider/model` 为键(与 `modelMaxLimits` / `modelMinLimits` 相同)。值为绝对 token 数或该模型上下文窗口的 `"X%"`。优先级:`modelMinNudgeLimits[provider/model]` → `minNudgeContextPercent` × 模型上下文 → 模型上下文未知时回退到仅增长行为。绝对值在模型上下文窗口未知时同样生效;`"X%"` 值在模型上下文未知时回退到全局百分比。示例:`{"openai/gpt-5.6": 150000, "openrouter/z-ai/glm-5.3": "20%"}` #### `compress.nudgeGrowthTokens` - **类型:** `number` @@ -442,6 +448,10 @@ ACP 从最多三层配置文件中读取(后加载的覆盖先加载的): "modelMinLimits": { "gpt-4o": 60000, "claude-3.5-sonnet": "50%" + }, + "modelMinNudgeLimits": { + "openai/gpt-5.6": 150000, + "openrouter/z-ai/glm-5.3": "20%" } } } diff --git a/README.md b/README.md index 6c98d06b..a81c3e2e 100644 --- a/README.md +++ b/README.md @@ -324,11 +324,12 @@ Each level overrides the previous, so project settings take priority over global // Soft upper threshold: above this, ACP keeps injecting strong // compression nudges (based on nudgeFrequency), so compression is // much more likely. Accepts: number or "X%" of model context window. - "maxContextLimit": "55%", - // Soft lower threshold for reminder nudges: below this, turn/iteration - // reminders are off (compression less likely). At/above this, reminders - // are on. Accepts: number or "X%" of model context window. - "minContextLimit": "45%", + "maxContextLimit": "80%", + // Soft lower threshold for turn/iteration reminder nudges: below this, + // those reminders are off (compression less likely). At/above this, they + // are on. Growth nudges have their own floor: minNudgeContextPercent. + // Accepts: number or "X%" of model context window. + "minContextLimit": "80%", // Optional per-model override for maxContextLimit by providerID/modelID. // If present, this wins over the global maxContextLimit. // Accepts: number or "X%". @@ -343,6 +344,14 @@ Each level overrides the previous, so project settings take priority over global // "openai/gpt-5.3-codex": 50000, // "anthropic/claude-sonnet-4.6": "25%" // }, + // Optional per-model override for the growth-nudge floor + // (minNudgeContextPercent). Keyed by providerID/modelID; accepts a + // token count or "X%" of that model's context window. If present, + // this wins over the global minNudgeContextPercent for that model. + // "modelMinNudgeLimits": { + // "openai/gpt-5.6": 150000, + // "openrouter/z-ai/glm-5.3": "20%" + // }, // How often the context-limit nudge fires (1 = every fetch, 5 = every 5th) "nudgeFrequency": 5, // Start adding compression reminders after this many diff --git a/README.zh-CN.md b/README.zh-CN.md index 20d853f5..28fdb55e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -279,9 +279,10 @@ ACP 使用自己的配置文件,按以下顺序搜索: // compression nudges (based on nudgeFrequency), so compression is // much more likely. Accepts: number or "X%" of model context window. "maxContextLimit": "55%", - // Soft lower threshold for reminder nudges: below this, turn/iteration - // reminders are off (compression less likely). At/above this, reminders - // are on. Accepts: number or "X%" of model context window. + // Soft lower threshold for turn/iteration reminder nudges: below this, + // those reminders are off (compression less likely). At/above this, they + // are on. Growth nudges have their own floor: minNudgeContextPercent. + // Accepts: number or "X%" of model context window. "minContextLimit": "45%", // Optional per-model override for maxContextLimit by providerID/modelID. // If present, this wins over the global maxContextLimit. @@ -297,6 +298,14 @@ ACP 使用自己的配置文件,按以下顺序搜索: // "openai/gpt-5.3-codex": 50000, // "anthropic/claude-sonnet-4.6": "25%" // }, + // Optional per-model override for the growth-nudge floor + // (minNudgeContextPercent). Keyed by providerID/modelID; accepts a + // token count or "X%" of that model's context window. If present, + // this wins over the global minNudgeContextPercent for that model. + // "modelMinNudgeLimits": { + // "openai/gpt-5.6": 150000, + // "openrouter/z-ai/glm-5.3": "20%" + // }, // How often the context-limit nudge fires (1 = every fetch, 5 = every 5th) "nudgeFrequency": 5, // Start adding compression reminders after this many diff --git a/dcp.schema.json b/dcp.schema.json index 5b6fd1db..a7775ae1 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -188,6 +188,21 @@ ] } }, + "modelMinNudgeLimits": { + "description": "Per-model override for the growth-nudge floor (minNudgeContextPercent) by exact provider/model key. Values are absolute tokens or \"X%\" of that model's context window. If set, this takes priority over the global minNudgeContextPercent for that model.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "number" + }, + { + "type": "string", + "pattern": "^\\d+(?:\\.\\d+)?%$" + } + ] + } + }, "nudgeFrequency": { "type": "number", "default": 5, @@ -229,9 +244,9 @@ }, "minNudgeContextPercent": { "type": "number", - "default": 15, + "default": 5, "minimum": 0, - "description": "Minimum context usage percent to show per-message nudges" + "description": "Minimum context usage percent for growth-triggered nudges; growth nudges below this floor are suppressed (0 disables the floor)" }, "maxSummaryLengthHard": { "type": "number", @@ -312,7 +327,7 @@ "protectedTools": ["skill", "compress"], "protectTags": false, "protectUserMessages": false, - "minNudgeContextPercent": 15, + "minNudgeContextPercent": 5, "maxSummaryLengthHard": 20000, "maxVisibleSegments": 50, "minCompressRange": 5000, diff --git a/devlog/2026-08-28_min-gate-growth-nudges/REQ.md b/devlog/2026-08-28_min-gate-growth-nudges/REQ.md new file mode 100644 index 00000000..d83819df --- /dev/null +++ b/devlog/2026-08-28_min-gate-growth-nudges/REQ.md @@ -0,0 +1,75 @@ +# REQ - Gate T1 growth nudges on the minNudgeContextPercent floor + +- Task ID: `2026-08-28_min-gate-growth-nudges` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: Done +- Priority: P1 +- Owner: ework-daemon (qwen3.8-27b) +- References: issue ranxianglei/opencode-acp#342 + +## 1. Background & Problem Statement + +- **Context**: ACP's T1 (message/range) compression nudges should not fire when the working context is too small to be worth compressing. The intended knob for that is `minNudgeContextPercent` (default **15**, a percent of the model context) — the "don't nudge below this" floor. It was plumbed into `computeShouldNudge` but **ignored** by the external trigger policy, so it was a no-op and growth nudges fired at any context size. (`minContextLimit` / `maxContextLimit` are a separate concern — the soft limit-reminder thresholds, both defaulting to **80%**.) +- **Current behavior (symptom)**: T1 *growth* nudges fire well below any configured floor. In the reporter's setup (400K window, `modelMinLimits=150000`, `modelMaxLimits=215000`, `nudgeGrowthTokens=50000`), ten `trigger=growth` nudges fired at 67K–152K tokens. Raising `nudgeGrowthTokens` is not a clean workaround because the same knob drives T2/T3 promotion. +- **Expected behavior**: + - T1 efficiency (growth) nudge = `currentTokens >= minNudgeContextPercent%×modelContext` AND `growth >= nudgeGrowthTokens` (AND `growth >= growthFloor`). + - T1 maximum nudge = `currentTokens >= maxContextLimit` (unchanged; bypasses the floor). + - Emergency (≥ `emergencyThresholdPercent`, default 98%) override unchanged. + - T2/T3 tier-promotion nudges remain independent of the floor (useful even when working context is small). +- **Impact**: users who tune the floor to defer compression get no such deferral for growth nudges; the model compresses early instead of at the configured floor, wasting compression cycles. + +## 2. Reproduction (if applicable) + +- **Environment**: opencode-acp 1.14.25, Node 22/24, 400K-context model. +- **Minimal reproduction steps**: + 1. Set `compress.minNudgeContextPercent` above the session's starting context (e.g. 37.5 on a 400K model = 150K floor). + 2. Let context grow by ≥ `nudgeGrowthTokens` while still below the floor. + 3. A `trigger=growth` nudge is injected despite `currentTokens < floor`. +- **Relevant configuration**: `compress.minNudgeContextPercent`, `compress.nudgeGrowthTokens`. + +## 3. Root cause + +- `computeShouldNudge` (external `context-compress-algorithms/trigger`) computes `shouldNudge = growthSinceLastNudge >= nudgeGrowthTokens || overMaxLimit`. `minNudgeContextPercent` is passed in but **ignored** (deprecated in the policy's param type). +- In `lib/messages/inject/inject.ts`, `nudgeAllowed = emergencyOverride || (decision.shouldNudge && growthSinceBaseline >= growthFloor)` — no context-size floor at all. So growth nudges fire at any context size once the growth threshold is met. +- **Why not `minContextLimit`**: the first revision gated on `overMinLimit`, but `minContextLimit` defaults to **80%** (`lib/config.ts:200`) and is documented as the "soft lower threshold for turn/iteration reminders" (README.md:328-331). Gating growth nudges on it would suppress ALL growth nudges below 80% for default users — effectively disabling compression for most of a session (flagged by @dog in issue #342). The correct floor is the low-default `minNudgeContextPercent` (15%). + +## 4. Constraints & Non-Goals + +- **Constraints**: + - The floor must default to a LOW value (`minNudgeContextPercent` = 15%) so default users still get growth nudges throughout a session. + - When the model context limit is unknown, the floor is unresolvable and growth nudges keep pre-#342 growth-only behavior (no accidental suppression). + - `overMaxLimit` and the emergency path must be unaffected (they bypass/override the floor). + - T2/T3 tier-promotion nudges must remain independent of the floor. + - No new dependencies; no change to the external `context-compress-algorithms` package (fix lives in ACP's `inject.ts`). + - No change to persisted state format or internal `dcp` tags. +- **Non-Goals** (out of scope): + - Percentage-based `nudgeGrowthTokens` (issue #300). + - The broader T1/T2/T3 decision-chain refactor (issue #300). + - A per-model `modelMinNudgeContextPercent` variant (the floor is a global percent for now; per-model is a follow-up enhancement). + - Making the max-limit path bypass the `growthFloor` cadence gate (pre-existing, documented anti-thrashing behavior — left as-is per issue #342 discussion). + +## 5. Acceptance Criteria (must be testable) + +- **Correctness**: + - [x] A T1 growth nudge does NOT fire when `currentTokens < minNudgeContextPercent%×modelContext` (even if `growth >= nudgeGrowthTokens` and `>= growthFloor`). + - [x] A T1 growth nudge DOES fire when `currentTokens >= floor` and `growth >= nudgeGrowthTokens` and `>= growthFloor`. + - [x] A T1 maximum nudge still fires when `currentTokens >= maxContextLimit` (bypasses the floor). + - [x] The emergency (≥98%) override still fires regardless of the floor. + - [x] When the model context limit is unknown, growth nudges keep pre-#342 behavior (floor unresolvable → no suppression). + - [x] T2/T3 tier-promotion nudges are unaffected by the floor. +- **Performance / Stability**: one floor check in the existing decision path — no measurable cost. +- **Regression**: + - [x] New/modified test cases added to test suite and passing (1035 tests, 0 failures). + - [x] Pre-existing test #27 (context 100K on a 1M model) updated into the [floor, max) range since the dormant 15% floor now suppresses below 150K. + - [x] Stale README/CONFIGURATION default docs corrected (45%/55% → 80%/80%). + +## 6. Proposed Approach + +- **Affected modules & entry files**: + - `lib/messages/inject/inject.ts` — compute `overMinNudgeFloor` from `minNudgeContextPercent` and add `(overMaxLimit || overMinNudgeFloor)` to the `nudgeAllowed` growth path. + - `lib/messages/inject/utils.ts` — revert the `minLimitResolved` addition (dead code in the floor approach). + - `tests/inject.test.ts` — 4 floor tests + 2 new (unresolvable model limit, T2 independence); fix pre-existing test #27. + - `README.md`, `CONFIGURATION.md` — default corrections (from the review commit, preserved). +- **Risks**: activating a previously-dormant field changes default behavior (growth nudges below 15% are now suppressed). Verified against the full suite; the only pre-existing test affected was #27 (100K context on a 1M model). +- **Rollback strategy**: revert the commits; no schema/config/data migrations. diff --git a/devlog/2026-08-28_min-gate-growth-nudges/WORKLOG.md b/devlog/2026-08-28_min-gate-growth-nudges/WORKLOG.md new file mode 100644 index 00000000..fc7a5278 --- /dev/null +++ b/devlog/2026-08-28_min-gate-growth-nudges/WORKLOG.md @@ -0,0 +1,123 @@ +# WORKLOG - Gate T1 growth nudges on the minNudgeContextPercent floor + +- Task ID: `2026-08-28_min-gate-growth-nudges` +- Home Repo: `opencode-acp` +- Status: Done +- Updated: 2026-08-28 + +> **2026-08-29 amendment**: the default floor was lowered **15% → 5%** (commit `f3a3fc8`, maintainer decision). See §7. + +## 1. Summary + +- **What was done**: added a growth-nudge floor to the T1 decision in `lib/messages/inject/inject.ts`. A growth nudge now requires the context to be at/above the **`minNudgeContextPercent` floor** (default 15% of the model context). `overMaxLimit` and the emergency override bypass the floor; when the model context limit is unknown the floor is unresolvable and growth nudges keep their pre-#342 growth-only behavior. T2/T3 tier-promotion nudges are untouched. +- **Why**: `computeShouldNudge()` (external `context-compress-algorithms/trigger`) only uses `overMinLimit` to pick the tips variant, so growth nudges fired well below any configured floor (issue #342: ten `trigger=growth` nudges at 67K–152K against a 150K minimum). `minNudgeContextPercent` (default 15) is the intended "don't nudge below this" knob and was previously a **no-op** (plumbed into `computeShouldNudge` but ignored by the policy). +- **Revision history (important)**: + 1. `0f35414` — first fix gated on `minContextLimit`. + 2. `f634222` (dual-agent review) — refined to only gate when `minContextLimit` resolves (`minLimitResolved`), and corrected the stale README/CONFIGURATION defaults (45%/55% → 80%/80%, matching `lib/config.ts` since v1.14.16). **Those README/CONFIGURATION fixes are preserved.** + 3. This commit — @dog flagged in issue #342 that `minContextLimit` defaults to **80%**, so gating growth nudges on it suppresses ALL growth nudges below 80% for default users (effectively disabling compression for most of a session). The floor was corrected to `minNudgeContextPercent` (15% default), low enough that default users still get compression throughout a session. The `minLimitResolved` mechanism was dropped (my approach uses `modelContextLimit` directly). +- **Behavior / compatibility changes**: YES — growth nudges below the `minNudgeContextPercent` floor are now suppressed. This activates a previously-dormant field. No change to persisted state format or internal `dcp` tags. +- **Risk level**: Low — one floor check in an existing decision path; max/emergency paths and T2/T3 are unaffected. + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| `0f35414` | fix: gate T1 growth nudges on minContextLimit (issue #342) — **superseded** | +| `f634222` | dual-agent review: `minLimitResolved` guard + README/CONFIGURATION default fixes — **README/CONFIGURATION fixes preserved** | +| `ecfe7f0` | fix: use `minNudgeContextPercent` (15%) as the growth-nudge floor, not `minContextLimit` (80% default) | +| `b71e10b` | docs: align README/CONFIGURATION/utils comment/test header with the floor redesign (re-pointed stale minContextLimit-gate references) | + +### Key Files + +- `lib/messages/inject/inject.ts` — `nudgeAllowed` now includes `(overMaxLimit || overMinNudgeFloor)` in the growth path, where `overMinNudgeFloor` is derived from `minNudgeContextPercent`. +- `lib/messages/inject/utils.ts` — reverted the `minLimitResolved` addition (dead code in the floor approach). +- `tests/inject.test.ts` — 4 floor tests (use `minNudgeContextPercent`); 2 new tests (unresolvable model limit, T2 independence); pre-existing test #27 raised into the [floor, max) range. +- `README.md`, `CONFIGURATION.md` — default corrections from the review commit (preserved); descriptions re-pointed to the floor redesign (`minContextLimit` = turn/iteration reminders only, `minNudgeContextPercent` = growth-nudge floor). +- `lib/messages/inject/utils.ts` — `@deprecated` comment on `minNudgeContextPercent` corrected (field is active; floor computed in `inject.ts`). +- `tests/inject.test.ts` — section header re-pointed from "minContextLimit gate" to "minNudgeContextPercent floor". + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `injectCompressNudges()` in `lib/messages/inject/inject.ts`. +- **The change** (inject.ts, `nudgeAllowed`): + ```ts + const minNudgeFloorTokens = + modelContextLimit !== undefined + ? Math.round(((config.compress?.minNudgeContextPercent ?? 15) / 100) * modelContextLimit) + : undefined + const overMinNudgeFloor = + minNudgeFloorTokens === undefined || + currentTokens === undefined || + currentTokens >= minNudgeFloorTokens + const nudgeAllowed = + emergencyOverride || + (decision.shouldNudge && + (overMaxLimit || overMinNudgeFloor) && // issue #342: growth floor + growthSinceBaseline !== undefined && + growthSinceBaseline >= growthFloor) + ``` +- **Why `minNudgeContextPercent`, NOT `minContextLimit`**: + - `minContextLimit` / `maxContextLimit` both default to **80%** (`lib/config.ts:199-200`). The README documents `minContextLimit` as the "soft lower threshold for **turn/iteration reminders**" (README.md:328-331) — not a growth-nudge floor. Using it as a growth floor would disable compression below 80% for default users. + - `minNudgeContextPercent` (default **15**, `lib/config.ts:202`) is a percent-of-model-context floor, low enough that default users still get growth nudges throughout a session, while remaining configurable (e.g. set to 37.5 for a 150K floor on a 400K model). It was already plumbed into `computeShouldNudge` (inject.ts:297) but ignored by the external policy — so it was the natural, intended field. + - `overMaxLimit ||` keeps the strong max-limit alert working even when the floor is set above the context (defensive against misconfiguration). +- **Unresolvable floor**: when `modelContextLimit` is unknown (a model that doesn't report `limit.context`), the floor can't be computed, so `overMinNudgeFloor` stays `true` (no gate) — preserving pre-#342 growth-only behavior. This is the same intent as the review commit's `minLimitResolved` guard, implemented directly. +- **Why the fix lives in ACP, not the external package**: `context-compress-algorithms` is a shared, version-pinned dependency; the floor is ACP-specific policy (ACP owns `minNudgeContextPercent` semantics). No dependency bump. +- **Caveat**: `minNudgeContextPercent` is a global percent (no per-model `modelMinNudgeContextPercent` variant). A user who set a per-model `modelMinLimits` (like the #342 reporter) expresses the floor as a global percent instead; a per-model variant is a follow-up enhancement. +- **T2/T3 independence**: the tier-promotion nudges (inject.ts, `tierChecks` loop) use their own `nudgeGrowthTokens`/`growthFloor` cadence and never consult the floor — preserved (locked by a dedicated test). + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +cd opencode-acp && npm install +npm run build +npm run typecheck +node --import tsx --test tests/*.test.ts +``` + +### Results + +- `npm run typecheck`: clean. +- `npm run build`: success; floor logic confirmed present in `dist/index.js` (`minNudgeFloorTokens`/`overMinNudgeFloor`), old `minGateOpen`/`overMaxLimit || overMinLimit` removed. +- `npm run test`: **1035 tests, 0 failures**. + +### New / adjusted tests (tests/inject.test.ts) + +| Test | Asserts | +|------|---------| +| `issue #342: growth nudge suppressed below the minNudgeContextPercent floor, fires once context crosses it` | Multi-turn: `minNudgeContextPercent=30` (300K floor) — 200K < 300K → suppressed (baseline + lastNudgeShownTokens preserved); 320K ≥ 300K + growth → fires. | +| `issue #342: full growth cycle baseline → nudge → compress → new baseline → nudge (floor open)` | Full cycle with the floor open; baseline resets on compress, nudge re-fires after new-baseline growth. | +| `issue #342: growth floor holds in production config (preserveRecentMessages > 0)` | §5.7.1 production-config requirement: floor suppresses below it with `preserveRecentMessages: 2` and compressible content present. | +| `issue #342: over-max nudge bypasses the growth floor` | Defensive: floor set above the context — over-max context still nudges (overMaxLimit bypass). | +| `issue #342: growth nudge still fires when the model context limit is unknown (floor unresolvable)` | Unknown model limit → floor unresolvable → pre-#342 growth-only behavior preserved (nudge fires). (Review-commit regression lock, re-pointed at the floor.) | +| `issue #342: T2 tier-promotion fires below the growth floor (independent of the floor)` | Floor set at 800K — T1 floor-suppressed, T2 fires on its own cadence (lastTier2NudgeTokens set). (Review-commit test, re-pointed at the floor.) | + +### Existing tests updated + +- `stale contextLimitAnchors ... (issue #27)` — context raised from 100K (10% of 1M) to 150K so it sits in the [15% floor, max-limit) range; the previously-dormant 15% floor now suppresses below 150K, so the original 100K context no longer satisfies the floor. + +### Note on `npm run format:check` + +Pre-existing repo-wide Prettier drift: the installed Prettier (3.9.5) reformats 412 files (including unmodified `lib/config.ts`, `lib/hooks.ts`). CI does not enforce format. New code matches the file's existing style (4-space, no-semi, double-quote); not reformatting to avoid a noisy repo-wide diff. + +## 5. Rollback Plan + +- Revert the commits; no schema/config/data migrations. + +## 6. Lessons Learned + +- **`minContextLimit` defaults to 80%, not 45%.** The README's `45%` example was stale (the real default is `"80%"` in `lib/config.ts:200` since v1.14.16). Never assume a documented example value is the default — check `config.ts`. The review commit corrected the docs; this commit corrects the code to match the intent. +- The first revision (gate on `minContextLimit`) was a **silent regression for default users**: it would have suppressed all growth nudges below 80%. The pre-existing test #27 (context 100K on a 1M model) only caught it because 100K < 150K floor — a test with context between 150K and 800K would have masked the regression. Always check the **default** config path, not just the configured path. +- `minNudgeContextPercent` was a dormant no-op field — the natural home for the growth floor. Activating a dormant field is lower-risk than repurposing a live one (`minContextLimit` also drives turn/iteration anchors). +- A dual-agent review pushed refinements onto the branch based on the original (minContextLimit) approach; the @dog feedback superseded them. Integrated the review's valuable non-conflicting work (README/CONFIGURATION fixes, the unresolvable-limit + T2-independence tests) while replacing the core gate. + +## 7. Amendment: default floor lowered 15% → 5% (maintainer decision) + +- **Commit**: `f3a3fc8` — `fix: lower default minNudgeContextPercent floor 15% -> 5%` +- **Why**: the 15% default was a silent, bug-level behavior change for the dominant user profile — **large-window models (e.g. 1M context)** with small baselines. Floor mechanics: the floor binds when `P% × W > baseline + 50K` (growth threshold). At 15% that binds on windows ≥ ~400–667K; on a 1M window every compress cycle is suppressed until 150K context usage. The typical working cycle (baseline 10–50K → grow +50K → nudge → max ~110K → compress → land 20–50K → repeat) never reaches 150K, so the nudge — and therefore compression — is starved and the working range shifts to [~40K, 150–200K], ~2× steady-state input tokens. Since compress resets `lastPerMessageNudgeTokens` to the post-compress size, the floor re-binds **every** cycle, not just the first. +- **Why 5%**: with the fixed 50K growth threshold, a 5% floor only binds when `5% × W > baseline + 50K`, i.e. windows ≥ ~1.2–2M for typical baselines — inert for essentially all real working cycles while still catching pathological tiny-window thrash via `minimum: 0` users opting out. Escape hatches: set it higher explicitly (15–30%) to wait for larger usage; `0` disables the floor entirely. +- **Files**: `lib/config.ts` (default 15→5), `lib/messages/inject/inject.ts` (policy passthrough + floor fallback `?? 5`, rationale comment), `dcp.schema.json` (default + stale description fixed), `CONFIGURATION.md`/`CONFIGURATION.zh-CN.md` (defaults + rationale), `tests/inject.test.ts` (2 comments re-pinned to the buildConfig factory value; new default-lock test). +- **Test**: `issue #342 follow-up: unset minNudgeContextPercent falls back to the low 5% default floor` — deletes the field from the config (fallback path), 1M window, baseline 50K, current 100K → asserts the nudge fires (100K ≥ 50K floor + 50K growth). Mutation-sensitive: reverting the fallback to `?? 15` puts the floor at 150K → suppressed → test fails (verified locally: 54/55 with mutation, 55/55 after restore; full suite 1036/1036, typecheck + build clean). diff --git a/devlog/2026-08-28_model-min-nudge-limits/REQ.md b/devlog/2026-08-28_model-min-nudge-limits/REQ.md new file mode 100644 index 00000000..0c764c5f --- /dev/null +++ b/devlog/2026-08-28_model-min-nudge-limits/REQ.md @@ -0,0 +1,77 @@ +# REQ - Per-model growth-nudge floor (modelMinNudgeLimits) for mixed-context installs + +- Task ID: `2026-08-28_model-min-nudge-limits` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: Done +- Priority: P2 +- Owner: ework-daemon (qwen3.8-27b) +- References: issue ranxianglei/opencode-acp#344, PR #343 (branch `2026-08-28_min-gate-growth-nudges`), issue #342 + +## 1. Background & Problem Statement + +- **Context**: PR #343 makes the T1 growth-nudge floor the **global** `minNudgeContextPercent` (default 15% of the model context window). Mixed-model installations cannot express per-model floors with a single global percent: + + | Model | Advertised window | Intended floor | Floor at global 37.5% | Floor at default 15% | + |-------|------------------|----------------|----------------------|---------------------| + | OpenAI GPT-5.6 (native) | 400,000 | 150,000 | 150,000 ✓ | 60,000 (reproduces #342 early nudges) | + | `openrouter/z-ai/glm-5.3` | 1,048,576 (OpenRouter API) | 200,000 | 393,216 (≈2× intended) | 157,286 | + + No single global value satisfies both: 37.5% delays the GLM floor to nearly twice the intended value; 15% puts native OpenAI back at 60K. +- **Current behavior (symptom)**: a single global `minNudgeContextPercent` cannot express per-model floors; users must pick a compromise that mis-tunes at least one model. +- **Expected behavior**: + - New optional config `compress.modelMinNudgeLimits: Record` keyed by `provider/model` (same keying as `modelMaxLimits` / `modelMinLimits`). + - Values may be absolute tokens or `"X%"` of that model's context window. + - Precedence: + 1. `modelMinNudgeLimits[provider/model]` + 2. `minNudgeContextPercent` × model context + 3. existing growth-only behavior when model context is unknown + - The existing `modelMinLimits` keeps its current turn/iteration-reminder meaning (unchanged). +- **Impact**: mixed-model installs (e.g. native OpenAI 400K + OpenRouter GLM 1M) can set exact token or percentage floors per model instead of a single global compromise. + +## 2. Reproduction (if applicable) + +- **Environment**: opencode-acp with PR #343 applied, Node 22/24, a session alternating between two models with different advertised context windows. +- **Minimal reproduction steps**: + 1. Use a 400K-window model and a 1M-window model in one installation. + 2. Set `minNudgeContextPercent` to 37.5 (floor 150K on the 400K model). + 3. On the 1M model the floor becomes 393K — nearly 2× the intended 200K. +- **Relevant configuration**: `compress.minNudgeContextPercent`, `compress.modelMinNudgeLimits` (new). + +## 3. Root cause + +- The floor computation in `injectCompressNudges` (`minNudgeFloorTokens` / `overMinNudgeFloor`, `lib/messages/inject/inject.ts`) only consults the global `minNudgeContextPercent`; there is no per-model override hook. The existing `modelMaxLimits` / `modelMinLimits` per-model pattern (`resolveContextTokenLimit` in `lib/messages/inject/utils.ts`) is not applied to the nudge floor. + +## 4. Constraints & Non-Goals + +- **Constraints**: + - Follow the existing `modelMinLimits` pattern exactly: same keying (`provider/model`), same value type (`number | `${number}%``), same validation, same merge/clone treatment. + - Precedence order as proposed in the issue: per-model entry → global percent → growth-only fallback when model context is unknown. + - An absolute per-model token floor must work even when the model context window is unknown (mirrors `modelMaxLimits`/`modelMinLimits` behavior); a per-model percent with unknown model context falls through to the global percent (also unresolvable → growth-only). + - `modelMinLimits` semantics are unchanged (turn/iteration reminders). + - `overMaxLimit` and the emergency override still bypass the floor; T2/T3 tier-promotion nudges remain independent. + - No new dependencies; no change to persisted state format or internal `dcp` tags. + - No `version` bump in `package.json` (feature branch; release handled separately). +- **Non-Goals** (out of scope): + - Merging PR #343 (this PR is based on it and lands after it). + - Per-model `nudgeGrowthTokens` or growth-ratio overrides. + - Wildcard/prefix model keys (exact `provider/model` keys only, like the existing model limits). + +## 5. Acceptance Criteria (must be testable) + +- **Correctness**: + - [ ] A per-model absolute floor in `modelMinNudgeLimits` wins over the global `minNudgeContextPercent` for that model (nudge suppressed below the per-model floor, fires at/above it). + - [ ] A per-model percent floor resolves against the per-model context window (e.g. `"25%"` of 1M = 250K). + - [ ] Models without a per-model entry keep using the global `minNudgeContextPercent` floor. + - [ ] Unknown model context: global floor unresolvable → growth-only behavior unchanged; per-model absolute floor still applies; per-model percent unresolvable → falls through to global (growth-only). + - [ ] `modelMinLimits` behavior is unchanged (no cross-interference). + - [ ] `overMaxLimit` bypass and the emergency override are unaffected by per-model floors. +- **Config plumbing**: + - [ ] `compress.modelMinNudgeLimits` is a valid config key (no "unknown key" warning) and is type-validated like `modelMinLimits` (object of `number | "X%"`). + - [ ] Three-layer merge: an explicit per-layer record replaces the inherited one (same `??` semantics as `modelMinLimits`). + - [ ] `dcp.schema.json` documents the new property. +- **Docs**: + - [ ] `CONFIGURATION.md` and `README.md` document `modelMinNudgeLimits`. +- **Tests**: + - [ ] New tests in the `issue #344:` block of `tests/inject.test.ts` covering all correctness criteria above, including a multi-turn cycle with side-effect assertions on `lastPerMessageNudgeTokens` / `lastNudgeShownTokens` and a production-config (`preserveRecentMessages > 0`) variant. + - [ ] `tests/config-validation.test.ts` covers the new key (non-recursion + type validation). diff --git a/devlog/2026-08-28_model-min-nudge-limits/WORKLOG.md b/devlog/2026-08-28_model-min-nudge-limits/WORKLOG.md new file mode 100644 index 00000000..409a6b04 --- /dev/null +++ b/devlog/2026-08-28_model-min-nudge-limits/WORKLOG.md @@ -0,0 +1,99 @@ +# WORKLOG - Per-model growth-nudge floor (modelMinNudgeLimits) for mixed-context installs + +- Task ID: `2026-08-28_model-min-nudge-limits` +- Home Repo: `opencode-acp` +- Status: Done +- Updated: 2026-08-28 + +> **2026-08-29 amendment**: the global default floor was lowered **15% → 5%** on the #343 base branch and merged into this stack (commit `1566c23`). See §8. + +## 1. Summary + +- **What was done**: added `compress.modelMinNudgeLimits: Record` — an optional per-model override for the T1 growth-nudge floor introduced by PR #343 (issue #342). Keyed by `provider/model` (same keying as `modelMaxLimits` / `modelMinLimits`); values are absolute tokens or `"X%"` of that model's context window. Floor resolution extracted into `resolveMinNudgeFloorTokens()` in `lib/messages/inject/utils.ts` with the precedence: (1) per-model entry, (2) global `minNudgeContextPercent` × model context, (3) `undefined` (growth-only) when the model context is unknown. Full config plumbing (type, validation, merge, deep-clone, JSON schema) follows the `modelMinLimits` pattern exactly. +- **Why**: a single global `minNudgeContextPercent` cannot express per-model floors for mixed-model installs — e.g. a 37.5% global floor gives 150K on a 400K-window model (intended) but 393K on a 1M-window model (≈2× the intended 200K), while 15% gives 60K on the 400K model (reproducing the #342 early-nudge symptom). Per-model floors let each model carry its own exact token or percentage floor. +- **Behavior / compatibility changes**: NO for existing setups — when `modelMinNudgeLimits` is unset (default), the floor computation is byte-for-byte the PR #343 global computation. Only installs that opt in to the new field change behavior. No change to persisted state format or internal `dcp` tags; `modelMinLimits` semantics untouched. +- **Risk level**: Low — one extracted helper on an existing decision path; global fallback path unchanged; max/emergency bypasses and T2/T3 untouched. + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| `50a117e` | feat: per-model growth-nudge floor `modelMinNudgeLimits` (issue #344) | +| `4c9d772` | docs: zh-CN doc parity for `modelMinNudgeLimits` (dual-agent review follow-up) | +| `940f4ca` | docs: restore #343 floor-redesign wording that the stack reverted (EN+ZH docs, test header, utils comment, #343 devlog) | + +### Key Files + +- `lib/messages/inject/utils.ts` — new exported `resolveMinNudgeFloorTokens(config, modelContextLimit, providerId, modelId)`: per-model absolute tokens apply even with unknown model context (mirrors `modelMaxLimits`); per-model `"X%"` requires the model context (clamped 0–100, rounded) and falls through to the global percent when unresolvable; global path is the PR #343 computation verbatim. +- `lib/messages/inject/inject.ts` — `minNudgeFloorTokens` now comes from `resolveMinNudgeFloorTokens(config, modelContextLimit, providerId, modelId)` (provider/model from the existing `getModelInfo(messages)` call); `overMinNudgeFloor` / `nudgeAllowed` logic unchanged. +- `lib/config.ts` — `CompressConfig.modelMinNudgeLimits` field; `mergeCompress` (`??` replace-inherited semantics, same as `modelMinLimits`); `deepCloneConfig` shallow-copies the record. +- `lib/config-validation.ts` — `compress.modelMinNudgeLimits` in `VALID_CONFIG_KEYS`; excluded from key recursion in `getConfigKeyPaths` (dynamic keys); validated by the existing `validateModelLimits` (object of `number | "X%"`, per-entry error at `compress.modelMinNudgeLimits.`). +- `dcp.schema.json` — `modelMinNudgeLimits` property (object, additionalProperties `number | "X%"` pattern). +- `CONFIGURATION.md` — new `#### compress.modelMinNudgeLimits` section (type/default/precedence/example); `minNudgeContextPercent` section cross-references it; per-model context limits example extended. +- `README.md` — commented `modelMinNudgeLimits` example after the `modelMinLimits` example. +- `CONFIGURATION.zh-CN.md` — zh-CN parity (added in the review follow-up): new `#### compress.modelMinNudgeLimits` section, `minNudgeContextPercent` cross-reference, per-model limits example extended. +- `README.zh-CN.md` — commented `modelMinNudgeLimits` example after the `modelMinLimits` example. +- `tests/inject.test.ts` — `userMsgWithModel()` helper (sets `info.model` so `getModelInfo` resolves provider/model) + 9-test `issue #344:` block. +- `tests/config-validation.test.ts` — non-recursion test + 3 `validateConfigTypes` tests (valid entries, invalid entry, non-object). + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `resolveMinNudgeFloorTokens()` in `lib/messages/inject/utils.ts`, called from `injectCompressNudges()` in `lib/messages/inject/inject.ts`. +- **The change** (utils.ts): + ```ts + export function resolveMinNudgeFloorTokens( + config: PluginConfig, + modelContextLimit: number | undefined, + providerId: string | undefined, + modelId: string | undefined, + ): number | undefined { + const parseModelLimit = (limit: number | `${number}%`): number | undefined => { + if (typeof limit === "number") return limit + if (modelContextLimit === undefined) return undefined + const parsedPercent = parseFloat(limit.slice(0, -1)) + if (isNaN(parsedPercent)) return undefined + const clampedPercent = Math.max(0, Math.min(100, Math.round(parsedPercent))) + return Math.round((clampedPercent / 100) * modelContextLimit) + } + const modelLimits = config.compress.modelMinNudgeLimits + if (modelLimits && providerId !== undefined && modelId !== undefined) { + const modelLimit = modelLimits[`${providerId}/${modelId}`] + if (modelLimit !== undefined) { + const resolved = parseModelLimit(modelLimit) + if (resolved !== undefined) return resolved + // Per-model percent with unknown model context: fall through to the global percent. + } + } + if (modelContextLimit === undefined) return undefined + return Math.round(((config.compress.minNudgeContextPercent ?? 15) / 100) * modelContextLimit) + } + ``` +- **Precedence decisions**: + - Per-model **absolute** tokens apply even when the model context window is unknown — mirrors `modelMaxLimits` / `modelMinLimits` (absolute limits never needed the window). This is the only way a mixed install can pin a floor for a model that doesn't report `limit.context`. + - Per-model **percent** with unknown model context falls through to the global percent — which is also unresolvable without the window — so the floor stays open (growth-only), preserving the PR #343 fallback. + - Global fallback is the PR #343 computation verbatim (`Math.round(((minNudgeContextPercent ?? 15) / 100) * modelContextLimit)`), so unset `modelMinNudgeLimits` is behavior-identical to PR #343. +- **Why a helper instead of inlining in inject.ts**: the percent-parse/clamp logic duplicates `parseLimitValue` in `resolveContextTokenLimit`; a named exported helper keeps `injectCompressNudges` readable and makes the precedence unit-testable. (A full merge of the two resolvers was considered and rejected — the nudge floor has different fallback semantics: nudge floor is open by default when unresolvable, context limits are closed.) +- **Merge semantics**: explicit per-layer record replaces the inherited one (`override.modelMinNudgeLimits ?? base.modelMinNudgeLimits`) — identical to `modelMaxLimits` / `modelMinLimits`; no per-key deep merge (consistent with existing model limits). +- **Tests** (`tests/inject.test.ts`, `issue #344:` block, 9 tests): + 1. Per-model absolute floor (150K) wins over global 15% (60K) on a 400K window — multi-turn: suppressed at 100K (asserts `shouldInjectThisTurn` + `lastNudgeShownTokens` + `lastPerMessageNudgeTokens` baseline preserved), fires at 150K with baseline unchanged. + 2. Per-model `"20%"` resolves against the per-model 1,048,576 window → 209,715; suppressed at 180K (which passes the 157,286 global floor), fires at 250K. + 3. Model without a per-model entry keeps the global floor (30% of 1M = 300K suppresses 200K on an unlisted `anthropic/claude-sonnet-4.6`). + 4. Per-model absolute floor applies with unknown model context (150K floor suppresses 100K, fires at 180K). + 5. Per-model percent with unknown model context falls through to growth-only (fires at 100K). + 6. Production config (`preserveRecentMessages: 2`): per-model 300K floor suppresses 200K where the 150K global floor would have fired. + 7. Full growth cycle with a per-model floor: baseline 150K → nudge at 250K → compress resets baseline to 180K → nudge at 230K (self-reset verified via `lastPerMessageNudgeTokens` / `lastNudgeShownTokens` each turn). + 8. `overMaxLimit` bypasses the per-model floor (550K > 500K max fires despite an 800K floor). + 9. `modelMinLimits` independence: a 900K per-model min limit does not lower turn/iteration reminders (`turnNudgeAnchors` empty at 200K) while the 100K nudge floor still gates the growth path. + - **Mutation check (AGENTS.md §5.7)**: with the per-model branch temporarily disabled (`if (false && ...)`), tests 1, 2, 4, 6 fail and the fallback/bypass/independence tests (3, 5, 7, 8, 9) still pass — exactly the expected split. Re-applied the fix; all green. + - Config plumbing tests: `tests/config-validation.test.ts` — non-recursion into dynamic keys; valid entries (`150000`, `"20%"`) accepted; invalid entry (`"lots"`) rejected at `compress.modelMinNudgeLimits.openai/gpt-5.6`; non-object record rejected. +- **Formatting note**: the repo is not prettier-clean under the installed prettier (3.9.5) or the pinned minimum (3.8.1) — identical churn on untouched HEAD files (e.g. the 120-char import at `lib/config.ts:6` exists on `origin/master`, and both CONFIGURATION docs carry ~69 pre-existing prettier hunks), and CI does not run `format:check`. All **added** lines in `lib/`, `tests/`, and `dcp.schema.json` were verified prettier-clean (hunk-overlap check per file); pre-existing churn was left untouched to keep the diff reviewable. The JSONC examples in the four docs keep the file's existing no-trailing-comma style for internal consistency (only a comma was added where the edit made a previously-last property non-last, which JSONC validity requires). +- **Dual-agent review** (AGENTS.md §5.3/§5.6, two independent agents on `ecfe7f0..50a117e`): both returned NO BLOCKERS. Code review verified the full precedence contract (5/5), edge cases (`""` rejected, `"0%"` → floor 0, `"150%"` clamped to 100, undefined provider guarded, mid-session model switch, `deepCloneConfig` on undefined record), and independently reproduced the mutation-check split. Test review verified §5.6 (imports, name fidelity, config completeness, token math) and §5.7 (multi-turn, side-effect assertions, production config, growth cycle) all PASS. One should-fix — missing zh-CN doc parity — was applied in the follow-up commit above. Nits not taken: negative-number validation gap (pre-existing, shared with `modelMaxLimits`/`modelMinLimits`), `userMsgWithModel` duplicating `userMsg` (cosmetic; the existing helper is shared with older tests). +- **Verification**: `npm run typecheck` ✓, `npm run build` ✓, `npm run test` ✓ (1048 pass / 0 fail, including 9 new inject tests + 4 new config-validation tests), `./scripts/ci/check-pr.sh` ✓ (branch name, devlog, version unchanged). + +## 8. Amendment: merged the 5% default-floor fix from the #343 base branch + +- **Commit**: `1566c23` — `merge: pull in the 5% default-floor fix from the #343 base branch` +- **What**: the base branch (`2026-08-28_min-gate-growth-nudges`, commits `f3a3fc8`+`2419871`) lowered the global default `minNudgeContextPercent` 15% → 5% (maintainer decision — a 15% floor binds on ≥400K windows and shifts large-window users' compress cycles ~2×; see the base branch devlog §7). This stack consumed the change via merge and aligned its own copy of the fallback: `resolveMinNudgeFloorTokens()`'s global-percent fallback `?? 15` → `?? 5` (+ rationale comment, JSDoc). Config default, JSON schema, and CONFIGURATION docs come from the merge. +- **Tests**: all #344 per-model tests kept (they pin explicit values, unaffected); the base branch's default-lock test (`issue #342 follow-up: unset minNudgeContextPercent falls back to the low 5% default floor`) moved before the #344 section — it initially FAILED against this branch's `?? 15` copy, catching exactly the stale-fallback inconsistency; passes after the fix. Suite: 1049/1049, typecheck + build clean. diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 27ba9016..8d79589d 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -28,6 +28,7 @@ export const VALID_CONFIG_KEYS = new Set([ "compress.minContextLimit", "compress.modelMaxLimits", "compress.modelMinLimits", + "compress.modelMinNudgeLimits", "compress.nudgeFrequency", "compress.minNudgeContextPercent", "compress.nudgeGrowthTokens", @@ -76,6 +77,7 @@ function getConfigKeyPaths(obj: Record, prefix = ""): string[] { if ( fullKey === "compress.modelMaxLimits" || fullKey === "compress.modelMinLimits" || + fullKey === "compress.modelMinNudgeLimits" || fullKey === "messageFilters.filters" ) { continue @@ -566,7 +568,10 @@ export function validateConfigTypes(config: Record): ValidationErro } const validateModelLimits = ( - key: "compress.modelMaxLimits" | "compress.modelMinLimits", + key: + | "compress.modelMaxLimits" + | "compress.modelMinLimits" + | "compress.modelMinNudgeLimits", limits: unknown, ): void => { if (limits === undefined) { @@ -606,6 +611,7 @@ export function validateConfigTypes(config: Record): ValidationErro validateModelLimits("compress.modelMaxLimits", compress.modelMaxLimits) validateModelLimits("compress.modelMinLimits", compress.modelMinLimits) + validateModelLimits("compress.modelMinNudgeLimits", compress.modelMinNudgeLimits) const validValues = ["ask", "allow", "deny"] if (compress.permission !== undefined && !validValues.includes(compress.permission)) { diff --git a/lib/config.ts b/lib/config.ts index 2a34c660..4df9b135 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -17,6 +17,7 @@ export interface CompressConfig { minContextLimit: number | `${number}%` modelMaxLimits?: Record modelMinLimits?: Record + modelMinNudgeLimits?: Record nudgeFrequency: number minNudgeContextPercent: number nudgeGrowthTokens?: number @@ -199,7 +200,7 @@ const defaultConfig: PluginConfig = { maxContextLimit: "80%", minContextLimit: "80%", nudgeFrequency: 5, - minNudgeContextPercent: 15, + minNudgeContextPercent: 5, iterationNudgeThreshold: 15, nudgeForce: "soft", protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS], @@ -370,6 +371,7 @@ export function mergeCompress( minContextLimit: override.minContextLimit ?? base.minContextLimit, modelMaxLimits: override.modelMaxLimits ?? base.modelMaxLimits, modelMinLimits: override.modelMinLimits ?? base.modelMinLimits, + modelMinNudgeLimits: override.modelMinNudgeLimits ?? base.modelMinNudgeLimits, nudgeFrequency: override.nudgeFrequency ?? base.nudgeFrequency, minNudgeContextPercent: override.minNudgeContextPercent ?? base.minNudgeContextPercent, nudgeGrowthTokens: override.nudgeGrowthTokens, @@ -433,6 +435,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { ...config.compress, modelMaxLimits: { ...config.compress.modelMaxLimits }, modelMinLimits: { ...config.compress.modelMinLimits }, + modelMinNudgeLimits: { ...config.compress.modelMinNudgeLimits }, protectedTools: [...config.compress.protectedTools], }, gc: { diff --git a/lib/messages/inject/inject.ts b/lib/messages/inject/inject.ts index 390f0a29..f5b413a9 100644 --- a/lib/messages/inject/inject.ts +++ b/lib/messages/inject/inject.ts @@ -33,6 +33,7 @@ import { excludeProtectedRanges, filterRecommendedRanges, resolveEffectiveFloor, + resolveMinNudgeFloorTokens, findLastNonIgnoredMessage, formatCompressibleRanges, getIterationNudgeThreshold, @@ -294,7 +295,7 @@ export const injectCompressNudges = ( overMinLimit, overMaxLimit, lastNudgeTokens: growthReference, - minNudgeContextPercent: config.compress?.minNudgeContextPercent ?? 15, + minNudgeContextPercent: config.compress?.minNudgeContextPercent ?? 5, nudgeGrowthTokens: effectiveThreshold, }) @@ -302,9 +303,43 @@ export const injectCompressNudges = ( currentTokens !== undefined && growthReference !== undefined ? currentTokens - growthReference : undefined + // Issue #342: a growth nudge must not fire below the configured floor. + // The floor is minNudgeContextPercent (default 5% of the model context), + // NOT minContextLimit (default 80%) — minContextLimit is documented as the + // "soft lower threshold for turn/iteration reminders" (README), and using it + // as a growth floor would suppress ALL growth nudges below 80% for default + // users, effectively disabling compression for most of a session. + // minNudgeContextPercent is the intended "don't nudge below this" floor and + // was previously a no-op (passed to the trigger policy but ignored). + // Issue #344: a per-model floor (modelMinNudgeLimits, keyed provider/model) + // takes priority over the global percent so mixed-model installs can set + // exact token or percentage floors per model. When the model context limit + // is unknown the floor cannot be computed (except for an absolute per-model + // floor), so the gate stays open (pre-#342 growth-only behavior). + // overMaxLimit and the emergency override bypass the floor; T2/T3 + // tier-promotion nudges below are unaffected. + // The global-percent default is deliberately LOW (5%): with the default + // nudgeGrowthTokens (50K), a growth nudge's current tokens are always + // >= baseline+50K, and a 5% floor only binds when 5% x window > + // baseline+50K (i.e. windows >= ~2M for typical baselines). A 15% default + // would bind on >=400K windows and shift every compress cycle's working + // range upward (~2x average context on 1M-window models) — a silent, + // bug-level behavior change for large-window users. Users who want a + // higher floor set it (per-model or global) explicitly. + const minNudgeFloorTokens = resolveMinNudgeFloorTokens( + config, + modelContextLimit, + providerId, + modelId, + ) + const overMinNudgeFloor = + minNudgeFloorTokens === undefined || + currentTokens === undefined || + currentTokens >= minNudgeFloorTokens const nudgeAllowed = emergencyOverride || (decision.shouldNudge && + (overMaxLimit || overMinNudgeFloor) && growthSinceBaseline !== undefined && growthSinceBaseline >= growthFloor) diff --git a/lib/messages/inject/utils.ts b/lib/messages/inject/utils.ts index aa07b01e..4d950daf 100644 --- a/lib/messages/inject/utils.ts +++ b/lib/messages/inject/utils.ts @@ -147,6 +147,70 @@ function resolveContextTokenLimit( return parseLimitValue(globalLimit) } +/** + * Resolve the T1 growth-nudge floor in tokens (issue #344). + * + * Precedence: + * 1. `config.compress.modelMinNudgeLimits["${providerId}/${modelId}"]` — + * absolute tokens, or "X%" of the model's context window. + * 2. `config.compress.minNudgeContextPercent` (default 5) × model context. + * 3. `undefined` when the model context is unknown — the caller keeps the + * pre-#342 growth-only behavior (no suppression). + * + * A per-model absolute floor applies even when the model context window is + * unknown (mirrors modelMaxLimits/modelMinLimits). A per-model percent that + * cannot be resolved (unknown model context) falls through to the global + * percent — which is also unresolvable, so the floor stays open. + */ +export function resolveMinNudgeFloorTokens( + config: PluginConfig, + modelContextLimit: number | undefined, + providerId: string | undefined, + modelId: string | undefined, +): number | undefined { + const parseModelLimit = (limit: number | `${number}%`): number | undefined => { + if (typeof limit === "number") { + return limit + } + + if (modelContextLimit === undefined) { + return undefined + } + + const parsedPercent = parseFloat(limit.slice(0, -1)) + if (isNaN(parsedPercent)) { + return undefined + } + + const roundedPercent = Math.round(parsedPercent) + const clampedPercent = Math.max(0, Math.min(100, roundedPercent)) + return Math.round((clampedPercent / 100) * modelContextLimit) + } + + const modelLimits = config.compress.modelMinNudgeLimits + if (modelLimits && providerId !== undefined && modelId !== undefined) { + const modelLimit = modelLimits[`${providerId}/${modelId}`] + if (modelLimit !== undefined) { + const resolved = parseModelLimit(modelLimit) + if (resolved !== undefined) { + return resolved + } + // Per-model percent with unknown model context: fall through to the global percent. + } + } + + if (modelContextLimit === undefined) { + return undefined + } + // The global-percent fallback is deliberately LOW (5%): with the default + // nudgeGrowthTokens (50K), a 5% floor only binds when 5% x window > + // baseline+50K (i.e. windows >= ~2M for typical baselines) — inert for + // typical working cycles. A 15% fallback would bind on >=400K windows and + // shift every compress cycle's working range upward (~2x average context + // on 1M-window models), a bug-level silent change for large-window users. + return Math.round(((config.compress.minNudgeContextPercent ?? 5) / 100) * modelContextLimit) +} + export function isContextOverLimits( config: PluginConfig, state: SessionState, @@ -221,7 +285,7 @@ export function computeShouldNudge(params: { overMinLimit: boolean overMaxLimit: boolean lastNudgeTokens: number | undefined - /** @deprecated Kept for backward compat; ignored. Cadence is growth-only now. */ + /** Passed through to the trigger policy (currently ignored by it). The growth-nudge floor derived from this field is computed separately in inject.ts (minNudgeContextPercent × model context). */ minNudgeContextPercent: number nudgeGrowthTokens: number }): NudgeDecision { diff --git a/tests/config-validation.test.ts b/tests/config-validation.test.ts index 57275e70..de238442 100644 --- a/tests/config-validation.test.ts +++ b/tests/config-validation.test.ts @@ -48,6 +48,13 @@ test("getInvalidConfigKeys does not recurse into modelMaxLimits dynamic keys", ( assert.deepEqual(result, []) }) +test("getInvalidConfigKeys does not recurse into modelMinNudgeLimits dynamic keys", () => { + const result = getInvalidConfigKeys({ + compress: { modelMinNudgeLimits: { "provider/model-xyz": 50000 } }, + }) + assert.deepEqual(result, []) +}) + test("getInvalidConfigKeys does not recurse into messageFilters.filters dynamic keys", () => { const result = getInvalidConfigKeys({ messageFilters: { filters: { "omo-mode-injection": { enabled: true } } }, @@ -243,3 +250,35 @@ test("getInvalidConfigKeys accepts new preserveRecent* keys", () => { }) assert.equal(result.length, 0) }) + +test("validateConfigTypes accepts valid compress.modelMinNudgeLimits entries", () => { + const result = validateConfigTypes({ + compress: { + modelMinNudgeLimits: { + "openai/gpt-5.6": 150000, + "openrouter/z-ai/glm-5.3": "20%", + }, + }, + }) + assert.deepEqual(result, []) +}) + +test("validateConfigTypes catches invalid entry in compress.modelMinNudgeLimits", () => { + const result = validateConfigTypes({ + compress: { + modelMinNudgeLimits: { "openai/gpt-5.6": "lots" }, + }, + }) + assert.equal(result.length, 1) + assert.equal(result[0].key, "compress.modelMinNudgeLimits.openai/gpt-5.6") + assert.equal(result[0].actual, '"lots"') +}) + +test("validateConfigTypes catches non-object compress.modelMinNudgeLimits", () => { + const result = validateConfigTypes({ + compress: { modelMinNudgeLimits: "openai/gpt-5.6" }, + }) + assert.equal(result.length, 1) + assert.equal(result[0].key, "compress.modelMinNudgeLimits") + assert.equal(result[0].actual, "string") +}) diff --git a/tests/inject.test.ts b/tests/inject.test.ts index 0af2920d..cd08b647 100644 --- a/tests/inject.test.ts +++ b/tests/inject.test.ts @@ -70,6 +70,25 @@ function userMsg(id: string, text: string): WithParts { } } +function userMsgWithModel( + id: string, + text: string, + providerID: string, + modelID: string, +): WithParts { + return { + info: { + id, + role: "user", + sessionID: SID, + agent: "a", + time: { created: 1 }, + model: { providerID, modelID }, + } as WithParts["info"], + parts: [textPart(id, text)], + } +} + function assistantMsg(id: string, text: string, toolParts?: any[]): WithParts { const parts = [...(toolParts ?? []), textPart(id, text)] return { @@ -347,6 +366,9 @@ test("injectCompressNudges: post-compress baseline then large growth DOES nudge" state.modelContextLimit = 1_000_000 const config = buildConfig() config.compress.maxContextLimit = 800_000 + // Growth floor is minNudgeContextPercent (15% default = 150K on a 1M model), + // below the turn-2 context (305K), so the floor is open and this test + // isolates the growth mechanism. minContextLimit no longer gates growth nudges. config.compress.minContextLimit = 550_000 // Turn 1: compress → baseline set to 250K @@ -379,6 +401,9 @@ test("nudge threshold halves after first nudge without compress (issue #23)", () state.nudges.lastPerMessageNudgeTokens = 100_000 const config = buildConfig() config.compress.maxContextLimit = 800_000 + // Growth floor is minNudgeContextPercent (15% default = 150K on a 1M model), + // at/below the turn contexts (150K/165K/175K), so the floor is open and this + // test isolates the threshold-halving mechanism. config.compress.minContextLimit = 200_000 const messages1: WithParts[] = [ @@ -1412,14 +1437,14 @@ test("stale contextLimitAnchors: contextLimitNudge NOT injected when context bel const messages: WithParts[] = [ userMsg("u1", "hello"), - assistantMsgWithTokens("a1", "done", { input: 90_000, output: 10_000 }, [ + assistantMsgWithTokens("a1", "done", { input: 140_000, output: 10_000 }, [ toolPart("c1", "x".repeat(620_000)), ]), userMsg("u2", "next"), ] injectCompressNudges(state, config, logger, messages, makePrompts()) - assert.equal(state.nudges.shouldInjectThisTurn, true, "nudge fires (50K growth >= 22500 floor)") + assert.equal(state.nudges.shouldInjectThisTurn, true, "nudge fires (100K growth >= 22500 growthFloor, 150K >= 15% floor)") assert.equal(state.nudges.contextLimitAnchors.size, 0, "stale contextLimitAnchors cleared") const injected = suffixText(messages) @@ -1868,3 +1893,753 @@ test("Issue #255: stable system prompt cache survives compression in multi-turn assert.ok(comp4.systemTokens < 200_000, "turn 4: system estimate must not inflate to later assistant input") }) +// ── Issue #342: T1 growth nudges must respect the minNudgeContextPercent floor ── +// computeShouldNudge() only uses overMinLimit/overMaxLimit to pick the tips +// variant, so the growth floor is enforced in inject.ts (nudgeAllowed): +// minNudgeContextPercent × model context. These tests lock that floor: growth +// nudges are suppressed below it and fire once context crosses it, while +// over-max / emergency paths and T2/T3 tier promotion remain independent. + +test("issue #342: growth nudge suppressed below the minNudgeContextPercent floor, fires once context crosses it", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // floor at 30% of 1M = 300K + + // Turn 1: currentTokens = 200K (180K+20K). Growth = 200K-100K = 100K >= 50K threshold + // AND >= 22.5K growthFloor, but 200K < 300K floor → the floor SUPPRESSES the growth nudge. + const turn1: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "200K < 300K floor → growth nudge suppressed despite 100K growth") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge shown below floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline preserved below floor (not advanced)") + + // Turn 2: currentTokens = 320K (300K+20K). Growth = 320K-100K = 220K >= 50K. + // 320K >= 300K floor → floor OPEN → nudge FIRES. + const turn2: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "more", { input: 300_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "320K >= 300K floor + 220K growth → nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 320_000, "lastNudgeShownTokens set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline NOT updated after nudge — only compress resets") +}) + +test("issue #342: full growth cycle baseline → nudge → compress → new baseline → nudge (min-gate open)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + state.messageIds.byRawId.set("u3", "m00005") + state.messageIds.byRawId.set("a3", "m00006") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // floor at 30% of 1M = 300K + + // Turn 1: first transform → baseline established at 150K. No nudge. + const turn1: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 150_000, "turn 1: baseline established at 150K") + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: baseline establishment only") + + // Turn 2: currentTokens = 350K (>= 300K floor), growth = 350K-150K = 200K >= 50K → nudge fires. + const turn2: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "more", { input: 330_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 2: 350K >= 300K floor + 200K growth → nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 350_000, "turn 2: nudge baseline set to currentTokens") + + // Turn 3: model compresses → baseline reset to post-compress 200K. + const turn3: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "compressing", { input: 180_000, output: 20_000 }, [ + compressToolPart("c2", "compressed"), + ]), + ] + injectCompressNudges(state, config, logger, turn3, {} as any) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 3: compress resets baseline to post-compress 200K") + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 3: compress turn, no nudge") + + // Turn 4: currentTokens = 400K (>= 300K floor), growth = 400K-200K = 200K >= 50K → nudge fires again. + const turn4: WithParts[] = [ + userMsg("u3", "more"), + assistantMsgWithTokens("a3", "result", { input: 380_000, output: 20_000 }, [ + toolPart("c3", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn4, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "turn 4: 400K >= 300K floor + 200K growth from new baseline → nudge fires again") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 200_000, "turn 4: baseline NOT updated after nudge") +}) + +test("issue #342: growth floor holds in production config (preserveRecentMessages > 0)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.preserveRecentMessages = 2 // production-like: last 2 messages protected + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // floor at 30% of 1M = 300K + + // 4 messages; last 2 (u2, a2) in preserve-recent zone, a1's tool output compressible. + // currentTokens = 200K (a2: 180K+20K) < 300K floor → the floor suppresses (compressible + // content exists, so this is the floor, not nothingToCompress). + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsg("u2", "next"), + assistantMsgWithTokens("a2", "more", { input: 180_000, output: 20_000 }), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, false, "200K < 300K floor → floor suppresses in production config") + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge shown below floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline preserved") +}) + +test("issue #342: over-max nudge bypasses the growth floor", () => { + // Defensive: even when the growth floor (minNudgeContextPercent) is set above + // the current context, an over-max context must still nudge — overMaxLimit + // bypasses the floor. + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 500_000 + config.compress.minNudgeContextPercent = 80 // floor at 80% of 1M = 800K (above context) + + // currentTokens = 550K (500K+50K). overMaxLimit (550K > 500K) but below the + // 800K floor. Growth = 450K >= 50K. The floor would suppress, but overMaxLimit + // bypasses it. + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 500_000, output: 50_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "550K > 500K max → nudge fires despite 550K < 800K floor (overMaxLimit bypass)") + assert.equal(state.nudges.lastNudgeShownTokens, 550_000, "nudge baseline set") +}) + +test("issue #342: growth nudge still fires when the model context limit is unknown (floor unresolvable)", () => { + // Regression lock: when the model does not report a context limit, the + // minNudgeContextPercent floor cannot be computed. The floor must NOT be + // treated as "below floor" — growth nudges keep their pre-#342 growth-only + // behavior instead of being suppressed for the whole session. + const state = createSessionState() + // state.modelContextLimit intentionally left undefined + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = "80%" // percent → unresolvable without a model limit + + // currentTokens = 200K (180K+20K). Growth = 200K-100K = 100K >= 50K threshold + // AND >= 22.5K growthFloor. Model limit unknown → floor unresolvable → nudge FIRES. + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "unknown model limit → floor unresolvable → growth-only behavior preserved (nudge fires)") + assert.equal(state.nudges.lastNudgeShownTokens, 200_000, "nudge baseline set") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 100_000, "baseline NOT updated after nudge") +}) + +test("issue #342: T2 tier-promotion fires below the growth floor (independent of the floor)", () => { + // T2/T3 tier-promotion nudges have an independent cadence and never consult + // the growth floor — they must fire even while T1 is floor-suppressed. + const state = createSessionState() + state.sessionId = "test-t2-floor" + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + + const config = buildConfig() + config.compress.maxContextLimit = 990_000 + config.compress.minNudgeContextPercent = 80 // floor at 80% of 1M = 800K (above the 200K context) + config.compress.nudgeGrowthTokens = 10_000 + config.compress.minNudgeGrowthFloor = 5_000 + config.compress.minNudgeGrowthRatio = 0.01 + + // Seed T1 blocks so tier1Tokens (25K) >= nudgeGrowthTokens (10K) + for (let i = 0; i < 5; i++) { + const blockId = i + 1 + state.prune.messages.blocksById.set(blockId, { + blockId, + runId: i + 1, + active: true, + tier: 1, + generation: "young", + survivedCount: 1, + directMessageIds: [], + effectiveMessageIds: [], + consumedBlockIds: [], + parentBlockIds: [], + summary: "T1 summary ".repeat(200), + summaryTokens: 5_000, + topic: `T1 block ${i}`, + createdAt: Date.now(), + }) + state.prune.messages.activeBlockIds.add(blockId) + } + + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + // currentTokens = 200K (180K+20K). T1: growth = 100K >= 10K threshold, but + // 200K < 800K floor → the floor suppresses T1. T2: tier1Tokens 25K >= 10K, + // cadence met, 5 candidates >= 2 → T2 fires below the growth floor. + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "T2 fires below the growth floor (independent cadence)") + assert.equal(state.nudges.lastTier2NudgeTokens, 200_000, "T2 fired (lastTier2NudgeTokens set) while T1 stayed floor-suppressed") +}) +test("issue #342 follow-up: unset minNudgeContextPercent falls back to the low 5% default floor", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + // Simulate a config that never set the field: the code must fall back to + // the (deliberately low) 5% default, NOT the pre-fix 15%. + delete (config.compress as { minNudgeContextPercent?: number }).minNudgeContextPercent + + // currentTokens = 100K (80K+20K). Growth = 100K-50K = 50K >= 50K threshold + // AND >= 22.5K growthFloor. Fallback floor = 5% of 1M = 50K → 100K >= 50K + // → the nudge FIRES. (A 15% fallback would put the floor at 150K and + // SUPPRESS this — the default must stay low so typical working cycles on + // large-window models are not shifted; 15% binds on ≥400K windows.) + const messages: WithParts[] = [ + userMsg("u1", "hello"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal(state.nudges.shouldInjectThisTurn, true, "100K >= 50K (5% default floor) with 50K growth → nudge fires") + assert.equal(state.nudges.lastNudgeShownTokens, 100_000, "nudge shown at current tokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline only advances on compress, not on nudge") +}) + +// ── Issue #344: per-model growth-nudge floor (modelMinNudgeLimits) ────────── +// Precedence: modelMinNudgeLimits[provider/model] → minNudgeContextPercent × +// model context → growth-only behavior when the model context is unknown. +// modelMinLimits keeps its turn/iteration-reminder meaning (independent). + +test("issue #344: per-model absolute floor wins over the global minNudgeContextPercent", () => { + const state = createSessionState() + state.modelContextLimit = 400_000 // GPT-5.6 native window + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 320_000 + config.compress.minNudgeContextPercent = 15 // global floor would be 60K on 400K + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 150_000 } + + // Turn 1: currentTokens = 100K (80K+20K). Growth = 100K-50K = 50K >= 50K + // threshold AND >= 22.5K growthFloor. The global floor (60K) would let this + // fire, but the per-model floor is 150K → 100K < 150K → SUPPRESSED. + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + false, + "100K < 150K per-model floor → suppressed (global 60K floor would have fired)", + ) + assert.equal( + state.nudges.lastNudgeShownTokens, + undefined, + "no nudge shown below per-model floor", + ) + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 50_000, + "baseline preserved below floor (not advanced)", + ) + + // Turn 2: currentTokens = 150K (130K+20K). Growth = 150K-50K = 100K >= 50K. + // 150K >= 150K per-model floor → nudge FIRES. + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openai", "gpt-5.6"), + assistantMsgWithTokens("a2", "more", { input: 130_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "150K >= 150K per-model floor + 100K growth → nudge fires", + ) + assert.equal(state.nudges.lastNudgeShownTokens, 150_000, "nudge baseline set to currentTokens") + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 50_000, + "baseline NOT updated after nudge — only compress resets", + ) +}) + +test("issue #344: per-model percent floor resolves against the per-model context window", () => { + const state = createSessionState() + state.modelContextLimit = 1_048_576 // GLM-5.3 via OpenRouter + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 900_000 + config.compress.minNudgeContextPercent = 15 // global floor ≈ 157,286 + config.compress.modelMinNudgeLimits = { "openrouter/z-ai/glm-5.3": "20%" } // 209,715 + + // Turn 1: currentTokens = 180K (160K+20K). Growth = 180K-50K = 130K >= 50K. + // 180K >= 157,286 (global floor) but 180K < 209,715 (per-model 20% of 1M) + // → SUPPRESSED — proves the percent resolves against the per-model window. + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "openrouter", "z-ai/glm-5.3"), + assistantMsgWithTokens("a1", "done", { input: 160_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + false, + "180K < 209,715 per-model floor (20% of 1,048,576) → suppressed despite passing the 157,286 global floor", + ) + assert.equal( + state.nudges.lastNudgeShownTokens, + undefined, + "no nudge shown below per-model floor", + ) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline preserved below floor") + + // Turn 2: currentTokens = 250K (230K+20K). Growth = 250K-50K = 200K >= 50K. + // 250K >= 209,715 per-model floor → nudge FIRES. + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "openrouter", "z-ai/glm-5.3"), + assistantMsgWithTokens("a1", "done", { input: 160_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openrouter", "z-ai/glm-5.3"), + assistantMsgWithTokens("a2", "more", { input: 230_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "250K >= 209,715 per-model floor + 200K growth → nudge fires", + ) + assert.equal(state.nudges.lastNudgeShownTokens, 250_000, "nudge baseline set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline NOT updated after nudge") +}) + +test("issue #344: models without a per-model entry keep the global minNudgeContextPercent floor", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 30 // global floor 300K + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 100_000 } // different model + + // User on anthropic/claude-sonnet-4.6 (no per-model entry). + // currentTokens = 200K (180K+20K). Growth = 200K-50K = 150K >= 50K. + // 200K < 300K global floor → SUPPRESSED — the per-model map must not + // disable the global floor for unlisted models. + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "anthropic", "claude-sonnet-4.6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + false, + "200K < 300K global floor → suppressed for unlisted model (per-model map does not disable the global floor)", + ) + assert.equal(state.nudges.lastNudgeShownTokens, undefined, "no nudge shown below global floor") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline preserved") +}) + +test("issue #344: per-model absolute floor applies when the model context limit is unknown", () => { + const state = createSessionState() + // state.modelContextLimit intentionally left undefined + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.maxContextLimit = 300_000 + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 150_000 } + + // Turn 1: currentTokens = 100K (80K+20K). Growth = 100K-50K = 50K >= 50K. + // The per-model absolute floor (150K) is resolvable without a context + // window (mirrors modelMaxLimits/modelMinLimits) → 100K < 150K → + // SUPPRESSED, even though the global percent floor is unresolvable. + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + false, + "100K < 150K per-model absolute floor → suppressed despite unknown model context", + ) + assert.equal( + state.nudges.lastNudgeShownTokens, + undefined, + "no nudge shown below per-model floor", + ) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline preserved below floor") + + // Turn 2: currentTokens = 180K (160K+20K). Growth = 180K-50K = 130K >= 50K. + // 180K >= 150K per-model floor → nudge FIRES. + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openai", "gpt-5.6"), + assistantMsgWithTokens("a2", "more", { input: 160_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "180K >= 150K per-model absolute floor + 130K growth → nudge fires", + ) + assert.equal(state.nudges.lastNudgeShownTokens, 180_000, "nudge baseline set to currentTokens") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline NOT updated after nudge") +}) + +test("issue #344: per-model percent with unknown model context falls through to growth-only behavior", () => { + const state = createSessionState() + // state.modelContextLimit intentionally left undefined + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = "80%" // unresolvable without a model limit + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": "25%" } // unresolvable + + // currentTokens = 100K (80K+20K). Growth = 100K-50K = 50K >= 50K. + // Per-model percent unresolvable (no model context) → falls through to the + // global percent (also unresolvable) → floor open → nudge FIRES + // (pre-#342 growth-only behavior preserved — no accidental suppression). + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 80_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "unresolvable per-model percent → global percent also unresolvable → growth-only behavior preserved (nudge fires)", + ) + assert.equal(state.nudges.lastNudgeShownTokens, 100_000, "nudge baseline set") + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline NOT updated after nudge") +}) + +test("issue #344: per-model floor holds in production config (preserveRecentMessages > 0)", () => { + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + + const config = buildConfig() + config.compress.preserveRecentMessages = 2 // production-like: last 2 messages protected + config.compress.maxContextLimit = 800_000 + config.compress.minNudgeContextPercent = 15 // global floor 150K + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 300_000 } + + // 4 messages; last 2 (u2, a2) in preserve-recent zone, a1's tool output + // compressible. currentTokens = 200K (a2: 180K+20K). The global floor + // (150K) would let this fire, but the per-model floor is 300K → + // 200K < 300K → SUPPRESSED (compressible content exists, so this is the + // floor, not nothingToCompress). + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openai", "gpt-5.6"), + assistantMsgWithTokens("a2", "more", { input: 180_000, output: 20_000 }), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + false, + "200K < 300K per-model floor → floor suppresses in production config (global 150K floor would have fired)", + ) + assert.equal( + state.nudges.lastNudgeShownTokens, + undefined, + "no nudge shown below per-model floor", + ) + assert.equal(state.nudges.lastPerMessageNudgeTokens, 50_000, "baseline preserved") +}) + +test("issue #344: full growth cycle with per-model floor baseline → nudge → compress → new baseline → nudge", () => { + const state = createSessionState() + state.modelContextLimit = 400_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + state.messageIds.byRawId.set("a2", "m00004") + state.messageIds.byRawId.set("u3", "m00005") + state.messageIds.byRawId.set("a3", "m00006") + + const config = buildConfig() + config.compress.maxContextLimit = 320_000 + config.compress.minNudgeContextPercent = 15 // global floor 60K (would fire in turn 2) + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 150_000 } + + // Turn 1: first transform → baseline established at 150K (130K+20K). No nudge. + const turn1: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn1, {} as any) + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 150_000, + "turn 1: baseline established at 150K", + ) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 1: baseline establishment only") + + // Turn 2: currentTokens = 250K (230K+20K) >= 150K per-model floor, + // growth = 250K-150K = 100K >= 50K → nudge fires. + const turn2: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openai", "gpt-5.6"), + assistantMsgWithTokens("a2", "more", { input: 230_000, output: 20_000 }, [ + toolPart("c2", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn2, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "turn 2: 250K >= 150K per-model floor + 100K growth → nudge fires", + ) + assert.equal( + state.nudges.lastNudgeShownTokens, + 250_000, + "turn 2: nudge baseline set to currentTokens", + ) + + // Turn 3: model compresses → baseline reset to post-compress 180K (160K+20K). + const turn3: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "work", { input: 130_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openai", "gpt-5.6"), + assistantMsgWithTokens("a2", "compressing", { input: 160_000, output: 20_000 }, [ + compressToolPart("c2", "compressed"), + ]), + ] + injectCompressNudges(state, config, logger, turn3, {} as any) + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 180_000, + "turn 3: compress resets baseline to post-compress 180K", + ) + assert.equal(state.nudges.shouldInjectThisTurn, false, "turn 3: compress turn, no nudge") + + // Turn 4: currentTokens = 230K (210K+20K) >= 150K per-model floor, + // growth = 230K-180K = 50K >= 50K → nudge fires again. + const turn4: WithParts[] = [ + userMsgWithModel("u3", "more", "openai", "gpt-5.6"), + assistantMsgWithTokens("a3", "result", { input: 210_000, output: 20_000 }, [ + toolPart("c3", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, turn4, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "turn 4: 230K >= 150K per-model floor + 50K growth from new baseline → nudge fires again", + ) + assert.equal( + state.nudges.lastPerMessageNudgeTokens, + 180_000, + "turn 4: baseline NOT updated after nudge", + ) +}) + +test("issue #344: over-max nudge bypasses the per-model floor", () => { + // Defensive: even when the per-model floor is set above the current + // context, an over-max context must still nudge — overMaxLimit bypasses + // the floor. + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 100_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + + const config = buildConfig() + config.compress.maxContextLimit = 500_000 + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 800_000 } // above the 550K context + + // currentTokens = 550K (500K+50K). overMaxLimit (550K > 500K) but below + // the 800K per-model floor. Growth = 450K >= 50K. The floor would + // suppress, but overMaxLimit bypasses it. + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 500_000, output: 50_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "550K > 500K max → nudge fires despite 550K < 800K per-model floor (overMaxLimit bypass)", + ) + assert.equal(state.nudges.lastNudgeShownTokens, 550_000, "nudge baseline set") +}) + +test("issue #344: modelMinLimits (turn/iteration reminders) stays independent of modelMinNudgeLimits", () => { + // modelMinLimits keeps its turn/iteration-reminder meaning: a HIGH + // per-model min limit must not suppress the growth nudge (which only + // consults the nudge floor), and a LOW per-model nudge floor must not + // lower the min limit (turn anchors stay off below it). + const state = createSessionState() + state.modelContextLimit = 1_000_000 + state.nudges.lastPerMessageNudgeTokens = 50_000 + state.messageIds.byRawId.set("u1", "m00001") + state.messageIds.byRawId.set("a1", "m00002") + state.messageIds.byRawId.set("u2", "m00003") + + const config = buildConfig() + config.compress.maxContextLimit = 800_000 + config.compress.minContextLimit = 50_000 + config.compress.modelMinLimits = { "openai/gpt-5.6": 900_000 } // high min → overMinLimit false at 200K + config.compress.minNudgeContextPercent = 15 + config.compress.modelMinNudgeLimits = { "openai/gpt-5.6": 100_000 } // low nudge floor + + // Last message is a user message (u2) with a preceding assistant (a1), so + // a turn anchor WOULD be added if overMinLimit were true. + // currentTokens = 200K (a1: 180K+20K). Growth = 200K-50K = 150K >= 50K. + // 200K >= 100K per-model nudge floor → growth nudge FIRES even though + // overMinLimit is false (200K < 900K per-model min). + const messages: WithParts[] = [ + userMsgWithModel("u1", "hello", "openai", "gpt-5.6"), + assistantMsgWithTokens("a1", "done", { input: 180_000, output: 20_000 }, [ + toolPart("c1", "x".repeat(80_000)), + ]), + userMsgWithModel("u2", "next", "openai", "gpt-5.6"), + ] + injectCompressNudges(state, config, logger, messages, {} as any) + assert.equal( + state.nudges.shouldInjectThisTurn, + true, + "growth nudge fires below the per-model min limit (growth path only consults the nudge floor)", + ) + assert.equal( + state.nudges.turnNudgeAnchors.size, + 0, + "high per-model min limit (900K) still suppresses turn reminders — modelMinNudgeLimits did not lower it", + ) + assert.equal(state.nudges.lastNudgeShownTokens, 200_000, "nudge baseline set") +})