diff --git a/src/main/rate-limits/antigravity-usage-mirror.test.ts b/src/main/rate-limits/antigravity-usage-mirror.test.ts index 84c92e1ad22..d4850223d83 100644 --- a/src/main/rate-limits/antigravity-usage-mirror.test.ts +++ b/src/main/rate-limits/antigravity-usage-mirror.test.ts @@ -30,15 +30,21 @@ describe('deriveAntigravityRateLimits', () => { expect(antigravity.error).toBeNull() }) - it('reports unavailable without quoting the Gemini failure', () => { + it('settles a failed quota read as an error without quoting the Gemini failure', () => { const antigravity = deriveAntigravityRateLimits( geminiSnapshot('error', 'Gemini project ID not found') ) expect(antigravity.provider).toBe('antigravity') - expect(antigravity.status).toBe('unavailable') + // Why: the Gemini read is Antigravity's read of the shared quota, so its failure is a failed + // read, not an absent one — `unavailable` would discard the last good snapshot downstream. + expect(antigravity.status).toBe('error') expect(antigravity.error).not.toContain('Gemini project ID not found') - expect(antigravity.error).toContain('Antigravity usage is not available') + // Why: still Orca's own Antigravity wording — but this lane retains a reading, so it may not + // say the usage is unavailable. The no-sign-in lane below keeps that phrasing. + expect(antigravity.error).toContain('Antigravity usage') + expect(antigravity.error).toContain('shared Google Code Assist quota') + expect(antigravity.error).not.toContain('not available') expect(antigravity.session).toBeNull() expect(antigravity.weekly).toBeNull() }) diff --git a/src/main/rate-limits/antigravity-usage-mirror.ts b/src/main/rate-limits/antigravity-usage-mirror.ts index 3cea6decc11..cca60599e2a 100644 --- a/src/main/rate-limits/antigravity-usage-mirror.ts +++ b/src/main/rate-limits/antigravity-usage-mirror.ts @@ -1,29 +1,35 @@ import type { ProviderRateLimits } from '../../shared/rate-limit-types' -// Why: the Antigravity CLI keeps its token in the OS keyring, not in the files the Gemini -// fetcher reads, so Orca never actually queries Antigravity. Only a *successful* Gemini read -// describes shared Google Code Assist quota; republishing a Gemini failure under the -// Antigravity provider id surfaced "Refresh failed" for a request that was never attempted. +// Why: Orca has no Antigravity endpoint — the Gemini read of shared Google Code Assist quota *is* +// Antigravity's read, which is why a successful one is mirrored verbatim. #15876 stopped this row +// quoting Gemini's raw error and blaming a sign-in that exists; both still hold. It also settled a +// failed read as `unavailable`, taken as a copy choice but really a retention verdict — +// `applyStalePolicy` discards on `unavailable`, retains on `error` — so one failure wiped this row +// and spared Gemini's. One read cannot be Antigravity's when it succeeds and nobody's when it +// fails, so a failed read settles `error` here too. That brings back the generic "Refresh failed" +// label deliberately: the reason below names the shared quota, so no Antigravity request is +// implied, and a bespoke label would suppress the tooltip's "showing cached data" suffix. const ANTIGRAVITY_NO_SIGN_IN_REASON = 'Antigravity usage is not available. Orca can only show shared Google Code Assist quota while a Gemini CLI sign-in is connected.' -// Why: a Gemini `error` means the sign-in exists and the quota read failed, so blaming a missing sign-in would misdirect the user. +// Why: this lane now retains the last reading, so the tooltip prints this line above a live +// meter — claiming the usage is unavailable would contradict the numbers beside it. const ANTIGRAVITY_QUOTA_UNREADABLE_REASON = - 'Antigravity usage is not available. Orca reads it from the shared Google Code Assist quota, which could not be read right now.' + 'Orca reads Antigravity usage from the shared Google Code Assist quota, which could not be read right now.' export function deriveAntigravityRateLimits(gemini: ProviderRateLimits): ProviderRateLimits { if (gemini.status === 'ok') { return { ...gemini, provider: 'antigravity' } } + // Why: a Gemini `error` means the sign-in exists and the read failed; anything else means there + // was nothing to read, which is genuinely absent rather than failed. + const quotaReadFailed = gemini.status === 'error' return { provider: 'antigravity', session: null, weekly: null, // Why: reuse the Gemini timestamp so activation freshness checks don't force a refetch every cycle. updatedAt: gemini.updatedAt, - error: - gemini.status === 'unavailable' - ? ANTIGRAVITY_NO_SIGN_IN_REASON - : ANTIGRAVITY_QUOTA_UNREADABLE_REASON, - status: 'unavailable' + error: quotaReadFailed ? ANTIGRAVITY_QUOTA_UNREADABLE_REASON : ANTIGRAVITY_NO_SIGN_IN_REASON, + status: quotaReadFailed ? 'error' : 'unavailable' } } diff --git a/src/main/rate-limits/gemini-oauth-sources.ts b/src/main/rate-limits/gemini-oauth-sources.ts index 386c8b281f3..b4cf80da569 100644 --- a/src/main/rate-limits/gemini-oauth-sources.ts +++ b/src/main/rate-limits/gemini-oauth-sources.ts @@ -68,7 +68,9 @@ export async function readGeminiCredentials(): Promise ) { return parsed as GeminiCredentials } - return null + // A file that exists but does not match the credential contract is a failed + // read. Preserve that distinction so callers do not report it as signed out. + throw new Error('Gemini CLI credentials file is invalid') } catch (err) { if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { return null diff --git a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts index fddd0a77d59..eda72c607f7 100644 --- a/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts +++ b/src/main/rate-limits/gemini-usage-fetcher.fallback.test.ts @@ -153,6 +153,36 @@ describe('fetchGeminiRateLimits fallback oauth creds', () => { expect(result.weekly).toBeNull() }) + it('reports a present but malformed oauth_creds.json as a failed read', async () => { + readFileMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('auth.json')) { + return JSON.stringify({}) + } + if (filePath.includes('oauth_creds.json')) { + return JSON.stringify({ + access_token: 42, + refresh_token: 'refresh', + expiry_date: Date.now() + }) + } + throw { code: 'ENOENT' } + }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('error') + expect(result.error).toContain('credentials file is invalid') + }) + + it('keeps an actually missing oauth_creds.json as absent', async () => { + readFileMock.mockRejectedValue({ code: 'ENOENT' }) + + const result = await fetchGeminiRateLimits(true) + + expect(result.status).toBe('unavailable') + expect(result.error).toContain('credentials not found') + }) + it('returns error when loadCodeAssist cannot resolve a project for oauth_creds path', async () => { // Why: when the fallback (oauth_creds.json) path has no project embedded // and loadCodeAssist fails, we surface a clear "project ID not found" diff --git a/src/main/rate-limits/service-antigravity-usage.test.ts b/src/main/rate-limits/service-antigravity-usage.test.ts index b617d909944..56f16d904c4 100644 --- a/src/main/rate-limits/service-antigravity-usage.test.ts +++ b/src/main/rate-limits/service-antigravity-usage.test.ts @@ -54,7 +54,7 @@ describe('RateLimitService Antigravity usage', () => { vi.mocked(fetchCodexRateLimits).mockResolvedValue(okProvider('codex', 20)) }) - it('does not republish a Gemini failure as an Antigravity refresh failure', async () => { + it('does not republish the Gemini failure text under the Antigravity provider', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValue( errorProvider('gemini', 'Gemini project ID not found') ) @@ -63,7 +63,8 @@ describe('RateLimitService Antigravity usage', () => { await service.refresh() const state = service.getState() - expect(state.antigravity?.status).toBe('unavailable') + // Why: a failed shared-quota read is a failed Antigravity read; only the wording is Antigravity's. + expect(state.antigravity?.status).toBe('error') expect(state.antigravity?.error).not.toContain('Gemini project ID not found') expect(state.antigravity?.session).toBeNull() // Why: the real Gemini failure must still surface under its own provider. @@ -83,7 +84,7 @@ describe('RateLimitService Antigravity usage', () => { expect(state.antigravity?.session?.usedPercent).toBe(42) }) - it('never leaves a cached Antigravity snapshot in the error retry lane', async () => { + it('keeps the last shared-quota reading when the quota read fails, exactly as Gemini does', async () => { vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) const service = new RateLimitService() await service.refresh() @@ -93,8 +94,32 @@ describe('RateLimitService Antigravity usage', () => { ) await service.refresh() - // Why: stale-retention would otherwise show Gemini numbers as "Refresh failed" Antigravity usage. - expect(service.getState().antigravity?.status).toBe('unavailable') - expect(service.getState().antigravity?.session).toBeNull() + // Why: one read backs both rows, so one failure must not wipe one row and spare the other. + const state = service.getState() + expect(state.antigravity?.status).toBe('error') + expect(state.antigravity?.session?.usedPercent).toBe(42) + expect(state.gemini?.status).toBe('error') + expect(state.gemini?.session?.usedPercent).toBe(42) + }) + + it('does not quote the Gemini failure while showing a retained Antigravity reading', async () => { + vi.mocked(fetchGeminiRateLimits).mockResolvedValueOnce(okProvider('gemini', 42, Date.now())) + const service = new RateLimitService() + await service.refresh() + + vi.mocked(fetchGeminiRateLimits).mockResolvedValue( + errorProvider('gemini', 'Token refresh failed') + ) + await service.refresh() + + // Why: #15876's real protection — the Antigravity row owns its wording even in the failure lane. + const antigravity = service.getState().antigravity + expect(antigravity?.error).not.toContain('Token refresh failed') + expect(antigravity?.error).toContain('shared Google Code Assist quota') + // Why: retention is what makes this lane new — the tooltip prints this reason under + // "Refresh failed — showing cached data" and directly above the retained meter, so a reason + // that calls the usage unavailable contradicts the numbers rendered next to it. + expect(antigravity?.session?.usedPercent).toBe(42) + expect(antigravity?.error).not.toContain('not available') }) }) diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index 8ce0b167a4a..bb2fa988fbe 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -1767,7 +1767,8 @@ export class RateLimitService { status: 'error' } satisfies ProviderRateLimits) - // Why: Antigravity can only borrow a *successful* Gemini read; a Gemini failure is not an Antigravity failure. + // Why: the Gemini read of shared Code Assist quota IS Antigravity's read, so its failure is + // Antigravity's failed read too — see antigravity-usage-mirror.ts for why that settles `error`. const antigravity = deriveAntigravityRateLimits(gemini) const opencodeGo =