test/billion-limit.test.js
Rama main · réplica de lectura del repositorio original.
// Billion switching CLI at a usage limit (server/billion-limit.js).
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { matchLimit, limitTick, resetLimitWatch, SWITCH_GAP_MS, SETTLE_MS } from '../server/billion-limit.js';
// Seen verbatim in worker screens on 2026-09-26, and read out of the CLIs.
const CLAUDE_WARN = "You've used 92% of your weekly limit · resets 10am (America/Los_Angeles)";
const CLAUDE_OUT = "You're out of usage credits. Run /usage-credits to keep using Fable 5.1 or /model to switch models.";
describe('matchLimit', () => {
it('reads Claude Code\'s warning with its percent and limit', () => {
expect(matchLimit(`● Done.\n\n${CLAUDE_WARN}\n> `)).toMatchObject({ kind: 'warning', used: 92, limit: 'weeklylimit' });
// Cursor moves stripped away can take the spaces with them.
expect(matchLimit("You'veused80%ofyoursessionlimit·resets3pm")).toMatchObject({ kind: 'warning', used: 80, limit: 'sessionlimit' });
});
it('reads Codex\'s warning as the share used', () => {
expect(matchLimit('⚠ Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.'))
.toMatchObject({ kind: 'warning', used: 75 });
});
it('reads the hard limits', () => {
for (const text of [
CLAUDE_OUT,
"You've hit your session limit · resets 3pm (America/Los_Angeles)",
"You've hit your weekly limit · resets Oct 3, 10am",
"You've hit your team's shared budget. /model to switch models.",
"You've reached your Fable limit",
"You're out of extra usage",
'Claude usage limit reached. Your limit will reset at 3pm.',
"■ You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again at 4:05 PM.",
"You're out of credits.",
'Your workspace is out of credits. Add credits to continue.',
'Usage limit reached. You\'ve reached your usage limit.',
]) expect(matchLimit(`some output\n${text}\n> `), text).toMatchObject({ kind: 'hard' });
expect(matchLimit(`x\n${CLAUDE_OUT}`).line).toBe(CLAUDE_OUT);
expect(matchLimit("──────You'vehityoursessionlimit·resets3pm").kind).toBe('hard');
expect(matchLimit("You'vereachedyourFablelimit").kind).toBe('hard');
});
it('a hard limit wins over a warning on the same screen', () => {
expect(matchLimit(`${CLAUDE_WARN}\n${CLAUDE_OUT}`).kind).toBe('hard');
});
it('leaves ordinary words about limits alone', () => {
for (const text of [
'The worker may hit its usage limit soon; I will check the board.',
'I have used 92% of the context window.',
"You've hit your fast limit · resets in 8m",
"You'vehityourfastlimit",
"You've used its skills recently",
'Rate limit exceeded, retrying in 2s',
'Posted a card: handle usage limits in the dispatcher.',
'Approaching usage limit',
'● Claude Code said "You\'re out of usage credits" before the switch.',
"It said You've hit your session limit, so I moved over.",
"The worker's screen: 'You've used 92% of your weekly limit'",
"- You've hit your session limit (from STATE.md)",
"> You're out of usage credits",
]) expect(matchLimit(text), text).toBeNull();
});
});
const T0 = 1_000_000_000;
let ids = 0;
// Billion at rest, `screen` the last thing it printed.
const billion = (screen, over = {}) => ({
id: `lim-${++ids}`, isBillion: true, exited: false, agent: 'claude',
state: 'WAITING', lastOutputAt: T0 - SETTLE_MS, ringBuffer: { getAll: () => [screen] }, ...over,
});
const deps = (over = {}) => ({
now: T0, env: {}, log: () => {},
send: vi.fn(() => true), ready: vi.fn(async () => true),
switchTo: vi.fn(async () => ({ session: {} })), notify: vi.fn(async () => ({})), tell: vi.fn(async () => ({})),
...over,
});
describe('limitTick', () => {
beforeEach(() => resetLimitWatch());
it('nudges once per threshold at a warning', async () => {
const s = billion(`${CLAUDE_WARN}\n> `);
const d = deps();
expect(await limitTick(s, d)).toBe('warned');
expect(d.send).toHaveBeenCalledWith(s, "Your Claude Code usage is at 92%; bring STATE.md up to date and commit now, in case you're switched.", T0);
expect(await limitTick(s, d)).toBeNull();
s.ringBuffer = { getAll: () => ["You've used 93% of your weekly limit"] };
expect(await limitTick(s, d)).toBeNull(); // same threshold
s.ringBuffer = { getAll: () => ["You've used 60% of your weekly limit"] };
expect(await limitTick(s, d)).toBeNull(); // under any threshold
expect(d.send).toHaveBeenCalledTimes(1);
const early = billion("You've used 76% of your weekly limit");
await limitTick(early, d);
early.ringBuffer = { getAll: () => ["You've used 91% of your weekly limit"] };
expect(await limitTick(early, d)).toBe('warned'); // crossed 90 as well
expect(d.switchTo).not.toHaveBeenCalled();
});
it('switches at the hard limit once Billion is quiet, and tells the owner', async () => {
const d = deps();
expect(await limitTick(billion(CLAUDE_OUT, { state: 'WORKING' }), d)).toBeNull();
expect(await limitTick(billion(CLAUDE_OUT, { lastOutputAt: T0 - 1000 }), d)).toBeNull();
expect(d.switchTo).not.toHaveBeenCalled();
expect(await limitTick(billion(CLAUDE_OUT), d)).toBe('switched');
expect(d.ready).toHaveBeenCalledWith('codex', expect.anything());
expect(d.switchTo).toHaveBeenCalledWith('codex', 'Claude Code hit its limit');
expect(d.tell).toHaveBeenCalledWith('Switched Billion to Codex: Claude Code hit its limit. HANDOVER.md written.');
expect(d.notify).not.toHaveBeenCalled();
});
it('never flaps: a limit on the new CLI soon after pauses and tells the owner once', async () => {
const d = deps();
await limitTick(billion(CLAUDE_OUT), d);
const codex = billion("■ You've hit your usage limit. Try again at 4:05 PM.", { agent: 'codex' });
const later = { ...d, now: T0 + SWITCH_GAP_MS - 1 };
expect(await limitTick(codex, later)).toBe('paused');
expect(await limitTick(codex, later)).toBeNull();
expect(d.switchTo).toHaveBeenCalledTimes(1);
expect(d.notify).toHaveBeenCalledTimes(1);
expect(d.notify.mock.calls[0][0]).toMatch(/^Billion paused: both Claude Code and Codex are at their limits/);
// After the window it may switch again.
expect(await limitTick(billion(codex.ringBuffer.getAll()[0], { agent: 'codex' }), { ...d, now: T0 + SWITCH_GAP_MS })).toBe('switched');
expect(d.switchTo).toHaveBeenLastCalledWith('claude', 'Codex hit its limit');
});
it('pauses when the other CLI is missing or logged out, without switching', async () => {
const d = deps({ ready: vi.fn(async () => false) });
const s = billion(CLAUDE_OUT);
expect(await limitTick(s, d)).toBe('paused');
expect(await limitTick(s, d)).toBeNull();
expect(d.switchTo).not.toHaveBeenCalled();
expect(d.notify).toHaveBeenCalledTimes(1);
expect(d.notify.mock.calls[0][0]).toMatch(/Codex is not installed or not logged in/);
// A new Billion (the owner pressed Start) is watched again.
expect(await limitTick(billion(CLAUDE_OUT), d)).toBe('paused');
});
it('reads Billion only, and nothing with BILLION_AUTO_SWITCH=0', async () => {
const d = deps();
expect(await limitTick(billion(CLAUDE_OUT, { isBillion: false }), d)).toBeNull();
expect(await limitTick(billion(CLAUDE_WARN, { isBillion: false }), d)).toBeNull();
const off = { ...d, env: { BILLION_AUTO_SWITCH: '0' } };
expect(await limitTick(billion(CLAUDE_OUT), off)).toBeNull();
expect(await limitTick(billion(CLAUDE_WARN), off)).toBeNull();
expect(d.send).not.toHaveBeenCalled();
expect(d.switchTo).not.toHaveBeenCalled();
});
it('a limit scrolled above the bottom of the screen is old news', async () => {
const d = deps();
const screen = [CLAUDE_OUT, ...Array.from({ length: 20 }, (_, i) => `line ${i} of Billion's own work`)].join('\n');
expect(await limitTick(billion(screen), d)).toBeNull();
});
});
describe('saveBillionAgent', async () => {
const { saveBillionAgent } = await import('../server/billion.js');
const { mkdtempSync, readFileSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
it('keeps the reason for a switch the server made', () => {
const file = join(mkdtempSync(join(tmpdir(), 'limit-')), 'billion-agent.json');
saveBillionAgent('codex', {}, file, 'Claude Code hit its limit');
expect(JSON.parse(readFileSync(file, 'utf8'))).toMatchObject({ agent: 'codex', reason: 'Claude Code hit its limit', at: expect.any(String) });
saveBillionAgent('claude', {}, file);
expect(JSON.parse(readFileSync(file, 'utf8')).reason).toBeUndefined();
});
});
// cliReady against stand-in CLIs: a definite no is false, a check that does not
// answer is null, so the logged-out notice (server.js) only follows a real no.
describe('cliReady', () => {
it.skipIf(process.platform === 'win32')('tells logged in, logged out and no answer apart', async () => {
const { mkdtempSync, writeFileSync, chmodSync } = await import('fs');
const { join } = await import('path');
const { tmpdir } = await import('os');
const { cliReady } = await import('../server/billion-limit.js');
const stub = (name, body) => {
const bin = mkdtempSync(join(tmpdir(), 'a007-cliready-'));
writeFileSync(join(bin, name), `#!/bin/sh\n${body}\n`);
chmodSync(join(bin, name), 0o755);
return { PATH: `${bin}:/usr/bin:/bin` };
};
expect(await cliReady('claude', { env: stub('claude', `echo '{"loggedIn":true}'`) })).toBe(true);
expect(await cliReady('claude', { env: stub('claude', `echo '{"loggedIn":false}'; exit 1`) })).toBe(false);
expect(await cliReady('claude', { env: stub('claude', 'echo not json') })).toBeNull();
expect(await cliReady('claude', { env: stub('claude', 'exit 3') })).toBeNull();
expect(await cliReady('codex', { env: stub('codex', 'exit 0') })).toBe(true);
expect(await cliReady('codex', { env: stub('codex', 'exit 1') })).toBe(false);
expect(await cliReady('claude', { env: { PATH: '/nonexistent' } })).toBe(false);
});
});
describe('persistent account rotation before CLI fallback', () => {
beforeEach(() => resetLimitWatch());
const rotation = over => ({ run: vi.fn(async () => ({ ok: true })), prepare: vi.fn(async () => ({ ok: true })), fallback: () => true, ...over });
it('keeps Codex running when CLI auto-switch is off even with account rotation enabled', async () => {
const r = rotation(), d = deps({ rotation: r, env: { BILLION_AUTO_SWITCH: '0' } });
const s = billion("You've hit your usage limit", { agent: 'codex' });
expect(await limitTick(s, d)).toBeNull();
expect(r.prepare).not.toHaveBeenCalled();
expect(r.run).not.toHaveBeenCalled();
expect(d.switchTo).not.toHaveBeenCalled();
expect(d.tell).not.toHaveBeenCalled();
});
it('rotates Claude accounts without a handover or the CLI switch cooldown', async () => {
const r = rotation(), d = deps({ rotation: r });
resetLimitWatch({ switchAt: T0 - 100 });
expect(await limitTick(billion(CLAUDE_OUT), d)).toBe('rotated');
expect(r.run).toHaveBeenCalledWith(expect.objectContaining({ kind: 'hard' }), { limited: true });
expect(d.switchTo).not.toHaveBeenCalled();
expect(await limitTick(billion(CLAUDE_OUT), d)).toBe('rotated');
});
it('hands over to Codex only after the Claude pool is exhausted', async () => {
const r = rotation({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })) });
const d = deps({ rotation: r });
expect(await limitTick(billion(CLAUDE_OUT), d)).toBe('switched');
expect(d.switchTo).toHaveBeenCalledWith('codex', expect.any(String));
});
it('waits for the reset with fallback off, then retries without extending the old limit', async () => {
const r = rotation({ run: vi.fn().mockResolvedValueOnce({ exhausted: true, retryAt: T0 + 60_000 }).mockResolvedValue({ ok: true }), fallback: () => false });
const d = deps({ rotation: r }), s = billion(CLAUDE_OUT);
expect(await limitTick(s, d)).toBe('paused');
expect(await limitTick(s, { ...d, now: T0 + 1000 })).toBeNull();
expect(r.run).toHaveBeenCalledTimes(1);
expect(await limitTick(s, { ...d, now: T0 + 60_001 })).toBe('rotated');
expect(r.run).toHaveBeenLastCalledWith(expect.anything(), { limited: false });
expect(d.switchTo).not.toHaveBeenCalled();
expect(d.notify).toHaveBeenCalledTimes(1);
});
it('does not change CLI after a failed rollback and does not spin while another action is busy', async () => {
const r = rotation({ run: vi.fn().mockResolvedValueOnce({ busy: true }).mockResolvedValue({ error: 'Restore the previous login', blocked: true }) });
const d = deps({ rotation: r }), s = billion(CLAUDE_OUT);
expect(await limitTick(s, d)).toBeNull();
expect(s.rotationMarked).toBeUndefined();
expect(await limitTick(s, d)).toBe('paused');
expect(d.switchTo).not.toHaveBeenCalled();
expect(d.notify).toHaveBeenCalledWith(expect.stringContaining('Restore the previous login'));
});
it('selects an available Claude login before the Codex-to-Claude handover', async () => {
const order = [], r = rotation({ prepare: vi.fn(async () => { order.push('account'); return { ok: true }; }) });
const d = deps({ rotation: r, switchTo: vi.fn(async () => { order.push('handover'); return { session: {} }; }) });
expect(await limitTick(billion("You've hit your usage limit", { agent: 'codex' }), d)).toBe('switched');
expect(order).toEqual(['account', 'handover']);
});
it('retries an exhausted Claude pool later while Codex waits, without repeated notifications', async () => {
const r = rotation({ prepare: vi.fn().mockResolvedValueOnce({ exhausted: true, retryAt: T0 + 1000 }).mockResolvedValue({ ok: true }) });
const d = deps({ rotation: r }), s = billion("You've hit your usage limit", { agent: 'codex' });
expect(await limitTick(s, d)).toBe('paused');
expect(await limitTick(s, { ...d, now: T0 + 500 })).toBeNull();
expect(await limitTick(s, { ...d, now: T0 + 1001 })).toBe('switched');
expect(d.notify).toHaveBeenCalledTimes(1);
});
});
describe('Codex account rotation before the handover to Claude Code', () => {
beforeEach(() => resetLimitWatch());
const CODEX_OUT = "■ You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again at Oct 25th, 2026 3:15 PM.";
const pool = over => ({ run: vi.fn(async () => ({ ok: true })), prepare: vi.fn(async () => ({ ok: true })), fallback: () => true, ...over });
// Value: protects=a handover that fails with a pool on waits the switch gap instead of retrying every tick;
// fails_when=the failed switchTo leaves only pausedFor, which an enabled pool lets through;
// why_new=no case fails the handover itself with a pool on; seam=none
it('waits the switch gap after a failed handover with a pool on', async () => {
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })) });
const d = deps({ codexRotation: c, switchTo: vi.fn(async () => ({ error: 'handover could not be written' })) }), s = billion(CODEX_OUT, { agent: 'codex' });
expect(await limitTick(s, d)).toBeNull();
expect(await limitTick(s, { ...d, now: T0 + 10_000 })).toBeNull();
expect(d.switchTo).toHaveBeenCalledTimes(1);
expect(c.run).toHaveBeenCalledTimes(1);
expect(s.rotationRetryAt).toBe(T0 + SWITCH_GAP_MS);
});
// Value: protects=a pool exhausted with the other CLI unavailable pauses, retries after the gap, and tells the owner once;
// fails_when=the pause keeps a past retry time and re-notifies (Telegram) every tick;
// why_new=no case pauses on `why` with a pool on and then ticks past the gap; seam=none
it('pauses once when the other CLI is unavailable, then retries only after the gap without telling the owner again', async () => {
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })) });
const d = deps({ codexRotation: c, ready: vi.fn(async () => false) }), s = billion(CODEX_OUT, { agent: 'codex' });
expect(await limitTick(s, d)).toBe('paused');
expect(await limitTick(s, { ...d, now: T0 + 10_000 })).toBeNull();
expect(await limitTick(s, { ...d, now: T0 + SWITCH_GAP_MS + 1 })).toBe('paused');
expect(await limitTick(s, { ...d, now: T0 + SWITCH_GAP_MS + 10_001 })).toBeNull();
expect(s.rotationRetryAt).toBe(T0 + 2 * SWITCH_GAP_MS + 1);
expect(d.notify).toHaveBeenCalledTimes(1);
// A rotation in between ends that pause: running out again is told again.
c.run.mockResolvedValueOnce({ ok: true });
expect(await limitTick(s, { ...d, now: T0 + 2 * SWITCH_GAP_MS + 2 })).toBe('rotated');
expect(await limitTick(s, { ...d, now: T0 + 3 * SWITCH_GAP_MS })).toBe('paused');
expect(d.notify).toHaveBeenCalledTimes(2);
});
it('still tells the owner of a pause after a failed handover', async () => {
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })) });
const ready = vi.fn().mockResolvedValueOnce(true).mockResolvedValue(false);
const d = deps({ codexRotation: c, ready, switchTo: vi.fn(async () => ({ error: 'Billion is already switching', busy: true })) }), s = billion(CODEX_OUT, { agent: 'codex' });
expect(await limitTick(s, d)).toBeNull();
expect(await limitTick(s, { ...d, now: T0 + 10_000 })).toBe('paused');
expect(d.notify).toHaveBeenCalledTimes(1);
});
it('retries next tick when the handover only met another switch in flight', async () => {
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })) });
const d = deps({ codexRotation: c, switchTo: vi.fn(async () => ({ error: 'Billion is already switching', busy: true })) }), s = billion(CODEX_OUT, { agent: 'codex' });
expect(await limitTick(s, d)).toBeNull();
expect(s.rotationRetryAt).toBeUndefined();
});
// Value: protects=with both pools on, an exhausted pool with fallback still hands over, picking the other CLI's login first;
// fails_when=the exhausted pool's reset wait is set before the handover and its login check returns early;
// why_new=every other case enables one pool only; seam=none
it('hands over with both pools on once its own pool is exhausted, and does not wait for that pool\'s reset', async () => {
const order = [];
const claude = pool({ prepare: vi.fn(async () => { order.push('claude login'); return { ok: true }; }) });
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 3 * 86_400_000 })) });
const d = deps({ rotation: claude, codexRotation: c, switchTo: vi.fn(async () => { order.push('handover'); return { session: {} }; }) });
expect(await limitTick(billion(CODEX_OUT, { agent: 'codex' }), d)).toBe('switched');
expect(order).toEqual(['claude login', 'handover']);
});
it('rotates Codex logins with CLI auto-switch off, and passes the wrapped notice for its reset time', async () => {
const c = pool(), d = deps({ codexRotation: c, env: { BILLION_AUTO_SWITCH: '0' } });
const wrapped = CODEX_OUT.replace('or try again', 'or\ntry again');
expect(await limitTick(billion(wrapped, { agent: 'codex' }), d)).toBe('rotated');
expect(c.run.mock.calls[0][0].retry).toContain('try again at Oct 25th, 2026 3:15 PM');
// Later screen text after a blank line is not part of the notice.
expect(matchLimit(`${CODEX_OUT}\n\nresets in 2h`).retry).not.toContain('resets in 2h');
expect(d.switchTo).not.toHaveBeenCalled();
});
it('hands over to Claude Code only once every Codex login is unavailable', async () => {
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })) });
const d = deps({ codexRotation: c });
expect(await limitTick(billion(CODEX_OUT, { agent: 'codex' }), d)).toBe('switched');
expect(d.switchTo).toHaveBeenCalledWith('claude', expect.any(String));
});
it('waits for a Codex reset with fallback off, telling the owner once', async () => {
const c = pool({ run: vi.fn(async () => ({ exhausted: true, retryAt: T0 + 60_000 })), fallback: () => false });
const d = deps({ codexRotation: c }), s = billion(CODEX_OUT, { agent: 'codex' });
expect(await limitTick(s, d)).toBe('paused');
expect(await limitTick(s, { ...d, now: T0 + 1000 })).toBeNull();
expect(d.notify).toHaveBeenCalledWith(expect.stringContaining('Codex accounts are unavailable'));
expect(d.switchTo).not.toHaveBeenCalled();
});
it('picks an eligible Codex login before a Claude-to-Codex handover', async () => {
const order = [], c = pool({ prepare: vi.fn(async () => { order.push('codex login'); return { ok: true }; }) });
const d = deps({ codexRotation: c, switchTo: vi.fn(async () => { order.push('handover'); return { session: {} }; }) });
expect(await limitTick(billion(CLAUDE_OUT), d)).toBe('switched');
expect(order).toEqual(['codex login', 'handover']);
});
});
describe('a Codex Billion paused with only the Claude pool enabled', () => {
beforeEach(() => resetLimitWatch());
// Value: protects=a Codex Billion paused at both limits retries the handover to Claude once the switch gap passes, while only the Claude account pool is on;
// fails_when=the pause/retry guards key on the Codex pool alone, so the Claude pool no longer re-arms the paused handover;
// why_new=existing Codex-side tests cover the prepare-failure path only, not the paused-by-switch-gap path; seam=none
it('hands over to Claude after the switch gap instead of staying paused', async () => {
const r = { run: vi.fn(), prepare: vi.fn(async () => ({ ok: true })), fallback: () => true };
const d = deps({ rotation: r }), s = billion("You've hit your usage limit", { agent: 'codex' });
resetLimitWatch({ switchAt: T0 - 100 });
expect(await limitTick(s, d)).toBe('paused');
expect(await limitTick(s, { ...d, now: T0 + SWITCH_GAP_MS + 1 })).toBe('switched');
expect(d.switchTo).toHaveBeenCalledWith('claude', expect.any(String));
expect(r.run).not.toHaveBeenCalled();
});
});