diff --git a/src/main/rate-limits/codex-fetcher-backend.test.ts b/src/main/rate-limits/codex-fetcher-backend.test.ts index 9102e54ab093..1cc3c2279948 100644 --- a/src/main/rate-limits/codex-fetcher-backend.test.ts +++ b/src/main/rate-limits/codex-fetcher-backend.test.ts @@ -97,6 +97,47 @@ describe('Codex backend rate-limit requests', () => { ) }) + it('classifies a sole seven-day backend primary window as weekly', async () => { + readFileMock.mockResolvedValue( + JSON.stringify({ + tokens: { access_token: 'access-token', account_id: 'account-id' } + }) + ) + vi.mocked(fetch) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + plan_type: 'plus', + rate_limit: { + primary_window: { + used_percent: 37, + limit_window_seconds: 7 * 24 * 60 * 60, + reset_at: 1_800_000_000 + } + }, + rate_limit_reset_credits: { available_count: 0 } + }) + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ available_count: 0, credits: [] }) + } as Response) + + await expect( + fetchCodexRateLimits({ + codexHomePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\.codex' + }) + ).resolves.toMatchObject({ + session: null, + weekly: { + usedPercent: 37, + windowMinutes: 10_080, + resetsAt: 1_800_000_000_000 + }, + status: 'ok' + }) + }) + it('aborts callers while sharing one stalled backend auth read', async () => { let resolveRead!: (content: string) => void readFileMock.mockImplementation( diff --git a/src/main/rate-limits/codex-fetcher.test.ts b/src/main/rate-limits/codex-fetcher.test.ts index 571229188c63..f9800a773b80 100644 --- a/src/main/rate-limits/codex-fetcher.test.ts +++ b/src/main/rate-limits/codex-fetcher.test.ts @@ -325,7 +325,7 @@ describe('fetchCodexRateLimits', () => { expect(ptySpawnMock).not.toHaveBeenCalled() }) - it('normalizes Codex RPC remaining-minute windows to fixed display durations', async () => { + it('normalizes floor-rounded Codex RPC window durations to canonical display durations', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) rpcChild.stdin.write.mockImplementation((line: string) => { @@ -368,6 +368,50 @@ describe('fetchCodexRateLimits', () => { expect(result.weekly?.windowMinutes).toBe(10080) }) + it('classifies a sole RPC weekly primary window as weekly', async () => { + const rpcChild = makeRpcChild() + childSpawnMock.mockReturnValue(rpcChild) + rpcChild.stdin.write.mockImplementation((line: string) => { + const msg = JSON.parse(line) as { id?: number; method?: string } + if (msg.method === 'initialize') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id: msg.id, result: {} })}\n`) + ) + }, 0) + } + if (msg.method === 'account/rateLimits/read') { + setTimeout(() => { + rpcChild.stdout.emit( + 'data', + Buffer.from( + `${JSON.stringify({ + jsonrpc: '2.0', + id: msg.id, + result: { + rateLimits: { + primary: { usedPercent: 37, windowDurationMins: 10_079 } + } + } + })}\n` + ) + ) + }, 0) + } + }) + + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + + await expect(resultPromise).resolves.toMatchObject({ + session: null, + weekly: { usedPercent: 37, windowMinutes: 10_080 }, + status: 'ok' + }) + }) + it('fills reset-credit count from the backend when the installed app-server omits it', async () => { const rpcChild = makeRpcChild() childSpawnMock.mockReturnValue(rpcChild) diff --git a/src/main/rate-limits/codex-fetcher.ts b/src/main/rate-limits/codex-fetcher.ts index dd328e007d5a..b7c24be16f4c 100644 --- a/src/main/rate-limits/codex-fetcher.ts +++ b/src/main/rate-limits/codex-fetcher.ts @@ -28,6 +28,12 @@ import { createAuthFilesystemOperation, type SharedAuthFilesystemOperation } from './auth-filesystem-operation' +import { + classifyCodexRateLimitWindows, + CODEX_SESSION_WINDOW_MINUTES, + CODEX_WEEKLY_WINDOW_MINUTES, + isCodexWeeklyWindowDuration +} from './codex-rate-limit-window-classification' const RPC_TIMEOUT_MS = 10_000 const WSL_RPC_TIMEOUT_MS = 25_000 @@ -476,7 +482,7 @@ function mapRpcWindow( return { usedPercent: Math.min(100, Math.max(0, raw.usedPercent)), - // Why: windowDurationMins reports remaining minutes, but the UI needs the fixed bucket duration for "5h"/"wk" labels. + // Why: windowDurationMins is a fixed per-window bucket size that legacy Codex paths floor-round (e.g. 299, 10079); normalize to the canonical duration for stable "5h"/"wk" labels. windowMinutes: expectedWindowMinutes, resetsAt, resetDescription @@ -528,10 +534,14 @@ async function fetchViaBackend( if (typeof payload.plan_type !== 'string') { return null } + const { session, weekly } = classifyCodexRateLimitWindows( + mapBackendUsageWindow(payload.rate_limit?.primary_window, CODEX_SESSION_WINDOW_MINUTES), + mapBackendUsageWindow(payload.rate_limit?.secondary_window, CODEX_WEEKLY_WINDOW_MINUTES) + ) return { provider: 'codex', - session: mapBackendUsageWindow(payload.rate_limit?.primary_window, 300), - weekly: mapBackendUsageWindow(payload.rate_limit?.secondary_window, 10080), + session, + weekly, // Surfaced for the status-bar Usage row (e.g. "Codex ยท Plus"). planType: payload.plan_type, ...(payload.rate_limit_reset_credits !== undefined @@ -701,8 +711,15 @@ async function fetchViaRpc(options?: FetchCodexRateLimitsOptions): Promise { + it('recognizes exact and RPC-rounded weekly durations', () => { + expect(isCodexWeeklyWindowDuration(10_080)).toBe(true) + expect(isCodexWeeklyWindowDuration(10_079)).toBe(true) + expect(isCodexWeeklyWindowDuration(300)).toBe(false) + }) + + it('classifies a sole weekly primary window as weekly', () => { + const weekly = window(10_080) + + expect(classifyCodexRateLimitWindows(weekly, null)).toEqual({ + session: null, + weekly + }) + }) + + it('keeps a sole five-hour primary window as the session limit', () => { + const session = window(300) + + expect(classifyCodexRateLimitWindows(session, null)).toEqual({ + session, + weekly: null + }) + }) + + it('keeps the traditional primary and secondary positions when both exist', () => { + const session = window(300) + const weekly = window(10_080) + + expect(classifyCodexRateLimitWindows(session, weekly)).toEqual({ session, weekly }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-window-classification.ts b/src/main/rate-limits/codex-rate-limit-window-classification.ts new file mode 100644 index 000000000000..dfc6dd61a805 --- /dev/null +++ b/src/main/rate-limits/codex-rate-limit-window-classification.ts @@ -0,0 +1,28 @@ +import type { RateLimitWindow } from '../../shared/rate-limit-types' + +export const CODEX_SESSION_WINDOW_MINUTES = 300 +export const CODEX_WEEKLY_WINDOW_MINUTES = 10_080 + +const CODEX_RPC_WINDOW_ROUNDING_TOLERANCE_MINUTES = 1 + +export function isCodexWeeklyWindowDuration(windowMinutes: number | undefined): boolean { + return ( + typeof windowMinutes === 'number' && + Number.isFinite(windowMinutes) && + Math.abs(windowMinutes - CODEX_WEEKLY_WINDOW_MINUTES) <= + CODEX_RPC_WINDOW_ROUNDING_TOLERANCE_MINUTES + ) +} + +export function classifyCodexRateLimitWindows( + primary: RateLimitWindow | null, + secondary: RateLimitWindow | null +): { session: RateLimitWindow | null; weekly: RateLimitWindow | null } { + // Why: newer Codex plans can expose their sole weekly quota as the primary + // window, so field position alone no longer identifies a five-hour session. + if (primary && !secondary && isCodexWeeklyWindowDuration(primary.windowMinutes)) { + return { session: null, weekly: primary } + } + + return { session: primary, weekly: secondary } +} diff --git a/src/renderer/src/components/status-bar/StatusBar.tsx b/src/renderer/src/components/status-bar/StatusBar.tsx index 992f7caf1218..c5a9511d5751 100644 --- a/src/renderer/src/components/status-bar/StatusBar.tsx +++ b/src/renderer/src/components/status-bar/StatusBar.tsx @@ -1238,10 +1238,13 @@ function VerboseProviderUsage({ return window !== null }) + // Why: weekly-only plans have no session window; the mini meter follows the weekly quota instead of vanishing. + const meterWindow = p.session ?? p.weekly + return ( <> - {p.session && !compact ? ( - + {meterWindow && !compact ? ( + ) : null} {visibleWindows.map((window, index) => ( diff --git a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx index a956695c805c..bae6bd89d5c5 100644 --- a/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx +++ b/src/renderer/src/components/status-bar/inline-usage-bars.test.tsx @@ -99,6 +99,59 @@ describe('InlineUsageBars', () => { expect(markup).toContain('32% used now') }) + it('labels a sole Codex weekly window as weekly, not a 5h session', async () => { + const { InlineUsageBars } = await import('./StatusBar') + // Shape the classifier emits for a weekly-only plan: session cleared, weekly set. + const limits: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: { + usedPercent: 37, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + const markup = renderToStaticMarkup() + + expect(markup).toContain('37% used wk') + expect(markup).not.toContain('37% used 5h') + }) + + it('keeps the mini meter when Codex exposes only a weekly window', async () => { + const { ProviderDetailsMenu } = await import('./StatusBar') + const limits: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: { + usedPercent: 37, + windowMinutes: 10_080, + resetsAt: null, + resetDescription: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('w-[48px] h-[6px]') + expect(markup).toContain('width:37%') + expect(markup).toContain('37% used wk') + }) + it('shows remaining copy and remaining meter fill', async () => { mocks.usagePercentageDisplay = 'remaining' const { InlineUsageBars } = await import('./StatusBar')