diff --git a/docs/bug-reproductions/8979-screenshots/after-fable-visible.png b/docs/bug-reproductions/8979-screenshots/after-fable-visible.png new file mode 100644 index 00000000000..0e8d49a8e34 Binary files /dev/null and b/docs/bug-reproductions/8979-screenshots/after-fable-visible.png differ diff --git a/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png b/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png new file mode 100644 index 00000000000..4100383237e Binary files /dev/null and b/docs/bug-reproductions/8979-screenshots/before-fable-hidden.png differ diff --git a/docs/claude-scoped-oauth-usage-limits.md b/docs/claude-scoped-oauth-usage-limits.md index e5eb3c9e2d3..bac2679f86c 100644 --- a/docs/claude-scoped-oauth-usage-limits.md +++ b/docs/claude-scoped-oauth-usage-limits.md @@ -25,7 +25,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri 2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite. 3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp. 4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks. -5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility. +5. Keep malformed, unrelated, or absent entries non-fatal. Do NOT gate on `is_active`: it marks which limit is currently binding, not whether the entry's data is valid, so an `is_active: false` Fable entry with a finite `percent` must still render (#8979). Accept a missing activity flag for compatibility. ## Data Flow @@ -40,7 +40,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri - `limits` is missing, null, malformed, or contains null entries. - A scoped entry names another model. - Fable percent is missing, non-numeric, or non-finite. -- Fable is inactive and should not be rendered. +- Fable is inactive (`is_active: false`) but still carries a finite `percent`/reset, so it must render (#8979). - `is_active` is omitted by an older server response but the remaining scoped entry is valid. - Both current and legacy fields exist; the current scoped entry wins. - Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent. @@ -50,7 +50,7 @@ The OAuth response contract evolved from dedicated model fields to generic entri - Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt. - Unit: assert scoped data wins over a legacy field. -- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available. +- Unit: assert malformed and unrelated scoped entries are ignored while legacy fallback remains available; assert an inactive-but-valid Fable entry still surfaces (#8979). - Regression: retain existing legacy-field and bare-`fable` behavior tests. - Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet. - Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface. @@ -76,8 +76,8 @@ No UI implementation changes. The existing Fable row must reappear with the same - Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable. - Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement. -- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. -- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback. +- Failure modes covered: malformed optional data, unrelated models, inactive-but-valid limits (still rendered, #8979), missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution. +- Test coverage required: current-schema success without PTY, precedence, inactive-but-valid rendering, malformed/unrelated entries, and legacy fallback. - Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work. - UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row. - Required review screenshots: Live Claude details with all three rows; surrounding status-bar context. diff --git a/src/main/rate-limits/claude-fetcher.test.ts b/src/main/rate-limits/claude-fetcher.test.ts index 96c1c1c75ae..95f7d607ac0 100644 --- a/src/main/rate-limits/claude-fetcher.test.ts +++ b/src/main/rate-limits/claude-fetcher.test.ts @@ -423,7 +423,7 @@ describe('fetchClaudeRateLimits', () => { expect(fetchViaPty).not.toHaveBeenCalled() }) - it('ignores inactive scoped Fable usage and retains the legacy OAuth fallback', async () => { + it('surfaces inactive scoped Fable usage over the legacy OAuth fallback', async () => { const configDir = '/Users/test/.claude' const authPreparation: ClaudeRuntimeAuthPreparation = { configDir, @@ -454,7 +454,47 @@ describe('fetchClaudeRateLimits', () => { ) await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ - fableWeekly: { usedPercent: 33 } + fableWeekly: { usedPercent: 90 } + }) + }) + + it('surfaces an inactive scoped Fable entry when no legacy Fable field exists (#8979)', async () => { + const configDir = '/Users/test/.claude' + const authPreparation: ClaudeRuntimeAuthPreparation = { + configDir, + envPatch: { CLAUDE_CONFIG_DIR: configDir }, + stripAuthEnv: false, + provenance: 'system' + } + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ claudeAiOauth: { accessToken: 'oauth-token' } }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 11 }, + seven_day: { utilization: 22 }, + limits: [ + { + kind: 'weekly_scoped', + percent: 64, + resets_at: '2026-07-24T20:00:00+00:00', + is_active: false, + scope: { model: { display_name: 'Fable' } } + } + ] + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + fableWeekly: { + usedPercent: 64, + resetsAt: Date.parse('2026-07-24T20:00:00+00:00') + } }) }) diff --git a/src/main/rate-limits/claude-fetcher.ts b/src/main/rate-limits/claude-fetcher.ts index ba21d32decf..e1ea780dea2 100644 --- a/src/main/rate-limits/claude-fetcher.ts +++ b/src/main/rate-limits/claude-fetcher.ts @@ -408,8 +408,9 @@ function mapFableWeeklyWindow(data: OAuthUsageResponse): RateLimitWindow | null const scoped = Array.isArray(data.limits) ? data.limits.find( (limit) => + // Why: is_active marks the currently-binding limit, not data validity; + // inactive Fable entries still carry a real percent/resets_at (#8979). limit?.kind === 'weekly_scoped' && - limit.is_active !== false && Number.isFinite(limit.percent) && limit.scope?.model?.display_name?.trim().toLowerCase() === 'fable' )