diff --git a/dcp.schema.json b/dcp.schema.json index 5b6fd1db..135cb5b2 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -298,6 +298,17 @@ "type": "boolean", "default": true, "description": "Always protect the most recent user message from compression." + }, + "overflowGuard": { + "type": "boolean", + "default": true, + "description": "Enable the request-side overflow guard (prune-to-fit). When the estimated wire size exceeds knownWindow - overflowGuardReserve, deterministically clear the oldest compressible (non-protected) tool outputs until the estimate fits. See #347." + }, + "overflowGuardReserve": { + "type": "number", + "default": 32768, + "minimum": 0, + "description": "Tokens reserved for the model's completion by the overflow guard. The guard keeps safeBudget = knownWindow - overflowGuardReserve. Should be at least the model's typical max output tokens (opencode falls back to 32000 when limit.output = 0)." } }, "default": { @@ -322,7 +333,9 @@ "lastSegmentSoftBlock": true, "preserveRecentMessages": 20, "preserveRecentTokens": 20000, - "preserveLastUserMessage": true + "preserveLastUserMessage": true, + "overflowGuard": true, + "overflowGuardReserve": 32768 } }, "gc": { diff --git a/devlog/2026-08-28_overflow-guard/DESIGN.md b/devlog/2026-08-28_overflow-guard/DESIGN.md new file mode 100644 index 00000000..76256d33 --- /dev/null +++ b/devlog/2026-08-28_overflow-guard/DESIGN.md @@ -0,0 +1,111 @@ +# DESIGN - Request-side overflow guard + uncalibrated-window WARN + +- Task ID: `2026-08-28_overflow-guard` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: Accepted + +## 1. Problem Statement + +- **What problem are we solving?** When a model reports `limit.context = 0`, ACP's + `state.modelContextLimit` is never set, so every percentage threshold resolves to + `undefined` and silently no-ops. The session then grows past the backend's real + window and dies on a provider 400 that opencode swallows (exit 0, no output) — a + silent, deterministic, unrecoverable death loop. +- **Why now?** Reported in #347 with a concrete, reproducible production failure + (sglang qwen3.8-27b, real window 262,144). It affects *every* custom provider + without a catalog entry. + +## 2. Goals & Non-Goals + +- **Goals**: + - Make the uncalibrated-window blindness *visible* (one-time WARN per session). + - Add a *request-side hard guard* that deterministically keeps the outgoing + request within the known window, independent of model cooperation. +- **Non-Goals**: + - Fixing opencode's exit-0-on-400 / maxTokens bugs (upstream). + - Learning the window from 400s (blocked — no response-error hook for plugins). + +## 3. Current Architecture + +- `createSystemPromptHandler` (hooks.ts) is the only writer of + `state.modelContextLimit`; it guards on `input.model?.limit?.context`, so a model + reporting `0` never sets it. The `#312` catalog reconciliation in + `createChatMessageTransformHandler` also misses (catalog drops `limit <= 0`). +- All percentage consumers (`parseLimitValue` in `inject/utils.ts`) return + `undefined` when `modelContextLimit` is `undefined`. +- `truncateLargeToolOutputs` (truncate-tools.ts) is the only existing space-freer, + but it returns early `if (!state.modelContextLimit)` — i.e. it is *also* blind to + the exact case we care about. + +## 4. Proposed Architecture + +``` +messages.transform (createChatMessageTransformHandler) + │ + ├─ reconcile modelContextLimit from catalog (#312) + ├─ updatePerTurnState + ├─ trackUncalibratedWindow(state, logger) [FIX 1 — new] + │ └─ if modelContextLimit undefined N turns → one-time WARN + │ + ├─ prune → truncateLargeToolOutputs + ├─ pruneToFit(state, config, logger, messages) [FIX 2 — new] + │ ├─ knownWindow = resolveKnownWindow(...) + │ │ = modelContextLimit ?? abs modelMaxLimits[p/m] ?? abs maxContextLimit + │ ├─ safeBudget = knownWindow - overflowGuardReserve + │ ├─ estimate = getCurrentTokenUsage + last-asst trailing tool outputs (B1) + │ │ + msgs after last assistant (N2) + WIRE_SAFETY_MARGIN (O(1)) + │ │ [precise content count if no provider usage, or if the last + │ │ assistant step ran a compress — stale base (N1)] + │ └─ if estimate > safeBudget: clear oldest non-protected tool outputs + │ (skip protected tools/paths, current turn, user msgs, recent zone) + │ until estimate - freed <= safeBudget + └─ ... (nudge injection, id injection, etc.) +``` + +- **Key components**: + - `pruneToFit` / `resolveKnownWindow` (`lib/messages/prune-to-fit.ts`). + - `trackUncalibratedWindow` (`lib/messages/uncalibrated-window.ts`). +- **Data flow**: The guard mutates tool parts' `state.output` in place (same + mechanism as `truncateLargeToolOutputs`), so the change applies to the outgoing + request. It is idempotent (already-cleared outputs are skipped). +- **API / interface changes**: Two new config knobs; three new *transient* (non- + persisted) `SessionState` fields; two new exported functions. + +## 5. Design Decisions & Rationale + +| Decision | Options Considered | Chosen | Why | +|----------|--------------------|--------|-----| +| Where to guard | (a) rely on nudges (model-driven); (b) request-side hard guard | (b) | Nudges are advisory and the model may not comply; a hard 400 needs a deterministic, model-independent fix. | +| `knownWindow` source | (a) `modelContextLimit` only; (b) also absolute `maxContextLimit` | (b) | Lets the guard protect users who declare an absolute budget even when the model reports no window. Percent values are *not* used as the window (they'd be the nudge threshold, not the real window → massive over-prune). | +| Completion reserve | (a) store `limit.output` in state; (b) fixed config knob | (b) `overflowGuardReserve` (default 32768) | Avoids state churn + model-switch staleness; 32768 covers opencode's 32000 fallback for `limit.output = 0`. User can tune down for small-output models. | +| Wire-size estimate | (a) always precise count; (b) O(1) provider usage + trailing tool outputs + after-last-assistant msgs + margin, precise only as fallback | (b) | The precise count is O(total tokens); running it every well-under-budget turn is wasteful. `getCurrentTokenUsage` is O(1) but reports the context size *after* the last LLM call — it omits (i) tool outputs appended *after* that call (opencode runs messages.transform on every LLM call, so a mid-turn sub-request carries fresh tool outputs) and (ii) any messages after the last assistant (the current user turn). We add both to close the gap (review findings B1, N2). The trailing-run count is exact for both text and tool-calls-only steps (a step's own usage cannot include its own tool results). `WIRE_SAFETY_MARGIN = 8192` covers nudges/ID tags appended after the guard. The precise count is also used when the last assistant step ran a `compress` — the provider usage is then stale (still includes the range `prune()` removes), so counting the already-pruned content avoids over-clearing (review finding N1). | +| What to free | (a) truncate (prefix+suffix); (b) clear entirely | (b) | In an overflow emergency, freeing maximum space is the priority; the model can re-run the tool. `truncateLargeToolOutputs` already handles the gentler truncation at the GC threshold. | +| WARN mechanism | (a) inline in handler; (b) extracted pure fn | (b) `trackUncalibratedWindow` | Testable in isolation; keeps the handler lean. Threshold 3 rules out the first-request race (system.transform runs after messages.transform). | + +## 6. Impact Analysis + +- **Backward compatibility**: Additive only. Two new config knobs (sensible defaults), + three new transient state fields (not persisted — old state files load fine; they + default to `0`/`false`), two new exports. No persisted-format or internal-tag change. +- **Performance**: No-op (O(1) check) when under budget. Precise tokenization only on + the first turn without provider token data. +- **Security**: None. +- **Dependencies**: None new. + +## 7. Migration Plan + +- **Steps**: + 1) Ship with `overflowGuard: true` by default. + 2) Users on custom providers see the one-time WARN and are told exactly what to + configure (`limit` in opencode.json or absolute `compress.maxContextLimit`). +- **Feature flags / gradual rollout**: `compress.overflowGuard: false` disables the + guard entirely (the WARN still fires, which is desirable). + +## 8. Open Questions + +- [ ] Should the guard also truncate (not just clear) as an intermediate step before + clearing entirely? (Deferred — clearing is the effective last resort; the + existing `truncateLargeToolOutputs` covers the gentler case.) +- [ ] Once opencode exposes a response-error hook, add "learn the window from 400s" + (issue Fix 3) to make the guard work with zero configuration. diff --git a/devlog/2026-08-28_overflow-guard/REQ.md b/devlog/2026-08-28_overflow-guard/REQ.md new file mode 100644 index 00000000..1eae99d5 --- /dev/null +++ b/devlog/2026-08-28_overflow-guard/REQ.md @@ -0,0 +1,98 @@ +# REQ - Request-side overflow guard + uncalibrated-window WARN + +- Task ID: `2026-08-28_overflow-guard` +- Home Repo: `opencode-acp` +- Created: 2026-08-28 +- Status: InProgress +- Priority: P1 +- Owner: ranxianglei +- References: https://github.com/ranxianglei/opencode-acp/issues/347 + +## 1. Background & Problem Statement + +- **Context**: ACP's percentage thresholds (`minContextLimit`, `maxContextLimit`, + `emergencyThresholdPercent`) are only as good as `state.modelContextLimit`. For a + custom OpenAI-compatible provider that reports `limit.context = 0`, the model-limit + catalog never records a window (`record()` drops `limit <= 0`) and the system hook + never sets `modelContextLimit` (it guards on `limit.context`). Every percentage + threshold then resolves to `undefined` and silently no-ops. +- **Current behavior (symptom)**: A long headless session grows past the backend's + real window (262,144 for the reporter's sglang qwen3.8-27b) and dies on every + resume with a `400 Bad Request` ("Requested token count exceeds the model's maximum + context length of 262144 tokens") that opencode swallows — exit 0, no output. + Deterministic and unrecoverable for that session id. No error is ever surfaced. +- **Expected behavior**: (a) The blindness is *visible* — a prominent one-time WARN + tells the user their model reports no window and what to configure. (b) The request + is *protected* — even without model cooperation, ACP deterministically keeps the + outgoing request within the known window so a 400 becomes a degraded-but-working + turn instead of a silent death loop. +- **Impact**: Any custom provider without a catalog entry has all percentage + protection silently disabled. The failure mode is the worst kind: silent (exit 0), + deterministic, and unrecoverable for the affected session. + +## 2. Reproduction (if applicable) + +- **Environment**: + - Node: 22 + - OS/Arch: linux + - opencode 1.14.46, plugin opencode-acp@latest, model `vllm-qwen/qwen3.8-27b` + declared in opencode.json **without** a `limit` (so `limit.context = 0`), backend + sglang real window 262,144, `compaction.auto: false`, per-message resume. +- **Minimal reproduction steps**: + 1) Run a long headless session against a provider that reports `limit.context = 0`. + 2) Let the context exceed the backend's real window. + 3) Resume the session → 400 → opencode exits 0 with no output, every time. +- **Relevant configuration**: no `limit` in opencode.json; default percentage + thresholds in acp.jsonc (or none). + +## 3. Constraints & Non-Goals + +- **Constraints**: + - Backward compatibility: no change to persisted state format beyond two new + *transient* (non-persisted) fields; no change to internal `dcp` tags. + - Performance: the guard's precise token count must not run on every well-under + budget turn (use the O(1) provider-reported usage as the primary estimate). + - The guard must never clear protected tools / protected file paths (Bug 39 parity). +- **Non-Goals** (explicitly out of scope): + - Fixing opencode's `exit 0 on 400` and `options.maxTokens not honored` bugs + (upstream opencode issues, filed separately). + - "Learn the window from 400s" (issue Fix 3) — **blocked**: opencode exposes no + response-error hook to plugins, so a plugin cannot observe the 400. + +## 4. Acceptance Criteria (must be testable) + +- **Correctness**: + - [x] When `modelContextLimit` stays undefined across ≥ 3 transforms, a one-time + WARN is logged per session (deduped), and the counter resets once a window + resolves. + - [x] When the estimated wire size exceeds `knownWindow - overflowGuardReserve`, + the oldest compressible (non-protected) tool outputs are cleared until the + estimate fits; the guard stops as soon as it fits and never touches the + current turn, user messages, or the recent-message protection zone. + - [x] `knownWindow` = `modelContextLimit` when set, else absolute + `compress.modelMaxLimits[provider/model]`, else absolute + `compress.maxContextLimit`; `undefined` (guard off) when only a percent is + configured and no window is known. +- **Performance / Stability**: + - [x] The guard is a no-op (no precise tokenization) when the O(1) estimate is + under budget. +- **Regression**: + - [x] New/modified test cases added to test suite and passing + (`tests/prune-to-fit.test.ts`, 25 tests). + +## 5. Proposed Approach (optional) + +- **Affected modules & entry files**: + - `lib/messages/prune-to-fit.ts` (new) — `pruneToFit` + `resolveKnownWindow`. + - `lib/messages/uncalibrated-window.ts` (new) — `trackUncalibratedWindow`. + - `lib/hooks.ts` — call both in the message-transform pipeline. + - `lib/config.ts`, `lib/config-validation.ts`, `dcp.schema.json` — new knobs + `compress.overflowGuard` (bool, default true) + `compress.overflowGuardReserve` + (number, default 32768). + - `lib/state/types.ts`, `lib/state/state.ts` — two transient fields. + - `lib/messages/index.ts` — barrel exports. +- **Risks**: Over-pruning when the user sets `maxContextLimit` well below the real + window (documented; the user controls the declared budget). Clearing tool outputs + loses that output until the tool is re-run (intentional, last-resort). +- **Rollback strategy**: Set `compress.overflowGuard: false` to disable the guard; + the WARN is harmless. Full rollback = revert the branch. diff --git a/devlog/2026-08-28_overflow-guard/WORKLOG.md b/devlog/2026-08-28_overflow-guard/WORKLOG.md new file mode 100644 index 00000000..22e5bdeb --- /dev/null +++ b/devlog/2026-08-28_overflow-guard/WORKLOG.md @@ -0,0 +1,199 @@ +# WORKLOG - Request-side overflow guard + uncalibrated-window WARN + +- Task ID: `2026-08-28_overflow-guard` +- Home Repo: `opencode-acp` +- Status: InReview +- Updated: 2026-08-29 + +## 1. Summary + +- **What was done** (1–3 sentences): Added a request-side hard guard + (`pruneToFit`) that deterministically clears the oldest compressible + (non-protected) tool outputs when the estimated wire size exceeds + `knownWindow - overflowGuardReserve`, and a one-time per-session WARN + (`trackUncalibratedWindow`) that surfaces the uncalibrated-window blindness. +- **Why** (1–3 sentences): When a model reports `limit.context = 0`, ACP's + percentage thresholds silently no-op and the session dies on a swallowed provider + 400 (#347). The guard makes the request fit without model cooperation; the WARN + makes the blindness visible and actionable. +- **Behavior / compatibility changes**: Yes — additive. New config knobs + `compress.overflowGuard` (default true) and `compress.overflowGuardReserve` + (default 32768); two transient (non-persisted) state fields. When the guard fires, + old tool outputs are replaced with a placeholder (the tool can be re-run). +- **Risk level**: Medium (clears tool outputs in an overflow emergency; gated by an + enable flag and only fires when over a known budget). + +## 2. Change Log + +### Commits + +| Commit | Description | +|--------|-------------| +| tip of `2026-08-28_overflow-guard` | feat: request-side overflow guard + uncalibrated-window WARN (#347) | + +### Review Round 1 (dual-agent) + +- **Blocking (fixed)** — B1: `estimateWireTokens` read only the last assistant's + provider-reported usage, which is the context size *after* the last LLM call and so + omits tool outputs appended *after* that call. A mid-turn sub-request (opencode runs + messages.transform on every LLM call) could therefore be under-estimated by the size + of a freshly-completed tool output and still 400. Fix: add the last assistant's + trailing completed tool outputs to the estimate (conservative — exact when the last + step has text, over-counts only for tool-calls-only steps, the safe direction). + Regression test added and verified to fail without the fix. +- **Non-blocking (fixed)**: the "recent-message protection zone" test was vacuous + (the gap stopped the guard before the zone, so it passed even with + `computeProtectedRefs` removed) — reworked so the gap forces the guard into the zone + and asserts the zone-protected message is skipped while an ERROR is logged. Added a + production-shape test (last assistant message carrying a trailing completed tool part) + covering current-turn protection. Added an ERROR log for the "over budget but nothing + clearable" case (previously a silent no-op). Added a test pinning an explicit + `overflowGuardReserve: 0` (nullish, not falsy). +- **Non-blocking (kept as-is, with rationale)**: the test keeps a local copy of + `CLEAR_PLACEHOLDER` rather than importing it — importing would make the assertion + tautological; the local copy catches source drift. + +### Review Round 2 (dual-agent — APPROVE, no blocking) + +- **Non-blocking (fixed)** — N1: after a `compress`, the last assistant's provider + usage (`base`) still includes the range `prune()` is about to replace with a summary, + so the estimate was inflated by (compressed − summary) tokens and the first + post-compress transform could over-clear. Fix: when the last assistant step carries a + completed `compress` part, use the precise count of the already-pruned messages. + Regression test added + mutation-verified (fails without the fix). +- **Non-blocking (fixed)** — N2: when the last message is a new user turn, messages + after the last assistant (the current user message) were absent from `base` and only + the fixed `WIRE_SAFETY_MARGIN` covered them — a large paste into a near-full context + could still 400. Fix: add the token count of messages after the last assistant to the + estimate. Regression test added + mutation-verified (fails without the fix). +- **Non-blocking (fixed)** — N3: the "over budget but nothing clearable" ERROR fired on + every transform while stuck (log spam). Fix: dedup via a transient + `overflowGuardStuckLogged` state field (set when it logs, reset when the estimate + drops back under budget). Test covers dedup + reset-on-recovery + re-log. +- **NITs (fixed)** — T1: `[...messages].reverse().find()` copied the array every + transform → backward scan (O(1) common case). T2: corrected a misleading docstring + (the trailing-run count is exact for tool-calls-only steps, not an over-count). T3: + added a production-shape test clearing a tool output on an old *assistant* message. + T4: mock messages now use the real `time: { created }` shape (not top-level + `createdAt`) so a future `lastCompaction > 0` test won't throw. T5: added a test for + the non-completed-trailing-part break path. T6: tightened a loose `>= 2` assertion to + the exact count `3`. + +### Key Files + +- `lib/messages/prune-to-fit.ts` — **new**. `pruneToFit` (the guard) + + `resolveKnownWindow` (window resolution). Clears oldest non-protected tool outputs + until the estimate fits. +- `lib/messages/uncalibrated-window.ts` — **new**. `trackUncalibratedWindow` (Fix 1 + WARN) + `UNCALIBRATED_WINDOW_WARN_THRESHOLD` (3). +- `lib/hooks.ts` — import + call `trackUncalibratedWindow` (after + `updatePerTurnState`) and `pruneToFit` (after `truncateLargeToolOutputs`). +- `lib/config.ts` — `CompressConfig` + `DEFAULT_CONFIG` + `mergeCompress`: new + `overflowGuard` / `overflowGuardReserve`. +- `lib/config-validation.ts` — `VALID_CONFIG_KEYS` + type validation for the two knobs. +- `dcp.schema.json` — schema entries + defaults for the two knobs. +- `lib/state/types.ts`, `lib/state/state.ts` — transient fields + `uncalibratedWindowTransforms` / `uncalibratedWindowWarned` / + `overflowGuardStuckLogged` (init + reset). +- `lib/messages/index.ts` — barrel exports for both new modules. +- `tests/prune-to-fit.test.ts` — **new**, 33 tests. + +## 3. Design & Implementation Notes + +- **Entry point / key function**: `pruneToFit(state, config, logger, messages)` in + `lib/messages/prune-to-fit.ts`; `trackUncalibratedWindow(state, logger)` in + `lib/messages/uncalibrated-window.ts`. +- **Key configuration items**: + - `compress.overflowGuard` (bool, default `true`) — enable the guard. + - `compress.overflowGuardReserve` (number, default `32768`) — completion reserve. +- **Key logic explanation** (if non-trivial): + - `resolveKnownWindow` returns `modelContextLimit` (the real window) when set, else + the absolute `modelMaxLimits[provider/model]`, else the absolute + `maxContextLimit`; `undefined` when only a percent is configured (a percent is a + nudge threshold, not a window — using it would massively over-prune). + - Estimate = `getCurrentTokenUsage` (O(1) provider usage) + the last assistant's + trailing completed tool outputs (appended after the last LLM call, so absent from + the provider usage — B1) + any messages after the last assistant (the current user + turn — N2) + `WIRE_SAFETY_MARGIN` (8192) for nudges/ID tags. Falls back to a + precise content count when there is no provider usage, or when the last assistant + step ran a `compress` (the provider usage is then stale — N1). + - The guard iterates oldest→newest, skipping the current turn, user messages, the + recent-message protection zone (`computeProtectedRefs`), protected tools, and + protected file paths (Bug 39 parity). It clears a tool output by setting + `part.state.output` to a placeholder, tracking freed tokens, and stopping once the + estimate fits. Logs WARN on success, ERROR if it clears everything but still + exceeds the window. + +## 4. Testing & Verification + +### Build & Test Commands + +```sh +npm run build +npm run typecheck +node --import tsx --test tests/prune-to-fit.test.ts +node --import tsx --test tests/*.test.ts +``` + +### Test Coverage + +- New/modified test files: `tests/prune-to-fit.test.ts` (new, 33 tests). +- Test count: 1062 total, 1062 pass, 0 fail (full suite). +- Key scenarios verified: + - `resolveKnownWindow`: model limit wins; per-model absolute fallback; global + absolute fallback; per-model precedence; percent → undefined; nothing → undefined. + - `pruneToFit`: no-op under budget; fire + oldest-first + stop-when-fit; clears + multiple for a large gap (never the last); skips protected tools; skips protected + file paths; idempotent on already-cleared; no-op when disabled; no-op with no + known window; fires via absolute `maxContextLimit`; no-op when `safeBudget <= 0`; + respects the recent-message protection zone (gap forced into the zone); no crash + on empty/no-tool input; logs WARN on fit, ERROR when it cannot fit, and ERROR when + over budget but nothing is clearable; counts trailing tool outputs appended after + the last LLM call (B1 regression — verified to fail without the fix); does not + clear the current turn's trailing tool output; respects an explicit + `overflowGuardReserve: 0` (nullish, not falsy); uses the precise estimate after a + `compress` so a stale base does not over-clear (N1, mutation-verified); counts the + current user message after the last assistant (N2, mutation-verified); logs the + "nothing clearable" ERROR once per stuck episode and re-logs after recovery (N3); + clears tool outputs on old assistant messages (production shape, T3); a + non-completed trailing part breaks the trailing-run count (T5). + - `trackUncalibratedWindow`: warns once at threshold; never warns when calibrated; + dedup across many transforms; counter resets on calibration then re-climbs; + multi-turn accumulation. + +### Results + +- **PASS/FAIL**: PASS (typecheck clean, build clean, 1062/1062 tests pass). +- **Key logs/data**: n/a (unit-level). + +## 5. Risk Assessment & Rollback + +- **Risk points**: + - Over-pruning when the user sets `maxContextLimit` well below the real window + (mitigated: the user controls the declared budget; documented). + - Clearing a tool output loses it until re-run (intentional, last-resort; the model + sees a descriptive placeholder). +- **Rollback method**: + - Revert commit(s): the tip commit of `2026-08-28_overflow-guard` + - Or set `compress.overflowGuard: false` to disable the guard at runtime. + - Rollback impact: none (additive change). +- **Compatibility notes** (data format, config schema): Additive only. Two new config + keys (validated + schema'd); two new transient state fields (not persisted, so old + state files load unchanged). No internal `dcp` tag changes. + +## 6. Lessons Learned (optional) + +- The `#312` catalog reconciliation and the system-hook writer *both* guard on + `limit.context`, so a `limit.context = 0` model is blind at two independent points — + a fix must account for both (the catalog miss is why the WARN counter only climbs + for genuinely uncalibrated models, not for the first-request race). +- `truncateLargeToolOutputs` already had the same blindness (`if (!modelContextLimit) + return`) — the new guard deliberately does *not* gate on `modelContextLimit` so it + works via the absolute `maxContextLimit` fallback. + +## 7. Follow-ups (optional) + +- [ ] Once opencode exposes a response-error hook to plugins, add "learn the window + from 400s" (issue #347 Fix 3) so the guard works with zero configuration. +- [ ] File upstream opencode issues: exit-0-on-400 (no error surfaced) and + `options.maxTokens` not honored (leaks as a raw `maxTokens` body key). diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 27ba9016..82d3ac1c 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -48,6 +48,8 @@ export const VALID_CONFIG_KEYS = new Set([ "compress.preserveRecentMessages", "compress.preserveRecentTokens", "compress.preserveLastUserMessage", + "compress.overflowGuard", + "compress.overflowGuardReserve", "gc", "gc.algorithm", "gc.promotionThreshold", @@ -537,6 +539,39 @@ export function validateConfigTypes(config: Record): ValidationErro }) } + if ( + compress.overflowGuard !== undefined && + typeof compress.overflowGuard !== "boolean" + ) { + errors.push({ + key: "compress.overflowGuard", + expected: "boolean", + actual: typeof compress.overflowGuard, + }) + } + + if ( + compress.overflowGuardReserve !== undefined && + typeof compress.overflowGuardReserve !== "number" + ) { + errors.push({ + key: "compress.overflowGuardReserve", + expected: "number", + actual: typeof compress.overflowGuardReserve, + }) + } + + if ( + typeof compress.overflowGuardReserve === "number" && + compress.overflowGuardReserve < 0 + ) { + errors.push({ + key: "compress.overflowGuardReserve", + expected: "non-negative number (>= 0)", + actual: `${compress.overflowGuardReserve}`, + }) + } + if ( typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1 diff --git a/lib/config.ts b/lib/config.ts index 2a34c660..10f4f279 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -40,6 +40,21 @@ export interface CompressConfig { preserveRecentTokens?: number /** Always protect the most recent user message (default: true). */ preserveLastUserMessage?: boolean + /** + * Enable the request-side overflow guard ("prune-to-fit"). When the estimated + * wire size of the outgoing request exceeds `knownWindow - overflowGuardReserve`, + * deterministically clear the oldest compressible (non-protected) tool outputs + * until the estimate fits — independent of model cooperation. See #347. + * Default: true. + */ + overflowGuard?: boolean + /** + * Tokens reserved for the model's completion by the overflow guard. The guard + * keeps `safeBudget = knownWindow - overflowGuardReserve`. Should be at least the + * model's typical max output tokens (opencode falls back to 32000 when the model + * reports `limit.output = 0`). Default: 32768. + */ + overflowGuardReserve?: number } export interface Commands { @@ -217,6 +232,8 @@ const defaultConfig: PluginConfig = { preserveRecentMessages: 5, preserveRecentTokens: 5000, preserveLastUserMessage: true, + overflowGuard: true, + overflowGuardReserve: 32768, }, gc: { algorithm: "truncate", @@ -392,6 +409,8 @@ export function mergeCompress( preserveRecentMessages: override.preserveRecentMessages ?? base.preserveRecentMessages, preserveRecentTokens: override.preserveRecentTokens ?? base.preserveRecentTokens, preserveLastUserMessage: override.preserveLastUserMessage ?? base.preserveLastUserMessage, + overflowGuard: override.overflowGuard ?? base.overflowGuard, + overflowGuardReserve: override.overflowGuardReserve ?? base.overflowGuardReserve, } } diff --git a/lib/hooks.ts b/lib/hooks.ts index 6c96e227..49bdc1be 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -9,6 +9,7 @@ import { injectCompressNudges, injectMessageIds, prune, + pruneToFit, stripHallucinations, stripHallucinationsFromString, stripStaleMetadata, @@ -41,6 +42,7 @@ import { createSessionState, saveSessionState, syncToolCache, updatePerTurnState import { cacheSystemPromptTokens } from "./ui/utils" import { runBatchCleanup } from "./gc/merge" import { getCurrentTokenUsage } from "./token-utils" +import { trackUncalibratedWindow } from "./messages/uncalibrated-window" const INTERNAL_AGENT_SIGNATURES = [ "You are a title generator", @@ -224,6 +226,13 @@ export function createChatMessageTransformHandler( }) } await updatePerTurnState(state, logger, messages) + + // [FIX #347] Surface the uncalibrated-window blindness instead of + // letting every percentage threshold silently no-op. When the model + // reports no context window (limit.context=0) modelContextLimit stays + // undefined, so min/max/emergency thresholds all resolve to undefined + // and only the absolute growth nudge fires. Warn once per session. + trackUncalibratedWindow(state, logger) } syncCompressPermissionState(state, config, hostPermissions, output.messages) @@ -255,6 +264,13 @@ export function createChatMessageTransformHandler( const prePruneTokens = getCurrentTokenUsage(state, output.messages) prune(state, logger, config, output.messages) truncateLargeToolOutputs(state, config, logger, output.messages) + // [FIX #347] Request-side hard guard: if the estimated wire size exceeds + // the known window minus the completion reserve, deterministically clear + // the oldest compressible tool outputs until it fits. Runs after the + // (weaker, modelContextLimit-gated) truncation above and works even when + // the model reports no window, as long as an absolute maxContextLimit is + // configured. Last line of defense against a provider 400. + pruneToFit(state, config, logger, output.messages) hideConsumedCompressCalls(state, output.messages) assignMessageRefs(state, output.messages) const compressionPriorities = buildPriorityMap(config, state, output.messages) diff --git a/lib/messages/index.ts b/lib/messages/index.ts index 32b61d7c..3680b368 100644 --- a/lib/messages/index.ts +++ b/lib/messages/index.ts @@ -1,4 +1,9 @@ export { prune } from "./prune" +export { pruneToFit, resolveKnownWindow } from "./prune-to-fit" +export { + trackUncalibratedWindow, + UNCALIBRATED_WINDOW_WARN_THRESHOLD, +} from "./uncalibrated-window" export { syncCompressionBlocks } from "./sync" export { injectCompressNudges } from "./inject/inject" export { computeInputBudget } from "./inject/utils" diff --git a/lib/messages/prune-to-fit.ts b/lib/messages/prune-to-fit.ts new file mode 100644 index 00000000..e4a8c8c6 --- /dev/null +++ b/lib/messages/prune-to-fit.ts @@ -0,0 +1,283 @@ +import { SessionState, WithParts } from "../state" +import type { PluginConfig } from "../config" +import { Logger } from "../logger" +import { + getCurrentTokenUsage, + countTokens, + countAllMessageTokens, + extractCompletedToolOutput, + COMPACTED_TOOL_OUTPUT_PLACEHOLDER, +} from "../token-utils" +import { + isToolNameProtected, + getFilePathsFromParameters, + isFilePathProtected, +} from "../protected-patterns" +import { computeProtectedRefs, findLastNonIgnoredMessage } from "./inject/utils" + +/** + * [FIX #347] Request-side hard guard ("prune-to-fit"). + * + * When the estimated wire size of the outgoing request exceeds + * `safeBudget = knownWindow - overflowGuardReserve`, deterministically clear the + * oldest compressible (non-protected) tool outputs until the estimate fits — + * independent of model cooperation. This converts a hard provider 400 + * ("Requested token count exceeds the model's maximum context length") into a + * degraded-but-working turn, instead of a silent exit-0 death loop. + * + * `knownWindow` is `state.modelContextLimit` (the real window, when the model + * reports one) OR the absolute `compress.maxContextLimit` / + * `compress.modelMaxLimits[provider/model]` (a number, not a percent) as a + * conservative lower bound. When neither is available the guard cannot fire — + * the uncalibrated-window WARN (Fix 1, hooks.ts) tells the user to configure a + * window so this guard can protect them. + */ + +/** Extra tokens added on top of the provider-reported usage to cover the new + * user message + nudges that are appended after the last assistant turn. */ +const WIRE_SAFETY_MARGIN = 8192 + +/** Placeholder written into a cleared tool output. Distinct from opencode's own + * compaction placeholder so the two mechanisms are distinguishable in logs. */ +const CLEAR_PLACEHOLDER = "[cleared by ACP overflow guard — re-run tool if needed]" + +/** Only clear outputs large enough that clearing them is worth the churn. */ +const MIN_CLEAR_TOKENS = 500 + +/** + * Resolve the context window the guard should keep the request within. + * + * Returns: + * - `state.modelContextLimit` when the model reports a window (the real window); + * - else the absolute `compress.modelMaxLimits[provider/model]` (number); + * - else the absolute `compress.maxContextLimit` (number); + * - else `undefined` (percent values cannot be resolved without a window — the + * guard stays off and the uncalibrated-window WARN guides the user). + */ +export function resolveKnownWindow( + config: PluginConfig, + state: SessionState, + providerId: string | undefined, + modelId: string | undefined, +): number | undefined { + if (state.modelContextLimit !== undefined) { + return state.modelContextLimit + } + + const maxLimits = config.compress.modelMaxLimits + if (maxLimits && providerId !== undefined && modelId !== undefined) { + const perModel = maxLimits[`${providerId}/${modelId}`] + if (typeof perModel === "number") return perModel + } + + const global = config.compress.maxContextLimit + if (typeof global === "number") return global + + return undefined +} + +/** + * Estimate the outgoing request's wire size (tokens). + * + * Primary: the last assistant turn's provider-reported usage + * (`getCurrentTokenUsage`) — the context size AFTER that LLM call — plus: + * (1) the trailing completed tool outputs of the last assistant message, which + * are appended AFTER the last LLM call and so absent from that usage + * (opencode runs messages.transform on every LLM call; a mid-turn + * sub-request otherwise misses fresh tool outputs — issue #347 B1). This + * count is exact for both text and tool-calls-only steps: a step's own + * usage cannot include its own tool results; + * (2) any messages after the last assistant (typically the current user message + * on a new turn), also absent from that usage (review N2); + * (3) {@link WIRE_SAFETY_MARGIN} for nudges / ID tags appended after the guard. + * + * Falls back to {@link preciseWireTokens} (count every message's content) when + * there is no provider usage data, OR when the usage is known-stale: the last + * assistant step ran a `compress`, so `base` still includes the range that + * `prune()` is about to replace with a summary (review N1). + */ +function estimateWireTokens(state: SessionState, messages: WithParts[]): number { + const base = getCurrentTokenUsage(state, messages) + if (base > 0) { + // Backward scan for the last assistant message — O(1) in the common case + // where it is the most recent message (no array copy; review T1). + let lastAsstIdx = -1 + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].info.role === "assistant") { + lastAsstIdx = i + break + } + } + + // A completed `compress` in the last assistant step means `base` still + // includes the range prune() has (or will) replace with a summary — the + // base is stale by (compressed − summary) tokens. Count the already- + // pruned messages precisely instead of over-clearing (review N1). + if (lastAsstIdx >= 0 && hasCompletedCompressPart(messages[lastAsstIdx]!)) { + return preciseWireTokens(state, messages) + } + + // Trailing completed tool outputs were appended after the last LLM call, + // so they are absent from `base` — count them (issue #347 B1). + let trailing = 0 + if (lastAsstIdx >= 0) { + const parts = Array.isArray(messages[lastAsstIdx]!.parts) + ? messages[lastAsstIdx]!.parts + : [] + for (let i = parts.length - 1; i >= 0; i--) { + const p = parts[i] + if (p?.type !== "tool") break + if (p.state.status !== "completed") break + trailing += countTokens(extractCompletedToolOutput(p) ?? "") + } + } + + // Messages after the last assistant (usually the current user message on + // a new turn) are also absent from `base` — count them (review N2). + let afterLastAsst = 0 + for (let i = lastAsstIdx + 1; i < messages.length; i++) { + afterLastAsst += countAllMessageTokens(messages[i]!) + } + + return base + trailing + afterLastAsst + WIRE_SAFETY_MARGIN + } + + return preciseWireTokens(state, messages) +} + +/** Precise wire-size estimate: count the content of every message ourselves, + * plus the system prompt. Used when there is no provider usage data, or when + * the provider usage is known-stale (a compression just ran). */ +function preciseWireTokens(state: SessionState, messages: WithParts[]): number { + let total = state.systemPromptTokens ?? 0 + for (const msg of messages) { + total += countAllMessageTokens(msg) + } + return total + WIRE_SAFETY_MARGIN +} + +function hasCompletedCompressPart(message: WithParts): boolean { + const parts = Array.isArray(message.parts) ? message.parts : [] + for (const p of parts) { + if (p?.type === "tool" && p.tool === "compress" && p.state.status === "completed") { + return true + } + } + return false +} + +/** + * Deterministically clear the oldest compressible (non-protected) tool outputs + * until the estimated wire size fits within `safeBudget`. Mutates the tool parts + * in place (same mechanism as `truncateLargeToolOutputs`). + * + * No-op when the guard is disabled, no window is known, or the estimate already + * fits. Logs a WARN when it clears outputs to fit and an ERROR when it clears + * everything it can but the context still exceeds the window. + */ +export function pruneToFit( + state: SessionState, + config: PluginConfig, + logger: Logger, + messages: WithParts[], +): void { + if (!config.compress.overflowGuard) return + + const knownWindow = resolveKnownWindow(config, state, state.modelProviderID, state.modelID) + if (knownWindow === undefined) return + + const reserve = config.compress.overflowGuardReserve ?? 32768 + const safeBudget = knownWindow - reserve + if (safeBudget <= 0) return + + const estimate = estimateWireTokens(state, messages) + if (estimate <= safeBudget) { + // Back under budget — clear the stuck flag so a future stuck episode + // logs its ERROR again (review N3). + state.overflowGuardStuckLogged = false + return + } + + const protectedRefs = computeProtectedRefs(messages, state, config.compress) + const lastNonIgnored = findLastNonIgnoredMessage(messages) + const lastMsgId = lastNonIgnored?.message.info.id + const protectedTools = config.compress.protectedTools + const protectedFilePatterns = config.protectedFilePatterns + + let freed = 0 + let clearedCount = 0 + + // Oldest → newest so the least-recent (least-relevant) outputs go first. + for (const msg of messages) { + if (estimate - freed <= safeBudget) break + + // Never touch the current turn. + if (msg.info.id === lastMsgId) continue + if (msg.info.role === "user") continue + + const ref = state.messageIds.byRawId.get(msg.info.id) + if (ref && protectedRefs.has(ref)) continue + + const parts = Array.isArray(msg.parts) ? msg.parts : [] + for (const part of parts) { + if (estimate - freed <= safeBudget) break + if (part?.type !== "tool") continue + + // Narrow the ToolState discriminated union to the completed variant + // (the only one carrying `output` / `input`). + const toolState = part.state + if (toolState.status !== "completed") continue + + const content = extractCompletedToolOutput(part) + if (content === undefined) continue + // Idempotency: skip outputs already cleared by this guard or by + // opencode's own compaction. + if (content === CLEAR_PLACEHOLDER) continue + if (content === COMPACTED_TOOL_OUTPUT_PLACEHOLDER) continue + + // Hard-exclude protected tools / protected file paths (Bug 39 parity). + if (isToolNameProtected(part.tool, protectedTools)) continue + if (protectedFilePatterns.length > 0) { + const filePaths = getFilePathsFromParameters(part.tool, toolState.input) + if (isFilePathProtected(filePaths, protectedFilePatterns)) continue + } + + const outputTokens = countTokens(content) + if (outputTokens < MIN_CLEAR_TOKENS) continue + + toolState.output = CLEAR_PLACEHOLDER + freed += outputTokens - countTokens(CLEAR_PLACEHOLDER) + clearedCount++ + } + } + + const after = estimate - freed + const detail = { + session: state.sessionId, + estimate, + safeBudget, + knownWindow, + reserve, + clearedCount, + freedTokens: Math.round(freed), + afterTokens: Math.round(after), + } + if (clearedCount === 0) { + // Over budget but nothing was clearable — the request is still about to + // 400. This condition persists across transforms, so log it once per + // stuck episode (the flag resets when the estimate drops under budget). + if (!state.overflowGuardStuckLogged) { + state.overflowGuardStuckLogged = true + logger.error("ACP overflow guard: over window but no clearable tool outputs", detail) + } + return + } + if (after <= safeBudget) { + logger.warn("ACP overflow guard: cleared tool outputs to fit context window", detail) + } else { + logger.error( + "ACP overflow guard: cleared tool outputs but context STILL exceeds window", + detail, + ) + } +} diff --git a/lib/messages/uncalibrated-window.ts b/lib/messages/uncalibrated-window.ts new file mode 100644 index 00000000..34e2c564 --- /dev/null +++ b/lib/messages/uncalibrated-window.ts @@ -0,0 +1,54 @@ +import type { SessionState } from "../state" +import type { Logger } from "../logger" + +/** + * [FIX #347] Number of consecutive message-transforms with an unresolved + * context window before we emit the one-time "uncalibrated window" WARN. Three + * is enough to rule out the first-request race (system.transform sets the limit + * AFTER messages.transform within one request) while still surfacing the + * blindness quickly. + */ +export const UNCALIBRATED_WINDOW_WARN_THRESHOLD = 3 + +/** + * [FIX #347] Track the uncalibrated-window condition and emit a one-time WARN. + * + * When the model reports no context window (`limit.context = 0`) the catalog + * never records a limit and `state.modelContextLimit` stays undefined — so every + * percentage threshold (min/max/emergency) resolves to undefined and only the + * absolute growth nudge fires. That blindness is what lets a session silently + * grow past the backend's real window and die on a provider 400 (#347). + * + * This helper counts consecutive transforms with an unresolved window and, once + * the threshold is reached, logs a prominent one-time WARN pointing the user at + * the fix (declare `limit` in opencode.json or set absolute + * `compress.maxContextLimit`/`minContextLimit`). The request-side overflow guard + * (`prune-to-fit.ts`) also needs a known window to fire, so this WARN covers + * both blind spots. + * + * The counter resets to 0 as soon as a window resolves, so a session that later + * switches to a model with a known window stops counting. + */ +export function trackUncalibratedWindow(state: SessionState, logger: Logger): void { + if (state.modelContextLimit === undefined) { + state.uncalibratedWindowTransforms++ + if ( + state.uncalibratedWindowTransforms >= UNCALIBRATED_WINDOW_WARN_THRESHOLD && + !state.uncalibratedWindowWarned + ) { + state.uncalibratedWindowWarned = true + logger.warn( + "Model reports no context window — ACP percentage thresholds are disabled", + { + session: state.sessionId, + provider: state.modelProviderID, + model: state.modelID, + transforms: state.uncalibratedWindowTransforms, + hint: 'set the model\'s `limit` in opencode.json (e.g. {"context": 262144, "output": 16384}) or use absolute compress.maxContextLimit / compress.minContextLimit in acp.jsonc; the request-side overflow guard also needs a known window to fire', + }, + ) + } + } else { + state.uncalibratedWindowTransforms = 0 + } +} diff --git a/lib/state/state.ts b/lib/state/state.ts index dc449536..d0fa09e8 100644 --- a/lib/state/state.ts +++ b/lib/state/state.ts @@ -207,6 +207,9 @@ export function createSessionState(): SessionState { modelID: undefined, systemPromptTokens: undefined, qualityGateRetryPending: false, + uncalibratedWindowTransforms: 0, + uncalibratedWindowWarned: false, + overflowGuardStuckLogged: false, } } @@ -249,6 +252,9 @@ export function resetSessionState(state: SessionState): void { state.modelID = undefined state.systemPromptTokens = undefined state.qualityGateRetryPending = false + state.uncalibratedWindowTransforms = 0 + state.uncalibratedWindowWarned = false + state.overflowGuardStuckLogged = false } export async function ensureSessionInitialized( diff --git a/lib/state/types.ts b/lib/state/types.ts index b9736f21..80e3dd2e 100644 --- a/lib/state/types.ts +++ b/lib/state/types.ts @@ -160,4 +160,24 @@ export interface SessionState { * - Normal call (no acknowledgeRisk) → quality runs normally */ qualityGateRetryPending: boolean + /** + * [FIX #347] Transient counter (NOT persisted): number of consecutive + * message-transforms in which `modelContextLimit` remained undefined. + * Reset to 0 as soon as a limit is resolved. When it reaches the warn + * threshold the session emits a one-time WARN that percentage thresholds are + * disabled because the model reports no context window. + */ + uncalibratedWindowTransforms: number + /** + * [FIX #347] Transient flag (NOT persisted): set to true once the + * uncalibrated-window WARN has fired for this session, so it only logs once. + */ + uncalibratedWindowWarned: boolean + /** + * [FIX #347] Transient flag (NOT persisted): the overflow guard is over + * budget with nothing clearable. The ERROR for that condition persists + * across transforms, so it is logged once per "stuck episode" and the flag + * resets when the estimate drops back under budget (review N3). + */ + overflowGuardStuckLogged: boolean } diff --git a/tests/prune-to-fit.test.ts b/tests/prune-to-fit.test.ts new file mode 100644 index 00000000..4b80423e --- /dev/null +++ b/tests/prune-to-fit.test.ts @@ -0,0 +1,858 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import type { SessionState, WithParts } from "../lib/state/types" +import type { PluginConfig } from "../lib/config" +import type { Logger } from "../lib/logger" +import { countTokens } from "../lib/token-utils" +import { pruneToFit, resolveKnownWindow } from "../lib/messages/prune-to-fit" +import { + trackUncalibratedWindow, + UNCALIBRATED_WINDOW_WARN_THRESHOLD, +} from "../lib/messages/uncalibrated-window" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Marker written by the guard into a cleared tool output (mirrors the source + * constant in lib/messages/prune-to-fit.ts). */ +const CLEAR_PLACEHOLDER = "[cleared by ACP overflow guard — re-run tool if needed]" + +interface LogCall { + level: "debug" | "info" | "warn" | "error" + message: string + data?: unknown +} + +function makeCapturingLogger(): { logger: Logger; calls: LogCall[] } { + const calls: LogCall[] = [] + const push = (level: LogCall["level"]) => (message: string, data?: unknown) => + calls.push({ level, message, data }) + const logger = { + debug: push("debug"), + info: push("info"), + warn: push("warn"), + error: push("error"), + child: () => logger, + } as unknown as Logger + return { logger, calls } +} + +const noopLogger: Logger = makeCapturingLogger().logger + +/** Token-dense output (~28k tokens) so clearing one frees a predictable, large + * amount. Repeated single chars compress far too aggressively to be useful. */ +const WORDS = + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma tau upsilon phi chi psi omega " +const DENSE = WORDS.repeat(1000) + +function makeConfig( + overrides: { + compress?: Partial + protectedFilePatterns?: string[] + } = {}, +): PluginConfig { + return { + enabled: true, + autoUpdate: false, + debug: false, + pruneNotification: "off", + pruneNotificationType: "chat", + commands: { enabled: true, protectedTools: [] }, + experimental: { allowSubAgents: false, customPrompts: false }, + protectedFilePatterns: overrides.protectedFilePatterns ?? [], + compress: { + permission: "allow", + showCompression: false, + summaryBuffer: true, + maxContextLimit: 150000, + minContextLimit: 50000, + nudgeFrequency: 5, + minNudgeContextPercent: 15, + iterationNudgeThreshold: 15, + nudgeForce: "soft", + protectedTools: [], + protectTags: false, + protectUserMessages: false, + maxSummaryLengthHard: 20000, + minCompressRange: 5000, + minNudgeGrowthRatio: 0.45, + minNudgeGrowthFloor: 5000, + emergencyThresholdPercent: "98%", + maxVisibleSegments: 50, + keepEmbedMaxChars: 2000, + lastSegmentSoftBlock: true, + preserveRecentMessages: 5, + preserveRecentTokens: 5000, + preserveLastUserMessage: true, + overflowGuard: true, + overflowGuardReserve: 32768, + ...overrides.compress, + }, + gc: { + algorithm: "truncate", + promotionThreshold: 5, + maxBlockAge: 15, + maxOldGenSummaryLength: 3000, + majorGcThresholdPercent: "100%", + }, + } as unknown as PluginConfig +} + +function makeState(overrides: Partial = {}): SessionState { + return { + sessionId: "session-1", + isSubAgent: false, + modelContextLimit: 200000, + modelProviderID: undefined, + modelID: undefined, + systemPromptTokens: undefined, + lastCompaction: 0, + currentTurn: 0, + prune: { + messages: { + byMessageId: new Map(), + blocksById: new Map(), + activeBlockIds: new Set(), + activeByAnchorMessageId: new Map(), + nextBlockId: 1, + nextRunId: 1, + markedForCleanup: new Set(), + }, + }, + nudges: { + contextLimitAnchors: new Set(), + turnNudgeAnchors: new Set(), + iterationNudgeAnchors: new Set(), + lastPerMessageNudgeTurn: 0, + lastPerMessageNudgeTokens: undefined, + lastNudgeShownTokens: undefined, + lastToolOutputNudgeTokens: undefined, + lastTier2NudgeTokens: undefined, + lastTier3NudgeTokens: undefined, + shouldInjectThisTurn: undefined, + compressBaselineSet: false, + lastProcessedCompressMessageId: undefined, + }, + stats: { pruneTokenCounter: 0, totalPruneTokens: 0 }, + messageIds: { byRawId: new Map(), byRef: new Map(), nextRef: 1 }, + compressionTiming: { startsByCallId: new Map(), pendingByCallId: new Map() }, + toolParameters: new Map(), + toolIdList: [], + qualityGateRetryPending: false, + uncalibratedWindowTransforms: 0, + uncalibratedWindowWarned: false, + overflowGuardStuckLogged: false, + ...overrides, + } as unknown as SessionState +} + +function makeToolMessage( + id: string, + output: string, + tool = "bash", + status: "completed" | "error" = "completed", +): WithParts { + return { + info: { + id, + role: "tool", + sessionID: "session-1", + time: { created: Date.now() }, + } as any, + parts: [ + { + type: "tool", + tool, + callID: `call-${id}`, + state: { status, output, input: {}, time: {} }, + }, + ] as any, + } +} + +function makeTextMessage(id: string, text: string, role: "user" | "assistant" = "user"): WithParts { + return { + info: { + id, + role, + sessionID: "session-1", + time: { created: Date.now() }, + } as any, + parts: [{ type: "text", text }] as any, + } +} + +function makeAssistantWithTokens(id: string, inputTokens: number, text = "ok"): WithParts { + return { + info: { + id, + role: "assistant", + sessionID: "session-1", + time: { created: Date.now() }, + tokens: { input: inputTokens, output: 100 }, + } as any, + parts: [{ type: "text", text }] as any, + } +} + +/** Production shape: an assistant message whose LAST part is a completed tool + * result appended after the LLM call (so it is absent from `tokens.input`). */ +function makeAssistantWithTrailingTool( + id: string, + inputTokens: number, + toolOutput: string, +): WithParts { + return { + info: { + id, + role: "assistant", + sessionID: "session-1", + time: { created: Date.now() }, + tokens: { input: inputTokens, output: 100 }, + } as any, + parts: [ + { type: "text", text: "ok" }, + { + type: "tool", + tool: "bash", + callID: `call-${id}`, + state: { status: "completed", output: toolOutput, input: {}, time: {} }, + }, + ] as any, + } +} + +function getOutput(msg: WithParts): string { + return (msg.parts[0] as any).state.output as string +} + +function isCleared(msg: WithParts): boolean { + return getOutput(msg) === CLEAR_PLACEHOLDER +} + +// --------------------------------------------------------------------------- +// resolveKnownWindow +// --------------------------------------------------------------------------- + +test("resolveKnownWindow: returns modelContextLimit when the model reports a window", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 262144 }) + assert.equal(resolveKnownWindow(config, state, "prov", "model"), 262144) +}) + +test("resolveKnownWindow: falls back to per-model absolute maxContextLimit", () => { + const config = makeConfig({ + compress: { + maxContextLimit: "80%", + modelMaxLimits: { "prov/model": 200000 }, + }, + }) + const state = makeState({ + modelContextLimit: undefined, + modelProviderID: "prov", + modelID: "model", + }) + assert.equal(resolveKnownWindow(config, state, "prov", "model"), 200000) +}) + +test("resolveKnownWindow: falls back to global absolute maxContextLimit (number)", () => { + const config = makeConfig({ compress: { maxContextLimit: 150000 } }) + const state = makeState({ modelContextLimit: undefined }) + assert.equal(resolveKnownWindow(config, state, "prov", "model"), 150000) +}) + +test("resolveKnownWindow: per-model limit takes precedence over global", () => { + const config = makeConfig({ + compress: { + maxContextLimit: 150000, + modelMaxLimits: { "prov/model": 250000 }, + }, + }) + const state = makeState({ + modelContextLimit: undefined, + modelProviderID: "prov", + modelID: "model", + }) + assert.equal(resolveKnownWindow(config, state, "prov", "model"), 250000) +}) + +test("resolveKnownWindow: returns undefined for a percent maxContextLimit with no window", () => { + const config = makeConfig({ compress: { maxContextLimit: "80%" } }) + const state = makeState({ modelContextLimit: undefined }) + assert.equal(resolveKnownWindow(config, state, "prov", "model"), undefined) +}) + +test("resolveKnownWindow: returns undefined when nothing is configured", () => { + const config = makeConfig({ compress: { maxContextLimit: "80%" } }) + const state = makeState({ modelContextLimit: undefined }) + // percent can't resolve without a window, no per-model entry + assert.equal(resolveKnownWindow(config, state, undefined, undefined), undefined) +}) + +// --------------------------------------------------------------------------- +// pruneToFit — firing / not firing +// --------------------------------------------------------------------------- + +test("pruneToFit: no-op when the estimate is under the safe budget", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + // estimate = 100000 + 100 + 8192 = 108292; safeBudget = 200000 - 32768 = 167232 + makeAssistantWithTokens("msg-asst", 100000), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), false, "should not clear when under budget") + assert.equal(getOutput(messages[0]!), DENSE) +}) + +test("pruneToFit: clears the oldest tool output when over budget, stops when it fits", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [] + for (let i = 0; i < 5; i++) messages.push(makeToolMessage(`msg-${i}`, DENSE)) + messages.push(makeTextMessage("msg-user", "hello")) + // estimate = 170000 + 100 + 8192 = 178292 > safeBudget 167232 → fire. + // One DENSE output frees ~28k tokens, far more than the ~11k gap → stop after one. + messages.push(makeAssistantWithTokens("msg-asst", 170000)) + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), true, "oldest tool output should be cleared") + for (let i = 1; i < 5; i++) { + assert.equal(isCleared(messages[i]!), false, `msg-${i} should be untouched (guard stopped)`) + } +}) + +test("pruneToFit: clears multiple oldest outputs for a large gap, never the last", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [] + for (let i = 0; i < 6; i++) messages.push(makeToolMessage(`msg-${i}`, DENSE)) + messages.push(makeTextMessage("msg-user", "hello")) + // estimate = 218940 + 100 + 8192 = 227232; gap = 60000 → needs ~3 outputs. + messages.push(makeAssistantWithTokens("msg-asst", 218940)) + + pruneToFit(state, config, noopLogger, messages) + + const clearedCount = messages.slice(0, 6).filter(isCleared).length + assert.equal( + clearedCount, + 3, + `expected exactly 3 cleared (gap 60000 / 27985 per clear), got ${clearedCount}`, + ) + assert.equal(isCleared(messages[0]!), true, "oldest should be cleared") + assert.equal( + isCleared(messages[5]!), + false, + "newest tool output should be protected (guard stopped)", + ) +}) + +test("pruneToFit: skips protected tools even when over budget", () => { + const config = makeConfig({ compress: { protectedTools: ["bash"] } }) + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [] + for (let i = 0; i < 5; i++) messages.push(makeToolMessage(`msg-${i}`, DENSE, "bash")) + messages.push(makeTextMessage("msg-user", "hello")) + messages.push(makeAssistantWithTokens("msg-asst", 218940)) + + pruneToFit(state, config, noopLogger, messages) + + for (let i = 0; i < 5; i++) { + assert.equal(isCleared(messages[i]!), false, `protected tool msg-${i} must not be cleared`) + } +}) + +test("pruneToFit: skips protected file paths even when over budget", () => { + const config = makeConfig({ protectedFilePatterns: ["**/secret.txt"] }) + const state = makeState({ modelContextLimit: 200000 }) + // A read tool whose file path matches the protected pattern. + const messages: WithParts[] = [ + { + info: { + id: "msg-0", + role: "tool", + sessionID: "s", + time: { created: Date.now() }, + } as any, + parts: [ + { + type: "tool", + tool: "read", + callID: "c0", + state: { + status: "completed", + output: DENSE, + input: { filePath: "/a/secret.txt" }, + time: {}, + }, + }, + ] as any, + }, + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 218940), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), false, "protected-file tool output must not be cleared") +}) + +test("pruneToFit: is idempotent — already-cleared outputs are not double-counted", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + const msgs: WithParts[] = [ + makeToolMessage("msg-0", CLEAR_PLACEHOLDER), // already cleared by a prior pass + makeToolMessage("msg-1", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 170000), + ] + + pruneToFit(state, config, noopLogger, msgs) + + assert.equal(getOutput(msgs[0]!), CLEAR_PLACEHOLDER, "already-cleared output stays as-is") + assert.equal(isCleared(msgs[1]!), true, "the next live output is cleared to make room") +}) + +test("pruneToFit: no-op when overflowGuard is disabled", () => { + const config = makeConfig({ compress: { overflowGuard: false } }) + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 218940), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), false, "disabled guard must not clear") +}) + +test("pruneToFit: no-op when no known window (percent maxContextLimit + no model limit)", () => { + const config = makeConfig({ compress: { maxContextLimit: "80%" } }) + const state = makeState({ modelContextLimit: undefined }) + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 218940), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), false, "guard cannot fire without a known window") +}) + +test("pruneToFit: fires using an absolute maxContextLimit when the model reports no window", () => { + const config = makeConfig({ compress: { maxContextLimit: 150000 } }) + const state = makeState({ modelContextLimit: undefined }) + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + // safeBudget = 150000 - 32768 = 117232; estimate = 140000 + 8292 = 148292 > 117232 + makeAssistantWithTokens("msg-asst", 140000), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), true, "guard should fire via absolute maxContextLimit") +}) + +test("pruneToFit: no-op when safeBudget is non-positive (reserve >= window)", () => { + const config = makeConfig({ compress: { overflowGuardReserve: 32768 } }) + const state = makeState({ modelContextLimit: 10000 }) + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 218940), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal(isCleared(messages[0]!), false, "guard must not run with a non-positive budget") +}) + +test("pruneToFit: respects the recent-message protection zone (byRawId + preserveRecentMessages)", () => { + const config = makeConfig({ compress: { preserveRecentMessages: 2, preserveRecentTokens: 0 } }) + const { logger, calls } = makeCapturingLogger() + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [] + for (let i = 0; i < 6; i++) messages.push(makeToolMessage(`msg-${i}`, DENSE)) + // estimate = 327000 + 100 + 8192 = 335292; gap = 168060 → needs ~6 clears, + // forcing the guard all the way to the protected zone. + messages.push(makeAssistantWithTokens("msg-asst", 327000)) + + // Give refs so computeProtectedRefs protects the last 2 visible messages + // (msg-5 + msg-asst). msg-4 sits just outside the zone. + const byRawId = new Map() + messages.forEach((m, i) => byRawId.set(m.info.id, `m${String(i + 1).padStart(5, "0")}`)) + state.messageIds.byRawId = byRawId + + pruneToFit(state, config, logger, messages) + + // Gap requires ~6 clears; the guard clears msg-0..4 (5) then reaches msg-5, + // which is in the protected zone and is skipped (not because the guard + // stopped — the budget is still exceeded). + for (let i = 0; i < 5; i++) { + assert.equal(isCleared(messages[i]!), true, `msg-${i} should be cleared`) + } + assert.equal( + isCleared(messages[5]!), + false, + "msg-5 is in the recent zone and must be protected", + ) + // Nothing more is clearable (msg-5 protected, msg-asst is the current turn) → ERROR. + const errors = calls.filter((c) => c.level === "error" && c.message.includes("overflow guard")) + assert.equal(errors.length, 1, "should log an ERROR when the zone blocks the last needed clear") +}) + +test("pruneToFit: no crash on empty messages / no tool outputs", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + pruneToFit(state, config, noopLogger, []) + pruneToFit(state, config, noopLogger, [ + makeTextMessage("m1", "text"), + makeAssistantWithTokens("m2", 218940), + ]) +}) + +test("pruneToFit: logs a WARN when it clears outputs to fit", () => { + const { logger, calls } = makeCapturingLogger() + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 170000), + ] + + pruneToFit(state, config, logger, messages) + + const warns = calls.filter((c) => c.level === "warn" && c.message.includes("overflow guard")) + assert.equal(warns.length, 1, "should log exactly one overflow-guard WARN") +}) + +test("pruneToFit: logs an ERROR when it clears everything but still exceeds the window", () => { + const { logger, calls } = makeCapturingLogger() + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // Only ONE clearable output, but the gap is far larger than it can free → ERROR. + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 500000), + ] + + pruneToFit(state, config, logger, messages) + + const errors = calls.filter((c) => c.level === "error" && c.message.includes("overflow guard")) + assert.equal(errors.length, 1, "should log an overflow-guard ERROR when it cannot fit") +}) + +test("pruneToFit: counts trailing tool outputs appended after the last LLM call (B1)", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // safeBudget = 200000 - 32768 = 167232. The last assistant's provider tokens + // (140000) do NOT include its trailing tool output (DENSE ~28001). Without the + // B1 fix, estimate = 140000 + 100 + 8192 = 148292 < 167232 → no fire. With the + // fix, estimate = 148292 + 28001 = 176293 > 167232 → fire. + const messages: WithParts[] = [ + makeToolMessage("msg-old", DENSE), + makeAssistantWithTrailingTool("msg-asst", 140000, DENSE), + ] + + pruneToFit(state, config, noopLogger, messages) + + // The guard fires and clears the OLDER output, not the last assistant's + // trailing output (current turn — protected via lastMsgId). + assert.equal(isCleared(messages[0]!), true, "older tool output should be cleared") + const trailing = (messages[1]!.parts[1] as any).state.output + assert.equal(trailing, DENSE, "last assistant's trailing tool output must NOT be cleared") +}) + +test("pruneToFit: does not clear the current turn's trailing tool output even when it is the only overflow", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // Only the last assistant carries a big trailing tool output; there is no + // older clearable output. The guard may fire (estimate over budget) but must + // not clear the current turn — it logs an ERROR instead. + const { logger, calls } = makeCapturingLogger() + const messages: WithParts[] = [makeAssistantWithTrailingTool("msg-asst", 200000, DENSE)] + + pruneToFit(state, config, logger, messages) + + const trailing = (messages[0]!.parts[1] as any).state.output + assert.equal(trailing, DENSE, "current turn's trailing tool output must NOT be cleared") + const errors = calls.filter((c) => c.level === "error" && c.message.includes("overflow guard")) + assert.equal(errors.length, 1, "should log an ERROR (over window, nothing clearable)") +}) + +test("pruneToFit: respects an explicit overflowGuardReserve of 0 (nullish, not falsy)", () => { + const config = makeConfig({ compress: { overflowGuardReserve: 0 } }) + const state = makeState({ modelContextLimit: 200000 }) + // safeBudget = 200000 - 0 = 200000 (reserve 0 respected, not defaulted to 32768). + // estimate = 195000 + 100 + 8192 = 203292 > 200000 → fire. + const messages: WithParts[] = [ + makeToolMessage("msg-0", DENSE), + makeTextMessage("msg-user", "hello"), + makeAssistantWithTokens("msg-asst", 195000), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal( + isCleared(messages[0]!), + true, + "reserve 0 must be respected (budget = full window)", + ) +}) + +// --------------------------------------------------------------------------- +// pruneToFit — estimator edge cases (review round 2) +// --------------------------------------------------------------------------- + +test("pruneToFit: uses the precise estimate after a compress so a stale base does not over-clear (review N1)", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // safeBudget = 167232. The last assistant's provider tokens (180000) are + // STALE: they still include the range a just-completed `compress` replaces + // with a summary. The real (post-prune) content is far smaller. + const messages: WithParts[] = [ + makeToolMessage("msg-old", DENSE), + { + info: { + id: "msg-asst", + role: "assistant", + sessionID: "session-1", + time: { created: Date.now() }, + tokens: { input: 180000, output: 100 }, + } as any, + parts: [ + { type: "text", text: "ok" }, + { + type: "tool", + tool: "compress", + callID: "call-c", + state: { status: "completed", output: "summary", input: {}, time: {} }, + }, + ] as any, + }, + ] + + pruneToFit(state, config, noopLogger, messages) + + // Precise estimate (real content ~28k + margin) is under budget → no clear. + // Without the N1 fix the stale base (180000) would push the estimate over + // budget and clear msg-old. + assert.equal( + isCleared(messages[0]!), + false, + "stale post-compress base must not trigger over-clearing", + ) +}) + +test("pruneToFit: counts the current user message after the last assistant (review N2)", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // safeBudget = 167232. The last message is a NEW user turn carrying a large + // paste (DENSE ~28k). base (assistant 150000) does not include it. Without + // N2 the estimate is 150100 + 8192 = 158292 < 167232 (no fire); with N2 it + // adds the user message → 186293 > 167232 (fire). + const messages: WithParts[] = [ + makeToolMessage("msg-old", DENSE), + makeAssistantWithTokens("msg-asst", 150000), + makeTextMessage("msg-user", DENSE, "user"), + ] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal( + isCleared(messages[0]!), + true, + "a large current user message must push the estimate over budget", + ) +}) + +test("pruneToFit: logs the 'nothing clearable' ERROR once per stuck episode, re-logging after recovery (review N3)", () => { + const { logger, calls } = makeCapturingLogger() + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // Only the current turn carries a big trailing tool output; nothing older is + // clearable → over budget with nothing to clear on every transform. + const stuck: WithParts[] = [makeAssistantWithTrailingTool("msg-asst", 200000, DENSE)] + pruneToFit(state, config, logger, stuck) // transform 1 → ERROR + pruneToFit(state, config, logger, stuck) // transform 2 → deduped + + // Recover: a small context is under budget → the stuck flag resets. + const recovered: WithParts[] = [makeAssistantWithTokens("msg-asst2", 100000)] + pruneToFit(state, config, logger, recovered) + + // Stuck again → the flag was reset, so the ERROR logs a second time. + pruneToFit(state, config, logger, stuck) + pruneToFit(state, config, logger, stuck) // deduped + + const errors = calls.filter((c) => c.level === "error" && c.message.includes("overflow guard")) + assert.equal(errors.length, 2, "stuck ERROR logs once per episode and re-logs after recovery") +}) + +test("pruneToFit: clears tool outputs carried on old ASSISTANT messages (production shape, review T3)", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // Production shape: tool results live on assistant messages. An OLD assistant + // message carries a big completed tool output; the guard should clear it. + const oldAsst: WithParts = { + info: { + id: "msg-old", + role: "assistant", + sessionID: "session-1", + time: { created: Date.now() }, + } as any, + parts: [ + { type: "text", text: "old step" }, + { + type: "tool", + tool: "bash", + callID: "call-old", + state: { status: "completed", output: DENSE, input: {}, time: {} }, + }, + ] as any, + } + // safeBudget = 167232; estimate = 170100 + 8192 = 178292 > 167232 → fire. + const messages: WithParts[] = [oldAsst, makeAssistantWithTokens("msg-asst", 170000)] + + pruneToFit(state, config, noopLogger, messages) + + const oldOutput = (oldAsst.parts[1] as any).state.output + assert.equal(oldOutput, CLEAR_PLACEHOLDER, "old assistant's tool output should be cleared") +}) + +test("pruneToFit: a non-completed trailing part breaks the trailing-run count (review T5)", () => { + const config = makeConfig() + const state = makeState({ modelContextLimit: 200000 }) + // safeBudget = 167232. The last assistant's trailing run breaks at its final + // RUNNING tool part, so the completed DENSE tool before it is NOT counted. + // base = 150100, trailing = 0 → estimate = 158292 < 167232 → no fire. + // (If the running part were skipped, trailing would add ~28k → 186293 → fire.) + const lastAsst: WithParts = { + info: { + id: "msg-asst", + role: "assistant", + sessionID: "session-1", + time: { created: Date.now() }, + tokens: { input: 150000, output: 100 }, + } as any, + parts: [ + { type: "text", text: "ok" }, + { + type: "tool", + tool: "bash", + callID: "call-d", + state: { status: "completed", output: DENSE, input: {}, time: {} }, + }, + { + type: "tool", + tool: "bash", + callID: "call-r", + state: { status: "running", input: {}, time: {} }, + }, + ] as any, + } + const messages: WithParts[] = [makeToolMessage("msg-old", DENSE), lastAsst] + + pruneToFit(state, config, noopLogger, messages) + + assert.equal( + isCleared(messages[0]!), + false, + "a running trailing part breaks the run → estimate stays under budget → no clear", + ) +}) + +// --------------------------------------------------------------------------- +// trackUncalibratedWindow (Fix 1: WARN on uncalibrated window) +// --------------------------------------------------------------------------- + +test("trackUncalibratedWindow: warns once after the threshold of uncalibrated transforms", () => { + const { logger, calls } = makeCapturingLogger() + const state = makeState({ modelContextLimit: undefined }) + + // Below threshold: no warn. + for (let i = 0; i < UNCALIBRATED_WINDOW_WARN_THRESHOLD - 1; i++) { + trackUncalibratedWindow(state, logger) + } + assert.equal(calls.filter((c) => c.level === "warn").length, 0, "no warn below threshold") + assert.equal(state.uncalibratedWindowTransforms, UNCALIBRATED_WINDOW_WARN_THRESHOLD - 1) + + // At threshold: warn fires. + trackUncalibratedWindow(state, logger) + assert.equal(calls.filter((c) => c.level === "warn").length, 1, "warn fires at threshold") + assert.equal(state.uncalibratedWindowWarned, true) +}) + +test("trackUncalibratedWindow: never warns when a window is resolved", () => { + const { logger, calls } = makeCapturingLogger() + const state = makeState({ modelContextLimit: 262144 }) + + for (let i = 0; i < UNCALIBRATED_WINDOW_WARN_THRESHOLD + 2; i++) { + trackUncalibratedWindow(state, logger) + } + assert.equal( + calls.filter((c) => c.level === "warn").length, + 0, + "no warn with a resolved window", + ) + assert.equal(state.uncalibratedWindowTransforms, 0, "counter stays 0 when calibrated") +}) + +test("trackUncalibratedWindow: warns only once (dedup across many transforms)", () => { + const { logger, calls } = makeCapturingLogger() + const state = makeState({ modelContextLimit: undefined }) + + for (let i = 0; i < UNCALIBRATED_WINDOW_WARN_THRESHOLD + 10; i++) { + trackUncalibratedWindow(state, logger) + } + assert.equal(calls.filter((c) => c.level === "warn").length, 1, "warn fires exactly once") +}) + +test("trackUncalibratedWindow: resets the counter when a window later resolves, then re-warns", () => { + const { logger, calls } = makeCapturingLogger() + const state = makeState({ modelContextLimit: undefined }) + + // Climb to the warn threshold and fire. + for (let i = 0; i < UNCALIBRATED_WINDOW_WARN_THRESHOLD; i++) + trackUncalibratedWindow(state, logger) + assert.equal(calls.filter((c) => c.level === "warn").length, 1) + + // A window resolves → counter resets. + state.modelContextLimit = 262144 + trackUncalibratedWindow(state, logger) + assert.equal(state.uncalibratedWindowTransforms, 0, "counter resets on calibration") + + // Window lost again → counter climbs from 0; warned flag still suppresses re-warn. + state.modelContextLimit = undefined + for (let i = 0; i < UNCALIBRATED_WINDOW_WARN_THRESHOLD; i++) + trackUncalibratedWindow(state, logger) + assert.equal( + calls.filter((c) => c.level === "warn").length, + 1, + "warned flag suppresses a second warn within the same session", + ) +}) + +test("trackUncalibratedWindow: counter accumulates across multiple turns (multi-turn)", () => { + const { logger, calls } = makeCapturingLogger() + const state = makeState({ modelContextLimit: undefined }) + + // Simulate three consecutive turns, one transform each, all uncalibrated. + trackUncalibratedWindow(state, logger) // turn 1 + trackUncalibratedWindow(state, logger) // turn 2 + assert.equal(state.uncalibratedWindowTransforms, 2, "counter persists across turns") + trackUncalibratedWindow(state, logger) // turn 3 → threshold + assert.equal(state.uncalibratedWindowTransforms, 3) + assert.equal(calls.filter((c) => c.level === "warn").length, 1, "warns on the third turn") +})