diff --git a/CHANGELOG.md b/CHANGELOG.md index e3547ce7a..0305fe69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ - **A quota window that resets now refreshes now.** The menubar's 30s tick compared the reset times it already holds — Claude's 5-hour and weekly windows, Codex's two windows, every Capacity Dock provider's — against the previous tick, and forces a refresh for the provider whose window just rolled over instead of leaving the pre-reset percentage on screen for up to five minutes. Each reset instant fires once; everything else keeps the existing cadence. - **The popover's quota warning can be read in light mode, and says which limit it is warning about.** The banner under the tagline painted its text in the same system yellow, orange or red as its 12% pill, so in light mode warning text measured 1.2:1 to 1.4:1 against the pill, and even red reached only 3.0:1; in dark mode red fell to 3.3:1. The pill keeps its hue, and the text and its glyph now take a per-appearance colour for each severity, a deep amber, rust or brick in light mode and a lifted orange or red in dark mode, that measures 5.3:1 or better everywhere. A test holds every severity at WCAG AA (4.5:1) against the pill composited over both ends of the popover's background in both appearances. The sentence also named no window. Its figure is the provider's worst window, so "Claude 70% of quota used" meant the 5-hour limit while Claude's own panel read weekly 34%, and it looked wrong. It now reads `Claude · 5-hour 71% · resets in 3h 12m`, with the countdown worded exactly as the Capacity Dock words it and one line per provider when several are warning. The countdown is left off when the provider reports no reset time or the reset has already passed. "Over limit" is kept for a window at or past 100%, where it used to cover anything from 90%. The flame's colour and the providers that trigger the banner are unchanged. - **The Trend tooltip shows the day's total again under its model breakdown.** Provider-filtered history now carries tokens, so with only Claude selected the tooltip header switched to a token count and the day's cost appeared nowhere, and the model rows could not stand in for it because only four of up to five models render. A Total row with the day's cost and tokens now closes the breakdown, as it does in the web dashboard, and the header figure it replaces is shown only for days without a breakdown. Fixes #1433. +- **Codex now gets the same early quota-reset notification Claude gets.** Its windows are keyed by a pre-localization label, so a language switch or the "· limit reached" suffix no longer drops the stored baseline, and when a provider reports absolute usage a spend-cap increase is no longer mistaken for a cleared counter. (#725) ### Changed (desktop) - **The desktop shell stops re-rendering the whole tree once a second.** AppMain owned a wall-clock `setInterval(1000)` that existed only so the footer could print "refreshed Ns ago", but every tick re-rendered the sidebar, the hero, the daily chart, the heatmap and every table 60 times a minute whether or not any data had changed. The per-second tick now lives in a leaf `RefreshedAt` component that owns just that label, and the shell itself re-renders only on real state changes plus a 15-second day check that fires exactly when the local calendar rolls over — that rollover matters because the overview memo keys bake in a today/month boundary, so midnight must produce one re-render to keep "Today" honest. Renderer render-count regressions are pinned by a churn test that fails if the shell re-renders on the per-second tick or on same-day clock checks. diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 5a05edd70..d7d56af3c 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1635,6 +1635,9 @@ final class AppStore { codexError = nil codexLoadState = .loaded await codexBankedResetAnnouncer.observe(usage.resetCredits) + // A bootstrap is the far side of a gap, so this fetch only seeds a + // baseline — the same discipline `bootstrapSubscription` uses. + await detectCodexEarlyResets(baselineIsTrusted: false) } catch let err as CodexSubscriptionService.FetchError { applyCodexFetchError(err) } catch { @@ -1657,6 +1660,11 @@ final class AppStore { if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } return false } + // Read before `beginCodexQuotaRefresh` moves the state to `.loading`; + // with a refresh already in flight the restore state is the real one. + let stateBeforeFetch = codexRefreshInFlightRequest == nil + ? codexLoadState + : (codexRefreshRestoreState ?? codexLoadState) let token = beginCodexQuotaRefresh() do { guard let usage = try await codexQuotaFetcher() else { @@ -1676,6 +1684,9 @@ final class AppStore { // side-effect of a successful fetch and must not be able to hold the // single-flight token open. await codexBankedResetAnnouncer.observe(usage.resetCredits) + await detectCodexEarlyResets( + baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted + ) return true } catch let err as CodexSubscriptionService.FetchError { guard isCurrentCodexQuotaRefresh(token) else { return false } @@ -1717,6 +1728,7 @@ final class AppStore { codexUsage = nil codexError = nil codexLoadState = .notBootstrapped + earlyQuotaResetMonitor.forget(providerID: CapacityDockProvider.codex.rawValue) // Same reason the snapshot store is wiped on the Claude side: a // reconnect under a different account must baseline again rather than // announce that account's entire inventory as new grants. @@ -2682,7 +2694,9 @@ final class AppStore { percent: credits.usedPercent / 100, resetsAt: credits.resetsAt, windowSeconds: credits.windowSeconds, - fetchedAt: usage.fetchedAt + fetchedAt: usage.fetchedAt, + storageLabel: credits.storageLabel, + usedUnits: credits.used ) if primary == nil { primary = row } details.append(row) @@ -2921,6 +2935,50 @@ final class AppStore { ) } + /// Hand Codex's freshly fetched windows to the same monitor. Codex windows + /// have no keys of their own, so each is identified by its pre-localization + /// `storageLabel` when the row has one, else by its display label (a period + /// or model name, which does not translate). + /// + /// A window with no `resetsAt` is passed with no reading, and one with no + /// validated duration with no duration: both make the detector say nothing. + private func detectCodexEarlyResets(baselineIsTrusted: Bool, now: Date = Date()) async { + guard let summary = codexQuotaSummary(filter: .codex) else { return } + let provider = CapacityDockProvider.codex + var observations: [EarlyQuotaResetMonitor.Observation] = [] + var seen: Set = [] + for row in summary.details { + let identity = row.storageLabel ?? row.label + guard !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + let key = EarlyQuotaResetFormat.windowKey(forLabel: identity) + guard seen.insert(key).inserted else { continue } + observations.append(EarlyQuotaResetMonitor.Observation( + windowKey: key, + windowName: EarlyQuotaResetFormat.windowName(forLabel: identity), + windowSeconds: row.windowSeconds, + // `QuotaSummary.Window` carries a 0...1 fraction; the detector + // reasons in 0...100 points. + reading: row.resetsAt.map { + EarlyQuotaResetReading( + percent: row.percent * 100, + resetsAt: $0, + observedAt: now, + usedUnits: row.usedUnits + ) + } + )) + } + guard !observations.isEmpty else { return } + await earlyQuotaResetMonitor.record( + providerID: provider.rawValue, + providerName: provider.displayName, + planLabel: summary.planLabel, + baselineIsTrusted: baselineIsTrusted, + observations: observations, + now: now + ) + } + /// Claude's rate-limit windows are fixed lengths, the same durations the /// pace captions project against. private static func claudeWindowSeconds(forKey key: String) -> Int? { diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index b68f652f8..92243d2c5 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -154,6 +154,12 @@ struct CodexUsage: Sendable, Equatable { var shortLabel: String { reached ? L("Monthly usage limit · limit reached") : L("Monthly usage limit") } + + /// The identity behind `shortLabel`: pre-localization and free of the + /// `reached` state, so the early-reset monitor's storage key and name + /// survive a language switch and do not flip at the limit boundary — + /// the goodwill reset the monitor announces happens exactly there. + var storageLabel: String { "Monthly usage limit" } } let plan: PlanType diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift index 81d4f9095..1cbb47b82 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift @@ -18,12 +18,26 @@ struct EarlyQuotaResetReading: Codable, Equatable, Sendable { let percent: Double let resetsAt: Date let observedAt: Date + /// Absolute usage in the provider's own units, when the adapter reports + /// one. The ratio alone cannot tell the two ways usage percent falls: a + /// vendor clearing the counter (a goodwill reset) and a vendor raising the + /// limit (a spend-cap increase) both drop it. Optional so records written + /// before the field existed still decode. + var usedUnits: Double? + + init(percent: Double, resetsAt: Date, observedAt: Date, usedUnits: Double? = nil) { + self.percent = percent + self.resetsAt = resetsAt + self.observedAt = observedAt + self.usedUnits = usedUnits + } /// A reading this build can reason about. Anything else is "no opinion". var isWellFormed: Bool { percent.isFinite && percent >= 0 && percent <= 100 && resetsAt.timeIntervalSince1970.isFinite && observedAt.timeIntervalSince1970.isFinite + && (usedUnits == nil || usedUnits!.isFinite) } } @@ -90,11 +104,15 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable { /// Decides whether two consecutive readings of the same window are an early /// reset. Pure: every clock value comes from the readings themselves. /// -/// The detector assumes a fixed-cycle window with a validated duration (Claude's -/// 5-hour and 7-day limits). A rolling window's reset time creeps forward on -/// every fetch, which is exactly what signal 1 must not read as a new cycle, so -/// callers must not pass rolling windows and a window without a duration gets -/// no opinion. +/// The detector assumes a fixed-cycle window with a duration the ADAPTER has +/// validated as fixed (Claude's 5-hour and 7-day constants, Codex's +/// `limitWindowSeconds`). A rolling window's reset time creeps forward on every +/// fetch, and no pair of readings can tell a rolling re-anchor observed across +/// a gap from a genuine cut-short cycle: both move the reset forward by the +/// elapsed time and both can drop the percent. The exclusion of rolling +/// windows is therefore the `windowSeconds` contract itself — an adapter that +/// cannot vouch for a fixed cycle passes nil, and a window without a duration +/// gets no opinion. enum EarlyQuotaResetDetector { /// Anything within this of a boundary is clock or timestamp noise, not a /// reset: vendors jitter `resets_at` by seconds between fetches, and local @@ -163,9 +181,16 @@ enum EarlyQuotaResetDetector { // Signal 1: a new cycle began while the old one still had time left. if jump >= skewTolerance { - // A new fixed cycle starts no earlier than our last look at the old - // one, so it cannot reset sooner than a window after that look. A - // reset time that merely creeps forward is not a new cycle. + // A successor cycle began when the vendor cut the old one short — + // after our last look at it, by definition of this pair — so its + // reset sits at or after (last look + one window), minus rounding. + // This is what rejects a same-cycle nudge (the vendor moving its + // reset a few hours later inside the ONE cycle: the "successor" + // that implies began before our last look). It cannot reject a + // rolling window's re-anchor, whose implied start is always "now": + // for that shape the anchor holds for any observation gap, and the + // exclusion is the windowSeconds contract, not this test (see the + // type doc). let anchoredToNewCycle = current.resetsAt >= previous.observedAt.addingTimeInterval(window - cycleAnchorTolerance) guard anchoredToNewCycle else { return nil } @@ -179,6 +204,13 @@ enum EarlyQuotaResetDetector { guard abs(jump) < skewTolerance else { return nil } guard previous.percent - current.percent >= minimumPercentDrop, current.percent <= maximumPercentAfterDrop else { return nil } + // A spend-cap increase is not a goodwill reset: the limit grew, the + // ratio fell, and the absolute usage did not. When the provider + // reports absolute units, require them to fall too; percent-only + // providers (Claude) keep the ratio test. + if let before = previous.usedUnits, let after = current.usedUnits { + guard after < before else { return nil } + } return event(.usageDropped, previous: previous, current: current, context: context) } @@ -215,6 +247,47 @@ enum EarlyQuotaResetFormat { } } + /// Storage identity for a window that has no key of its own. Claude's + /// windows keep the snapshot store's keys; Codex identifies its windows by + /// a label slugified here. The label MUST be pre-localized English — + /// adapters whose display label translates or carries state pass + /// `QuotaSummary.Window.storageLabel` instead, and the caller prefers it — + /// because a slug of a translated string both drops the stored baseline on + /// a language switch and lets two translated siblings collide on one key. + /// + /// Callers must pass a label with something in it; a blank one is skipped + /// before it reaches here. + static func windowKey(forLabel label: String) -> String { + var slug = "" + var pendingSeparator = false + for scalar in label.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + if pendingSeparator { slug.append("_") } + slug.unicodeScalars.append(scalar) + pendingSeparator = false + } else if !slug.isEmpty { + pendingSeparator = true + } + } + return slug.isEmpty ? "window" : slug + } + + /// Copy noun for a window named only by its display label. A label that + /// already says what it caps ("Monthly usage limit") keeps its own noun; one + /// that names only a period ("Weekly", "5-hour") gains "limit" so the + /// notification reads as a sentence. + /// + /// English, like `claudeWindowName(forKey:)`, because this is the name that + /// is persisted with the event: `limitName`, `usageName` and `windowNoun` + /// translate it at render. + static func windowName(forLabel label: String) -> String { + let trimmed = label + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !trimmed.isEmpty else { return trimmed } + return trimmed.hasSuffix("limit") ? trimmed : "\(trimmed) limit" + } + /// "2d 3h", "18h", "40m" — rounded to the unit it prints, so a lead of /// 1h57m reads "2h" rather than truncating to "1h". static func lead(seconds: TimeInterval) -> String { @@ -232,8 +305,10 @@ enum EarlyQuotaResetFormat { /// stays the English name `claudeWindowName(forKey:)` produced and the /// translation happens here, at render. Keyed on that English name rather /// than by stripping `" limit"` off the end, which is a rule only English - /// obeys. A label outside the known set — a provider wired up later — - /// keeps the old suffix behaviour and reads through untranslated. + /// obeys. A label outside the known set — Codex's, composed by + /// `windowName(forLabel:)` — keeps the suffix behaviour: the provider's noun + /// reads through untranslated and only the word this file added to it is + /// routed, the same shape `usageName` already used for its default. /// "weekly limit" -> "weekly limit": the cap itself. static func limitName(_ name: String) -> String { @@ -242,7 +317,9 @@ enum EarlyQuotaResetFormat { case "weekly limit": L("weekly limit") case "Opus weekly limit": L("Opus weekly limit") case "Sonnet weekly limit": L("Sonnet weekly limit") - default: name + default: name.hasSuffix(" limit") + ? L("%@ limit", String(name.dropLast(" limit".count))) + : name } } @@ -258,13 +335,18 @@ enum EarlyQuotaResetFormat { } /// "weekly limit" -> "weekly usage": what the vendor cleared, not the cap. + /// A name outside the known set whose noun already says "usage" — Codex's + /// "monthly usage limit" — is left alone rather than doubled into + /// "monthly usage usage". static func usageName(_ name: String) -> String { switch name { - case "5-hour limit": L("5-hour usage") - case "weekly limit": L("weekly usage") - case "Opus weekly limit": L("Opus weekly usage") - case "Sonnet weekly limit": L("Sonnet weekly usage") - default: L("%@ usage", windowNoun(name)) + case "5-hour limit": return L("5-hour usage") + case "weekly limit": return L("weekly usage") + case "Opus weekly limit": return L("Opus weekly usage") + case "Sonnet weekly limit": return L("Sonnet weekly usage") + default: + let noun = windowNoun(name) + return noun.hasSuffix("usage") ? noun : L("%@ usage", noun) } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 6a3ca9987..500ff28d4 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -51,19 +51,36 @@ struct QuotaSummary: Equatable { /// preserved for legacy/unsupported summaries and is not fresh enough /// to support a pace projection. let fetchedAt: Date? + /// Pre-localization, state-independent name for this window, when the + /// adapter has one that differs from `label`. The early-reset monitor + /// keys and names windows from this when present: a display label that + /// translates (or carries state such as "· limit reached") must not + /// become storage identity, or a language switch drops the baseline + /// and two translated siblings collide on one key. + let storageLabel: String? + /// Absolute usage the provider reported for this window, in the + /// provider's own units (credits, requests…), when it reports one. + /// The percent alone cannot tell a vendor clearing the counter from a + /// limit that grew: both drop the ratio. Nil when the adapter has no + /// absolute figure. + let usedUnits: Double? init( label: String, percent: Double, resetsAt: Date?, windowSeconds: Int? = nil, - fetchedAt: Date? = nil + fetchedAt: Date? = nil, + storageLabel: String? = nil, + usedUnits: Double? = nil ) { self.label = label self.percent = percent self.resetsAt = resetsAt self.windowSeconds = windowSeconds self.fetchedAt = fetchedAt + self.storageLabel = storageLabel + self.usedUnits = usedUnits } /// A pace estimate is valid only while the underlying sample remains diff --git a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings index d8d54aa26..176a7ef97 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings @@ -683,6 +683,7 @@ "%1$@'s %2$@ reset %3$@ early. %4$@" = "%1$@'s %2$@ reset %3$@ early. %4$@"; "%1$@ cleared your %2$@ %3$@ before its reset. %4$@" = "%1$@ cleared your %2$@ %3$@ before its reset. %4$@"; "%lldh" = "%lldh"; +"%@ limit" = "%@ limit"; "5-hour limit" = "5-hour limit"; "weekly limit" = "weekly limit"; "Opus weekly limit" = "Opus weekly limit"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings index f6aa62cac..854e04178 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings @@ -674,6 +674,7 @@ "%1$@'s %2$@ reset %3$@ early. %4$@" = "%1$@ 的%2$@提前 %3$@重置。%4$@"; "%1$@ cleared your %2$@ %3$@ before its reset. %4$@" = "%1$@ 清空了你的%2$@,比其重置提前 %3$@。%4$@"; "%lldh" = "%lld 小时"; +"%@ limit" = "%@ 限额"; "5-hour limit" = "5 小时限额"; "weekly limit" = "每周限额"; "Opus weekly limit" = "Opus 每周限额"; diff --git a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift index 6a3f55c1c..e39e8231e 100644 --- a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift @@ -43,6 +43,126 @@ private let beforeEarlyReset = reading(percent: 80, resetsIn: 18 * 3600, observe /// A new cycle, anchored a full window after the previous look at the old one. private let afterEarlyReset = reading(percent: 0, resetsIn: week) +// MARK: - Review fixes (#1339): anchoring, spend caps, stable identity + +@Test("A successor schedule meaningfully EARLIER than the old one is a flip-flop, not a reset") +func backwardsSuccessorStaysSilent() throws { + // A replica briefly serving a cycle whose reset sits before the one we + // already stored is the flip-flop the announcement dedupe also guards; the + // detector itself stays silent on it rather than feeding it forward. + let previous = EarlyQuotaResetReading( + percent: 80, + resetsAt: now.addingTimeInterval(eighteenHours), + observedAt: now.addingTimeInterval(-300) + ) + let current = EarlyQuotaResetReading( + percent: 0, + resetsAt: now.addingTimeInterval(eighteenHours).addingTimeInterval(-3 * 3600), + observedAt: now + ) + #expect(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context()) == nil) +} + +@Test("A rolling window re-anchoring across a fetch gap is excluded by the duration contract, not detected") +func rollingTrackerNeedsTheContract() throws { + // The pair is genuinely indistinguishable from a cut-short cycle (see the + // detector's type doc): reset moved forward by the observation gap, percent + // fell across the boundary. The guard is that the ADAPTER passes + // windowSeconds only for cycles it can vouch are fixed — so with no + // vouched duration, the detector has no opinion at all. + let gap: TimeInterval = 30 * 60 + let previous = EarlyQuotaResetReading( + percent: 70, + resetsAt: now.addingTimeInterval(-gap).addingTimeInterval(week), + observedAt: now.addingTimeInterval(-gap) + ) + let current = EarlyQuotaResetReading( + percent: 5, + resetsAt: now.addingTimeInterval(week), + observedAt: now + ) + #expect(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context(windowSeconds: nil)) == nil) +} + +@Test("Sub-tolerance creep of the reset time is not a new cycle") +func creepingResetStaysSilent() throws { + // A fixed window's vendor jitters `resets_at` by seconds between fetches; + // only a move past the skew tolerance can begin signal 1. + let previous = EarlyQuotaResetReading( + percent: 60, + resetsAt: now.addingTimeInterval(week), + observedAt: now.addingTimeInterval(-300) + ) + let current = EarlyQuotaResetReading( + percent: 2, + resetsAt: now.addingTimeInterval(week + 45), + observedAt: now + ) + // Jump is under the tolerance, so the reading falls through to signal 2's + // ratio test — which this percent collapse satisfies, so it reports the + // usage-dropped form, never reset-moved-forward. + let event = try #require(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context())) + #expect(event.signal == .usageDropped) +} + +@Test("A Codex spend-cap increase is not a goodwill reset even when the ratio collapses") +func spendCapIncreaseStaysSilent() throws { + // Limit raised 100 -> 1000 credits; usage ROSE 90 -> 95; the ratio fell + // 90% -> 9.5%, satisfying both the 40-point drop and the ≤10% landing of + // signal 2. The absolute figures say the vendor gave capacity by raising + // the cap, not by clearing the counter, so it stays silent. + let previous = EarlyQuotaResetReading( + percent: 90, resetsAt: now.addingTimeInterval(week), observedAt: now.addingTimeInterval(-300), usedUnits: 90 + ) + let current = EarlyQuotaResetReading( + percent: 9.5, resetsAt: now.addingTimeInterval(week), observedAt: now, usedUnits: 95 + ) + #expect(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context()) == nil) +} + +@Test("A real cleared counter falls in absolute units too and still fires") +func clearedCounterStillFiresWithUnits() throws { + let previous = EarlyQuotaResetReading( + percent: 80, resetsAt: now.addingTimeInterval(week), observedAt: now.addingTimeInterval(-300), usedUnits: 800 + ) + let current = EarlyQuotaResetReading( + percent: 2, resetsAt: now.addingTimeInterval(week), observedAt: now, usedUnits: 20 + ) + let event = try #require(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context())) + #expect(event.signal == .usageDropped) +} + +@Test("Percent-only providers keep the ratio test (Claude has no absolute units)") +func percentOnlyDropStillFires() throws { + let previous = EarlyQuotaResetReading( + percent: 80, resetsAt: now.addingTimeInterval(week), observedAt: now.addingTimeInterval(-300) + ) + let current = EarlyQuotaResetReading( + percent: 2, resetsAt: now.addingTimeInterval(week), observedAt: now + ) + let event = try #require(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context())) + #expect(event.signal == .usageDropped) +} + +@Test("A window keyed from a localized or state-suffixed display label is keyed by its storage label instead") +func storageLabelStabilizesTheKey() { + // The Codex credit row's display label localizes and appends "· limit + // reached"; both the reached and unreached, English and translated forms + // must resolve to ONE storage identity via storageLabel. + let displayVariants = [ + "Monthly usage limit", + "Monthly usage limit · limit reached", + "每月使用限额", + "每月使用限额 · 已达上限", + ] + let keys = Set(displayVariants.map { EarlyQuotaResetFormat.windowKey(forLabel: $0) }) + // Slugs of the display forms disagree (the old behavior: four baselines, + // two of them shared between languages); the adapter passes storageLabel + // so the caller never slugifies any of these. + #expect(keys.count > 1) + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "Monthly usage limit") == "monthly_usage_limit") +} + @Suite("Early quota reset detection") struct EarlyQuotaResetDetectorTests { @Test("A reset time that jumps to a new cycle before the old one ended is an early reset") @@ -515,3 +635,369 @@ private final class RecordingEarlyResetNotifier: UpdateNotifier { posts.append((title, body, identifier)) } } + +// MARK: - Codex, not just Claude + +/// One provider's identity for the same weekly window, so the guards below run +/// unchanged against Claude and Codex. Codex's window has no key of its own: +/// unlike Claude's it is identified by its display label. +struct EarlyResetProviderCase: Sendable, CustomStringConvertible { + let providerID: String + let providerName: String + let windowKey: String + let windowName: String + let planLabel: String + + var description: String { providerName } +} + +private let claudeCase = EarlyResetProviderCase( + providerID: "claude", + providerName: "Claude", + windowKey: "seven_day", + windowName: "weekly limit", + planLabel: "Max 20x" +) + +private let codexCase = EarlyResetProviderCase( + providerID: "codex", + providerName: "Codex", + windowKey: EarlyQuotaResetFormat.windowKey(forLabel: "Weekly"), + windowName: EarlyQuotaResetFormat.windowName(forLabel: "Weekly"), + planLabel: "Plus" +) + +private let everyProviderCase = [claudeCase, codexCase] + +private func context( + _ provider: EarlyResetProviderCase, + windowSeconds: Int? = weekSeconds, + previousPlanLabel: String? = nil, + currentPlanLabel: String? = nil, + baselineIsTrusted: Bool = true +) -> EarlyQuotaResetDetector.Context { + EarlyQuotaResetDetector.Context( + providerID: provider.providerID, + providerName: provider.providerName, + windowKey: provider.windowKey, + windowName: provider.windowName, + windowSeconds: windowSeconds, + previousPlanLabel: previousPlanLabel ?? provider.planLabel, + currentPlanLabel: currentPlanLabel ?? provider.planLabel, + baselineIsTrusted: baselineIsTrusted + ) +} + +@Suite("Early quota reset detection, Claude and Codex") +struct EarlyQuotaResetProviderScopeTests { + @Test("Both signals fire for any provider and name it", arguments: everyProviderCase) + func bothSignalsFire(_ provider: EarlyResetProviderCase) throws { + let jumped = try #require(EarlyQuotaResetDetector.detect( + previous: beforeEarlyReset, current: afterEarlyReset, context: context(provider) + )) + #expect(jumped.providerID == provider.providerID) + #expect(jumped.signal == .resetMovedForward) + #expect(jumped.earlyBySeconds == eighteenHours) + #expect(jumped.notificationTitle == "\(provider.providerName) quota reset early") + #expect(jumped.notificationBody + == "\(provider.providerName)'s weekly limit reset 18h early. You're back to 100%.") + + let dropped = try #require(EarlyQuotaResetDetector.detect( + previous: reading(percent: 92, resetsIn: eighteenHours, observedAgo: 300), + current: reading(percent: 1, resetsIn: eighteenHours), + context: context(provider) + )) + #expect(dropped.providerID == provider.providerID) + #expect(dropped.signal == .usageDropped) + #expect(dropped.notificationTitle == "\(provider.providerName) quota cleared early") + #expect(dropped.notificationBody + == "\(provider.providerName) cleared your weekly usage 18h before its reset. " + + "You're back to 99%.") + for text in [dropped.notificationTitle, dropped.notificationBody] { + #expect(!text.contains("reset early")) + } + } + + @Test("A window named only by its display label gets a stable key and readable copy") + func labelDerivedNaming() throws { + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "Weekly") == "weekly") + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "5-hour") == "5_hour") + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "GPT-5.3-Codex-Spark · Weekly") + == "gpt_5_3_codex_spark_weekly") + // Sibling rows must not collide, or one would overwrite the other's + // baseline inside the same provider record. + let keys = ["Weekly", "5-hour", "Monthly usage limit", "Auto", "API"] + .map(EarlyQuotaResetFormat.windowKey(forLabel:)) + #expect(Set(keys).count == keys.count) + + #expect(EarlyQuotaResetFormat.windowName(forLabel: "Weekly") == "weekly limit") + #expect(EarlyQuotaResetFormat.windowName(forLabel: "5-hour") == "5-hour limit") + // A label that already names what it caps keeps its own noun, and the + // usage phrasing must not double it into "monthly usage usage". + #expect(EarlyQuotaResetFormat.windowName(forLabel: "Monthly usage limit") + == "monthly usage limit") + let event = try #require(EarlyQuotaResetDetector.detect( + previous: reading(percent: 92, resetsIn: eighteenHours, observedAgo: 300), + current: reading(percent: 1, resetsIn: eighteenHours), + context: EarlyQuotaResetDetector.Context( + providerID: "codex", + providerName: "Codex", + windowKey: EarlyQuotaResetFormat.windowKey(forLabel: "Monthly usage limit"), + windowName: EarlyQuotaResetFormat.windowName(forLabel: "Monthly usage limit"), + windowSeconds: 30 * 24 * 3600, + previousPlanLabel: "Plus", + currentPlanLabel: "Plus", + baselineIsTrusted: true + ) + )) + #expect(event.notificationBody + == "Codex cleared your monthly usage 18h before its reset. You're back to 99%.") + } +} + +@Suite("Early quota reset, provider isolation and stored state") +@MainActor +struct EarlyQuotaResetProviderStateTests { + @Test("An early reset on one provider never moves another's state") + func providersAreIsolated() async throws { + try await withIsolatedMonitor { monitor, notifier, _ in + // Both providers see the same pre-reset window. + for id in ["claude", "codex"] { + await monitor.record( + providerID: id, providerName: id == "claude" ? "Claude" : "Codex", + planLabel: "Max 20x", baselineIsTrusted: true, + observations: [providerObservation(id, beforeEarlyReset)], + now: now.addingTimeInterval(-300) + ) + } + // Only Claude resets early. + let claudeEvent = await monitor.record( + providerID: "claude", providerName: "Claude", planLabel: "Max 20x", + baselineIsTrusted: true, + observations: [providerObservation("claude", afterEarlyReset)], + now: now + ) + #expect(claudeEvent?.providerID == "claude") + #expect(notifier.posts.count == 1) + #expect(notifier.posts.first?.title == "Claude quota reset early") + + // Codex resets too, off its own untouched baseline, and is announced + // in its own name. + let codexEvent = await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Max 20x", + baselineIsTrusted: true, + observations: [providerObservation("codex", afterEarlyReset)], + now: now + ) + #expect(codexEvent?.providerID == "codex") + #expect(notifier.posts.count == 2) + #expect(notifier.posts.last?.title == "Codex quota reset early") + } + } + + @Test("A Codex reset announced once is not announced again") + func codexDedupeHolds() async throws { + try await withIsolatedMonitor { monitor, notifier, defaults in + await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", beforeEarlyReset)], + now: now.addingTimeInterval(-300) + ) + await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", afterEarlyReset)], + now: now + ) + #expect(notifier.posts.count == 1) + + let relaunched = EarlyQuotaResetMonitor(defaults: defaults, makeNotifier: { notifier }) + await relaunched.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", reading(percent: 80, resetsIn: eighteenHours - 600))], + now: now.addingTimeInterval(600) + ) + await relaunched.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", reading(percent: 0, resetsIn: week))], + now: now.addingTimeInterval(1200) + ) + #expect(notifier.posts.count == 1) + } + } + + @Test("A Claude record written by the build that shipped this feature still counts") + func storedClaudeRecordIsCompatible() async throws { + // Announced already: the update must not re-notify. + #expect(try await legacyRecordPostCount(announced: true) == 0) + // The same record with nothing announced does post. Without this the + // silence above would also be produced by a record the monitor can no + // longer find or decode, which is exactly the regression to catch. + #expect(try await legacyRecordPostCount(announced: false) == 1) + } + + /// Runs one fetch against a state record written in the shape, and under the + /// exact defaults key, that #1329 shipped. + private func legacyRecordPostCount(announced: Bool) async throws -> Int { + var count = 0 + try await withIsolatedMonitor { monitor, notifier, defaults in + let scheduled = Int(now.addingTimeInterval(eighteenHours).timeIntervalSince1970) + let observed = Int(now.addingTimeInterval(-300).timeIntervalSince1970) + let announcedList = announced ? "[\(scheduled)]" : "[]" + let legacy = """ + {"planLabel":"Max 20x",\ + "windows":{"seven_day":{"percent":80,\ + "resetsAt":\(scheduled),"observedAt":\(observed)}},\ + "announced":{"seven_day":\(announcedList)}} + """ + // Spelled out, not built from the constant: a changed key must fail + // this rather than silently take the record with it. + defaults.set(Data(legacy.utf8), forKey: "codeburn.quota.earlyReset.state.claude") + + await monitor.record( + providerID: "claude", providerName: "Claude", planLabel: "Max 20x", + baselineIsTrusted: true, + observations: [weeklyObservation(afterEarlyReset)], + now: now + ) + count = notifier.posts.count + } + return count + } +} + +private func providerObservation( + _ providerID: String, + _ reading: EarlyQuotaResetReading? +) -> EarlyQuotaResetMonitor.Observation { + EarlyQuotaResetMonitor.Observation( + windowKey: providerID == "claude" ? "seven_day" : "weekly", + windowName: "weekly limit", + windowSeconds: weekSeconds, + reading: reading + ) +} + +@Suite("Early quota reset wiring, Codex") +@MainActor +struct EarlyQuotaResetCodexWiringTests { + @Test("A Codex weekly window reset early is announced in Codex's name") + func codexRefreshAnnounces() async throws { + try await withCodexStore { store, notifier in + store.codexQuotaFetcher = { Self.usage(percent: 80, resetsIn: 18 * 3600) } + #expect(await store.refreshCodexReportingSuccess()) + #expect(notifier.posts.isEmpty) + + store.codexQuotaFetcher = { Self.usage(percent: 0, resetsIn: week) } + #expect(await store.refreshCodexReportingSuccess()) + #expect(notifier.posts.count == 1) + #expect(notifier.posts.first?.title == "Codex quota reset early") + #expect(notifier.posts.first?.body.hasPrefix("Codex's weekly limit reset 18h early.") == true) + } + } + + @Test("A Codex window with no validated duration stays silent") + func codexWithoutDurationIsSilent() async throws { + try await withCodexStore { store, notifier in + // The same cleared counter as below, on a credit row whose adapter + // could not vouch for a fixed cycle length. + store.codexQuotaFetcher = { + Self.usage(percent: nil, resetsIn: 18 * 3600, creditWindowSeconds: nil, credit: (used: 900, reached: false)) + } + _ = await store.refreshCodexReportingSuccess() + store.codexQuotaFetcher = { + Self.usage(percent: nil, resetsIn: 18 * 3600, creditWindowSeconds: nil, credit: (used: 0, reached: false)) + } + _ = await store.refreshCodexReportingSuccess() + #expect(notifier.posts.isEmpty) + } + } + + @Test("The credit row keeps one baseline across the limit-reached boundary") + func creditRowSurvivesLimitReached() async throws { + try await withCodexStore { store, notifier in + // At the limit the display label gains "· limit reached"; the + // vendor then clears the counter with the reset time unchanged. + store.codexQuotaFetcher = { + Self.usage(percent: nil, resetsIn: 18 * 3600, credit: (used: 1000, reached: true)) + } + _ = await store.refreshCodexReportingSuccess() + store.codexQuotaFetcher = { + Self.usage(percent: nil, resetsIn: 18 * 3600, credit: (used: 0, reached: false)) + } + _ = await store.refreshCodexReportingSuccess() + #expect(notifier.posts.count == 1) + #expect(notifier.posts.first?.title == "Codex quota cleared early") + } + } + + private func withCodexStore( + _ body: @MainActor (AppStore, RecordingEarlyResetNotifier) async throws -> Void + ) async throws { + let suiteName = "codeburn.quota.earlyReset.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let notifier = RecordingEarlyResetNotifier() + let store = AppStore() + store.earlyQuotaResetMonitor = EarlyQuotaResetMonitor(defaults: defaults, makeNotifier: { notifier }) + store.codexBankedResetAnnouncer = CodexBankedResetAnnouncer( + defaults: defaults, + store: MemoryCodexBankedResetStore(), + makeNotifier: { notifier } + ) + store.codexLoadState = .loaded + store.codexQuotaBootstrapChecker = { true } + try await body(store, notifier) + } + + /// A weekly rate window, or (with `percent: nil`) a credit-metered + /// workspace whose only limit is the monthly allowance. + nonisolated private static func usage( + percent: Double?, + resetsIn: TimeInterval, + creditWindowSeconds: Int? = 30 * 24 * 3600, + credit: (used: Double, reached: Bool)? = nil + ) -> CodexUsage { + let resetsAt = Date().addingTimeInterval(resetsIn) + return CodexUsage( + plan: .plus, + primary: percent.map { + CodexUsage.Window( + usedPercent: $0, + resetsAt: resetsAt, + limitWindowSeconds: 7 * 24 * 3600 + ) + }, + secondary: nil, + additionalLimits: [], + creditsBalance: nil, + hasCredits: credit != nil, + creditsUnlimited: false, + creditLimit: credit.map { + CodexUsage.CreditLimit( + used: $0.used, + limit: 1000, + usedPercent: $0.used / 10, + resetsAt: resetsAt, + windowSeconds: creditWindowSeconds, + reached: $0.reached + ) + }, + resetCredits: nil, + fetchedAt: Date() + ) + } +} + +private final class MemoryCodexBankedResetStore: CodexBankedResetStateStoring, @unchecked Sendable { + private let lock = NSLock() + private var state = CodexBankedResetState() + + func load() async -> CodexBankedResetState { lock.withLock { state } } + func save(_ state: CodexBankedResetState) async { lock.withLock { self.state = state } } +}