Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve session baseline when skipping non-session windows

Filtering snapshot.primary through isSessionWindow means Claude snapshots that only contain weekly fallback data now return no session window, which immediately clears lastKnownSessionRemaining in handleSessionQuotaTransition. In the real fallback scenario this can suppress a legitimate restored alert: if the user was previously depleted, one refresh with missing five_hour drops the baseline, and the next valid 5-hour snapshot is treated as first-seen data instead of a depleted→restored transition.

Useful? React with 👍 / 👎.

return (primary, .primary)
}
if provider == .copilot, let secondary = snapshot.secondary {
Expand All @@ -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.
Expand Down
14 changes: 13 additions & 1 deletion Sources/CodexBarCore/Providers/Claude/ClaudeUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment thread
Sanjays2402 marked this conversation as resolved.
else {
throw ClaudeUsageError.parseFailed("missing session data")
}

Expand Down
43 changes: 43 additions & 0 deletions Tests/CodexBarTests/ClaudeUsageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
}
96 changes: 96 additions & 0 deletions Tests/CodexBarTests/UsageStoreSessionQuotaTransitionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
}
}