mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +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>
211 lines
8.4 KiB
TypeScript
211 lines
8.4 KiB
TypeScript
/**
|
||
* 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();
|
||
});
|
||
});
|
||
});
|