Merge #777: auto-compact and auto-clear from the statusLine context size

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-11 20:18:51 +01:00
commit 6fa2dfa549
16 files changed
+579 -45

No files matched your search

+3
View File
@@ -489,6 +489,9 @@ export class MockSession extends EventEmitter {
/** Mirrors Session.ownEnvValue, same default. */
ownEnvValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteContextReport: the statusline route hands over the context size. */
noteContextReport = vi.fn();
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
noteReportedModel = vi.fn((source: 'statusline' | 'screen', raw: unknown): boolean => {
@@ -52,6 +52,35 @@ describe('POST /api/status-telemetry', () => {
});
});
describe('context report (auto-compact and auto-clear)', () => {
const noted = () => h.ctx._session.noteContextReport as ReturnType<typeof vi.fn>;
const context = (cw: Record<string, unknown>) => ({ sessionId: SID, data: { context_window: cw } });
it('hands the context size and the window to the session', async () => {
const res = await post(context({ total_input_tokens: 562411, context_window_size: 1000000 }));
expect(res.statusCode).toBe(200);
expect(noted()).toHaveBeenCalledWith({ tokens: 562411, windowSize: 1000000 });
});
it('accepts a null window and passes the tokens on without one', async () => {
const res = await post(context({ total_input_tokens: 5, context_window_size: null }));
expect(res.statusCode).toBe(200);
expect(noted()).toHaveBeenCalledWith({ tokens: 5, windowSize: undefined });
});
it('keeps the last report when a render carries no context size', async () => {
await post(context({ used_percentage: 10 }));
await post({ sessionId: SID, data: {} });
expect(noted()).not.toHaveBeenCalled();
});
it('takes no report from a CLI without a statusline exporter', async () => {
h.ctx._session.mode = 'shell';
await post(context({ total_input_tokens: 562411 }));
expect(noted()).not.toHaveBeenCalled();
});
});
it('does not broadcast for an unknown session; returns an EMPTY footer, never a brand word', async () => {
const res = await post({ sessionId: 'does-not-exist', data: REAL });
expect(res.statusCode).toBe(200);
+343
View File
@@ -0,0 +1,343 @@
/**
* The auto-compact and auto-clear trigger: which number it reads, and what it does after a
* command went out. `decideAutoThreshold` is pure, so its cases need no session. The class tests
* run the real SessionAutoOps on fake timers to pin the part the function cannot see: when the
* values it compares against are taken. The last block pins the wiring from a statusline report
* to the checks on a real Session.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
SessionAutoOps,
decideAutoThreshold,
type AutoThresholdInput,
type ContextReport,
} from '../src/session-auto-ops.js';
import { Session } from '../src/session.js';
const input = (over: Partial<AutoThresholdInput>): AutoThresholdInput => ({
threshold: 100_000,
report: null,
reportTokensAtCommand: null,
counterTokens: 0,
counterBaseline: 0,
...over,
});
describe('decideAutoThreshold', () => {
describe('with a statusline report', () => {
it('fires at the threshold and not below it', () => {
expect(decideAutoThreshold(input({ report: { tokens: 100_000 } }))).toEqual({
trigger: true,
tokens: 100_000,
source: 'statusline',
});
expect(decideAutoThreshold(input({ report: { tokens: 99_999 } })).trigger).toBe(false);
});
it('wins over the counter in both directions', () => {
// The real case: the counter misses the cached context.
expect(decideAutoThreshold(input({ report: { tokens: 243_000 }, counterTokens: 81_000 })).trigger).toBe(true);
expect(decideAutoThreshold(input({ report: { tokens: 50_000 }, counterTokens: 200_000 })).trigger).toBe(false);
});
it('treats a report of 0 as a report, not as a missing one', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 0 }, counterTokens: 200_000 }));
expect(decision).toMatchObject({ trigger: false, source: 'statusline' });
});
});
describe('without a report', () => {
it('falls back to the counter', () => {
expect(decideAutoThreshold(input({ counterTokens: 100_000 }))).toEqual({
trigger: true,
tokens: 100_000,
source: 'counter',
});
expect(decideAutoThreshold(input({ counterTokens: 99_999 })).trigger).toBe(false);
});
it('counts from the value at the last compact, since the counter never drops', () => {
const after = { counterBaseline: 150_000 };
expect(decideAutoThreshold(input({ ...after, counterTokens: 150_000 })).trigger).toBe(false);
expect(decideAutoThreshold(input({ ...after, counterTokens: 249_999 })).trigger).toBe(false);
expect(decideAutoThreshold(input({ ...after, counterTokens: 250_000 }))).toMatchObject({
trigger: true,
tokens: 100_000,
});
});
it('never goes negative when the counter was reset below the baseline', () => {
expect(decideAutoThreshold(input({ counterBaseline: 150_000, counterTokens: 10 })).tokens).toBe(0);
});
});
describe('after a command went out', () => {
it('ignores the report from before the command, however long it stays the newest', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 600_000 }, reportTokensAtCommand: 600_000 }));
expect(decision.trigger).toBe(false);
});
it('does not fire on the lower report the compact leaves behind', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 9_000 }, reportTokensAtCommand: 600_000 }));
expect(decision.trigger).toBe(false);
});
it('fires again on a higher report, so a swallowed /compact cannot lock it off', () => {
const decision = decideAutoThreshold(input({ report: { tokens: 601_000 }, reportTokensAtCommand: 600_000 }));
expect(decision.trigger).toBe(true);
});
it('still ignores an out-of-order post of the old number after the lower report', () => {
const lower = decideAutoThreshold(input({ report: { tokens: 9_000 }, reportTokensAtCommand: 600_000 }));
const late = decideAutoThreshold(input({ report: { tokens: 600_000 }, reportTokensAtCommand: 600_000 }));
expect([lower.trigger, late.trigger]).toEqual([false, false]);
});
});
});
describe('SessionAutoOps thresholds', () => {
let report: ContextReport | null;
let counter: number;
let writes: string[];
let ops: SessionAutoOps;
const settle = () => vi.advanceTimersByTimeAsync(60_000);
beforeEach(() => {
vi.useFakeTimers();
report = null;
counter = 0;
writes = [];
ops = new SessionAutoOps({
writeCommand: async (cmd) => {
writes.push(cmd);
return true;
},
isWorking: () => false,
isStopped: () => false,
getTotalTokens: () => counter,
getContextReport: () => report,
getSessionId: () => 'test',
});
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
ops.destroy();
vi.restoreAllMocks();
vi.useRealTimers();
});
it('compacts once on a report, and not again while that report stays the newest', async () => {
ops.setAutoCompact(true, 100_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
// Past the cooldown, as a compact of this size is: the old number is re-posted on a render.
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
});
it('takes the number from the moment the command goes out, not from the trigger', async () => {
let working = true;
ops = new SessionAutoOps({
writeCommand: async (cmd) => {
writes.push(cmd);
return true;
},
isWorking: () => working,
isStopped: () => false,
getTotalTokens: () => counter,
getContextReport: () => report,
getSessionId: () => 'test',
});
ops.setAutoCompact(true, 100_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await vi.advanceTimersByTimeAsync(5_000);
expect(writes).toEqual([]);
report = { tokens: 650_000 }; // the turn it waited for finished and reported
working = false;
await settle();
expect(writes).toEqual(['/compact\r']);
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
});
it('compacts again when the number moves on, so a swallowed /compact heals itself', async () => {
ops.setAutoCompact(true, 100_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await settle();
report = { tokens: 601_000 };
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/compact\r']);
});
it('does not let the stale report trigger an auto-clear after a compact', async () => {
ops.setAutoCompact(true, 100_000);
ops.setAutoClear(true, 140_000);
report = { tokens: 600_000 };
ops.checkAutoCompact();
await settle();
ops.checkAutoClear();
await settle();
expect(writes).toEqual(['/compact\r']);
});
it('clears once on a report, and not again while that report stays the newest', async () => {
ops.setAutoClear(true, 140_000);
report = { tokens: 600_000 };
ops.checkAutoClear();
await settle();
ops.checkAutoClear();
await settle();
expect(writes).toEqual(['/clear\r']);
});
it('on the summed counter, does not compact again until it has grown by the threshold', async () => {
ops.setAutoCompact(true, 100_000);
counter = 100_000;
ops.checkAutoCompact();
await settle();
counter = 150_000;
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r']);
counter = 200_000;
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/compact\r']);
});
it('on the pane counter, compacts again as soon as the number passes its last high', async () => {
ops.setAutoCompact(true, 150_000);
counter = 150_000;
ops.noteAbsoluteCounter();
ops.checkAutoCompact();
await settle();
counter = 150_001; // the pane fell after the compact, and only this move reaches the check
ops.noteAbsoluteCounter();
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/compact\r']);
});
it('on the counter, restarts from 0 after a clear', async () => {
ops.setAutoCompact(true, 100_000);
ops.setAutoClear(true, 140_000);
counter = 100_000;
ops.checkAutoCompact();
await settle();
counter = 240_000;
ops.checkAutoClear();
await settle();
expect(writes).toEqual(['/compact\r', '/clear\r']);
counter = 100_000; // the session zeroed it on 'autoClear' and it grew back
ops.checkAutoCompact();
await settle();
expect(writes).toEqual(['/compact\r', '/clear\r', '/compact\r']);
});
describe('threshold cap', () => {
it('allows up to 1M while the window is unknown', () => {
ops.setAutoCompact(true, 1_000_000);
ops.setAutoClear(true, 1_000_000);
expect(ops.autoCompactThreshold).toBe(1_000_000);
expect(ops.autoClearThreshold).toBe(1_000_000);
});
it('stops at 1M while the window is unknown', () => {
ops.setAutoCompact(true, 1_000_001);
expect(ops.autoCompactThreshold).toBe(1_000_000);
});
it('falls to the default below the minimum', () => {
ops.setAutoCompact(true, 1);
ops.setAutoClear(true, 1);
expect(ops.autoCompactThreshold).toBe(110_000);
expect(ops.autoClearThreshold).toBe(140_000);
});
it('follows the reported window', () => {
report = { tokens: 1, windowSize: 200_000 };
ops.setAutoCompact(true, 200_000);
expect(ops.autoCompactThreshold).toBe(200_000);
ops.setAutoCompact(true, 200_001);
expect(ops.autoCompactThreshold).toBe(200_000);
ops.setAutoClear(true, 200_001);
expect(ops.autoClearThreshold).toBe(200_000);
});
});
});
describe('Session.noteContextReport', () => {
const session = () => new Session({ workingDir: '/tmp', mode: 'claude' } as ConstructorParameters<typeof Session>[0]);
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it('runs the checks on the report, the counter being far below it', async () => {
vi.useFakeTimers();
vi.spyOn(console, 'log').mockImplementation(() => {});
const s = session();
const write = vi.spyOn(s, 'writeViaMux').mockResolvedValue(true);
s.setAutoCompact(true, 100_000);
s.noteContextReport({ tokens: 243_000, windowSize: 1_000_000 });
await vi.advanceTimersByTimeAsync(60_000);
expect(write).toHaveBeenCalledWith('/compact\r');
expect(s.toState().contextWindowSize).toBe(1_000_000);
s.setAutoCompact(false);
});
});
describe('the pane token parser under the raised cap', () => {
type Parser = { parseTokensFromStatusLine(text: string): void };
const total = (s: Session) => s.toState().inputTokens + s.toState().outputTokens;
it('takes an M value up to the cap, so the counter can reach a threshold above 500k', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const s = new Session({ workingDir: '/tmp', mode: 'claude' } as ConstructorParameters<typeof Session>[0]);
const parse = (text: string) => (s as never as Parser).parseTokensFromStatusLine(text);
for (const k of [100, 200, 300, 400, 500]) parse(`${k}k tokens`);
parse('0.6M tokens');
expect(total(s)).toBe(600_000);
parse('1.1M tokens');
expect(total(s)).toBe(600_000);
vi.restoreAllMocks();
});
it('marks the counter absolute only when the number moves', () => {
const s = new Session({ workingDir: '/tmp', mode: 'claude' } as ConstructorParameters<typeof Session>[0]);
const parse = (text: string) => (s as never as Parser).parseTokensFromStatusLine(text);
const ops = (s as never as { _autoOps: SessionAutoOps })._autoOps;
const note = vi.spyOn(ops, 'noteAbsoluteCounter');
parse('50k tokens');
expect(note).toHaveBeenCalledTimes(1);
parse('20k tokens'); // the pane after a compact: the counter stays, so nothing runs
expect(note).toHaveBeenCalledTimes(1);
vi.restoreAllMocks();
});
});
+1
View File
@@ -36,6 +36,7 @@ describe('SessionAutoOps auto-resume', () => {
isWorking: () => working,
isStopped: () => stopped,
getTotalTokens: () => 0,
getContextReport: () => null,
getSessionId: () => 'test-session',
});
});
+9
View File
@@ -188,6 +188,15 @@ describe('parsePromptCache', () => {
});
describe('parseSessionStatus', () => {
it('reads the context window size, and drops a value that is no size', () => {
const size = (context_window_size: number) => parseSessionStatus({ context_window: { context_window_size } });
expect(size(1000000)?.contextWindowSize).toBe(1000000);
expect(parseSessionStatus({ context_window: {} })).toBeNull();
expect(size(0)).toBeNull();
expect(size(-1)).toBeNull();
expect(size(Number.POSITIVE_INFINITY)).toBeNull();
});
it('carries the prompt-cache state into the footer status', () => {
expect(parseSessionStatus({ ...REAL, ...WARM_CACHE })?.cache).toEqual(parsePromptCache(WARM_CACHE));
// prompt_cache alone IS session status (a cold flip arrives with nothing else changed)