diff --git a/Sources/CodeCaps/MonitorModel.swift b/Sources/CodeCaps/MonitorModel.swift index d778a25b..af622fd4 100644 --- a/Sources/CodeCaps/MonitorModel.swift +++ b/Sources/CodeCaps/MonitorModel.swift @@ -2668,7 +2668,11 @@ final class MonitorModel: ObservableObject { let previous = exhaustionObservationState[key] guard previous == nil || observedAt > previous!.observedAt else { continue } changed = true - if remaining > 0 { + // The shared cap rule decides, so the Mac's exhausted alert fires + // at the same threshold as the "At cap" line and as the phone's: + // a reading at or below the threshold is a spent allowance, and a + // reading above it is headroom (Kody review 2026-10-21). + if !QuotaCapRule.isAtCap(remainingPercent: remaining) { exhaustionObservationState[key] = ExhaustionObservationState(observedAt: observedAt, alert: nil) activeExhaustionAlerts.removeValue(forKey: key) continue @@ -2678,7 +2682,7 @@ final class MonitorModel: ObservableObject { continue } let title = "Quota Exhausted: " + window.provider - let body = window.label + " has no quota remaining." + let body = window.label + " has reached its limit." let payload = LocalQuotaSnapshot.AlertPayload( id: "quotaExhaustion|" + key + "|" + String(observedAt.timeIntervalSince1970), kind: "quotaExhaustion", providerKey: window.canonicalProviderKey, diff --git a/Sources/CodeCaps/SettingsViews.swift b/Sources/CodeCaps/SettingsViews.swift index 56cd71f2..04be9b73 100644 --- a/Sources/CodeCaps/SettingsViews.swift +++ b/Sources/CodeCaps/SettingsViews.swift @@ -1398,7 +1398,7 @@ struct SettingsNotificationsPage: View { Eyebrow("ALERT DELIVERY") } footer: { Text("Anomalies compare usage with your average; runaway usage compares it with your peak." + sentenceGap - + "Quota Exhausted fires at zero remaining." + sentenceGap + + "Quota Exhausted alerts when \(QuotaCapRule.spentThreshold.formatted(.number.precision(.fractionLength(0...1))))% or less remains." + sentenceGap + "Provider bells choose which scheduled and vendor resets alert on this Mac.") } if let row = model.displaySections.first(where: { diff --git a/Sources/QuotaCore/QuotaModels.swift b/Sources/QuotaCore/QuotaModels.swift index a3c8e412..8db34836 100644 --- a/Sources/QuotaCore/QuotaModels.swift +++ b/Sources/QuotaCore/QuotaModels.swift @@ -285,8 +285,40 @@ public struct QuotaWindow: Codable, Equatable, Sendable { } else if status == .unknown, bounded != nil { copy.status = QuotaWindowStatus.derived(remainingPercent: bounded) } - copy.skip = skip || copy.isExhausted - copy.skipReason = skipReason ?? (copy.isExhausted ? "quota exhausted" : nil) + // `skip` means "a reading that must not be believed", which is not the + // same question as "is the allowance spent". The shared cap rule + // answers the second one for any reading at or under the threshold, + // and a spent window is still a window with a reading in it: when + // `skip` followed the rule, a window at 0.3% was dropped from the + // picture in picture along with its cap line, while the menu bar still + // announced the cap, so the same window was live in one place and + // gone in the other (Kody review 2026-10-22). + // + // The provider's own exhausted word is believed only when the reading + // disagrees with it, because only the provider knows why (a rejected + // session, a suspended plan) and the reading cannot say. It is also + // the answer when there is no reading at all. An exact zero is the + // one reading that is never shown as a meter. + // + // The reading is what makes this rule safe to apply twice, which the + // display path does: the picture in picture rebuilds a snapshot from + // an already derived window, and a rule that read the derived + // `isExhausted` back as the provider's word would hide the window + // again on the second pass. + let providerWordCounts: Bool + if let bounded { + providerWordCounts = isExhausted && bounded > QuotaCapRule.spentThreshold + } else { + providerWordCounts = isExhausted + } + copy.skip = skip || bounded == 0 || providerWordCounts + // The reason explains a skip, so it is attached to a skip and never + // to a window that stays visible, and it never claims an exhaustion + // the window does not have: a provider that hides a healthy window + // without saying why gets no reason at all, because "quota exhausted" + // on a window with room left is a false claim, and a consumer that + // filters on the reason would act on it (Kody review 2026-10-26). + copy.skipReason = skipReason ?? (copy.skip && copy.isExhausted ? "quota exhausted" : nil) let monthlyHint = copy.canonicalProviderKey == "cursor" && copy.label.lowercased().contains("plan") if let rewritten = QuotaWindowVocabulary.sanitizeLabelIfNeeded( diff --git a/Sources/QuotaCore/QuotaPublisher.swift b/Sources/QuotaCore/QuotaPublisher.swift index 2a3d91ed..fb592e1f 100644 --- a/Sources/QuotaCore/QuotaPublisher.swift +++ b/Sources/QuotaCore/QuotaPublisher.swift @@ -255,7 +255,14 @@ public actor QuotaPublisher { var meta: [String: Any] = [ "bucketId": bucketId, - "isExhausted": window.isExhausted || QuotaCapRule.isAtCap(remainingPercent: clampedRemaining), + // The shared cap rule decides, as it does on every surface: a + // reading at or below the threshold is a spent allowance, and + // the provider's own exhausted word is the fallback for a + // window that reports no figure at all. A local zero test + // here left the wire disagreeing with the at-cap line the + // owner reads (Kody review 2026-10-21). + "isExhausted": QuotaCapRule.isExhausted(remainingPercent: clampedRemaining, + providerSaysExhausted: window.isExhausted), "remainingUnknown": clampedRemaining == nil, "scale": "percent_0_100", "source": Self.producerId, diff --git a/Tests/CodeCapsTests/ExhaustionCapRuleTests.swift b/Tests/CodeCapsTests/ExhaustionCapRuleTests.swift new file mode 100644 index 00000000..037bfa57 --- /dev/null +++ b/Tests/CodeCapsTests/ExhaustionCapRuleTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import CodeCaps +import QuotaCore + +/// The exhausted alert and the at-cap line have to agree on one threshold. +/// +/// The shared rule spends a window at or below half a point remaining, and the +/// interface rounds that to zero percent, so a window reading 0.4% draws +/// "At cap" on the card. The alert path used its own zero test, so the owner +/// saw "At cap" and never received the "Quota Exhausted" notification +/// (Kody review 2026-10-21). +/// +/// The Mac's alert path lives inside a large `MonitorModel`, so the rule it +/// reads is pinned by reading the source, the way the phone's own companion +/// model is pinned in `CompanionModelTests`. +final class ExhaustionCapRuleTests: XCTestCase { + private static var monitorModelURL: URL? { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/CodeCapsTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent("Sources/CodeCaps/MonitorModel.swift") + } + + private static var settingsViewsURL: URL? { + URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/CodeCaps/SettingsViews.swift") + } + + private func read(_ url: URL?) throws -> String { + guard let url, FileManager.default.fileExists(atPath: url.path) else { + throw XCTSkip("The source file is not present at \(url?.path ?? "")") + } + return try String(contentsOf: url, encoding: .utf8) + } + + /// The Mac's exhausted alert fires on the shared rule. + func testTheMacAlertFiresOnTheSharedRuleRatherThanOnZero() throws { + let source = try read(Self.monitorModelURL) + XCTAssertTrue( + source.contains("if !QuotaCapRule.isAtCap(remainingPercent: remaining)"), + "the Mac's exhausted alert must read the shared cap rule, so it fires at the same threshold as the at-cap line" + ) + XCTAssertFalse( + source.contains("if remaining > 0 {"), + "a local zero test here left the Mac quiet while the card said 'At cap'" + ) + } + + /// The settings copy names the threshold the app actually uses, so it + /// cannot drift back to "zero remaining" and describe a rule the app no + /// longer follows. + func testTheSettingsCopyNamesTheSharedThreshold() throws { + let source = try read(Self.settingsViewsURL) + XCTAssertFalse( + source.contains("Quota Exhausted fires at zero remaining"), + "the copy has to describe the threshold the app uses, not the one it used to use" + ) + XCTAssertTrue( + source.contains("QuotaCapRule.spentThreshold"), + "the copy must quote the shared threshold itself, so a change to the rule cannot leave the copy behind" + ) + XCTAssertTrue( + source.contains(".formatted(.number.precision(.fractionLength(0...1)))"), + "the threshold is a Double, so a plain interpolation would print 1.0% once the rule reaches a whole number" + ) + } + + /// The rule itself spends a window at or below the threshold and calls a + /// larger reading headroom, whichever surface asks. + func testTheSharedRuleIsTheOneBothSurfacesAsk() { + XCTAssertTrue(QuotaCapRule.isAtCap(remainingPercent: 0.4)) + XCTAssertTrue(QuotaCapRule.isAtCap(remainingPercent: 0)) + XCTAssertFalse(QuotaCapRule.isAtCap(remainingPercent: 0.6)) + XCTAssertFalse(QuotaCapRule.isAtCap(remainingPercent: nil)) + // The reading decides, and the provider's own word is the fallback + // for a window that reports no figure at all. + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: 0.4, providerSaysExhausted: false)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: 50, providerSaysExhausted: true)) + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: nil, providerSaysExhausted: true)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: nil, providerSaysExhausted: false)) + } +} diff --git a/Tests/CodeCapsTests/PipInteractionTests.swift b/Tests/CodeCapsTests/PipInteractionTests.swift index 8721d645..ff4d3ba7 100644 --- a/Tests/CodeCapsTests/PipInteractionTests.swift +++ b/Tests/CodeCapsTests/PipInteractionTests.swift @@ -23,6 +23,36 @@ final class PipInteractionTests: XCTestCase { } } + /// A window that is at its cap is still a window the panel shows. + /// + /// The shared cap rule calls any reading at or under 0.5% spent, because + /// the interface rounds a reading to the nearest whole percent. When the + /// export also hid such a window, the picture in picture dropped it and + /// its cap line while the menu bar still announced the cap, so the same + /// window was live in one place and gone in the other. + func testANearZeroReadingStaysVisibleInThePictureInPicture() throws { + let (model, defaults, suite) = GlanceFixtures.makeModel(view: .fromMac, alarmsAll: false, fleet: false) + defer { defaults.removePersistentDomain(forName: suite) } + let window = GlanceFixtures.window("anthropic:5h", provider: "anthropic", + label: "5h", token: "5h", remaining: 0.3, resetIn: 18_000) + model.injectForTests(sections: QuotaResponse(generatedAt: "", windows: [window]) + .platformSections(now: GlanceFixtures.now), now: GlanceFixtures.now) + let row = try XCTUnwrap(model.displaySections.first { $0.id == "anthropic" }) + + let current = PipWidgetController.currentWindows(for: row, now: model.now) + XCTAssertEqual(current.count, 1, "a window at 0.3% is at its cap, not a reading to hide") + XCTAssertEqual(current.first?.remainingPercent, 0.3) + XCTAssertNil(PipWidgetController.readingStatus(for: row, now: model.now)) + + // The cap line is built from the same filtered list, so it has to be + // there too: a spent window that is visible still says it is spent. + let section = QuotaPlatformSection(providerKey: row.providerKey, + providerLabel: row.section.providerLabel, + expected: row.section.expected, + windows: current) + XCTAssertNotNil(section.atCapWindow(), "the cap line still reads the shared cap rule") + } + func testBodyDragIncludesLogosBarsAndTextButNotResizeEdgesOrControls() { let size = NSSize(width: 400, height: 180) for point in [NSPoint(x: 18, y: 70), NSPoint(x: 160, y: 110), NSPoint(x: 310, y: 40)] { diff --git a/Tests/QuotaCoreTests/LocalQuotaSnapshotTests.swift b/Tests/QuotaCoreTests/LocalQuotaSnapshotTests.swift index 816b24be..24a1e1ed 100644 --- a/Tests/QuotaCoreTests/LocalQuotaSnapshotTests.swift +++ b/Tests/QuotaCoreTests/LocalQuotaSnapshotTests.swift @@ -228,6 +228,103 @@ final class LocalQuotaSnapshotTests: XCTestCase { XCTAssertEqual(QuotaWindowStatus.derived(remainingPercent: 20), .available) } + /// A window that is at its cap is not a window that must be hidden. + /// + /// The shared cap rule calls any reading at or under 0.5% spent, because + /// the interface rounds a reading to the nearest whole percent. When + /// `skip` followed that rule, a window at 0.3% was dropped from the + /// picture in picture along with its cap line, while the menu bar still + /// announced the cap, so the same window was live in one place and gone + /// in the other. `skip` keeps its own meaning: a reading that must not + /// be believed. + func testSkipDoesNotFollowTheSharedCapThreshold() { + // At the cap by the shared rule, but a real reading that is not zero. + let nearZero = QuotaWindow(id: "near-zero", provider: "openai", label: "5h", + remainingPercent: 0.3, occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertTrue(nearZero.isExhausted, "0.3% is at the cap, so the window is spent") + XCTAssertEqual(nearZero.status, .exhausted) + XCTAssertFalse(nearZero.skip, "a window at 0.3% is at its cap, not a reading to hide") + XCTAssertNil(nearZero.skipReason) + + // Exactly zero is still hidden, and still says why. + let zero = QuotaWindow(id: "zero", provider: "openai", label: "5h", + remainingPercent: 0, occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertTrue(zero.isExhausted) + XCTAssertTrue(zero.skip, "an exact zero is the one reading that is not believed") + XCTAssertEqual(zero.skipReason, "quota exhausted") + + // The provider's own flag still hides the window, and its own reason + // is never talked over. + let reported = QuotaWindow(id: "reported", provider: "openai", label: "5h", + remainingPercent: 15, isExhausted: true, status: .exhausted, + skip: true, skipReason: "session rejected", + occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertTrue(reported.skip) + XCTAssertEqual(reported.skipReason, "session rejected") + + // A provider that calls a window exhausted while the reading says + // there is room left is believed: only the provider knows why, and + // the reading cannot say. + let contradicted = QuotaWindow(id: "contradicted", provider: "openai", label: "5h", + remainingPercent: 15, isExhausted: true, + occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertTrue(contradicted.isExhausted) + XCTAssertTrue(contradicted.skip, "a provider's word against a reading is believed") + XCTAssertEqual(contradicted.skipReason, "quota exhausted", "a hidden window says why it is hidden") + + // The same word beside a reading that already agrees with it settles + // nothing: the window is spent either way, and it stays visible. + let agreed = QuotaWindow(id: "agreed", provider: "openai", label: "5h", + remainingPercent: 0.3, isExhausted: true, + occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertTrue(agreed.isExhausted) + XCTAssertFalse(agreed.skip) + + // A provider that hides a window without saying why gets no reason + // invented for it: the window has room left, so "quota exhausted" + // would be a false claim for a consumer that filters on the reason. + let silent = QuotaWindow(id: "silent", provider: "openai", label: "5h", + remainingPercent: 40, skip: true, + occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertTrue(silent.skip) + XCTAssertFalse(silent.isExhausted, "40% is not a spent window") + XCTAssertNil(silent.skipReason, "a reason must never claim an exhaustion the window does not have") + + // An unreadable reading is not a spent one and raises nothing. + let unreadable = QuotaWindow(id: "unreadable", provider: "cursor", label: "Included plan", + remainingUnknown: true, occurredAt: "2026-10-03T23:00:00Z") + .normalizedForExport() + XCTAssertFalse(unreadable.isExhausted) + XCTAssertFalse(unreadable.skip) + XCTAssertNil(unreadable.skipReason) + + // The derivation is applied twice on the display path, the picture in + // picture rebuilds a snapshot from an already derived window, so the + // second pass has to reach the same answer as the first. + for source in [ + QuotaWindow(id: "a", provider: "openai", label: "5h", remainingPercent: 0.3, + occurredAt: "2026-10-03T23:00:00Z"), + QuotaWindow(id: "b", provider: "openai", label: "5h", remainingPercent: 0, + occurredAt: "2026-10-03T23:00:00Z"), + QuotaWindow(id: "c", provider: "openai", label: "5h", remainingPercent: 15, + isExhausted: true, occurredAt: "2026-10-03T23:00:00Z"), + QuotaWindow(id: "d", provider: "openai", label: "5h", remainingPercent: 15, + isExhausted: true, skip: true, skipReason: "session rejected", + occurredAt: "2026-10-03T23:00:00Z"), + QuotaWindow(id: "e", provider: "cursor", label: "Included plan", remainingUnknown: true, + occurredAt: "2026-10-03T23:00:00Z"), + ] { + let once = source.normalizedForExport() + XCTAssertEqual(once.normalizedForExport(), once, "\(source.id) is not stable under a second pass") + } + } + func testExhaustedFlagHasTheSameStatusInExportAndDisplayAtNonzeroPercent() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } diff --git a/Tests/QuotaCoreTests/QuotaPublisherTests.swift b/Tests/QuotaCoreTests/QuotaPublisherTests.swift index c4be725c..6194f3c9 100644 --- a/Tests/QuotaCoreTests/QuotaPublisherTests.swift +++ b/Tests/QuotaCoreTests/QuotaPublisherTests.swift @@ -489,4 +489,74 @@ final class QuotaPublisherTests: XCTestCase { XCTFail("Unexpected error: \(error)") } } + + // MARK: - The wire's exhausted flag follows the shared cap rule + + private func makeCapWindow( + id: String, + window token: String, + remaining: Double, + providerWord: Bool + ) -> QuotaWindow { + QuotaWindow( + id: id, + provider: "anthropic", + providerKey: "anthropic", + providerLabel: "Anthropic", + via: nil, + sourceApp: nil, + modelId: nil, + modelType: nil, + label: "Claude \(token)", + remainingPercent: remaining, + absoluteRemaining: nil, + absoluteLimit: nil, + quotaUnit: nil, + planName: nil, + remainingUnknown: false, + isExhausted: providerWord, + resetAt: "2026-09-14T22:00:00Z", + window: token, + status: .available, + skip: false, + skipReason: nil, + occurredAt: "2026-09-14T19:00:00Z", + source: "local" + ) + } + + /// The wire's exhausted flag has to read the same rule the interface + /// reads. + /// + /// A window reading 0.4% is spent by the shared rule, and the interface + /// rounds that to zero percent, so the wire has to say the window is + /// exhausted too: otherwise the phone's cap line says "At cap" while the + /// exhausted alert stays quiet. A window reading 40% is headroom, and the + /// provider's own exhausted word, which the Mac's export never clears, + /// must not overrule a reading that says there is room left + /// (Kody review 2026-10-21). + func testTheExportedExhaustedFlagFollowsTheSharedCapRule() throws { + let publisher = QuotaPublisher() + let windows = [ + makeCapWindow(id: "spent", window: "5h", remaining: 0.4, providerWord: false), + makeCapWindow(id: "headroom", window: "weekly", remaining: 40, providerWord: false), + makeCapWindow(id: "stale-word", window: "monthly", remaining: 40, providerWord: true) + ] + let data = try publisher.buildUsageMonitorV2Payload( + windows: windows, occurredAtIso: "2026-09-14T19:00:00Z", + machineName: "Test-Mac", producerInstanceId: "test-instance") + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let events = try XCTUnwrap(json["events"] as? [[String: Any]]) + XCTAssertEqual(events.count, 3) + + let flags = events.map { event -> Bool in + let meta = event["metadata"] as? [String: Any] + return meta?["isExhausted"] as? Bool ?? false + } + XCTAssertEqual( + flags, + [true, false, false], + "a 0.4% reading is spent by the shared rule, and a 40% reading is headroom whatever the provider's word says" + ) + } }