Ctrl K
ComunidadGuías…

test/billion-limit.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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();
  });
});