mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Auto-resume on usage limit ("token pause" control, opt-in checkbox at the
top of the Respawn tab, off by default):
- usage-limit-patterns.ts (new, pure): detects all Claude Code limit
messages (1.0.x-2.1.x eras incl. "5-hour limit reached - resets 8pm",
"You've hit your limit - resets 1:40pm (TZ)", weekly date forms, raw
"usage limit reached|<epoch>") and parses the reset time. Conservative:
no parseable future reset time, no action.
- SessionAutoOps: arms a timer at reset+2min, sends Esc (dismisses the
rate-limit dialog) + "continue"; dedups footer redraws, retries every
5min on stale times, cancels when Claude starts working, persists and
re-arms across Codeman restarts (SessionState.autoResumeEnabled/At).
- Respawn guard: cycles are blocked while limit-paused so /clear cannot
wipe the paused conversation (respawnBlocked reason 'usage_limit').
- POST /api/sessions/:id/auto-resume; SSE session:limitPauseScheduled/
limitResume/limitResumeCancelled; toasts + status line in the modal.
- Respawn tab tidied: single-row prompt fields, merged behavior row.
Mobile fixes (0.9.8 regressions, user-reported):
- Resize arbitration is now activity-based: a desktop sizing claim only
blocks phone resizes while the desktop typed within 90s
(Session.DESKTOP_CLAIM_IDLE_MS). Idle desktop -> phone takes the pane;
next desktop keystroke re-asserts the desktop layout server-side
(noteDesktopActivity via ws-routes input). Phones re-send dims every
30s (visible tab only, skipped while the keyboard is open) so attaching
under a hot claim self-corrects. Fixes the desktop-width-stream-in-
narrow-xterm soup (mid-word wraps, tmux dot fill, Ink overdraw).
- Cross-device reflows (takeover/re-assert) emit a debounced needsRefresh
so all clients reload the buffer instead of stacking ghost Ink frames.
- Keyboard accessory/toolbar lift restored: measure keyboardOffset
against window.innerHeight (layout viewport), not the shrunken .app -
on iOS the offset computed to 0, leaving both bars hidden behind the
OS keyboard with a dead gap above.
- Removed the mobile header utility ("three dots") toggle entirely;
the headerRight tray stays collapsed on small viewports.
Tests: usage-limit-patterns (36), session-auto-resume (21), resize
arbitration (+6), session routes (+4), respawn guard (+2); MockSession
auto-resume/sizing stubs; mobile tabs test updated for toggle removal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
297 lines
11 KiB
TypeScript
297 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,
|
|
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();
|
|
});
|
|
});
|
|
});
|