diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 36cc0bf2a..c6339d25d 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -608,7 +608,7 @@ final class UsageStore { provider: UsageProvider, snapshot: UsageSnapshot) -> (window: RateWindow, source: SessionQuotaWindowSource)? { - if let primary = snapshot.primary { + if let primary = snapshot.primary, Self.isSessionWindow(primary) { return (primary, .primary) } if provider == .copilot, let secondary = snapshot.secondary { @@ -617,6 +617,21 @@ final class UsageStore { return nil } + /// A "session" window is the short (~5h) rolling lane that session-quota + /// notifications are designed for. When Claude's OAuth response omits the + /// five-hour block, `ClaudeUsageFetcher` promotes a weekly window into + /// `primary` so the menu bar still renders usable data — but that weekly + /// window must not drive session-quota depleted/restored transitions. + /// Treat anything up to ~6 hours as a session lane; longer windows are + /// weekly/model-specific fallbacks and are ignored here. + private static func isSessionWindow(_ window: RateWindow) -> Bool { + guard let minutes = window.windowMinutes else { + // Unknown duration — preserve legacy behaviour (treat as session). + return true + } + return minutes <= 360 + } + func handleSessionQuotaTransition(provider: UsageProvider, snapshot: UsageSnapshot) { // Session quota notifications are tied to the primary session window. Copilot free plans can // expose only chat quota, so allow Copilot to fall back to secondary for transition tracking. diff --git a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift index dd4848a53..f28959bbf 100644 --- a/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift @@ -833,7 +833,19 @@ extension ClaudeUsageFetcher { resetDescription: resetDescription) } - guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) else { + // Fall back through the available windows when the five-hour session + // window is missing from the response (observed in the wild for some + // organisations — see https://github.com/steipete/CodexBar/issues/726). + // Previously we threw away the entire snapshot, discarding weekly and + // model-specific data the user could still see. Now we surface the + // most relevant available window as `primary` so the menu bar renders + // something useful instead of an "unavailable" state. + guard let primary = makeWindow(usage.fiveHour, windowMinutes: 5 * 60) + ?? makeWindow(usage.sevenDay, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDaySonnet, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOpus, windowMinutes: 7 * 24 * 60) + ?? makeWindow(usage.sevenDayOAuthApps, windowMinutes: 7 * 24 * 60) + else { throw ClaudeUsageError.parseFailed("missing session data") } diff --git a/Tests/CodexBarTests/ClaudeUsageTests.swift b/Tests/CodexBarTests/ClaudeUsageTests.swift index 6e400fd6b..4cdc6b2d1 100644 --- a/Tests/CodexBarTests/ClaudeUsageTests.swift +++ b/Tests/CodexBarTests/ClaudeUsageTests.swift @@ -1389,4 +1389,47 @@ extension ClaudeUsageTests { } #expect(flags.respectPromptCooldownFlags == [true]) } + + /// Regression for https://github.com/steipete/CodexBar/issues/726: when the + /// OAuth usage response is missing the `five_hour` block, the snapshot must + /// still be produced using whichever windows are available instead of being + /// thrown away entirely. + @Test + func `mapOAuthUsage falls back to seven-day window when five_hour is absent`() throws { + let json = """ + { + "seven_day": { "utilization": 42, "resets_at": "2025-12-29T23:00:00.000Z" }, + "seven_day_sonnet": { "utilization": 17, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snapshot.primary.usedPercent == 42) + #expect(snapshot.secondary?.usedPercent == 42) + #expect(snapshot.opus?.usedPercent == 17) + } + + /// Regression for https://github.com/steipete/CodexBar/issues/726: when + /// `five_hour.utilization` itself is missing (the exact shape from the + /// reporter's debug output), we should still map the remaining data. + @Test + func `mapOAuthUsage falls back when five_hour has no utilization`() throws { + let json = """ + { + "five_hour": { "resets_at": "2025-12-23T16:00:00.000Z" }, + "seven_day": { "utilization": 9, "resets_at": "2025-12-29T23:00:00.000Z" } + } + """ + let snapshot = try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + #expect(snapshot.primary.usedPercent == 9) + } + + /// When *every* window is missing we still want to signal an error rather + /// than synthesise fake data. + @Test + func `mapOAuthUsage throws when no windows are present`() throws { + let json = "{}" + #expect(throws: ClaudeUsageError.self) { + try ClaudeUsageFetcher._mapOAuthUsageForTesting(Data(json.utf8)) + } + } } diff --git a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift index 168ebe3d9..543be37ee 100644 --- a/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift +++ b/Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift @@ -77,4 +77,100 @@ struct UsageStoreSessionQuotaTransitionTests { #expect(notifier.posts.isEmpty) } + + /// Regression for https://github.com/steipete/CodexBar/pull/741: when the + /// Claude OAuth response is missing the `five_hour` window, + /// `ClaudeUsageFetcher` promotes a weekly window into `primary` so the menu + /// bar still renders. That weekly window MUST NOT drive session-quota + /// depleted/restored notifications, because it does not represent the 5h + /// session lane. + @Test + func `claude weekly primary fallback does not emit session quota notifications`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-weekly-fallback"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let weeklyMinutes = 7 * 24 * 60 + + // First snapshot: weekly-as-primary at 20% used — establishes baseline. + let first = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: weeklyMinutes, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: first) + + // Second snapshot: weekly-as-primary crosses into depleted territory. + // Under the old code this would fire a spurious session-depleted + // notification; with the session-window guard it must stay silent. + let second = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: weeklyMinutes, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: second) + + #expect(notifier.posts.isEmpty) + } + + /// Sanity check: a genuine 5h session window still drives notifications so + /// the guard introduced above is not over-broad. + @Test + func `claude five hour primary still emits session quota notifications`() { + let settings = SettingsStore( + configStore: testConfigStore(suiteName: "UsageStoreSessionQuotaTransitionTests-claude-five-hour"), + zaiTokenStore: NoopZaiTokenStore(), + syntheticTokenStore: NoopSyntheticTokenStore()) + settings.refreshFrequency = .manual + settings.statusChecksEnabled = false + settings.sessionQuotaNotificationsEnabled = true + + let notifier = SessionQuotaNotifierSpy() + let store = UsageStore( + fetcher: UsageFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0), + settings: settings, + sessionQuotaNotifier: notifier) + + let sessionMinutes = 5 * 60 + + let baseline = UsageSnapshot( + primary: RateWindow( + usedPercent: 20, + windowMinutes: sessionMinutes, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: baseline) + + let depleted = UsageSnapshot( + primary: RateWindow( + usedPercent: 100, + windowMinutes: sessionMinutes, + resetsAt: nil, + resetDescription: nil), + secondary: nil, + updatedAt: Date()) + store.handleSessionQuotaTransition(provider: .claude, snapshot: depleted) + + #expect(notifier.posts.contains(where: { $0.provider == .claude })) + } }