fix(rate-limits): surface Fable weekly usage when is_active is false (#8979) (#9389)

This commit is contained in:
OrcaWin
2026-07-20 00:39:08 -07:00
committed by GitHub
parent f25fb9f400
commit ddb5b930e1
5 changed files with 49 additions and 8 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+5 -5
View File
@@ -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.
+42 -2
View File
@@ -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')
}
})
})
+2 -1
View File
@@ -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'
)