From e96967c22492fe312258bf6ab7f5883250cbc4be Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 9 Jun 2026 14:39:41 -0400 Subject: [PATCH] feat(bar): per-window subscription quota detail + codex multi-session scan Expose per-window detail (5h / weekly / Opus / Sonnet) on native subscription rows instead of collapsing to a single percentage, so the bar can show the binding window, resets, and a burn-rate pace line. Fix the Codex collector to scan recent session rollouts newest-first for the latest non-null rate_limits (today's exec-mode session is null) and mark the result stale with its source time, so Codex quota appears instead of silently vanishing. --- src/web-server/routes/bar-routes.ts | 55 +++++++- .../usage/codex-local-quota-collector.ts | 126 ++++++++++++++---- .../usage/native-quota-collector.ts | 83 +++++++++++- .../codex-local-quota-collector.test.ts | 121 ++++++++++++++--- .../web-server/native-quota-collector.test.ts | 102 +++++++++++++- 5 files changed, 441 insertions(+), 46 deletions(-) diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index 948f852a..7c9a1542 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -28,6 +28,32 @@ import type { DailyUsage, HourlyUsage } from '../usage/types'; // Types // ============================================================================ +/** + * Per-window quota detail for native subscription rows (Claude/Codex). + * + * Carries BOTH used and remaining percent so the macOS bar never re-derives + * them. CLIProxy rows omit the parent `quotaWindows` field entirely, keeping + * the payload backward compatible. + * + * JSON shape (decode test pins these exact keys): the inner object keys stay + * camelCase (usedPercent/remainingPercent/resetAt/windowMinutes); only the + * parent field name serializes to snake_case ("quota_windows"). + */ +export interface QuotaWindowDetail { + /** Stable key: "five_hour" | "seven_day" | "seven_day_opus" | "seven_day_sonnet". */ + key: string; + /** Display label: "5h" | "week" | "Opus · week" | "Sonnet · week". */ + label: string; + /** Used percentage (0-100). */ + usedPercent: number; + /** Remaining percentage (0-100). Carried explicitly, never derived in Swift. */ + remainingPercent: number; + /** ISO timestamp when this window resets, null if unknown. */ + resetAt: string | null; + /** Window length in minutes (300 | 10080), null if unknown. */ + windowMinutes: number | null; +} + /** Single account glance row returned by /api/bar/summary */ export interface BarSummaryRow { /** Account identifier (email or custom name) */ @@ -67,6 +93,18 @@ export interface BarSummaryRow { fetchedAt: string; /** True if account token is expired and needs re-authentication */ needsReauth: boolean; + /** + * Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, + * Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode + * tests and the Swift legacy path stay unaffected. Serialized as + * "quota_windows". + */ + quotaWindows?: QuotaWindowDetail[]; + /** + * Native-only ISO mtime of the source session that supplied a stale Codex + * reading. Present only when stale; serialized as "stale_as_of". + */ + staleAsOf?: string | null; } // ============================================================================ @@ -177,6 +215,21 @@ function withTimeout(p: Promise, ms: number): Promise { }); } +/** + * Map a row to its wire shape. The native-only additions use snake_case parent + * keys ("quota_windows" / "stale_as_of") to match the existing payload's mixed + * casing; inner QuotaWindowDetail keys stay camelCase and pass through as-is. + * Rows without the native fields serialize byte-identically to before, so + * CLIProxy decode/encode tests are unaffected. + */ +function serializeBarRow(row: BarSummaryRow): Record { + const { quotaWindows, staleAsOf, ...rest } = row; + const wire: Record = { ...rest }; + if (quotaWindows !== undefined) wire.quota_windows = quotaWindows; + if (staleAsOf !== undefined && staleAsOf !== null) wire.stale_as_of = staleAsOf; + return wire; +} + // ============================================================================ // Per-account health derivation // ============================================================================ @@ -496,7 +549,7 @@ export function createBarRouter(deps: BarRouterDeps): Router { const getNative = deps.getNativeAccountRows ?? (async () => [] as BarSummaryRow[]); const nativeRows = (await withTimeout(getNative(), NATIVE_SIDELOAD_TIMEOUT_MS)) ?? []; - res.json([...rows, ...nativeRows]); + res.json([...rows, ...nativeRows].map(serializeBarRow)); } catch (err) { console.error('[bar-routes] /summary error:', (err as Error).message); res.status(500).json({ error: 'Internal server error' }); diff --git a/src/web-server/usage/codex-local-quota-collector.ts b/src/web-server/usage/codex-local-quota-collector.ts index 1099c147..ffea21b5 100644 --- a/src/web-server/usage/codex-local-quota-collector.ts +++ b/src/web-server/usage/codex-local-quota-collector.ts @@ -2,19 +2,41 @@ * Codex local quota collector (zero network). * * Codex writes a `rate_limits` object into its rollout session logs - * (~/.codex/sessions////rollout-*.jsonl). We read the most recent - * session's last non-null rate_limits to surface the user's Codex subscription - * quota WITHOUT any network call — pure local file read. + * (~/.codex/sessions////rollout-*.jsonl). We surface the user's Codex + * subscription quota WITHOUT any network call — pure local file read. * - * Exec-mode sessions often never emit rate_limits (the field stays null); in - * that case we return null so the bar simply omits the Codex row rather than - * inventing a fake one. + * Exec-mode sessions often never emit rate_limits (the field stays null). The + * NEWEST session is frequently exec-mode, so reading only it would emit no row + * even though an older interactive session still carries real quota. We instead + * scan recent sessions newest-first and use the first one that yields a usable + * rate_limits, marking the result stale when that source file is old. We only + * return null when no scanned session has any rate_limits — then the bar omits + * the row rather than inventing a fake one. */ import * as fs from 'node:fs'; import * as path from 'node:path'; import { resolveCodexConfigPaths } from '../services/codex-dashboard-service'; +/** + * One Codex quota window normalized for the bar's per-window detail. + * `key`/`label` map primary->five_hour/"5h", secondary->seven_day/"week". + */ +export interface CodexLocalQuotaWindow { + /** Stable window key: "five_hour" | "seven_day". */ + key: string; + /** Short display label: "5h" | "week". */ + label: string; + /** Used percentage (0-100). */ + usedPercent: number; + /** Remaining percentage (0-100) = 100 - usedPercent. */ + remainingPercent: number; + /** ISO timestamp when this window resets, null if unknown. */ + resetAt: string | null; + /** Window length in minutes (300 / 10080), null if absent in the raw log. */ + windowMinutes: number | null; +} + /** Normalized Codex quota snapshot from a local session log. */ export interface CodexLocalQuota { /** Remaining percentage (0-100): min across primary/secondary windows. */ @@ -25,6 +47,13 @@ export interface CodexLocalQuota { tier: string | null; /** True when the source file is older than the freshness window. */ stale: boolean; + /** + * ISO mtime of the session file that SUPPLIED this data, present only when + * stale. Lets the bar render an "as of HH:mm (older session)" footnote. + */ + staleAsOf: string | null; + /** Per-window detail (primary -> five_hour, secondary -> seven_day). */ + windows: CodexLocalQuotaWindow[]; } /** Injectable seams for deterministic tests (no real fs / no tail subprocess). */ @@ -45,9 +74,17 @@ const TAIL_LINES = 200; /** A source older than this is reported stale (but still emitted). */ const STALE_AFTER_MS = 5 * 60 * 1000; +/** + * Cap on how many recent session files we scan newest-first before giving up. + * Bounds the walk when Codex genuinely never reported (avoids touching the whole + * history) while still reaching past several exec-mode sessions to a real one. + */ +const MAX_SESSIONS_SCANNED = 20; + interface CodexRateWindow { usedPercent: number; resetsAtSeconds: number | null; + windowMinutes: number | null; } interface CodexRateLimits { @@ -73,6 +110,7 @@ function parseWindow(value: unknown): CodexRateWindow | null { return { usedPercent, resetsAtSeconds: asFiniteNumber(obj['resets_at']), + windowMinutes: asFiniteNumber(obj['window_minutes']), }; } @@ -162,9 +200,52 @@ function computeNextReset(rate: CodexRateLimits): string | null { return new Date(Math.min(...resets) * 1000).toISOString(); } +function windowDetail(window: CodexRateWindow, key: string, label: string): CodexLocalQuotaWindow { + const usedPercent = Math.max(0, Math.min(100, window.usedPercent)); + return { + key, + label, + usedPercent, + remainingPercent: 100 - usedPercent, + resetAt: + window.resetsAtSeconds !== null + ? new Date(window.resetsAtSeconds * 1000).toISOString() + : null, + windowMinutes: window.windowMinutes, + }; +} + +/** Build per-window detail (primary -> 5h, secondary -> week), omitting absent. */ +function buildWindows(rate: CodexRateLimits): CodexLocalQuotaWindow[] { + const windows: CodexLocalQuotaWindow[] = []; + if (rate.primary) windows.push(windowDetail(rate.primary, 'five_hour', '5h')); + if (rate.secondary) windows.push(windowDetail(rate.secondary, 'seven_day', 'week')); + return windows; +} + +/** Scan one session file's tail backward for the latest non-null rate_limits. */ +async function readRateLimitsFromFile( + file: string, + tailLinesImpl: (file: string, lines: number) => Promise +): Promise { + let lines: string[]; + try { + lines = await tailLinesImpl(file, TAIL_LINES); + } catch { + return null; + } + for (let i = lines.length - 1; i >= 0; i--) { + const found = extractRateLimits(lines[i]); + if (found) return found; + } + return null; +} + /** - * Read the latest Codex session's most recent rate_limits and normalize it. - * Returns null when no session carries a usable rate_limits object. + * Scan recent Codex sessions newest-first and normalize the first usable + * rate_limits. Staleness is computed from the mtime of the file that SUPPLIED + * the data (not the newest file), so a real-but-old session reads correctly as + * stale. Returns null only when none of the scanned sessions carries quota. */ export async function getCodexLocalQuota( deps: CodexLocalQuotaDeps = {} @@ -182,31 +263,28 @@ export async function getCodexLocalQuota( const rolloutFiles = collectRolloutFiles(sessionsDir, existsImpl, readdirImpl); if (rolloutFiles.length === 0) return null; - // Filename carries an ISO timestamp, so the lexicographically-last file is - // the most recent session. - const latest = rolloutFiles[rolloutFiles.length - 1]; + // Filenames carry an ISO timestamp, so the lexicographic order is chronological. + // Walk newest-first, bounded, until a session yields a usable rate_limits. + const newestFirst = rolloutFiles.slice().reverse().slice(0, MAX_SESSIONS_SCANNED); - let lines: string[]; - try { - lines = await tailLinesImpl(latest, TAIL_LINES); - } catch { - return null; - } - - // Iterate backwards for the most recent non-null rate_limits. let rate: CodexRateLimits | null = null; - for (let i = lines.length - 1; i >= 0; i--) { - const found = extractRateLimits(lines[i]); + let sourceFile: string | null = null; + for (const file of newestFirst) { + const found = await readRateLimitsFromFile(file, tailLinesImpl); if (found) { rate = found; + sourceFile = file; break; } } - if (!rate) return null; + if (!rate || !sourceFile) return null; let stale = false; + let staleAsOf: string | null = null; try { - stale = now - statMtimeMsImpl(latest) > STALE_AFTER_MS; + const mtimeMs = statMtimeMsImpl(sourceFile); + stale = now - mtimeMs > STALE_AFTER_MS; + if (stale) staleAsOf = new Date(mtimeMs).toISOString(); } catch { // Unknown mtime -> treat as fresh; the data itself is still valid. stale = false; @@ -217,5 +295,7 @@ export async function getCodexLocalQuota( nextReset: computeNextReset(rate), tier: rate.planType, stale, + staleAsOf, + windows: buildWindows(rate), }; } diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index 1fac4721..80ea8eef 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -27,7 +27,7 @@ import { import { fetchClaudeQuotaWithToken } from '../../cliproxy/quota/quota-fetcher-claude'; import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-collector'; import type { ClaudeQuotaResult } from '../../cliproxy/quota/quota-types'; -import type { BarSummaryRow } from '../routes/bar-routes'; +import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; // ============================================================================ // Safety constants (concrete, named, module-level) @@ -174,7 +174,69 @@ function deriveClaudeNextReset(quota: ClaudeQuotaResult): string | null { return resets.length > 0 ? resets[0].iso : null; } +/** Window length in minutes by Claude rate-limit family. */ +const FIVE_HOUR_MINUTES = 300; +const SEVEN_DAY_MINUTES = 10080; + +/** + * Build the per-window detail for a Claude subscription row. + * + * 5h + weekly come from coreUsage (the canonical core summary). Opus/Sonnet + * weekly splits come from quota.windows[] and only exist on Max plans, so they + * are omitted entirely when absent. Each window carries BOTH used and remaining + * percent so the bar never re-derives them. + */ +function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] { + const windows: QuotaWindowDetail[] = []; + + const fiveHour = quota.coreUsage?.fiveHour; + if (fiveHour) { + windows.push({ + key: 'five_hour', + label: '5h', + usedPercent: 100 - fiveHour.remainingPercent, + remainingPercent: fiveHour.remainingPercent, + resetAt: fiveHour.resetAt, + windowMinutes: FIVE_HOUR_MINUTES, + }); + } + + const weekly = quota.coreUsage?.weekly; + if (weekly) { + windows.push({ + key: 'seven_day', + label: 'week', + usedPercent: 100 - weekly.remainingPercent, + remainingPercent: weekly.remainingPercent, + resetAt: weekly.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + + // Opus/Sonnet weekly splits are Max-only; surface them when the API carries + // them, otherwise omit so non-Max plans get exactly the two core windows. + const splitLabels: Record = { + seven_day_opus: 'Opus · week', + seven_day_sonnet: 'Sonnet · week', + }; + for (const w of quota.windows) { + const label = splitLabels[w.rateLimitType]; + if (!label) continue; + windows.push({ + key: w.rateLimitType, + label, + usedPercent: w.usedPercent, + remainingPercent: w.remainingPercent, + resetAt: w.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + + return windows; +} + function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow { + const quotaWindows = buildClaudeQuotaWindows(quota); return { account_id: CLAUDE_PROVIDER, provider: CLAUDE_PROVIDER, @@ -191,10 +253,26 @@ function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: numb cached: false, fetchedAt: new Date(now).toISOString(), needsReauth: false, + // Omit the field entirely (rather than an empty array) when no windows + // resolved, so the wire shape stays minimal. + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), }; } +/** Map the Codex local windows into the row's per-window detail shape. */ +function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] { + return quota.windows.map((w) => ({ + key: w.key, + label: w.label, + usedPercent: w.usedPercent, + remainingPercent: w.remainingPercent, + resetAt: w.resetAt, + windowMinutes: w.windowMinutes, + })); +} + function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { + const quotaWindows = buildCodexQuotaWindows(quota); return { account_id: CODEX_PROVIDER, provider: CODEX_PROVIDER, @@ -213,6 +291,9 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { cached: false, fetchedAt: new Date(now).toISOString(), needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + // staleAsOf is only present (and serialized) when the source session is old. + ...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}), }; } diff --git a/tests/unit/web-server/codex-local-quota-collector.test.ts b/tests/unit/web-server/codex-local-quota-collector.test.ts index 682202d8..97a15e55 100644 --- a/tests/unit/web-server/codex-local-quota-collector.test.ts +++ b/tests/unit/web-server/codex-local-quota-collector.test.ts @@ -23,14 +23,25 @@ function tokenCountLine(rateLimits: unknown): string { }); } -function writeRollout(name: string, lines: string[]): string { - const day = path.join(sessionsDir, '2026', '06', '09'); +function writeRollout(sessions: string, name: string, lines: string[]): string { + const day = path.join(sessions, '2026', '06', '09'); fs.mkdirSync(day, { recursive: true }); const file = path.join(day, name); fs.writeFileSync(file, lines.join('\n') + '\n'); return file; } +/** + * Each test gets its OWN codex home so the multi-session scan never bleeds a + * fixture from another test (the scan walks ALL recent sessions, not just one). + */ +function freshHome(slug: string): { env: NodeJS.ProcessEnv; sessions: string } { + const home = path.join(tmpDir, `.codex-${slug}`); + const sessions = path.join(home, 'sessions'); + fs.mkdirSync(sessions, { recursive: true }); + return { env: { CODEX_HOME: home }, sessions }; +} + beforeAll(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-local-quota-')); codexHome = path.join(tmpDir, '.codex'); @@ -44,7 +55,8 @@ afterAll(() => { describe('getCodexLocalQuota', () => { it('parses the last non-null rate_limits into a normalized quota', async () => { - writeRollout('rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + const { env, sessions } = freshHome('parse'); + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ tokenCountLine(null), tokenCountLine({ primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 }, @@ -53,7 +65,7 @@ describe('getCodexLocalQuota', () => { }), ]); - const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() }); + const quota = await getCodexLocalQuota({ env, now: Date.now() }); expect(quota).not.toBeNull(); // min(100-0, 100-48) = 52 expect(quota?.quotaPercentage).toBe(52); @@ -61,20 +73,94 @@ describe('getCodexLocalQuota', () => { // soonest reset = min(1781033803, 1781192122) -> primary expect(quota?.nextReset).toBe(new Date(1781033803 * 1000).toISOString()); expect(quota?.stale).toBe(false); + expect(quota?.staleAsOf).toBeNull(); }); - it('returns null when every rate_limits is null (exec-mode session)', async () => { - // A newer session (later filename) with only null rate_limits wins the sort. - writeRollout('rollout-2026-06-09T11-00-00-bbbb.jsonl', [ + it('surfaces per-window detail incl. window_minutes (300 / 10080)', async () => { + const { env, sessions } = freshHome('windows'); + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine({ + primary: { used_percent: 19.0, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 30.0, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'pro', + }), + ]); + + const quota = await getCodexLocalQuota({ env, now: Date.now() }); + expect(quota?.windows).toHaveLength(2); + + const five = quota?.windows.find((w) => w.key === 'five_hour'); + expect(five?.label).toBe('5h'); + expect(five?.usedPercent).toBe(19); + expect(five?.remainingPercent).toBe(81); + expect(five?.windowMinutes).toBe(300); + expect(five?.resetAt).toBe(new Date(1781033803 * 1000).toISOString()); + + const week = quota?.windows.find((w) => w.key === 'seven_day'); + expect(week?.label).toBe('week'); + expect(week?.usedPercent).toBe(30); + expect(week?.remainingPercent).toBe(70); + expect(week?.windowMinutes).toBe(10080); + expect(week?.resetAt).toBe(new Date(1781192122 * 1000).toISOString()); + }); + + it('scans an OLDER session when the newest is exec-mode (rate_limits:null)', async () => { + const { env, sessions } = freshHome('fallback'); + // Older interactive session carries real quota. + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine({ + primary: { used_percent: 0.0, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 48.0, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'pro', + }), + ]); + // Newest session is exec-mode: only null rate_limits. + writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [ tokenCountLine(null), tokenCountLine(null), ]); - const quota = await getCodexLocalQuota({ env: { CODEX_HOME: codexHome }, now: Date.now() }); + + const quota = await getCodexLocalQuota({ env, now: Date.now() }); + // Skips the null-newest file, reads the older file's rate_limits. + expect(quota).not.toBeNull(); + expect(quota?.quotaPercentage).toBe(52); + expect(quota?.tier).toBe('pro'); + }); + + it('returns null when NO scanned session carries rate_limits (no fake row)', async () => { + const { env, sessions } = freshHome('all-null'); + writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [tokenCountLine(null)]); + writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [ + tokenCountLine(null), + tokenCountLine(null), + ]); + const quota = await getCodexLocalQuota({ env, now: Date.now() }); expect(quota).toBeNull(); }); - it('flags stale when the source file mtime is older than 5 minutes', async () => { - const file = writeRollout('rollout-2026-06-09T12-00-00-cccc.jsonl', [ + it('flags stale from the SOURCE file mtime and sets staleAsOf', async () => { + const { env, sessions } = freshHome('stale'); + // Newest is exec-mode (null); the data comes from the older file, so stale + // must reflect the OLDER file's mtime, not the newest's. + const sourceFile = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ + tokenCountLine({ + primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 }, + secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 }, + plan_type: 'plus', + }), + ]); + writeRollout(sessions, 'rollout-2026-06-09T11-00-00-bbbb.jsonl', [tokenCountLine(null)]); + + const sourceMtime = fs.statSync(sourceFile).mtimeMs; + const quota = await getCodexLocalQuota({ env, now: sourceMtime + 6 * 60 * 1000 }); + expect(quota?.stale).toBe(true); + expect(quota?.staleAsOf).toBe(new Date(sourceMtime).toISOString()); + expect(quota?.tier).toBe('plus'); + }); + + it('is fresh (no staleAsOf) when the source file is recent', async () => { + const { env, sessions } = freshHome('fresh'); + const file = writeRollout(sessions, 'rollout-2026-06-09T10-00-00-aaaa.jsonl', [ tokenCountLine({ primary: { used_percent: 10, window_minutes: 300, resets_at: 1781033803 }, secondary: { used_percent: 5, window_minutes: 10080, resets_at: 1781192122 }, @@ -82,19 +168,14 @@ describe('getCodexLocalQuota', () => { }), ]); const mtime = fs.statSync(file).mtimeMs; - // now = mtime + 6 minutes -> stale - const quota = await getCodexLocalQuota({ - env: { CODEX_HOME: codexHome }, - now: mtime + 6 * 60 * 1000, - }); - expect(quota?.stale).toBe(true); - expect(quota?.tier).toBe('plus'); + const quota = await getCodexLocalQuota({ env, now: mtime + 60 * 1000 }); + expect(quota?.stale).toBe(false); + expect(quota?.staleAsOf).toBeNull(); }); it('returns null when there are no rollout files at all', async () => { - const emptyHome = path.join(tmpDir, '.codex-empty'); - fs.mkdirSync(path.join(emptyHome, 'sessions'), { recursive: true }); - const quota = await getCodexLocalQuota({ env: { CODEX_HOME: emptyHome }, now: Date.now() }); + const { env } = freshHome('empty'); + const quota = await getCodexLocalQuota({ env, now: Date.now() }); expect(quota).toBeNull(); }); }); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 13501fa3..cd66991b 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -47,6 +47,34 @@ function successQuota(): ClaudeQuotaResult { }; } +/** A Max-plan quota carrying the Opus/Sonnet weekly splits in windows[]. */ +function maxQuotaWithSplits(): ClaudeQuotaResult { + const base = successQuota(); + return { + ...base, + windows: [ + { + rateLimitType: 'seven_day_opus', + label: 'Opus weekly', + status: 'allowed', + utilization: 0.25, + usedPercent: 25, + remainingPercent: 75, + resetAt: '2026-06-15T00:00:00.000Z', + }, + { + rateLimitType: 'seven_day_sonnet', + label: 'Sonnet weekly', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-06-15T00:00:00.000Z', + }, + ], + }; +} + function reauthQuota(): ClaudeQuotaResult { return { success: false, @@ -115,6 +143,51 @@ describe('Claude native row mapping', () => { expect(row?.needsReauth).toBe(false); }); + it('emits quotaWindows with five_hour + seven_day from coreUsage (no opus/sonnet on non-Max)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => successQuota(), clock); + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'claude-code'); + + expect(row?.quotaWindows).toHaveLength(2); + const five = row?.quotaWindows?.find((w) => w.key === 'five_hour'); + expect(five?.label).toBe('5h'); + expect(five?.remainingPercent).toBe(42); + expect(five?.usedPercent).toBe(58); // 100 - 42 + expect(five?.windowMinutes).toBe(300); + expect(five?.resetAt).toBe('2026-06-09T20:00:00.000Z'); + + const week = row?.quotaWindows?.find((w) => w.key === 'seven_day'); + expect(week?.label).toBe('week'); + expect(week?.remainingPercent).toBe(70); + expect(week?.usedPercent).toBe(30); + expect(week?.windowMinutes).toBe(10080); + + // Max-only splits absent here. + expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_opus')).toBeUndefined(); + expect(row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet')).toBeUndefined(); + }); + + it('adds seven_day_opus + seven_day_sonnet windows when present (Max plan)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeDeps(async () => maxQuotaWithSplits(), clock); + const rows = await getNativeAccountRows(deps); + const row = rows.find((r) => r.provider === 'claude-code'); + + // five_hour, seven_day, seven_day_opus, seven_day_sonnet + expect(row?.quotaWindows).toHaveLength(4); + const opus = row?.quotaWindows?.find((w) => w.key === 'seven_day_opus'); + expect(opus?.label).toBe('Opus · week'); + expect(opus?.usedPercent).toBe(25); + expect(opus?.remainingPercent).toBe(75); + expect(opus?.windowMinutes).toBe(10080); + + const sonnet = row?.quotaWindows?.find((w) => w.key === 'seven_day_sonnet'); + expect(sonnet?.label).toBe('Sonnet · week'); + expect(sonnet?.usedPercent).toBe(60); + expect(sonnet?.remainingPercent).toBe(40); + }); + it('emits a reauth error row on 401/needsReauth', async () => { const clock = { now: 1_000_000 }; const deps = makeDeps(async () => reauthQuota(), clock); @@ -288,6 +361,25 @@ describe('Codex path', () => { nextReset: '2026-06-09T19:00:00.000Z', tier: 'pro', stale: false, + staleAsOf: null, + windows: [ + { + key: 'five_hour', + label: '5h', + usedPercent: 19, + remainingPercent: 81, + resetAt: '2026-06-09T19:00:00.000Z', + windowMinutes: 300, + }, + { + key: 'seven_day', + label: 'week', + usedPercent: 48, + remainingPercent: 52, + resetAt: '2026-06-14T00:00:00.000Z', + windowMinutes: 10080, + }, + ], }), now: () => clock.now, }; @@ -297,6 +389,9 @@ describe('Codex path', () => { expect(row?.quota_percentage).toBe(52); expect(row?.tier).toBe('pro'); expect(row?.health).toBe('ok'); + expect(row?.quotaWindows).toHaveLength(2); + expect(row?.quotaWindows?.[0].windowMinutes).toBe(300); + expect(row?.staleAsOf).toBeUndefined(); }); it('flags health warning when the Codex source is stale', async () => { @@ -308,11 +403,16 @@ describe('Codex path', () => { nextReset: null, tier: null, stale: true, + staleAsOf: '2026-06-09T13:30:00.000Z', + windows: [], }), now: () => clock.now, }; const rows = await getNativeAccountRows(deps); - expect(rows.find((r) => r.provider === 'codex')?.health).toBe('warning'); + const row = rows.find((r) => r.provider === 'codex'); + expect(row?.health).toBe('warning'); + // staleAsOf flows through so the bar can render the freshness footnote. + expect(row?.staleAsOf).toBe('2026-06-09T13:30:00.000Z'); }); it('omits the codex row when there is no rate_limits (exec-mode)', async () => {