Files
Codeman/test/session-auto-resume.test.ts
T
RandalixandClaude Opus 5.5 84a6896cae 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>
2026-11-06 13:19:36 +01:00

298 lines
11 KiB
TypeScript

/**
* Tests for SessionAutoOps auto-resume on usage limit (token pause control).
*
* Uses fake timers; the SessionAutoOps callbacks are plain mocks, so no
* real session, tmux, or ports are involved. Port: N/A
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SessionAutoOps } from '../src/session-auto-ops.js';
import { Session } from '../src/session.js';
const BUFFER_MS = 2 * 60_000; // RESUME_BUFFER_MS in session-auto-ops.ts
const ESC_DELAY_MS = 600; // RESUME_ESC_DELAY_MS
function limitLine(resetInMs: number): string {
// Raw API epoch form gives exact control over the parsed reset time
const epoch = Math.floor((Date.now() + resetInMs) / 1000);
return `Claude AI usage limit reached|${epoch}`;
}
describe('SessionAutoOps auto-resume', () => {
let ops: SessionAutoOps;
let writeCommand: ReturnType<typeof vi.fn>;
let working: boolean;
let stopped: boolean;
beforeEach(() => {
vi.useFakeTimers();
// Whole-second clock: limitLine() floors to epoch seconds, and a fractional
// start time would shift the fire point by up to 999ms (flaky assertions)
vi.setSystemTime(Math.floor(Date.now() / 1000) * 1000);
writeCommand = vi.fn(async () => true);
working = false;
stopped = false;
ops = new SessionAutoOps({
writeCommand,
isWorking: () => working,
isStopped: () => stopped,
getTotalTokens: () => 0,
getContextReport: () => null,
getSessionId: () => 'test-session',
});
});
afterEach(() => {
ops.destroy();
vi.useRealTimers();
});
it('does nothing when disabled', () => {
ops.processCleanData(limitLine(60 * 60_000));
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
});
it('arms a schedule at reset time + buffer when enabled', () => {
ops.setAutoResume(true);
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
ops.processCleanData(limitLine(60 * 60_000)); // resets in 1h
expect(ops.isLimitPaused).toBe(true);
expect(ops.autoResumeAt).not.toBeNull();
const expected = Date.now() + 60 * 60_000 + BUFFER_MS;
expect(Math.abs(ops.autoResumeAt! - expected)).toBeLessThan(2000);
expect(scheduled).toHaveBeenCalledTimes(1);
});
it('dedups repeated footer redraws of the same limit message', () => {
ops.setAutoResume(true);
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
const line = limitLine(30 * 60_000);
ops.processCleanData(line);
ops.processCleanData(line);
ops.processCleanData(line);
expect(scheduled).toHaveBeenCalledTimes(1);
});
it('reschedules when an EARLIER reset time appears', () => {
ops.setAutoResume(true);
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
ops.processCleanData(limitLine(60 * 60_000));
const firstAt = ops.autoResumeAt!;
ops.processCleanData(limitLine(10 * 60_000));
expect(scheduled).toHaveBeenCalledTimes(2);
expect(ops.autoResumeAt!).toBeLessThan(firstAt);
});
it('keeps the schedule when a LATER/stale time appears', () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
const firstAt = ops.autoResumeAt!;
ops.processCleanData(limitLine(60 * 60_000)); // later → ignored
ops.processCleanData(limitLine(-5 * 60_000)); // overdue → never preempts
expect(ops.autoResumeAt).toBe(firstAt);
});
it('fires Escape then the continue prompt at the scheduled time', async () => {
ops.setAutoResume(true);
const resumed = vi.fn();
ops.on('limitResume', resumed);
ops.processCleanData(limitLine(10 * 60_000));
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + 100);
expect(writeCommand).toHaveBeenCalledWith('\x1b');
expect(resumed).not.toHaveBeenCalled(); // continue still pending
await vi.advanceTimersByTimeAsync(ESC_DELAY_MS + 50);
expect(writeCommand).toHaveBeenCalledWith('continue\r');
expect(resumed).toHaveBeenCalledTimes(1);
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
});
it('ignores stale-footer re-detections while the resume is in flight', async () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + 100);
// Esc sent; before the continue fires, the stale footer redraws
ops.processCleanData(limitLine(-1000));
expect(ops.isLimitPaused).toBe(false); // not re-armed mid-resume
await vi.advanceTimersByTimeAsync(ESC_DELAY_MS + 50);
expect(writeCommand).toHaveBeenCalledWith('continue\r');
});
it('re-arms a retry when still limited after a resume attempt', async () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
expect(writeCommand).toHaveBeenCalledWith('continue\r');
// The submit echoes a fresh limit line with an already-past reset → retry path
const scheduled = vi.fn();
ops.on('limitPauseScheduled', scheduled);
ops.processCleanData(limitLine(-1000));
expect(scheduled).toHaveBeenCalledTimes(1);
expect(ops.isLimitPaused).toBe(true);
// Retry fires within RESUME_RETRY_MS (5 min)
expect(ops.autoResumeAt! - Date.now()).toBeLessThanOrEqual(5 * 60_000 + 1000);
});
it('skips the resume when Claude is already working at fire time', async () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.processCleanData(limitLine(10 * 60_000));
working = true;
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
expect(writeCommand).not.toHaveBeenCalled();
expect(cancelled).toHaveBeenCalledWith({ reason: 'working' });
expect(ops.isLimitPaused).toBe(false);
});
it('cancels the pending schedule when Claude starts working', () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.processCleanData(limitLine(10 * 60_000));
expect(ops.isLimitPaused).toBe(true);
ops.notifyWorking();
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
expect(cancelled).toHaveBeenCalledWith({ reason: 'working' });
});
it('notifyWorking is a no-op when nothing is armed', () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.notifyWorking();
expect(cancelled).not.toHaveBeenCalled();
});
it('disabling cancels the pending schedule', () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
ops.setAutoResume(false);
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
// and detection stays off
ops.processCleanData(limitLine(10 * 60_000));
expect(ops.isLimitPaused).toBe(false);
});
it('destroy clears timers without emitting', () => {
ops.setAutoResume(true);
const cancelled = vi.fn();
ops.on('limitResumeCancelled', cancelled);
ops.processCleanData(limitLine(10 * 60_000));
ops.destroy();
expect(ops.isLimitPaused).toBe(false);
expect(cancelled).not.toHaveBeenCalled();
vi.advanceTimersByTime(60 * 60_000);
expect(writeCommand).not.toHaveBeenCalled();
});
it('does not fire after the session stops', async () => {
ops.setAutoResume(true);
ops.processCleanData(limitLine(10 * 60_000));
stopped = true;
await vi.advanceTimersByTimeAsync(10 * 60_000 + BUFFER_MS + ESC_DELAY_MS + 200);
expect(writeCommand).not.toHaveBeenCalled();
});
describe('Session wiring (terminal output → detection → events)', () => {
it('detects a limit message flowing through the expensive-parser path', () => {
const session = new Session({ workingDir: '/tmp' }); // mode 'claude'
const scheduled = vi.fn();
session.on('limitPauseScheduled', scheduled);
session.setAutoResume(true);
// Same choke-point the claude-mode PTY handler uses (throttled batch)
(session as unknown as { _processExpensiveParsers(d: string): void })._processExpensiveParsers(
'5-hour limit reached ∙ resets 8pm'
);
expect(session.isLimitPaused).toBe(true);
expect(session.autoResumeAt).not.toBeNull();
expect(scheduled).toHaveBeenCalledTimes(1);
expect(session.toState().autoResumeEnabled).toBe(true);
expect(session.toState().autoResumeAt).toBe(session.autoResumeAt!);
session.setAutoResume(false); // clears the armed timer
});
it('catches an already-displayed limit message when enabling mid-pause', () => {
const session = new Session({ workingDir: '/tmp' });
// limit footer already on screen before the user finds the checkbox
(session as unknown as { _terminalBuffer: { append(d: string): void } })._terminalBuffer.append(
'\x1b[33m5-hour limit reached ∙ resets 8pm\x1b[0m'
);
session.setAutoResume(true);
expect(session.isLimitPaused).toBe(true);
session.setAutoResume(false);
});
it('ignores stale scrollback (past reset time) when enabling', () => {
const session = new Session({ workingDir: '/tmp' });
const pastEpoch = Math.floor(Date.now() / 1000) - 3600;
(session as unknown as { _terminalBuffer: { append(d: string): void } })._terminalBuffer.append(
`Claude AI usage limit reached|${pastEpoch}`
);
session.setAutoResume(true);
expect(session.isLimitPaused).toBe(false);
session.setAutoResume(false);
});
it('stays inert when the checkbox is disabled (default)', () => {
const session = new Session({ workingDir: '/tmp' });
(session as unknown as { _processExpensiveParsers(d: string): void })._processExpensiveParsers(
'5-hour limit reached ∙ resets 8pm'
);
expect(session.isLimitPaused).toBe(false);
expect(session.toState().autoResumeEnabled).toBe(false);
});
});
describe('restoreAutoResume (recovery after Codeman restart)', () => {
it('re-arms a future schedule', () => {
ops.restoreAutoResume(true, Date.now() + 30 * 60_000);
expect(ops.autoResumeEnabled).toBe(true);
expect(ops.isLimitPaused).toBe(true);
expect(ops.autoResumeAt! - Date.now()).toBeGreaterThan(29 * 60_000);
});
it('fires an overdue schedule shortly after boot', async () => {
ops.restoreAutoResume(true, Date.now() - 60_000);
expect(ops.isLimitPaused).toBe(true);
await vi.advanceTimersByTimeAsync(5_000 + ESC_DELAY_MS + 200);
expect(writeCommand).toHaveBeenCalledWith('\x1b');
expect(writeCommand).toHaveBeenCalledWith('continue\r');
});
it('enables without arming when no schedule was persisted', () => {
ops.restoreAutoResume(true);
expect(ops.autoResumeEnabled).toBe(true);
expect(ops.isLimitPaused).toBe(false);
expect(ops.autoResumeAt).toBeNull();
});
});
});