mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
The review items of #777, finished here so the PR can land. A statusline report fires the trigger in the middle of a turn, and the command then waits for that turn to end. Until now it went out whatever had happened meanwhile: with auto-clear at 140k, a report of 150k fired while the session worked, Claude Code compacted by itself, the report fell to 20k, the session went idle and /clear wiped the conversation. Both actions now ask again at that moment and return unsent when the context is no longer at the threshold, when the option was switched off, or when its threshold was raised past the context. The skipped command stamps nothing, so the next report is judged like any other, and the autoCompact and autoClear events carry the size at the moment of sending. With auto-resume on, a trigger that fired before a usage limit hit sent /clear once the pane went idle, and auto-resume then typed continue into an empty conversation. The checks start nothing from the limit message until the resume prompt went out, and an action that waited through the start of a pause returns unsent. Also: the threshold cap never drops below the 1,000 minimum (a reported window of 500 stored a threshold of 500), a test pins that a conversation switch drops the report, and docs/api-reference.md names the new contextWindowSize session field. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
544 lines
19 KiB
TypeScript
544 lines
19 KiB
TypeScript
/**
|
|
* 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 working: boolean;
|
|
let ops: SessionAutoOps;
|
|
|
|
const settle = () => vi.advanceTimersByTimeAsync(60_000);
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
report = null;
|
|
counter = 0;
|
|
writes = [];
|
|
working = false;
|
|
ops = new SessionAutoOps({
|
|
writeCommand: async (cmd) => {
|
|
writes.push(cmd);
|
|
return true;
|
|
},
|
|
isWorking: () => working,
|
|
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']);
|
|
});
|
|
|
|
// A report fires the trigger in the middle of a turn, and the command waits for that turn to
|
|
// end. What was true at the trigger can be false by then, so the decision is made again there.
|
|
describe('when the command goes out', () => {
|
|
it('does not clear a context that fell while it waited for the turn to end', async () => {
|
|
ops.setAutoClear(true, 140_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoClear();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
expect(writes).toEqual([]);
|
|
|
|
report = { tokens: 20_000 }; // Claude Code compacted by itself during the turn
|
|
working = false;
|
|
await settle();
|
|
expect(writes).toEqual([]);
|
|
expect(ops.isClearing).toBe(false);
|
|
|
|
// Nothing was stamped by the skipped command, so the same number fires when it comes back.
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoClear();
|
|
await settle();
|
|
expect(writes).toEqual(['/clear\r']);
|
|
});
|
|
|
|
it('does not compact a context that fell while it waited for the turn to end', async () => {
|
|
ops.setAutoCompact(true, 100_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoCompact();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
report = { tokens: 20_000 };
|
|
working = false;
|
|
await settle();
|
|
expect(writes).toEqual([]);
|
|
expect(ops.isCompacting).toBe(false);
|
|
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoCompact();
|
|
await settle();
|
|
expect(writes).toEqual(['/compact\r']);
|
|
});
|
|
|
|
it('reports the size at the moment the command went out', async () => {
|
|
const fired = vi.fn();
|
|
ops.on('autoCompact', fired);
|
|
ops.setAutoCompact(true, 100_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoCompact();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
report = { tokens: 180_000 };
|
|
working = false;
|
|
await settle();
|
|
expect(fired).toHaveBeenCalledWith(expect.objectContaining({ tokens: 180_000 }));
|
|
});
|
|
|
|
it('sends nothing once the option was switched off during the wait', async () => {
|
|
ops.setAutoCompact(true, 100_000);
|
|
ops.setAutoClear(true, 140_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoClear();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
ops.setAutoClear(false);
|
|
working = false;
|
|
await settle();
|
|
expect(writes).toEqual([]);
|
|
|
|
// The compact it held off while it waited is free to run again.
|
|
ops.checkAutoCompact();
|
|
await settle();
|
|
expect(writes).toEqual(['/compact\r']);
|
|
});
|
|
|
|
it('follows a threshold that was raised during the wait', async () => {
|
|
ops.setAutoClear(true, 140_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoClear();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
ops.setAutoClear(true, 300_000);
|
|
working = false;
|
|
await settle();
|
|
expect(writes).toEqual([]);
|
|
});
|
|
});
|
|
|
|
// With auto-resume on, a /clear sent into a usage-limit pause is followed by the resume
|
|
// prompt: `continue` typed into an empty conversation.
|
|
describe('during a usage-limit pause', () => {
|
|
const limitLine = (resetInMs: number) =>
|
|
`Claude AI usage limit reached|${Math.floor((Date.now() + resetInMs) / 1000)}`;
|
|
|
|
beforeEach(() => {
|
|
vi.setSystemTime(Math.floor(Date.now() / 1000) * 1000);
|
|
ops.setAutoResume(true);
|
|
});
|
|
|
|
it('holds a clear that fired before the limit hit, and lets the next report decide after the resume', async () => {
|
|
ops.setAutoClear(true, 140_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoClear();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
ops.processCleanData(limitLine(60 * 60_000)); // the turn ends on the limit
|
|
working = false;
|
|
await vi.advanceTimersByTimeAsync(30 * 60_000);
|
|
expect(ops.isLimitPaused).toBe(true);
|
|
expect(writes).toEqual([]);
|
|
|
|
await vi.advanceTimersByTimeAsync(40 * 60_000); // past the reset and its buffer
|
|
expect(writes).toEqual(['\x1b', 'continue\r']);
|
|
|
|
// The resumed turn ran and ended; the context is still above the threshold.
|
|
ops.notifyWorking();
|
|
ops.checkAutoClear();
|
|
await settle();
|
|
expect(writes).toEqual(['\x1b', 'continue\r', '/clear\r']);
|
|
});
|
|
|
|
it('holds a compact the same way', async () => {
|
|
ops.setAutoCompact(true, 100_000);
|
|
working = true;
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoCompact();
|
|
await vi.advanceTimersByTimeAsync(5_000);
|
|
|
|
ops.processCleanData(limitLine(60 * 60_000));
|
|
working = false;
|
|
await vi.advanceTimersByTimeAsync(30 * 60_000);
|
|
expect(writes).toEqual([]);
|
|
});
|
|
|
|
it('starts nothing on a report that arrives while the session is paused', async () => {
|
|
ops.setAutoCompact(true, 100_000);
|
|
ops.setAutoClear(true, 140_000);
|
|
ops.processCleanData(limitLine(60 * 60_000));
|
|
report = { tokens: 150_000 };
|
|
ops.checkAutoCompact();
|
|
ops.checkAutoClear();
|
|
expect([ops.isCompacting, ops.isClearing]).toEqual([false, false]);
|
|
await vi.advanceTimersByTimeAsync(30 * 60_000);
|
|
expect(writes).toEqual([]);
|
|
});
|
|
|
|
it('does not send between the Escape and the resume prompt', async () => {
|
|
ops.setAutoClear(true, 140_000);
|
|
ops.processCleanData(limitLine(60_000));
|
|
await vi.advanceTimersByTimeAsync(60_000 + 2 * 60_000); // the Escape went out
|
|
expect(writes).toEqual(['\x1b']);
|
|
|
|
report = { tokens: 150_000 }; // the Escape redraws the statusline
|
|
ops.checkAutoClear();
|
|
expect(ops.isClearing).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(600);
|
|
expect(writes).toEqual(['\x1b', 'continue\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('never stores a threshold below the minimum, whatever window was reported', () => {
|
|
report = { tokens: 1, windowSize: 500 };
|
|
ops.setAutoCompact(true, 5_000);
|
|
ops.setAutoClear(true, 5_000);
|
|
expect(ops.autoCompactThreshold).toBe(1_000);
|
|
expect(ops.autoClearThreshold).toBe(1_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);
|
|
});
|
|
|
|
it('drops the report when the conversation changes, so the old size cannot fire on the new one', async () => {
|
|
vi.useFakeTimers();
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
const s = session();
|
|
const write = vi.spyOn(s, 'writeViaMux').mockResolvedValue(true);
|
|
s.adoptClaudeSessionId('11111111-1111-4111-8111-111111111111');
|
|
s.noteContextReport({ tokens: 90_000, windowSize: 200_000 });
|
|
expect(s.toState().contextWindowSize).toBe(200_000);
|
|
|
|
s.adoptClaudeSessionId('22222222-2222-4222-8222-222222222222'); // a /clear switched it
|
|
expect(s.toState().contextWindowSize).toBeUndefined();
|
|
|
|
// 90k is above this threshold, and it belonged to the conversation that is gone.
|
|
s.setAutoCompact(true, 50_000);
|
|
(s as never as { _autoOps: SessionAutoOps })._autoOps.checkAutoCompact();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(write).not.toHaveBeenCalled();
|
|
s.setAutoCompact(false);
|
|
});
|
|
|
|
it('keeps the report on a reattach to the same conversation', () => {
|
|
const s = session();
|
|
s.adoptClaudeSessionId('11111111-1111-4111-8111-111111111111');
|
|
s.noteContextReport({ tokens: 90_000, windowSize: 200_000 });
|
|
s.adoptClaudeSessionId('11111111-1111-4111-8111-111111111111');
|
|
expect(s.toState().contextWindowSize).toBe(200_000);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|