From fd7ebe3f474f7775cdc6baafbdd5f7c0d857eeb9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 26 Jul 2026 15:52:03 -0700 Subject: [PATCH] fix(codex): cache weekly-only accounts when switching Codex accounts (#10467) * fix(codex): cache weekly-only accounts when switching Codex accounts refreshForCodexAccountChange snapshotted the outgoing account only when this.state.codex.session was populated. Weekly-only plans report no session window, so their snapshot was dropped and the account switcher's inline bars rendered empty for exactly those accounts. Accept a populated weekly window as well. #10136 made this reachable: before duration-based classification, a weekly-only quota landed in the session slot, so the gate happened to pass. Claude is intentionally untouched; it has no weekly-only plan shape. * test(rate-limits): pin that a windowless outgoing Codex account is not cached The widened weekly-only gate had no test for its lower bound: replacing it with a bare truthy check on state.codex passed all 70 tests, which would cache an empty fetching placeholder and render a blank inline bar row in the switcher. --- src/main/rate-limits/service.test.ts | 54 ++++++++++++++++++++++++++++ src/main/rate-limits/service.ts | 4 ++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dfe2755bdc2..245ebed42d2 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1751,6 +1751,60 @@ describe('RateLimitService', () => { ) }) + it('caches an outgoing weekly-only Codex account so the switcher keeps its inline bars', async () => { + const service = new RateLimitService() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-weekly', managedHomePath: '/tmp/account-weekly/home' } + ]) + + const weeklyOnly: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: { usedPercent: 76, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(weeklyOnly) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('account-weekly') + + expect(service.getState().inactiveCodexAccounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + accountId: 'account-weekly', + rateLimits: expect.objectContaining({ + session: null, + weekly: expect.objectContaining({ usedPercent: 76 }) + }) + }) + ]) + ) + }) + + it('does not cache an outgoing Codex account that has no usage windows', async () => { + const service = new RateLimitService() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-empty', managedHomePath: '/tmp/account-empty/home' } + ]) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(errorProvider('codex', 'codex not signed in')) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('account-empty') + + expect(service.getState().inactiveCodexAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'account-empty' })]) + ) + }) + it('does not cache host Claude usage under an outgoing WSL account', async () => { const service = new RateLimitService() service.setInactiveClaudeAccountsResolver(() => [ diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index a1700729ac8..4b0a5e266af 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -378,9 +378,11 @@ export class RateLimitService { target?: CodexAccountSelectionTarget ): Promise { const nextTarget = normalizeCodexAccountSelectionTarget(target) + // Why: weekly-only plans report no session window, so gating on session alone + // dropped their snapshot and left the switcher's inline bars empty. if ( outgoingAccountId && - this.state.codex?.session && + (this.state.codex?.session || this.state.codex?.weekly) && this.isSameCodexTarget(this.codexFetchTarget, nextTarget) ) { this.inactiveCodexCache.set(outgoingAccountId, this.state.codex)