mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
feat(session,mobile): auto-resume on usage limit + mobile view fixes
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>
This commit is contained in:
@@ -132,54 +132,23 @@ describe('Tab Navigation', () => {
|
||||
expect(modalClass).toMatch(/active/);
|
||||
});
|
||||
|
||||
it('top-left mobile menu button opens the header utility tray', async () => {
|
||||
it('header has no utility toggle and the tray stays collapsed on mobile', async () => {
|
||||
// The three-dot header utility toggle was removed (owner decision,
|
||||
// 2026-06-10): nothing interactive may occupy the top-left corner, and
|
||||
// the headerRight tray stays collapsed (hidden) on small viewports.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.modal.active').forEach((modal) => modal.classList.remove('active'));
|
||||
document.getElementById('headerRight')?.classList.add('mobile-collapsed');
|
||||
const toggle = document.getElementById('mobileHeaderUtilityToggle');
|
||||
toggle?.classList.remove('active');
|
||||
toggle?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
const topLeftElements = await page.evaluate(() => {
|
||||
return document.elementsFromPoint(16, 16).map((el) => ({
|
||||
tag: el.tagName,
|
||||
id: el.id,
|
||||
className: String(el.className),
|
||||
closestButtonId: el.closest('button')?.id ?? '',
|
||||
}));
|
||||
});
|
||||
const toggleCount = await page.locator('#mobileHeaderUtilityToggle').count();
|
||||
expect(toggleCount).toBe(0);
|
||||
|
||||
expect(topLeftElements[0]?.closestButtonId).toBe('mobileHeaderUtilityToggle');
|
||||
|
||||
const toggleBox = await page.locator('#mobileHeaderUtilityToggle').boundingBox();
|
||||
expect(toggleBox?.width ?? 0).toBeGreaterThanOrEqual(44);
|
||||
expect(toggleBox?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.touchscreen.tap(
|
||||
(toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) / 2,
|
||||
(toggleBox?.y ?? 0) + (toggleBox?.height ?? 0) / 2
|
||||
);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const trayClass = await page.locator('#headerRight').getAttribute('class');
|
||||
const expanded = await page.locator('#mobileHeaderUtilityToggle').getAttribute('aria-expanded');
|
||||
const trayBox = await page.locator('#headerRight').boundingBox();
|
||||
const topTrayElement = await page.evaluate(() => {
|
||||
const trayVisible = await page.evaluate(() => {
|
||||
const tray = document.getElementById('headerRight');
|
||||
const rect = tray?.getBoundingClientRect();
|
||||
if (!rect) return '';
|
||||
return (
|
||||
document
|
||||
.elementsFromPoint(rect.left + Math.min(24, rect.width / 2), rect.top + Math.min(24, rect.height / 2))
|
||||
.find((el) => el.id === 'headerRight' || el.closest?.('#headerRight'))
|
||||
?.closest?.('#headerRight')?.id ?? ''
|
||||
);
|
||||
return tray ? getComputedStyle(tray).display !== 'none' : false;
|
||||
});
|
||||
expect(trayClass).not.toMatch(/mobile-collapsed/);
|
||||
expect(expanded).toBe('true');
|
||||
expect(trayBox?.x ?? 9999).toBeLessThanOrEqual((toggleBox?.x ?? 0) + (toggleBox?.width ?? 0) + 8);
|
||||
expect(topTrayElement).toBe('headerRight');
|
||||
expect(trayVisible).toBe(false);
|
||||
});
|
||||
|
||||
it('tabs remain visible on large phone and tablet headers', async () => {
|
||||
|
||||
@@ -222,6 +222,15 @@ export class MockSession extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/** Auto-resume on usage limit (token pause control) */
|
||||
autoResumeEnabled: boolean = false;
|
||||
autoResumeAt: number | null = null;
|
||||
isLimitPaused: boolean = false;
|
||||
setAutoResume = vi.fn((enabled: boolean) => {
|
||||
this.autoResumeEnabled = enabled;
|
||||
if (!enabled) this.autoResumeAt = null;
|
||||
});
|
||||
|
||||
/** Check if session is busy */
|
||||
isBusy = vi.fn(() => false);
|
||||
|
||||
@@ -239,6 +248,7 @@ export class MockSession extends EventEmitter {
|
||||
/** Stubs for the desktop sizing claims used by resize arbitration */
|
||||
claimDesktopSizing = vi.fn();
|
||||
releaseDesktopSizing = vi.fn();
|
||||
noteDesktopActivity = vi.fn();
|
||||
|
||||
/** Stub for runPrompt */
|
||||
runPrompt = vi.fn(async () => {});
|
||||
|
||||
@@ -144,6 +144,42 @@ describe('RespawnController', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Usage-limit pause guard', () => {
|
||||
it('blocks the cycle while the session is paused on a usage limit', async () => {
|
||||
let cycleStarted = false;
|
||||
let blockedReason: string | null = null;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
controller.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockedReason = data.reason;
|
||||
});
|
||||
|
||||
session.isLimitPaused = true;
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(cycleStarted).toBe(false);
|
||||
expect(blockedReason).toBe('usage_limit');
|
||||
expect(controller.state).toBe('watching');
|
||||
});
|
||||
|
||||
it('cycles normally when the pause is not active', async () => {
|
||||
let cycleStarted = false;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
|
||||
session.isLimitPaused = false;
|
||||
controller.start();
|
||||
session.simulateCompletionMessage();
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
|
||||
expect(cycleStarted).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Respawn Cycle', () => {
|
||||
it('should start cycle when completion message detected and confirmed', async () => {
|
||||
let cycleStarted = false;
|
||||
|
||||
@@ -263,6 +263,63 @@ describe('session-routes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/auto-resume ==========
|
||||
|
||||
describe('POST /api/sessions/:id/auto-resume', () => {
|
||||
it('enables auto-resume on usage limit', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.autoResume.enabled).toBe(true);
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId)!;
|
||||
expect(session.setAutoResume).toHaveBeenCalledWith(true);
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything());
|
||||
});
|
||||
|
||||
it('disables auto-resume', async () => {
|
||||
const session = harness.ctx.sessions.get(harness.ctx._sessionId)!;
|
||||
session.autoResumeEnabled = true;
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.autoResume.enabled).toBe(false);
|
||||
expect(session.setAutoResume).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('rejects invalid body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/auto-resume`,
|
||||
payload: { enabled: 'yes' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.errorCode).toBe(ApiErrorCode.INVALID_INPUT);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/auto-resume',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/input ==========
|
||||
|
||||
describe('POST /api/sessions/:id/input', () => {
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
/** Must exceed Session.DESKTOP_CLAIM_IDLE_MS (90s) */
|
||||
const PAST_IDLE_MS = 91_000;
|
||||
|
||||
type ResizeableSessionInternals = {
|
||||
ptyProcess: { resize: (cols: number, rows: number) => void };
|
||||
_ptyCols: number;
|
||||
@@ -109,4 +112,100 @@ describe('Session resize arbitration', () => {
|
||||
|
||||
expect(resize).toHaveBeenCalledWith(100, 30);
|
||||
});
|
||||
|
||||
describe('idle-desktop override (whoever is active wins)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('lets a mobile client take the pane once the desktop claim goes idle', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).not.toHaveBeenCalled(); // fresh claim → ignored
|
||||
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).toHaveBeenCalledWith(48, 28); // idle desktop → applied
|
||||
});
|
||||
|
||||
it('keeps blocking mobile while the desktop stays active via typed input', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS - 10_000);
|
||||
session.noteDesktopActivity(); // user typed on desktop
|
||||
vi.advanceTimersByTime(20_000); // idle since claim, but not since input
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' });
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-asserts the desktop layout on desktop input after a mobile override', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' }); // desktop sizes the pane
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // phone takes over
|
||||
expect(resize).toHaveBeenLastCalledWith(48, 28);
|
||||
|
||||
session.noteDesktopActivity(); // desktop user types again
|
||||
expect(resize).toHaveBeenLastCalledWith(208, 45); // layout restored
|
||||
});
|
||||
|
||||
it('does not re-assert when no mobile override happened', () => {
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
const resize = attachFakePty(session, 160, 48);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
resize.mockClear();
|
||||
session.noteDesktopActivity();
|
||||
expect(resize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits needsRefresh after a mobile takeover and after a desktop re-assert', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
attachFakePty(session, 160, 48);
|
||||
const refresh = vi.fn();
|
||||
session.on('needsRefresh', refresh);
|
||||
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
session.claimDesktopSizing(Symbol('desktop-conn'));
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(refresh).not.toHaveBeenCalled(); // plain desktop resize: no refresh
|
||||
|
||||
vi.advanceTimersByTime(PAST_IDLE_MS);
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // takeover
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
|
||||
session.noteDesktopActivity(); // re-assert to 208x45
|
||||
vi.advanceTimersByTime(800);
|
||||
expect(refresh).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not emit needsRefresh for ordinary single-device resizes', () => {
|
||||
vi.useFakeTimers();
|
||||
const session = new Session({ workingDir: '/tmp', mode: 'shell' });
|
||||
attachFakePty(session, 160, 48);
|
||||
const refresh = vi.fn();
|
||||
session.on('needsRefresh', refresh);
|
||||
|
||||
session.resize(48, 28, { viewportType: 'mobile' }); // mobile-only, no claims
|
||||
session.resize(208, 45, { viewportType: 'desktop' });
|
||||
session.resize(100, 30); // untyped
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Tests for usage-limit pause detection (auto-resume on usage limit).
|
||||
*
|
||||
* Message corpus mirrors real Claude Code output observed across
|
||||
* 1.0.x–2.1.x (GitHub issues + official error docs). Time expectations are
|
||||
* computed with the same local-Date APIs the implementation uses, so the
|
||||
* tests are timezone-independent.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
|
||||
|
||||
/** Fixed "now" for deterministic tests: a real timestamp, any value works. */
|
||||
const NOW = new Date(2026, 5, 10, 14, 0, 0).getTime(); // local Jun 10 2026, 2:00pm
|
||||
|
||||
/** Expected epoch ms for the next local occurrence of hour:minute after NOW. */
|
||||
function nextLocal(hour: number, minute = 0, from = NOW): number {
|
||||
const d = new Date(from);
|
||||
let ts = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute).getTime();
|
||||
if (ts <= from) ts += 24 * 60 * 60 * 1000;
|
||||
return ts;
|
||||
}
|
||||
|
||||
describe('detectUsageLimitPause', () => {
|
||||
describe('era 2 footer forms (v1.0.109+, ∙ separator, no timezone)', () => {
|
||||
it.each([
|
||||
['5-hour limit reached ∙ resets 8pm', 20, 0],
|
||||
['Session limit reached ∙ resets 8pm', 20, 0],
|
||||
['Weekly limit reached ∙ resets 6pm', 18, 0],
|
||||
['5-hour limit reached ∙ resets 10:30pm', 22, 30],
|
||||
['5-hour limit reached ∙ resets 3am', 3, 0],
|
||||
['5-hour limit reached ∙ resets 12am', 0, 0],
|
||||
['5-hour limit reached ∙ resets 12pm', 12, 0],
|
||||
])('parses "%s"', (msg, hour, minute) => {
|
||||
const det = detectUsageLimitPause(msg, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(nextLocal(hour, minute));
|
||||
});
|
||||
|
||||
it('rolls over to tomorrow when the time already passed today', () => {
|
||||
// NOW is 2:00pm local; "resets 9am" must be tomorrow 9am
|
||||
const det = detectUsageLimitPause('5-hour limit reached ∙ resets 9am', NOW);
|
||||
expect(det!.resetAt).toBe(nextLocal(9, 0));
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era 3 forms (v2.0.55+, · separator, IANA timezone, action hints)', () => {
|
||||
it('parses with timezone and /upgrade suffix', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
'5-hour limit reached · resets 3pm (Europe/Stockholm) · /upgrade to Max 20x or turn on /extra-usage',
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
// 3pm in Stockholm (UTC+2 in June) = 13:00 UTC
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getUTCHours()).toBe(13);
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
});
|
||||
|
||||
it('parses generic "Limit reached" with minutes and timezone', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
'Limit reached · resets 11:30am (Asia/Calcutta) · /upgrade to Max or turn on /extra-usage',
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
// 11:30am IST (UTC+5:30) = 06:00 UTC
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getUTCHours()).toBe(6);
|
||||
expect(d.getUTCMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to local time for unresolvable timezone (Etc/Unknown)', () => {
|
||||
const det = detectUsageLimitPause('Limit reached · resets 5pm (Etc/Unknown)', NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(nextLocal(17, 0));
|
||||
});
|
||||
|
||||
it('parses weekly date form "Dec 2, 7pm"', () => {
|
||||
const det = detectUsageLimitPause('Weekly limit reached · resets Dec 2, 7pm (Europe/Moscow) ·', NOW);
|
||||
// Dec 2 is >8 days from Jun 10 → implausible horizon → rejected
|
||||
expect(det).toBeNull();
|
||||
});
|
||||
|
||||
it('parses near-future date form within the horizon', () => {
|
||||
const det = detectUsageLimitPause('Weekly limit reached ∙ resets Jun 15, 1pm', NOW);
|
||||
expect(det).not.toBeNull();
|
||||
const expected = new Date(2026, 5, 15, 13, 0).getTime();
|
||||
expect(det!.resetAt).toBe(expected);
|
||||
});
|
||||
|
||||
it('parses malformed separator-less render', () => {
|
||||
const det = detectUsageLimitPause('Limit reached resets 1:30pm (Asia/Calcutta) ·', NOW);
|
||||
expect(det).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('era 4 forms (v2.1.x, "You\'ve hit your limit")', () => {
|
||||
it.each([
|
||||
["You've hit your limit · resets 1:40pm (UTC)", 13, 40],
|
||||
["You've hit your session limit · resets 3:45pm", 15, 45],
|
||||
["You've hit your Opus limit · resets 3:45pm", 15, 45],
|
||||
])('parses "%s"', (msg) => {
|
||||
const det = detectUsageLimitPause(msg, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
});
|
||||
|
||||
it('parses day-of-week form "resets Mon 12:00am"', () => {
|
||||
const det = detectUsageLimitPause("You've hit your weekly limit · resets Mon 12:00am", NOW);
|
||||
expect(det).not.toBeNull();
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getDay()).toBe(1); // Monday
|
||||
expect(d.getHours()).toBe(0);
|
||||
expect(det!.resetAt).toBeGreaterThan(NOW);
|
||||
expect(det!.resetAt).toBeLessThanOrEqual(NOW + 8 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('parses "May 5 at 9pm" date-with-at form (next-year rollover)', () => {
|
||||
// NOW is Jun 10 2026 — "May 5" is >2 days past → next year → beyond horizon → null
|
||||
const det = detectUsageLimitPause("You've hit your limit · resets May 5 at 9pm (UTC)", NOW);
|
||||
expect(det).toBeNull();
|
||||
});
|
||||
|
||||
it('parses "Jun 12 at 9pm" date-with-at form within horizon', () => {
|
||||
const det = detectUsageLimitPause("You've hit your limit · resets Jun 12 at 9pm", NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(new Date(2026, 5, 12, 21, 0).getTime());
|
||||
});
|
||||
|
||||
it('parses extra-usage form "You\'re out of extra usage"', () => {
|
||||
const det = detectUsageLimitPause("You're out of extra usage · resets 1pm (UTC)", NOW);
|
||||
expect(det).not.toBeNull();
|
||||
const d = new Date(det!.resetAt);
|
||||
expect(d.getUTCHours()).toBe(13);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era 1 inline form (v1.0.x)', () => {
|
||||
it('parses "Your limit will reset at 2pm (America/New_York)"', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
'Claude usage limit reached. Your limit will reset at 2pm (America/New_York)',
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
// 2pm EDT (UTC-4 in June) = 18:00 UTC
|
||||
expect(new Date(det!.resetAt).getUTCHours()).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe('raw API epoch form', () => {
|
||||
it('parses "Claude AI usage limit reached|<epoch>"', () => {
|
||||
const epoch = Math.floor(NOW / 1000) + 3600; // resets in 1h
|
||||
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBe(epoch * 1000);
|
||||
});
|
||||
|
||||
it('returns past epoch as-is (stale message → caller retries soon)', () => {
|
||||
const epoch = Math.floor(NOW / 1000) - 600;
|
||||
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
|
||||
expect(det).not.toBeNull();
|
||||
expect(det!.resetAt).toBeLessThan(NOW);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcript and multi-line contexts', () => {
|
||||
it('detects the ⎿-prefixed transcript echo', () => {
|
||||
const det = detectUsageLimitPause(
|
||||
"⎿ You've hit your limit · resets 6pm (Asia/Bangkok)\n /upgrade to increase your usage limit.",
|
||||
NOW
|
||||
);
|
||||
expect(det).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns the LAST parseable occurrence in a chunk', () => {
|
||||
const chunk = [
|
||||
'5-hour limit reached ∙ resets 3pm',
|
||||
'some scrollback text',
|
||||
'5-hour limit reached ∙ resets 5pm',
|
||||
].join('\n');
|
||||
const det = detectUsageLimitPause(chunk, NOW);
|
||||
expect(det!.resetAt).toBe(nextLocal(17, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('false-positive guards', () => {
|
||||
it.each([
|
||||
'the rate limit reached in tests was 30/min',
|
||||
'limit reached',
|
||||
'Usage limit reached', // bare banner: no reset time → not actionable
|
||||
'we reset at dawn',
|
||||
'resets 3pm', // time without a limit phrase
|
||||
'The speed limit reached 120 km/h before it resets',
|
||||
'5-hour limit reached ∙ resets 25pm', // invalid hour
|
||||
'limit reached ∙ resets 99:99pm',
|
||||
])('ignores "%s"', (msg) => {
|
||||
expect(detectUsageLimitPause(msg, NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores empty and irrelevant data', () => {
|
||||
expect(detectUsageLimitPause('', NOW)).toBeNull();
|
||||
expect(detectUsageLimitPause('compiling project...', NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it('requires the reset time within the window after the phrase', () => {
|
||||
const farApart = 'limit reached' + ' x'.repeat(200) + ' resets 3pm';
|
||||
expect(detectUsageLimitPause(farApart, NOW)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user