diff --git a/hooks/gsd-statusline.js b/hooks/gsd-statusline.js index 2cbf6baec..9bfbdb1f5 100755 --- a/hooks/gsd-statusline.js +++ b/hooks/gsd-statusline.js @@ -124,9 +124,15 @@ function runStatusline() { const remaining = data.context_window?.remaining_percentage; // Context window display (shows USED percentage scaled to usable context) - // Claude Code reserves ~16.5% for autocompact buffer, so usable context - // is 83.5% of the total window. We normalize to show 100% at that point. - const AUTO_COMPACT_BUFFER_PCT = 16.5; + // Claude Code reserves a buffer for autocompact. By default this is ~16.5% + // of the total window, but users can override it via CLAUDE_CODE_AUTO_COMPACT_WINDOW + // (a token count). When the env var is set, compute the buffer % dynamically so + // the meter correctly reflects early-compaction configurations (#2219). + const totalCtx = data.context_window?.total_tokens || 1_000_000; + const acw = parseInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW || '0', 10); + const AUTO_COMPACT_BUFFER_PCT = acw > 0 + ? Math.min(100, (acw / totalCtx) * 100) + : 16.5; let ctx = ''; if (remaining != null) { // Normalize: subtract buffer from remaining, scale to usable range diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index eecbe0ffc..93483445f 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -247,3 +247,78 @@ describe('readGsdState', () => { assert.deepEqual(s, {}); }); }); + +// ─── CLAUDE_CODE_AUTO_COMPACT_WINDOW context meter (#2219) ────────────────── + +describe('context meter respects CLAUDE_CODE_AUTO_COMPACT_WINDOW (#2219)', () => { + const { execFileSync } = require('node:child_process'); + const hookPath = path.join(__dirname, '..', 'hooks', 'gsd-statusline.js'); + + /** + * Run the statusline hook with a synthetic context_window payload and + * return the used_pct written to the bridge file. + */ + function runHook(remainingPct, totalTokens, acwEnv) { + const sessionId = `test-2219-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const payload = JSON.stringify({ + model: { display_name: 'Claude' }, + workspace: { current_dir: os.tmpdir() }, + session_id: sessionId, + context_window: { + remaining_percentage: remainingPct, + total_tokens: totalTokens, + }, + }); + + const env = { ...process.env }; + if (acwEnv != null) { + env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = String(acwEnv); + } else { + delete env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } + + try { + execFileSync(process.execPath, [hookPath], { + input: payload, + env, + timeout: 4000, + }); + } catch (e) { + // Non-zero exit is fine — hook exits 0 on success, but we're reading + // the bridge file, not the exit code. Ignore exit failures here. + } + + const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`); + const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf8')); + fs.unlinkSync(bridgePath); + return bridge.used_pct; + } + + test('default buffer (no env var): 50% remaining → ~60% used', () => { + // Default 16.5% buffer: usableRemaining = (50 - 16.5) / (100 - 16.5) * 100 ≈ 40.12% + // used ≈ 100 - 40.12 = 59.88 → rounded 60 + const used = runHook(50, 1_000_000, null); + assert.strictEqual(used, 60); + }); + + test('CLAUDE_CODE_AUTO_COMPACT_WINDOW=400000: 50% remaining → ~83% used', () => { + // With 1M total, 400k window → buffer = 40%. usableRemaining = (50 - 40) / (100 - 40) * 100 ≈ 16.67% + // used ≈ 100 - 16.67 = 83.33 → rounded 83 + const used = runHook(50, 1_000_000, 400_000); + assert.strictEqual(used, 83); + }); + + test('CLAUDE_CODE_AUTO_COMPACT_WINDOW=0 falls back to default buffer', () => { + // Explicit "0" means unset — should behave like no env var (16.5% buffer) + const used = runHook(50, 1_000_000, 0); + assert.strictEqual(used, 60); + }); + + test('buffer capped at 100% when ACW exceeds total context', () => { + // Pathological: ACW > totalCtx → buffer = 100%. With no usable range left, + // usableRemaining = max(0, (50-100)/(100-100)*100) = max(0, -Inf) = 0, + // so used = 100 (context reported as completely full). + const used = runHook(50, 1_000_000, 2_000_000); + assert.strictEqual(used, 100); + }); +});