mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
feat: auto-compact and auto-clear from the statusLine context size
Both checks compared getTotalTokens() against the threshold. That counter leaves out cache reads, so it stays far below the real context, and the 500k cap ruled out thresholds on a 1M window. POST /api/status-telemetry now hands context_window.total_input_tokens and context_window_size to Session.noteContextReport(), which runs both checks. The decision is a pure function, decideAutoThreshold(). The counter stays the fallback when no report has arrived. After a /compact or /clear the pre-command report is ignored by value, since it stays the newest number until the command finishes. The first differing report came later, so a swallowed command fires again instead of locking the automation off. A counter summed from JSON usage never drops after a compact, so it counts from its value at the last one. The pane parser's counter is absolute and only moves past its high, so a move clears that baseline and it fires as it did before. Thresholds go up to the reported window, 1M when unknown, and a higher value stops at the window. MAX_SESSION_TOKENS follows, and the pane parser's M-suffix guard checks that cap instead of a fixed 0.5. Refs #585 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fc25bf5f83
commit
84a6896cae
16 files changed
+579
-45
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,7 @@ describe('SessionAutoOps auto-resume', () => {
|
||||
isWorking: () => working,
|
||||
isStopped: () => stopped,
|
||||
getTotalTokens: () => 0,
|
||||
getContextReport: () => null,
|
||||
getSessionId: () => 'test-session',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user