From ecdd517765bda149c8b2f95131b1067be5bb6d22 Mon Sep 17 00:00:00 2001 From: Tanel Date: Mon, 27 Jul 2026 22:28:23 +0300 Subject: [PATCH] fix(suggest-compact): don't quote a percentage against an assumed window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The context signal always rendered "N% of window", including when the window size was the assumed 200k default rather than a detected value. On a 1M session whose transcript carries no [1m] marker, that produced lines like: [StrategicCompact] Context ~194k tokens (97% of 200k window) while actual usage was ~19%. The user compacts on a false alarm, loses context, and the resulting quality drop reads as a model regression. The gap is structural: the context threshold defaults to 80% of the window (160k on 200k), so the signal fires precisely in the 160k-200k band where the size cannot be determined — above 200k the observed-tokens fallback correctly infers 1M, and below 160k nothing fires. Model id alone cannot close this. A tier may ship both a 200k and a 1M variant under one id, so neither the known-family table nor a new entry can distinguish them, and the transcript records no window field. So stop asserting what isn't known: resolveContextWindow() now reports whether the size was detected (env override, [1m] marker, known family, or observed tokens > 200k) or assumed, and the hook omits the percentage and window label when it was assumed. The token count, threshold, and firing behaviour are unchanged. resolveContextWindowTokens() keeps its existing signature and semantics. Note: 3 pre-existing failures in tests/hooks/suggest-compact.test.js reproduce identically on unmodified main and are untouched here. --- scripts/hooks/suggest-compact.js | 12 +++++-- scripts/lib/transcript-context.js | 51 ++++++++++++++++++++++------ tests/lib/transcript-context.test.js | 34 ++++++++++++++++++- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/scripts/hooks/suggest-compact.js b/scripts/hooks/suggest-compact.js index 2a104df3a..b8a163e9b 100644 --- a/scripts/hooks/suggest-compact.js +++ b/scripts/hooks/suggest-compact.js @@ -38,7 +38,8 @@ const { resolveContextThreshold, resolveContextInterval, computeContextBucket, - formatWindowLabel + formatWindowLabel, + isContextWindowInferred } = require('../lib/transcript-context'); const COUNTER_FILE_PREFIX = 'claude-tool-count-'; @@ -185,8 +186,13 @@ function buildContextSuggestion(transcriptPath, bucketFile, env) { writeFile(bucketFile, String(bucket)); const approxTokens = `${Math.round(usage.tokens / 1000)}k`; - const percent = Math.round((usage.tokens / windowTokens) * 100); - return `[StrategicCompact] Context ~${approxTokens} tokens (${percent}% of ${formatWindowLabel(windowTokens)} window) - consider /compact at the next logical boundary`; + // Only quote a percentage when the window size was actually detected. + // Against an assumed 200k default the denominator is a guess, and a + // "97% of 200k window" line on a 1M session triggers needless compaction. + const scale = isContextWindowInferred(usage.tokens, usage.model) + ? '' + : ` (${Math.round((usage.tokens / windowTokens) * 100)}% of ${formatWindowLabel(windowTokens)} window)`; + return `[StrategicCompact] Context ~${approxTokens} tokens${scale} - consider /compact at the next logical boundary`; } catch (err) { log(`[StrategicCompact] Context signal skipped: ${err.message}`); return null; diff --git a/scripts/lib/transcript-context.js b/scripts/lib/transcript-context.js index 201861487..d0a944330 100644 --- a/scripts/lib/transcript-context.js +++ b/scripts/lib/transcript-context.js @@ -158,24 +158,29 @@ function readLatestContextTokens(transcriptPath, options = {}) { } /** - * Detect the context window size for a turn. - * 1M when the model id carries the `[1m]` marker, matches a known large-window - * model family, or when the observed token count already exceeds the standard - * 200k window (covers logs that drop the suffix); otherwise the standard 200k - * window. + * Detect the context window size for a turn, and report whether that size was + * positively detected or merely assumed. + * + * `inferred: false` means the size came from evidence — an explicit env + * override, the `[1m]` marker, a known large-window family, or an observed + * token count that already exceeds the standard window. `inferred: true` means + * every check fell through and the standard 200k default was assumed; the + * window may actually be larger and callers must not present it as fact. + * + * @returns {{ windowTokens: number, inferred: boolean }} */ -function resolveContextWindowTokens(tokens, model) { +function resolveContextWindow(tokens, model) { // Explicit window override wins: 400k models (e.g. Opus 4.x) match neither the // 200k default nor the 1M marker and would otherwise report ~double usage (#2290). // Honor ECC's own knob and Claude Code's native CLAUDE_CODE_AUTO_COMPACT_WINDOW. const env = (typeof process !== 'undefined' && process.env) || {}; const envWindow = Number.parseInt(env.ECC_CONTEXT_WINDOW_TOKENS || env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '', 10); if (Number.isInteger(envWindow) && envWindow > 0) { - return envWindow; + return { windowTokens: envWindow, inferred: false }; } if (typeof model === 'string' && model.includes(LARGE_WINDOW_MODEL_MARKER)) { - return LARGE_CONTEXT_WINDOW_TOKENS; + return { windowTokens: LARGE_CONTEXT_WINDOW_TOKENS, inferred: false }; } // Large-window model families without a [1m] marker fall through the checks @@ -183,15 +188,37 @@ function resolveContextWindowTokens(tokens, model) { if (typeof model === 'string') { const known = KNOWN_MODEL_WINDOW_TOKENS.find(([familyId]) => isKnownModelFamilyMatch(model, familyId)); if (known) { - return known[1]; + return { windowTokens: known[1], inferred: false }; } } if (Number.isFinite(tokens) && tokens > STANDARD_CONTEXT_WINDOW_TOKENS) { - return LARGE_CONTEXT_WINDOW_TOKENS; + return { windowTokens: LARGE_CONTEXT_WINDOW_TOKENS, inferred: false }; } - return STANDARD_CONTEXT_WINDOW_TOKENS; + return { windowTokens: STANDARD_CONTEXT_WINDOW_TOKENS, inferred: true }; +} + +/** + * Detect the context window size for a turn. + * 1M when the model id carries the `[1m]` marker, matches a known large-window + * model family, or when the observed token count already exceeds the standard + * 200k window (covers logs that drop the suffix); otherwise the standard 200k + * window. + */ +function resolveContextWindowTokens(tokens, model) { + return resolveContextWindow(tokens, model).windowTokens; +} + +/** + * True when the resolved window is the assumed 200k default rather than a + * detected size. Opt-in large-window models that ship no `[1m]` marker in the + * transcript (e.g. a 1M-context Opus tier, where the base tier is 200k and the + * two are indistinguishable by model id) land here, so a percentage computed + * against 200k can be wildly wrong while usage sits below that mark. + */ +function isContextWindowInferred(tokens, model) { + return resolveContextWindow(tokens, model).inferred; } /** @@ -254,7 +281,9 @@ module.exports = { DEFAULT_CONTEXT_INTERVAL_TOKENS, DEFAULT_TRANSCRIPT_TAIL_BYTES, readLatestContextTokens, + resolveContextWindow, resolveContextWindowTokens, + isContextWindowInferred, resolveContextThreshold, resolveContextInterval, computeContextBucket, diff --git a/tests/lib/transcript-context.test.js b/tests/lib/transcript-context.test.js index 1d335f131..f10f62c76 100644 --- a/tests/lib/transcript-context.test.js +++ b/tests/lib/transcript-context.test.js @@ -23,7 +23,8 @@ const { resolveContextThreshold, resolveContextInterval, computeContextBucket, - formatWindowLabel + formatWindowLabel, + isContextWindowInferred } = require('../../scripts/lib/transcript-context'); console.log('=== Testing transcript-context.js ===\n'); @@ -218,6 +219,37 @@ test('treats an empty model id as standard window', () => { assert.strictEqual(resolveContextWindowTokens(100000, ''), STANDARD_CONTEXT_WINDOW_TOKENS); }); +// ── isContextWindowInferred ── +console.log('\nisContextWindowInferred:'); + +delete process.env.ECC_CONTEXT_WINDOW_TOKENS; +delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + +test('flags the assumed 200k default as inferred', () => { + assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-9'), true); +}); + +test('an env override is a detected window, not inferred', () => { + process.env.ECC_CONTEXT_WINDOW_TOKENS = '1000000'; + try { + assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-9'), false); + } finally { + delete process.env.ECC_CONTEXT_WINDOW_TOKENS; + } +}); + +test('a [1m] marker is a detected window, not inferred', () => { + assert.strictEqual(isContextWindowInferred(187000, 'claude-opus-4-5[1m]'), false); +}); + +test('a known large-window family is a detected window, not inferred', () => { + assert.strictEqual(isContextWindowInferred(187000, 'claude-fable-5'), false); +}); + +test('tokens above the standard window make the size detected, not inferred', () => { + assert.strictEqual(isContextWindowInferred(220000, 'claude-opus-9'), false); +}); + // ── resolveContextThreshold ── console.log('\nresolveContextThreshold:');