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:
arkon
2026-06-10 20:41:34 +02:00
co-authored by Claude Fable 5
parent 055f18fb66
commit 68310619a7
27 changed files with 1511 additions and 182 deletions
+9 -40
View File
@@ -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 () => {
+10
View File
@@ -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 () => {});
+36
View File
@@ -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;
+57
View File
@@ -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', () => {
+296
View File
@@ -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();
});
});
});
+100 -1
View File
@@ -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();
});
});
});
+210
View File
@@ -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();
});
});
});