From 6285e0941840e3d998ec178fee96240e191743ab Mon Sep 17 00:00:00 2001 From: Victor Quiroz Date: Wed, 29 Jul 2026 10:21:56 +0200 Subject: [PATCH] feat(core): add activity observation for smart refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ProviderActivityObservation with stable metric IDs, usage/credits kinds, and availability. Attach to ProviderQuota and update all providers to emit semantic activity metrics. Replace UsageSnapshot with canonical comparison returning a structured Decision. Expand slow interval options to every minute 1–60 and fast options to 5-second steps 10–60. --- Sources/App/QuotaViewModel+Lifecycle.swift | 20 +- Sources/Core/AutoRefreshPreferences.swift | 4 +- Sources/Core/ProviderProtocol.swift | 44 +++ Sources/Core/SmartRefreshPolicy.swift | 188 ++++++----- .../ClaudeCode/ClaudeCodeProvider.swift | 20 +- .../CursorProvider+ActivityObservation.swift | 39 +++ .../Cursor/CursorProvider+Normalization.swift | 40 +++ Sources/Providers/Cursor/CursorProvider.swift | 55 +-- .../Providers/DeepSeek/DeepSeekProvider.swift | 35 +- .../OpenAICodex/OpenAICodexProvider.swift | 49 ++- Sources/Providers/ZAI/ZAIProvider.swift | 30 ++ .../AppTests/AutoRefreshViewModelTests.swift | 48 ++- .../ClaudeCodeActivityObservationTests.swift | 34 ++ .../AutoRefreshPreferencesTests.swift | 21 +- Tests/CoreTests/SmartRefreshPolicyTests.swift | 314 +++++++++++++----- .../CursorProviderTests.swift | 12 + .../DeepSeekProviderTests.swift | 12 + .../OpenAICodexProviderTests.swift | 8 + Tests/ZAIProviderTests/ZAIProviderTests.swift | 17 + .../core/09-refine-smart-refresh-detection.md | 145 ++++++++ 20 files changed, 910 insertions(+), 225 deletions(-) create mode 100644 Sources/Providers/Cursor/CursorProvider+ActivityObservation.swift create mode 100644 Sources/Providers/Cursor/CursorProvider+Normalization.swift create mode 100644 Tests/ClaudeCodeProviderTests/ClaudeCodeActivityObservationTests.swift create mode 100644 specs/core/09-refine-smart-refresh-detection.md diff --git a/Sources/App/QuotaViewModel+Lifecycle.swift b/Sources/App/QuotaViewModel+Lifecycle.swift index e7c5aa6..fe331f4 100644 --- a/Sources/App/QuotaViewModel+Lifecycle.swift +++ b/Sources/App/QuotaViewModel+Lifecycle.swift @@ -153,7 +153,7 @@ extension QuotaViewModel { if AutoRefreshPreferences.mode == .smart { if case let .loaded(quota) = providerStates[providerId] { - _ = smartRefreshPolicy.recordSuccess(quota, for: providerId) + _ = recordSmartSuccess(quota, for: providerId) } } if fetchTasks[providerId] == nil { @@ -168,7 +168,7 @@ extension QuotaViewModel { else { continue } - _ = smartRefreshPolicy.recordSuccess(quota, for: providerId) + _ = recordSmartSuccess(quota, for: providerId) } } @@ -209,7 +209,7 @@ extension QuotaViewModel { switch result { case let .success(quota): if !suppressSmartSuccess { - _ = smartRefreshPolicy.recordSuccess(quota, for: providerId) + _ = recordSmartSuccess(quota, for: providerId) } case .failure: _ = smartRefreshPolicy.recordFailure(for: providerId) @@ -219,6 +219,20 @@ extension QuotaViewModel { startAutoRefresh(for: providerId) } + @discardableResult + func recordSmartSuccess( + _ quota: ProviderQuota, + for providerId: String + ) -> SmartRefreshPolicy.Decision { + let decision = smartRefreshPolicy.recordSuccess(quota, for: providerId) + let reasons = decision.reasons.map(\.rawValue).sorted().joined(separator: ",") + log( + "smartRefresh: provider=\(providerId) classification=\(decision.classification) " + + "cadence=\(decision.cadence) reasons=\(reasons)" + ) + return decision + } + func syncFastRefreshStatus(for providerId: String) { let shouldShowStatus = AutoRefreshPreferences.mode == .smart && isEligibleForAutoRefresh(providerId) diff --git a/Sources/Core/AutoRefreshPreferences.swift b/Sources/Core/AutoRefreshPreferences.swift index 45cfb41..7e7670c 100644 --- a/Sources/Core/AutoRefreshPreferences.swift +++ b/Sources/Core/AutoRefreshPreferences.swift @@ -8,8 +8,8 @@ public enum AutoRefreshMode: String, CaseIterable, Hashable, Sendable { public enum AutoRefreshPreferences { public static let defaultSlowInterval: TimeInterval = 5 * 60 public static let defaultFastInterval: TimeInterval = 30 - public static let slowIntervalOptions: [TimeInterval] = [60, 5 * 60, 15 * 60, 30 * 60, 60 * 60] - public static let fastIntervalOptions: [TimeInterval] = [10, 15, 30, 45, 60] + public static let slowIntervalOptions: [TimeInterval] = (1 ... 60).map { TimeInterval($0 * 60) } + public static let fastIntervalOptions: [TimeInterval] = stride(from: 10, through: 60, by: 5).map(TimeInterval.init) private nonisolated(unsafe) static var defaults: UserDefaults = .standard diff --git a/Sources/Core/ProviderProtocol.swift b/Sources/Core/ProviderProtocol.swift index e7c2517..3e42302 100644 --- a/Sources/Core/ProviderProtocol.swift +++ b/Sources/Core/ProviderProtocol.swift @@ -8,6 +8,7 @@ public struct ProviderQuota: Sendable { public let lastUpdated: Date public let error: String? public let isStale: Bool + public let activityObservation: ProviderActivityObservation? /// Optional peak-hours pricing config. Providers that have time-based /// multipliers (e.g. z.ai's GLM Coding Plan) populate this so the view /// can render a peak/off-peak block without any provider-specific code. @@ -21,6 +22,7 @@ public struct ProviderQuota: Sendable { lastUpdated: Date, error: String? = nil, isStale: Bool = false, + activityObservation: ProviderActivityObservation? = nil, peakHoursConfig: PeakHoursConfig? = nil ) { self.providerId = providerId @@ -30,10 +32,52 @@ public struct ProviderQuota: Sendable { self.lastUpdated = lastUpdated self.error = error self.isStale = isStale + self.activityObservation = activityObservation self.peakHoursConfig = peakHoursConfig } } +public struct ProviderActivityObservation: Equatable, Sendable { + public let metrics: [ProviderActivityMetric] + public let availability: ProviderAvailability? + + public init( + metrics: [ProviderActivityMetric] = [], + availability: ProviderAvailability? = nil + ) { + self.metrics = metrics + self.availability = availability + } +} + +public struct ProviderActivityMetric: Equatable, Sendable { + public enum Kind: String, Equatable, Sendable { + case usage + case credits + } + + public enum Value: Equatable, Sendable { + case number(Decimal) + case discrete(String) + } + + public let id: String + public let kind: Kind + public let value: Value + + public init(id: String, kind: Kind, value: Value) { + self.id = id + self.kind = kind + self.value = value + } +} + +public enum ProviderAvailability: Equatable, Sendable { + case available + case unavailable + case unknown +} + // MARK: - Peak-hours config /// Provider-agnostic: adding a new provider with peak hours requires no diff --git a/Sources/Core/SmartRefreshPolicy.swift b/Sources/Core/SmartRefreshPolicy.swift index 00a2110..5dbf0c5 100644 --- a/Sources/Core/SmartRefreshPolicy.swift +++ b/Sources/Core/SmartRefreshPolicy.swift @@ -6,6 +6,34 @@ public struct SmartRefreshPolicy: Sendable { case fast } + public enum Classification: Equatable, Sendable { + case baseline + case unchanged + case changed + } + + public enum ChangeReason: String, CaseIterable, Hashable, Sendable { + case usage + case credits + case availability + } + + public struct Decision: Equatable, Sendable { + public let classification: Classification + public let cadence: Cadence + public let reasons: Set + + init( + classification: Classification, + cadence: Cadence, + reasons: Set = [] + ) { + self.classification = classification + self.cadence = cadence + self.reasons = reasons + } + } + private var states: [String: State] = [:] public init() {} @@ -14,33 +42,38 @@ public struct SmartRefreshPolicy: Sendable { public mutating func recordSuccess( _ quota: ProviderQuota, for providerId: String - ) -> Cadence { - let snapshot = UsageSnapshot(quota: quota) + ) -> Decision { + let snapshot = ActivitySnapshot(observation: quota.activityObservation) var state = states[providerId] ?? State() guard let previousSnapshot = state.snapshot else { state.snapshot = snapshot states[providerId] = state - return state.cadence + return Decision(classification: .baseline, cadence: state.cadence) } state.snapshot = snapshot - guard previousSnapshot != snapshot else { - if state.cadence == .fast { - state.consecutiveUnchangedChecks += 1 - if state.consecutiveUnchangedChecks == Self.unchangedChecksBeforeSlowing { - state.cadence = .slow - state.consecutiveUnchangedChecks = 0 - } - } + let reasons = previousSnapshot.changeReasons(comparedTo: snapshot) + guard reasons.isEmpty else { + state.cadence = .fast + state.consecutiveUnchangedChecks = 0 states[providerId] = state - return state.cadence + return Decision( + classification: .changed, + cadence: state.cadence, + reasons: reasons + ) } - state.cadence = .fast - state.consecutiveUnchangedChecks = 0 + if state.cadence == .fast { + state.consecutiveUnchangedChecks += 1 + if state.consecutiveUnchangedChecks == Self.unchangedChecksBeforeSlowing { + state.cadence = .slow + state.consecutiveUnchangedChecks = 0 + } + } states[providerId] = state - return state.cadence + return Decision(classification: .unchanged, cadence: state.cadence) } @discardableResult @@ -73,87 +106,80 @@ public struct SmartRefreshPolicy: Sendable { private extension SmartRefreshPolicy { struct State: Sendable { - var snapshot: UsageSnapshot? + var snapshot: ActivitySnapshot? var cadence: Cadence = .slow var consecutiveUnchangedChecks = 0 } - struct UsageSnapshot: Equatable, Sendable { - let lines: [UsageLineSnapshot] + struct ActivitySnapshot: Equatable, Sendable { + let metrics: [ProviderActivityMetric] + let availability: ProviderAvailability? - init(quota: ProviderQuota) { - lines = quota.lines.map(UsageLineSnapshot.init).sorted() - } - } + init(observation: ProviderActivityObservation?) { + guard let observation else { + metrics = [] + availability = nil + return + } - struct UsageLineSnapshot: Equatable, Comparable, Sendable { - let label: String - let used: Double? - let total: Double? - let percentage: Double? - let unit: String? - let resetDate: Date? - let details: [UsageDetailSnapshot] - - init(line: UsageLine) { - label = line.label - used = line.used - total = line.total - percentage = line.percentage - unit = line.unit - resetDate = line.resetDate - details = (line.details ?? []).map(UsageDetailSnapshot.init).sorted() + assert( + Set(observation.metrics.map(\.id)).count == observation.metrics.count, + "Provider activity metric IDs must be unique." + ) + metrics = observation.metrics.sorted { $0.id < $1.id } + availability = observation.availability } - static func < (lhs: Self, rhs: Self) -> Bool { - if lhs.label != rhs.label { - return lhs.label < rhs.label - } - if lhs.used != rhs.used { - return optionalLess(lhs.used, rhs.used) - } - if lhs.total != rhs.total { - return optionalLess(lhs.total, rhs.total) - } - if lhs.percentage != rhs.percentage { - return optionalLess(lhs.percentage, rhs.percentage) - } - if lhs.unit != rhs.unit { - return optionalLess(lhs.unit, rhs.unit) - } - if lhs.resetDate != rhs.resetDate { - return optionalLess(lhs.resetDate, rhs.resetDate) - } - for (leftDetail, rightDetail) in zip(lhs.details, rhs.details) where leftDetail != rightDetail { - return leftDetail < rightDetail + func changeReasons(comparedTo current: Self) -> Set { + var reasons: Set = [] + + if !metrics.isEmpty, !current.metrics.isEmpty { + let previousMetrics = Dictionary( + metrics.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let currentMetrics = Dictionary( + current.metrics.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + for metricId in Set(previousMetrics.keys).union(currentMetrics.keys) { + switch (previousMetrics[metricId], currentMetrics[metricId]) { + case let (.some(previous), .some(next)) where previous != next: + reasons.formUnion([previous.kind.reason, next.kind.reason]) + case let (.some(previous), .none): + reasons.insert(previous.kind.reason) + case let (.none, .some(next)): + reasons.insert(next.kind.reason) + case (.none, .none), (.some, .some): + break + } + } } - return lhs.details.count < rhs.details.count - } - private static func optionalLess( - _ lhs: Value?, - _ rhs: Value? - ) -> Bool { - switch (lhs, rhs) { - case (.none, .some): true - case (.some, .none): false - case let (.some(left), .some(right)): left < right - case (.none, .none): false + if isKnown(availability), isKnown(current.availability), availability != current.availability { + reasons.insert(.availability) } + return reasons } } +} - struct UsageDetailSnapshot: Equatable, Comparable, Sendable { - let label: String - let value: String - - init(detail: UsageDetail) { - label = detail.label - value = detail.value +private extension ProviderActivityMetric.Kind { + var reason: SmartRefreshPolicy.ChangeReason { + switch self { + case .usage: + .usage + case .credits: + .credits } + } +} - static func < (lhs: Self, rhs: Self) -> Bool { - lhs.label == rhs.label ? lhs.value < rhs.value : lhs.label < rhs.label - } +private func isKnown(_ availability: ProviderAvailability?) -> Bool { + switch availability { + case .some(.available), .some(.unavailable): + true + case .none, .some(.unknown): + false } } diff --git a/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift b/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift index 4363811..b081bc9 100644 --- a/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift +++ b/Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift @@ -216,7 +216,25 @@ public struct ClaudeCodeProvider: AIProvider { headline: headline, lines: lines, lastUpdated: lastUpdated, - isStale: isStale + isStale: isStale, + activityObservation: activityObservation(from: cache.rateLimits) + ) + } + + private func activityObservation(from rateLimits: RateLimits?) -> ProviderActivityObservation { + let metrics = [ + activityMetric(id: "five-hour-usage", window: rateLimits?.fiveHour), + activityMetric(id: "weekly-usage", window: rateLimits?.sevenDay), + ].compactMap { $0 } + return ProviderActivityObservation(metrics: metrics) + } + + private func activityMetric(id: String, window: Window?) -> ProviderActivityMetric? { + guard let percentage = window?.usedPercentage else { return nil } + return ProviderActivityMetric( + id: id, + kind: .usage, + value: .number(Decimal(percentage)) ) } diff --git a/Sources/Providers/Cursor/CursorProvider+ActivityObservation.swift b/Sources/Providers/Cursor/CursorProvider+ActivityObservation.swift new file mode 100644 index 0000000..329b6be --- /dev/null +++ b/Sources/Providers/Cursor/CursorProvider+ActivityObservation.swift @@ -0,0 +1,39 @@ +import Core +import Foundation + +extension CursorProvider { + func activityObservation( + plan: PlanData?, + onDemand: OnDemandData?, + spendLimitUsage: CursorSpendLimitUsage? + ) -> ProviderActivityObservation { + var metrics: [ProviderActivityMetric] = [] + + if let includedSpend = plan?.includedSpend { + metrics.append(activityMetric(id: "included-usage", kind: .usage, cents: includedSpend)) + } + if let onDemandSpend = onDemand?.used { + metrics.append(activityMetric(id: "on-demand-spend", kind: .usage, cents: onDemandSpend)) + } + if let spendLimitUsage = spendLimitUsage?.pooledUsed { + metrics.append(activityMetric(id: "spend-limit-usage", kind: .usage, cents: spendLimitUsage)) + } + if let bonusCredits = plan?.bonusSpend, bonusCredits > 0 { + metrics.append(activityMetric(id: "bonus-credits", kind: .credits, cents: bonusCredits)) + } + + return ProviderActivityObservation(metrics: metrics) + } + + private func activityMetric( + id: String, + kind: ProviderActivityMetric.Kind, + cents: Int + ) -> ProviderActivityMetric { + ProviderActivityMetric( + id: id, + kind: kind, + value: .number(Decimal(cents) / 100) + ) + } +} diff --git a/Sources/Providers/Cursor/CursorProvider+Normalization.swift b/Sources/Providers/Cursor/CursorProvider+Normalization.swift new file mode 100644 index 0000000..5917c37 --- /dev/null +++ b/Sources/Providers/Cursor/CursorProvider+Normalization.swift @@ -0,0 +1,40 @@ +import Foundation + +extension CursorProvider { + func normalizedPlan(from response: CursorUsageResponse) -> PlanData? { + if let plan = response.planUsage { + return PlanData( + totalPercentUsed: plan.totalPercentUsed, + includedSpend: plan.includedSpend, + limit: plan.limit, + bonusSpend: plan.bonusSpend, + autoPercentUsed: plan.autoPercentUsed, + apiPercentUsed: plan.apiPercentUsed + ) + } + if let legacy = response.individualUsage?.plan { + return PlanData( + totalPercentUsed: legacy.totalPercentUsed, + includedSpend: legacy.used, + limit: legacy.limit, + bonusSpend: nil, + autoPercentUsed: nil, + apiPercentUsed: nil + ) + } + return nil + } + + func normalizedOnDemand(from response: CursorUsageResponse) -> OnDemandData? { + if let spend = response.spendLimitUsage { + return OnDemandData( + used: spend.individualUsed, + limit: spend.individualLimit + ) + } + if let legacy = response.individualUsage?.onDemand { + return OnDemandData(used: legacy.used, limit: legacy.limit) + } + return nil + } +} diff --git a/Sources/Providers/Cursor/CursorProvider.swift b/Sources/Providers/Cursor/CursorProvider.swift index 7c4b568..1d19cd7 100644 --- a/Sources/Providers/Cursor/CursorProvider.swift +++ b/Sources/Providers/Cursor/CursorProvider.swift @@ -135,13 +135,14 @@ public struct CursorProvider: AIProvider { func map(_ response: CursorUsageResponse) -> ProviderQuota { let resetDate = Self.dateFromMsString(response.billingCycleEnd) let plan = normalizedPlan(from: response) + let onDemand = normalizedOnDemand(from: response) var lines: [UsageLine] = [] if let plan { lines.append(contentsOf: planLines(plan, resetDate: resetDate)) } - if let onDemand = normalizedOnDemand(from: response) { + if let onDemand { if let line = onDemandLine(onDemand) { lines.append(line) } @@ -163,7 +164,12 @@ public struct CursorProvider: AIProvider { providerName: Self.providerName, headline: headline, lines: lines, - lastUpdated: Date() + lastUpdated: Date(), + activityObservation: activityObservation( + plan: plan, + onDemand: onDemand, + spendLimitUsage: response.spendLimitUsage + ) ) } @@ -261,47 +267,6 @@ public struct CursorProvider: AIProvider { } return amountLeft } - - // MARK: - Normalization - - /// Unifies the new `planUsage` shape and the legacy `individualUsage.plan` - /// shape into one model. - private func normalizedPlan(from response: CursorUsageResponse) -> PlanData? { - if let plan = response.planUsage { - return PlanData( - totalPercentUsed: plan.totalPercentUsed, - includedSpend: plan.includedSpend, - limit: plan.limit, - bonusSpend: plan.bonusSpend, - autoPercentUsed: plan.autoPercentUsed, - apiPercentUsed: plan.apiPercentUsed - ) - } - if let legacy = response.individualUsage?.plan { - return PlanData( - totalPercentUsed: legacy.totalPercentUsed, - includedSpend: legacy.used, - limit: legacy.limit, - bonusSpend: nil, - autoPercentUsed: nil, - apiPercentUsed: nil - ) - } - return nil - } - - private func normalizedOnDemand(from response: CursorUsageResponse) -> OnDemandData? { - if let spend = response.spendLimitUsage { - return OnDemandData( - used: spend.individualUsed, - limit: spend.individualLimit - ) - } - if let legacy = response.individualUsage?.onDemand { - return OnDemandData(used: legacy.used, limit: legacy.limit) - } - return nil - } } // MARK: - Helpers @@ -322,7 +287,7 @@ private extension CursorProvider { // MARK: - Normalized models -private struct PlanData { +struct PlanData { let totalPercentUsed: Double? let includedSpend: Int? let limit: Int? @@ -331,7 +296,7 @@ private struct PlanData { let apiPercentUsed: Double? } -private struct OnDemandData { +struct OnDemandData { let used: Int? let limit: Int? } diff --git a/Sources/Providers/DeepSeek/DeepSeekProvider.swift b/Sources/Providers/DeepSeek/DeepSeekProvider.swift index bd40d19..4cf335a 100644 --- a/Sources/Providers/DeepSeek/DeepSeekProvider.swift +++ b/Sources/Providers/DeepSeek/DeepSeekProvider.swift @@ -184,10 +184,43 @@ public struct DeepSeekProvider: AIProvider { providerName: Self.providerName, headline: headline, lines: lines, - lastUpdated: Date() + lastUpdated: Date(), + activityObservation: activityObservation(from: response) ) } + private func activityObservation( + from response: DeepSeekBalanceResponse + ) -> ProviderActivityObservation { + let metrics = response.balanceInfos.flatMap { balanceInfo in + [ + activityMetric( + id: "total-balance-\(balanceInfo.currency.lowercased())", + value: balanceInfo.totalBalance + ), + activityMetric( + id: "granted-balance-\(balanceInfo.currency.lowercased())", + value: balanceInfo.grantedBalance + ), + activityMetric( + id: "topped-up-balance-\(balanceInfo.currency.lowercased())", + value: balanceInfo.toppedUpBalance + ), + ].compactMap { $0 } + } + return ProviderActivityObservation( + metrics: metrics, + availability: response.isAvailable ? .available : .unavailable + ) + } + + private func activityMetric(id: String, value: String) -> ProviderActivityMetric? { + guard let value = Decimal(string: value, locale: Locale(identifier: "en_US_POSIX")) else { + return nil + } + return ProviderActivityMetric(id: id, kind: .credits, value: .number(value)) + } + private func computeHeadline( response: DeepSeekBalanceResponse, lines: [UsageLine] diff --git a/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift b/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift index fbce54e..ffa9e09 100644 --- a/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift +++ b/Sources/Providers/OpenAICodex/OpenAICodexProvider.swift @@ -96,10 +96,57 @@ public struct OpenAICodexProvider: AIProvider { providerName: Self.providerName, headline: headline(for: windows), lines: lines, - lastUpdated: Date() + lastUpdated: Date(), + activityObservation: activityObservation(for: snapshot) ) } + private func activityObservation( + for snapshot: CodexRateLimitSnapshot? + ) -> ProviderActivityObservation { + var metrics: [ProviderActivityMetric] = [] + + if let primary = snapshot?.primary?.usedPercent { + metrics.append(ProviderActivityMetric( + id: "primary-window-usage", + kind: .usage, + value: .number(Decimal(primary)) + )) + } + if let secondary = snapshot?.secondary?.usedPercent { + metrics.append(ProviderActivityMetric( + id: "secondary-window-usage", + kind: .usage, + value: .number(Decimal(secondary)) + )) + } + if snapshot?.credits?.unlimited == true { + metrics.append(ProviderActivityMetric( + id: "credits", + kind: .credits, + value: .discrete("unlimited") + )) + } else if let balance = snapshot?.credits?.balance { + appendNumericCreditMetric(balance, to: &metrics) + } + + return ProviderActivityObservation(metrics: metrics) + } + + private func appendNumericCreditMetric( + _ balance: String, + to metrics: inout [ProviderActivityMetric] + ) { + guard let value = Decimal(string: balance, locale: Locale(identifier: "en_US_POSIX")) else { + return + } + metrics.append(ProviderActivityMetric( + id: "credits", + kind: .credits, + value: .number(value) + )) + } + private func usageLine(_ window: CodexRateLimitWindow) -> UsageLine { UsageLine( label: windowLabel(for: window.windowDurationMins), diff --git a/Sources/Providers/ZAI/ZAIProvider.swift b/Sources/Providers/ZAI/ZAIProvider.swift index 4387074..90864db 100644 --- a/Sources/Providers/ZAI/ZAIProvider.swift +++ b/Sources/Providers/ZAI/ZAIProvider.swift @@ -244,10 +244,40 @@ public struct ZAIProvider: AIProvider { headline: headline, lines: lines, lastUpdated: Date(), + activityObservation: activityObservation(from: limits), peakHoursConfig: Self.peakHoursConfig ) } + private func activityObservation(from limits: [ZAILimit]) -> ProviderActivityObservation { + let metrics = limits.compactMap { limit -> ProviderActivityMetric? in + guard let id = activityMetricID(for: limit), + let value = limit.currentValue ?? limit.usage ?? limit.percentage + else { + return nil + } + return ProviderActivityMetric( + id: id, + kind: .usage, + value: .number(Decimal(value)) + ) + } + return ProviderActivityObservation(metrics: metrics) + } + + private func activityMetricID(for limit: ZAILimit) -> String? { + switch (limit.type, limit.unit) { + case ("TOKENS_LIMIT", 3): + "five-hour-usage" + case ("TOKENS_LIMIT", 6): + "weekly-usage" + case ("TIME_LIMIT", 5): + "monthly-web-tool-usage" + default: + nil + } + } + private func mapLimit(_ limit: ZAILimit) -> UsageLine? { guard let labelKey = ZAILimitLabel.lookup(type: limit.type, unit: limit.unit) else { return nil diff --git a/Tests/AppTests/AutoRefreshViewModelTests.swift b/Tests/AppTests/AutoRefreshViewModelTests.swift index 0045e39..ce4872c 100644 --- a/Tests/AppTests/AutoRefreshViewModelTests.swift +++ b/Tests/AppTests/AutoRefreshViewModelTests.swift @@ -52,11 +52,11 @@ final class AutoRefreshViewModelTests: XCTestCase { viewModel.setAutoRefreshEnabled(true, for: RefreshSpyProvider.providerId) await waitForIntervals(on: recorder, count: 1) - viewModel.setAutoRefreshSlowInterval(15 * 60) + viewModel.setAutoRefreshSlowInterval(17 * 60) await waitForIntervals(on: recorder, count: 2) let intervals = await recorder.intervals() - XCTAssertEqual(intervals, [5 * 60, 15 * 60]) + XCTAssertEqual(intervals, [5 * 60, 17 * 60]) XCTAssertEqual(provider.fetchCallCount, 1) } @@ -85,6 +85,31 @@ final class AutoRefreshViewModelTests: XCTestCase { XCTAssertEqual(intervals.last, 30) } + func testPresentationOnlySmartRefreshKeepsSlowSchedule() async { + AutoRefreshPreferences.setEnabled(true, for: RefreshSpyProvider.providerId) + let provider = RefreshSpyProvider() + let recorder = IntervalRecorder() + let viewModel = makeViewModel(provider: provider) { interval in + await recorder.record(interval) + throw CancellationError() + } + + await waitForFetches(on: provider, count: 1) + await waitForIntervals(on: recorder, count: 1) + viewModel.setAutoRefreshMode(.smart) + await waitForIntervals(on: recorder, count: 2) + provider.presentationRevision += 1 + + viewModel.manualRefresh(for: RefreshSpyProvider.providerId) + await waitForFetches(on: provider, count: 2) + await waitForIntervals(on: recorder, count: 3) + + XCTAssertEqual(viewModel.smartRefreshPolicy.cadence(for: RefreshSpyProvider.providerId), .slow) + XCTAssertFalse(viewModel.isFastAutomaticRefreshActive(for: RefreshSpyProvider.providerId)) + let intervals = await recorder.intervals() + XCTAssertEqual(intervals.last, 5 * 60) + } + func testFastRefreshStatusIdentifiesOnlyTheActiveProvider() async { AutoRefreshPreferences.mode = .smart AutoRefreshPreferences.setEnabled(true, for: RefreshSpyProvider.providerId) @@ -242,6 +267,7 @@ private final class RefreshSpyProvider: AIProvider, ProactiveRefreshable, @unche static let authShape: ProviderAuth.Shape = .apiKeyFree var percentage = 10.0 + var presentationRevision = 0 var fetchCallCount = 0 var proactiveRefreshCallCount = 0 @@ -254,9 +280,16 @@ private final class RefreshSpyProvider: AIProvider, ProactiveRefreshable, @unche return ProviderQuota( providerId: Self.providerId, providerName: Self.providerName, - headline: "\(percentage)%", - lines: [UsageLine(label: "Usage", percentage: percentage)], - lastUpdated: Date() + headline: "\(percentage)% \(presentationRevision)", + lines: [UsageLine(label: "Usage \(presentationRevision)", percentage: percentage)], + lastUpdated: Date(), + activityObservation: ProviderActivityObservation(metrics: [ + ProviderActivityMetric( + id: "usage", + kind: .usage, + value: .number(Decimal(percentage)) + ), + ]) ) } @@ -285,7 +318,10 @@ private final class SecondaryRefreshSpyProvider: AIProvider, @unchecked Sendable providerName: Self.providerName, headline: "10%", lines: [UsageLine(label: "Usage", percentage: 10)], - lastUpdated: Date() + lastUpdated: Date(), + activityObservation: ProviderActivityObservation(metrics: [ + ProviderActivityMetric(id: "usage", kind: .usage, value: .number(10)), + ]) ) } } diff --git a/Tests/ClaudeCodeProviderTests/ClaudeCodeActivityObservationTests.swift b/Tests/ClaudeCodeProviderTests/ClaudeCodeActivityObservationTests.swift new file mode 100644 index 0000000..b1a3a7a --- /dev/null +++ b/Tests/ClaudeCodeProviderTests/ClaudeCodeActivityObservationTests.swift @@ -0,0 +1,34 @@ +@testable import ClaudeCodeProvider +import Core +import Foundation +import XCTest + +final class ClaudeCodeActivityObservationTests: XCTestCase { + func testFetchQuotaMapsOnlyConsumptionIntoActivityObservation() async throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + + let cacheURL = temporaryDirectory.appendingPathComponent("claude-code.json") + let cacheStore = StatuslineCacheStore(cacheURL: cacheURL) + try cacheStore.write(StatuslineCache( + writtenAt: Date().timeIntervalSince1970, + rateLimits: RateLimits( + fiveHour: Window(usedPercentage: 42, resetsAt: 1_713_127_600), + sevenDay: Window(usedPercentage: 60, resetsAt: 1_713_500_000) + ) + )) + + let quota = try await ClaudeCodeProvider(cacheStore: cacheStore).fetchQuota( + auth: .apiKeyFree, + baseURL: ClaudeCodeProvider.baseURL + ) + + XCTAssertNil(quota.activityObservation?.availability) + XCTAssertEqual(quota.activityObservation?.metrics, [ + ProviderActivityMetric(id: "five-hour-usage", kind: .usage, value: .number(42)), + ProviderActivityMetric(id: "weekly-usage", kind: .usage, value: .number(60)), + ]) + } +} diff --git a/Tests/CoreTests/AutoRefreshPreferencesTests.swift b/Tests/CoreTests/AutoRefreshPreferencesTests.swift index 9b7761e..9c4d545 100644 --- a/Tests/CoreTests/AutoRefreshPreferencesTests.swift +++ b/Tests/CoreTests/AutoRefreshPreferencesTests.swift @@ -42,18 +42,27 @@ final class AutoRefreshPreferencesTests: XCTestCase { func testSharedValuesPersistWhenSupported() { AutoRefreshPreferences.mode = .smart - AutoRefreshPreferences.slowInterval = 15 * 60 - AutoRefreshPreferences.fastInterval = 45 + AutoRefreshPreferences.slowInterval = 17 * 60 + AutoRefreshPreferences.fastInterval = 25 XCTAssertEqual(AutoRefreshPreferences.mode, .smart) - XCTAssertEqual(AutoRefreshPreferences.slowInterval, 15 * 60) - XCTAssertEqual(AutoRefreshPreferences.fastInterval, 45) + XCTAssertEqual(AutoRefreshPreferences.slowInterval, 17 * 60) + XCTAssertEqual(AutoRefreshPreferences.fastInterval, 25) + } + + func testIntervalOptionsCoverEverySupportedStop() { + XCTAssertEqual(AutoRefreshPreferences.slowIntervalOptions.count, 60) + XCTAssertEqual(AutoRefreshPreferences.slowIntervalOptions.first, 60) + XCTAssertEqual(AutoRefreshPreferences.slowIntervalOptions.last, 60 * 60) + XCTAssertEqual(AutoRefreshPreferences.slowIntervalOptions[16], 17 * 60) + + XCTAssertEqual(AutoRefreshPreferences.fastIntervalOptions, [10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60]) } func testInvalidStoredValuesResolveToDefaults() { defaults.set("unknown", forKey: "automatic-refresh-mode") - defaults.set(7, forKey: "automatic-refresh-slow-interval") - defaults.set(90, forKey: "automatic-refresh-fast-interval") + defaults.set(7 * 60 + 1, forKey: "automatic-refresh-slow-interval") + defaults.set(27, forKey: "automatic-refresh-fast-interval") XCTAssertEqual(AutoRefreshPreferences.mode, .regular) XCTAssertEqual(AutoRefreshPreferences.slowInterval, 5 * 60) diff --git a/Tests/CoreTests/SmartRefreshPolicyTests.swift b/Tests/CoreTests/SmartRefreshPolicyTests.swift index 0da9efc..16adeee 100644 --- a/Tests/CoreTests/SmartRefreshPolicyTests.swift +++ b/Tests/CoreTests/SmartRefreshPolicyTests.swift @@ -6,128 +6,284 @@ final class SmartRefreshPolicyTests: XCTestCase { func testFirstSuccessEstablishesSlowBaseline() { var policy = SmartRefreshPolicy() - XCTAssertEqual(policy.recordSuccess(quota(percentage: 10), for: "provider"), .slow) - XCTAssertEqual(policy.cadence(for: "provider"), .slow) + let decision = policy.recordSuccess(quota(), for: "provider") + + XCTAssertEqual(decision.classification, .baseline) + XCTAssertEqual(decision.cadence, .slow) + XCTAssertTrue(decision.reasons.isEmpty) } - func testChangeEntersAndSustainsFastMode() { + func testSemanticChangesEnterAndSustainFastMode() { var policy = SmartRefreshPolicy() - _ = policy.recordSuccess(quota(percentage: 10), for: "provider") + _ = policy.recordSuccess(quota(usage: 10), for: "provider") + + let firstChange = policy.recordSuccess(quota(usage: 20), for: "provider") + let secondChange = policy.recordSuccess(quota(usage: 30), for: "provider") - XCTAssertEqual(policy.recordSuccess(quota(percentage: 20), for: "provider"), .fast) - XCTAssertEqual(policy.recordSuccess(quota(percentage: 30), for: "provider"), .fast) + XCTAssertEqual(firstChange.classification, .changed) + XCTAssertEqual(firstChange.cadence, .fast) + XCTAssertEqual(firstChange.reasons, [.usage]) + XCTAssertEqual(secondChange.cadence, .fast) XCTAssertEqual(policy.consecutiveUnchangedChecks(for: "provider"), 0) } func testThreeUnchangedFastChecksReturnToSlow() { var policy = SmartRefreshPolicy() - _ = policy.recordSuccess(quota(percentage: 10), for: "provider") - _ = policy.recordSuccess(quota(percentage: 20), for: "provider") + _ = policy.recordSuccess(quota(usage: 10), for: "provider") + _ = policy.recordSuccess(quota(usage: 20), for: "provider") - XCTAssertEqual(policy.recordSuccess(quota(percentage: 20), for: "provider"), .fast) + let firstUnchanged = policy.recordSuccess(quota(usage: 20), for: "provider") + + XCTAssertEqual(firstUnchanged.classification, .unchanged) + XCTAssertEqual(firstUnchanged.cadence, .fast) XCTAssertEqual(policy.consecutiveUnchangedChecks(for: "provider"), 1) - XCTAssertEqual(policy.recordSuccess(quota(percentage: 20), for: "provider"), .fast) + + let secondUnchanged = policy.recordSuccess(quota(usage: 20), for: "provider") + XCTAssertEqual(secondUnchanged.cadence, .fast) XCTAssertEqual(policy.consecutiveUnchangedChecks(for: "provider"), 2) - XCTAssertEqual(policy.recordSuccess(quota(percentage: 20), for: "provider"), .slow) + + let thirdUnchanged = policy.recordSuccess(quota(usage: 20), for: "provider") + XCTAssertEqual(thirdUnchanged.cadence, .slow) XCTAssertEqual(policy.consecutiveUnchangedChecks(for: "provider"), 0) } func testChangedFastResultResetsUnchangedCount() { var policy = SmartRefreshPolicy() - _ = policy.recordSuccess(quota(percentage: 10), for: "provider") - _ = policy.recordSuccess(quota(percentage: 20), for: "provider") - _ = policy.recordSuccess(quota(percentage: 20), for: "provider") + _ = policy.recordSuccess(quota(usage: 10), for: "provider") + _ = policy.recordSuccess(quota(usage: 20), for: "provider") + _ = policy.recordSuccess(quota(usage: 20), for: "provider") + + let decision = policy.recordSuccess(quota(usage: 30), for: "provider") - XCTAssertEqual(policy.recordSuccess(quota(percentage: 30), for: "provider"), .fast) + XCTAssertEqual(decision.classification, .changed) + XCTAssertEqual(decision.cadence, .fast) XCTAssertEqual(policy.consecutiveUnchangedChecks(for: "provider"), 0) } func testFailurePreservesBaselineAndReturnsToSlow() { var policy = SmartRefreshPolicy() - _ = policy.recordSuccess(quota(percentage: 10), for: "provider") - _ = policy.recordSuccess(quota(percentage: 20), for: "provider") + _ = policy.recordSuccess(quota(usage: 10), for: "provider") + _ = policy.recordSuccess(quota(usage: 20), for: "provider") XCTAssertEqual(policy.recordFailure(for: "provider"), .slow) XCTAssertEqual(policy.consecutiveUnchangedChecks(for: "provider"), 0) - XCTAssertEqual(policy.recordSuccess(quota(percentage: 20), for: "provider"), .slow) - XCTAssertEqual(policy.recordSuccess(quota(percentage: 30), for: "provider"), .fast) + + let unchanged = policy.recordSuccess(quota(usage: 20), for: "provider") + let changed = policy.recordSuccess(quota(usage: 30), for: "provider") + + XCTAssertEqual(unchanged.classification, .unchanged) + XCTAssertEqual(unchanged.cadence, .slow) + XCTAssertEqual(changed.classification, .changed) + XCTAssertEqual(changed.reasons, [.usage]) } - func testSnapshotIgnoresOrderingAndPresentationFields() { + func testPresentationOnlyChangesRemainUnchanged() { + let base = presentationQuota(isUpdated: false) + let changedPresentation = presentationQuota(isUpdated: true) var policy = SmartRefreshPolicy() - let resetDate = Date(timeIntervalSince1970: 1000) - let first = ProviderQuota( - providerId: "provider", - providerName: "First name", - headline: "First headline", - lines: [ - UsageLine( - label: "Secondary", - used: 2, - total: 10, - percentage: 20, - unit: "requests", - resetDate: resetDate, - details: [UsageDetail(label: "B", value: "2"), UsageDetail(label: "A", value: "1")] - ), - UsageLine(label: "Primary", percentage: 10), - ], - lastUpdated: Date(timeIntervalSince1970: 1), - error: "old error", - isStale: true + + _ = policy.recordSuccess(base, for: "provider") + let decision = policy.recordSuccess(changedPresentation, for: "provider") + + XCTAssertEqual(decision.classification, .unchanged) + XCTAssertEqual(decision.cadence, .slow) + XCTAssertTrue(decision.reasons.isEmpty) + } + + func testMetricReorderingAndEquivalentNumericFormattingRemainUnchanged() throws { + let ten = try XCTUnwrap(Decimal(string: "10")) + let tenWithDecimal = try XCTUnwrap(Decimal(string: "10.0")) + let twoAndAHalf = try XCTUnwrap(Decimal(string: "2.50")) + let twoAndAHalfWithTrailingZero = try XCTUnwrap(Decimal(string: "2.500")) + var policy = SmartRefreshPolicy() + _ = policy.recordSuccess( + quota(metrics: [ + metric(id: "usage", kind: .usage, value: ten), + metric(id: "credits", kind: .credits, value: twoAndAHalf), + ]), + for: "provider" ) - let second = ProviderQuota( - providerId: "provider", - providerName: "Second name", - headline: "Second headline", - lines: [ - UsageLine(label: "Primary", percentage: 10), - UsageLine( - label: "Secondary", - used: 2, - total: 10, - percentage: 20, - unit: "requests", - resetDate: resetDate, - details: [UsageDetail(label: "A", value: "1"), UsageDetail(label: "B", value: "2")] - ), - ], - lastUpdated: Date(timeIntervalSince1970: 2), - error: nil, - isStale: false + + let decision = policy.recordSuccess( + quota(metrics: [ + metric(id: "credits", kind: .credits, value: twoAndAHalfWithTrailingZero), + metric(id: "usage", kind: .usage, value: tenWithDecimal), + ]), + for: "provider" + ) + + XCTAssertEqual(decision.classification, .unchanged) + XCTAssertEqual(decision.cadence, .slow) + } + + func testUsageAndCreditChangesReportBothReasonsIncludingMetricRemovalAndAddition() { + var policy = SmartRefreshPolicy() + _ = policy.recordSuccess( + quota(metrics: [ + metric(id: "usage", kind: .usage, value: Decimal(10)), + metric(id: "old-credit", kind: .credits, value: Decimal(5)), + ]), + for: "provider" ) - _ = policy.recordSuccess(first, for: "provider") + let decision = policy.recordSuccess( + quota(metrics: [ + metric(id: "usage", kind: .usage, value: Decimal(20)), + metric(id: "new-credit", kind: .credits, value: Decimal(8)), + ]), + for: "provider" + ) - XCTAssertEqual(policy.recordSuccess(second, for: "provider"), .slow) + XCTAssertEqual(decision.classification, .changed) + XCTAssertEqual(decision.reasons, [.usage, .credits]) + XCTAssertEqual(decision.cadence, .fast) } - func testMeaningfulUsageChangesAreDetectedAndProviderStateIsIsolated() { + func testKnownAvailabilityTransitionReportsOnlyAvailability() { var policy = SmartRefreshPolicy() - _ = policy.recordSuccess(quota(percentage: 10), for: "first") - _ = policy.recordSuccess(quota(percentage: 10), for: "second") + _ = policy.recordSuccess(quota(usage: 10, availability: .available), for: "provider") + + let decision = policy.recordSuccess(quota(usage: 10, availability: .unavailable), for: "provider") + + XCTAssertEqual(decision.classification, .changed) + XCTAssertEqual(decision.reasons, [.availability]) + XCTAssertEqual(decision.cadence, .fast) + } - XCTAssertEqual(policy.recordSuccess(quota(percentage: 20), for: "first"), .fast) + func testUnknownAndAbsentObservationsEstablishBaselinesWithoutActivity() { + var policy = SmartRefreshPolicy() + _ = policy.recordSuccess(quota(observation: nil), for: "provider") + + let initialKnown = policy.recordSuccess(quota(usage: 10, availability: .unknown), for: "provider") + let knownAvailability = policy.recordSuccess(quota(usage: 10, availability: .available), for: "provider") + let absent = policy.recordSuccess(quota(observation: nil), for: "provider") + let restored = policy.recordSuccess(quota(usage: 20, availability: .unavailable), for: "provider") + let transition = policy.recordSuccess(quota(usage: 20, availability: .available), for: "provider") + + XCTAssertEqual(initialKnown.classification, .unchanged) + XCTAssertEqual(knownAvailability.classification, .unchanged) + XCTAssertEqual(absent.classification, .unchanged) + XCTAssertEqual(restored.classification, .unchanged) + XCTAssertEqual(transition.classification, .changed) + XCTAssertEqual(transition.reasons, [.availability]) + } + + func testEmptyObservationsDoNotMasqueradeAsMetricRemovalOrAddition() { + var policy = SmartRefreshPolicy() + _ = policy.recordSuccess(quota(usage: 10), for: "provider") + + let empty = policy.recordSuccess(quota(metrics: []), for: "provider") + let restored = policy.recordSuccess(quota(usage: 20), for: "provider") + + XCTAssertEqual(empty.classification, .unchanged) + XCTAssertEqual(restored.classification, .unchanged) + XCTAssertEqual(restored.cadence, .slow) + } + + func testProviderStateIsIsolated() { + var policy = SmartRefreshPolicy() + _ = policy.recordSuccess(quota(usage: 10), for: "first") + _ = policy.recordSuccess(quota(usage: 10), for: "second") + + let firstDecision = policy.recordSuccess(quota(usage: 20), for: "first") + let secondDecision = policy.recordSuccess(quota(usage: 10), for: "second") + + XCTAssertEqual(firstDecision.cadence, .fast) + XCTAssertEqual(secondDecision.cadence, .slow) XCTAssertEqual(policy.cadence(for: "second"), .slow) - XCTAssertEqual( - policy.recordSuccess( - quota(percentage: 10, resetDate: Date(timeIntervalSince1970: 100)), - for: "second" - ), - .fast - ) } private func quota( - percentage: Double, - resetDate: Date? = nil + usage: Double = 10, + availability: ProviderAvailability? = nil, + metrics: [ProviderActivityMetric]? = nil, + observation: ProviderActivityObservation? = ProviderActivityObservation(), + providerName: String = "Provider", + headline: String = "Headline", + lines: [UsageLine] = [UsageLine(label: "Usage", percentage: 10)], + lastUpdated: Date = Date(), + error: String? = nil, + isStale: Bool = false, + peakHoursConfig: PeakHoursConfig? = nil ) -> ProviderQuota { - ProviderQuota( + let resolvedObservation = observation.map { _ in + ProviderActivityObservation( + metrics: metrics ?? [metric(id: "usage", kind: .usage, value: Decimal(usage))], + availability: availability + ) + } + return ProviderQuota( providerId: "provider", - providerName: "Provider", - headline: "\(percentage)%", - lines: [UsageLine(label: "Usage", percentage: percentage, resetDate: resetDate)], - lastUpdated: Date() + providerName: providerName, + headline: headline, + lines: lines, + lastUpdated: lastUpdated, + error: error, + isStale: isStale, + activityObservation: resolvedObservation, + peakHoursConfig: peakHoursConfig + ) + } + + private func metric( + id: String, + kind: ProviderActivityMetric.Kind, + value: Decimal + ) -> ProviderActivityMetric { + ProviderActivityMetric(id: id, kind: kind, value: .number(value)) + } + + private func presentationQuota(isUpdated: Bool) -> ProviderQuota { + quota( + usage: 10, + providerName: isUpdated ? "Second name" : "First name", + headline: isUpdated ? "Second headline" : "First headline", + lines: presentationLines(isUpdated: isUpdated), + lastUpdated: Date(timeIntervalSince1970: isUpdated ? 2 : 1), + error: isUpdated ? nil : "Old error", + isStale: !isUpdated, + peakHoursConfig: presentationPeakHours(isUpdated: isUpdated) + ) + } + + private func presentationLines(isUpdated: Bool) -> [UsageLine] { + if isUpdated { + return [ + UsageLine( + label: "Localized usage", + used: 9, + total: 100, + percentage: 90, + unit: "tokens", + resetDate: Date(timeIntervalSince1970: 200), + details: [ + UsageDetail(label: "B", value: "2"), + UsageDetail(label: "A", value: "updated"), + ] + ), + UsageLine(label: "Additional line", percentage: 50), + ] + } + return [ + UsageLine( + label: "Usage", + used: 1, + total: 10, + percentage: 10, + unit: "requests", + resetDate: Date(timeIntervalSince1970: 100), + details: [UsageDetail(label: "A", value: "1")] + ), + ] + } + + private func presentationPeakHours(isUpdated: Bool) -> PeakHoursConfig { + PeakHoursConfig( + timeZone: TimeZone(identifier: isUpdated ? "Asia/Shanghai" : "UTC"), + peakStartHour: isUpdated ? 14 : 1, + peakEndHour: isUpdated ? 18 : 2, + peakMultiplier: isUpdated ? 4 : 3, + offPeakMultiplier: isUpdated ? 1 : 2 ) } } diff --git a/Tests/CursorProviderTests/CursorProviderTests.swift b/Tests/CursorProviderTests/CursorProviderTests.swift index f55566d..4ec10dd 100644 --- a/Tests/CursorProviderTests/CursorProviderTests.swift +++ b/Tests/CursorProviderTests/CursorProviderTests.swift @@ -49,6 +49,18 @@ final class CursorProviderTests: XCTestCase { XCTAssertNotNil(includedLine.resetDate) } + func testFetchQuota_mapsSemanticUsageAndCreditsIntoActivityObservation() async throws { + let quota = try await fetchWithMock(CursorTestFixtures.usageResponse()) + + XCTAssertNil(quota.activityObservation?.availability) + XCTAssertEqual(quota.activityObservation?.metrics, [ + ProviderActivityMetric(id: "included-usage", kind: .usage, value: .number(50)), + ProviderActivityMetric(id: "on-demand-spend", kind: .usage, value: .number(15)), + ProviderActivityMetric(id: "spend-limit-usage", kind: .usage, value: .number(5)), + ProviderActivityMetric(id: "bonus-credits", kind: .credits, value: .number(20)), + ]) + } + func testFetchQuota_addsBonusCreditsLineWhenPresent() async throws { let quota = try await fetchWithMock(CursorTestFixtures.usageResponse()) diff --git a/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift b/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift index 7f4cc81..8a77015 100644 --- a/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift +++ b/Tests/DeepSeekProviderTests/DeepSeekProviderTests.swift @@ -77,6 +77,17 @@ final class DeepSeekProviderTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(quota.lines[2].total), 100.00, accuracy: 0.001) } + func testFetchQuota_mapsCreditsAndAvailabilityIntoActivityObservation() async throws { + let quota = try await fetchWithMock(validResponseJSON()) + + XCTAssertEqual(quota.activityObservation?.availability, .available) + XCTAssertEqual(quota.activityObservation?.metrics, [ + ProviderActivityMetric(id: "total-balance-cny", kind: .credits, value: .number(110)), + ProviderActivityMetric(id: "granted-balance-cny", kind: .credits, value: .number(10)), + ProviderActivityMetric(id: "topped-up-balance-cny", kind: .credits, value: .number(100)), + ]) + } + func testFetchQuota_tagsEachLineWithRawCurrencyCode() async throws { let quota = try await fetchWithMock(validResponseJSON()) @@ -115,6 +126,7 @@ final class DeepSeekProviderTests: XCTestCase { XCTAssertEqual(quota.headline, "No balance available") // Lines are still returned so the user can see what's left. XCTAssertEqual(quota.lines.count, 3) + XCTAssertEqual(quota.activityObservation?.availability, .unavailable) } // MARK: - Headline shows total balance, currency-aware diff --git a/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift b/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift index 5d67971..9d23af2 100644 --- a/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift +++ b/Tests/OpenAICodexProviderTests/OpenAICodexProviderTests.swift @@ -164,6 +164,11 @@ final class OpenAICodexProviderTests: XCTestCase { XCTAssertEqual(quota.lines[0].details?.first?.label, "Credits") XCTAssertEqual(quota.lines[0].details?.first?.value, "12.5") XCTAssertTrue(quota.headline.hasPrefix("20%")) + XCTAssertEqual(quota.activityObservation?.metrics, [ + ProviderActivityMetric(id: "primary-window-usage", kind: .usage, value: .number(20)), + ProviderActivityMetric(id: "secondary-window-usage", kind: .usage, value: .number(40)), + ProviderActivityMetric(id: "credits", kind: .credits, value: .number(12.5)), + ]) } func testProvider_omitsMissingWindowsAndUsesUnlimitedCredits() { @@ -183,6 +188,9 @@ final class OpenAICodexProviderTests: XCTestCase { XCTAssertEqual(quota.lines.count, 1) XCTAssertEqual(quota.lines[0].label, "Credits") XCTAssertEqual(quota.lines[0].details?.first?.value, "Unlimited credits") + XCTAssertEqual(quota.activityObservation?.metrics, [ + ProviderActivityMetric(id: "credits", kind: .credits, value: .discrete("unlimited")), + ]) } // MARK: - coalesced fetches diff --git a/Tests/ZAIProviderTests/ZAIProviderTests.swift b/Tests/ZAIProviderTests/ZAIProviderTests.swift index f78f885..b3c29a5 100644 --- a/Tests/ZAIProviderTests/ZAIProviderTests.swift +++ b/Tests/ZAIProviderTests/ZAIProviderTests.swift @@ -93,6 +93,23 @@ final class ZAIProviderTests: XCTestCase { XCTAssertEqual(fiveHour.used, 420) } + func testFetchQuota_mapsOnlyConsumptionIntoActivityObservation() async throws { + MockURLProtocol.responseData = validResponseJSON() + MockURLProtocol.responseStatusCode = 200 + + let quota = try await provider.fetchQuota( + auth: .apiKey("test-key"), + baseURL: ZAIProvider.baseURL + ) + + XCTAssertEqual(quota.activityObservation?.availability, nil) + XCTAssertEqual(quota.activityObservation?.metrics, [ + ProviderActivityMetric(id: "five-hour-usage", kind: .usage, value: .number(420)), + ProviderActivityMetric(id: "weekly-usage", kind: .usage, value: .number(600)), + ProviderActivityMetric(id: "monthly-web-tool-usage", kind: .usage, value: .number(15)), + ]) + } + /// z.ai's monthly web-tool line carries `currentValue` (actual used) and /// `usage` (the allowance/cap). `currentValue` must map to `used` and /// `usage` to `total` — not the reverse. diff --git a/specs/core/09-refine-smart-refresh-detection.md b/specs/core/09-refine-smart-refresh-detection.md new file mode 100644 index 0000000..d98340c --- /dev/null +++ b/specs/core/09-refine-smart-refresh-detection.md @@ -0,0 +1,145 @@ +## Objective + +Make Smart refresh react only to provider-reported usage, credit, or availability changes through an explicit, testable activity signal while giving users finer control over its polling intervals. + +## Context + +- `Sources/Core/SmartRefreshPolicy.swift` — currently compares display-oriented quota fields, so reset dates, labels, units, limits, and detail text can produce false activity. +- `Sources/Core/ProviderProtocol.swift` — needs a provider-neutral activity observation that is separate from `ProviderQuota` presentation data. +- `Sources/Core/AutoRefreshPreferences.swift` — currently offers only five slow and five fast interval choices across otherwise suitable ranges. +- `Sources/App/QuotaViewModel+Lifecycle.swift` — consumes policy decisions and schedules the next slow or fast check. +- `Sources/App/RefreshSettingsView.swift` — renders the discrete slow and fast interval sliders for Regular and Smart modes. +- `Sources/Providers/ZAI/ZAIProvider.swift`, `Sources/Providers/ClaudeCode/ClaudeCodeProvider.swift`, `Sources/Providers/OpenAICodex/OpenAICodexProvider.swift`, `Sources/Providers/DeepSeek/DeepSeekProvider.swift`, and `Sources/Providers/Cursor/CursorProvider.swift` — each provider already knows which response fields represent consumption, credits, and account availability. +- `Tests/CoreTests/SmartRefreshPolicyTests.swift` — needs a table-driven regression matrix for real activity and presentation-only changes. +- `Tests/CoreTests/AutoRefreshPreferencesTests.swift` and `Tests/AppTests/AutoRefreshViewModelTests.swift` — need coverage for the denser interval choices, persistence, and rescheduling. +- Provider suites need focused mapping tests that prove each provider emits the intended activity observation. +- This refines meaningful-change detection from (core 08 AC5, core 08 AC7) without changing its per-provider cadence, three-unchanged-check exit, or failure behavior. + +## Acceptance Criteria + +### AC1: Existing false positives and missed signals are captured + +- **Given** two successful quota results whose displayed reset date, label, unit, total allowance, detail text, line order, or presentation metadata differs while current usage, credits, and availability are unchanged +- **When** the Smart comparison regression tests run +- **Then** every case is classified as unchanged +- **And** changing only `lastUpdated`, `headline`, `error`, `isStale`, or peak-hours metadata remains unchanged +- **And** a provider-reported availability transition is classified as changed even when the rendered headline and quota lines are otherwise unchanged. + +### AC2: Providers emit an explicit activity observation + +- **Given** a provider maps a successful upstream response into `ProviderQuota` +- **When** it describes the response for Smart refresh +- **Then** it supplies a provider-neutral activity observation containing zero or more stable metrics and an optional availability state +- **And** every metric has a nonlocalized stable ID, a kind of `usage` or `credits`, and a canonical comparable value +- **And** availability distinguishes `available`, `unavailable`, and `unknown` +- **And** the observation contains no API key, account identifier, request body, or user-facing formatted string +- **And** adding a provider requires no provider-ID branch in Core, App, or another provider. + +### AC3: Only semantic activity changes enter fast mode + +- **Given** a provider has an established Smart baseline +- **When** a later successful observation changes a usage metric, changes a credit metric, adds or removes a tracked usage or credit metric, or changes between known availability states +- **Then** that provider enters or remains in fast mode +- **And** the unchanged-check counter resets to zero +- **And** the transition identifies only the reason categories `usage`, `credits`, or `availability` +- **And** one provider's transition does not alter another provider's baseline, counter, cadence, or next refresh date. + +### AC4: Display and quota metadata cannot enter fast mode + +- **Given** two successful results have equal activity observations +- **When** any other `ProviderQuota`, `UsageLine`, or `UsageDetail` field changes +- **Then** Smart refresh classifies the result as unchanged +- **And** labels, units, total allowances, reset dates, detail ordering, detail text unrelated to credits, line ordering, and line presentation do not enter fast mode +- **And** localized text changes cannot affect the activity comparison +- **And** Core does not fall back to comparing the rendered quota model when an observation is present. + +### AC5: Unknown and absent observations are conservative + +- **Given** a successful result has no activity observation, an unknown availability state, or no metrics +- **When** Smart refresh compares it with its previous successful result +- **Then** absence alone does not masquerade as usage or availability activity +- **And** `unknown` becoming `available` or `unavailable`, or the reverse, establishes the new known state without entering fast mode +- **And** a later transition between `available` and `unavailable` does enter fast mode +- **And** the successful result can still establish or update the baseline used by later checks. + +### AC6: Policy decisions expose safe diagnostic reasons + +- **Given** the policy records a successful result +- **When** it returns its decision +- **Then** the decision reports baseline, unchanged, or changed together with the resulting cadence +- **And** a changed decision reports a set containing only `usage`, `credits`, and/or `availability` +- **And** diagnostics and logs may record those reason categories but never metric values or provider payloads +- **And** scheduling behavior does not depend on log output. + +### AC7: Existing cadence and failure rules remain intact + +- **Given** activity comparison returns baseline, unchanged, or changed +- **When** the Smart state machine updates +- **Then** the first observation establishes a slow baseline, a changed observation enters fast mode, and exactly three consecutive unchanged fast checks return the provider to slow mode as defined by (core 08 AC4, core 08 AC5, core 08 AC6) +- **And** a changed observation while fast resets the unchanged counter +- **And** fetch and proactive-refresh failures preserve the last successful baseline, return the provider to slow mode, and do not count as availability changes or unchanged checks (core 08 AC10) +- **And** Regular mode and providers without automatic refresh enabled do not create or advance Smart scheduling state. + +### AC8: Current providers map only their semantic signals + +- **Given** each current provider maps a successful response +- **When** its activity observation is inspected +- **Then** z.ai and Claude Code expose their current consumption values without reset times or limits +- **And** OpenAI Codex exposes current window consumption plus credit balance or unlimited-credit state +- **And** DeepSeek exposes current balance values as credits plus its upstream `is_available` state +- **And** Cursor exposes current included usage, on-demand spend, spend-limit usage, and available bonus credits without billing-cycle dates or configured limits +- **And** each provider owns its stable metric IDs and mapping without knowledge of any other provider. + +### AC9: Numeric normalization avoids noise without hiding visible activity + +- **Given** an upstream provider reports numeric usage or credits +- **When** the provider creates its activity observation +- **Then** it uses the same meaningful precision represented by its quota mapping rather than a localized display string +- **And** equivalent numeric values compare equally regardless of response formatting such as `10`, `10.0`, or `"10.00"` +- **And** a change large enough to alter the provider's represented usage or credit value is classified as changed +- **And** Core does not contain provider-specific tolerances or rounding rules. + +### AC10: Detection and scheduling are covered end to end + +- **Given** table-driven policy fixtures, provider mapping fixtures, an injected sleeper, and provider spies +- **When** the Core, provider, and App tests run +- **Then** they cover every included and excluded field from AC1–AC5, multi-reason changes, metric reordering, metric addition and removal, unknown availability, and provider isolation +- **And** they prove a semantic activity change schedules the fast interval while a presentation-only change schedules the slow interval or advances the existing fast-mode unchanged counter +- **And** they prove three unchanged semantic observations exit fast mode and failures do not become availability transitions +- **And** tests perform no network request, Keychain mutation, child-process spawn, or wall-clock sleep +- **And** all existing provider and automatic-refresh suites continue to pass. + +### AC11: Slow and fast sliders offer finer interval choices + +- **Given** the existing slow range of one through 60 minutes and fast range of 10 through 60 seconds +- **When** the user adjusts automatic-refresh intervals +- **Then** the slow slider offers every whole minute from one through 60 minutes +- **And** the fast slider offers every five-second value from 10 through 60 seconds +- **And** Regular mode shows the slow slider and uses its selected value for every opted-in provider +- **And** Smart mode shows both sliders and uses the slow value outside fast mode and the fast value while activity is detected +- **And** the defaults remain five minutes for slow refresh and 30 seconds for fast refresh +- **And** every slider stop displays a localized duration and exposes that value to assistive technology +- **And** every supported value persists across relaunch and reschedules eligible providers without overlapping work +- **And** stored values outside the supported ranges or increments resolve to the existing defaults as defined by (core 08 AC13, core 08 AC15). + +## Plan + +1. Add a small Core value model for Smart activity observations. Use stable metric IDs, a `usage`/`credits` kind, canonical numeric or discrete values, and optional three-state availability. Keep it independent of display labels and provider IDs. +2. Attach the observation to successful `ProviderQuota` values. Permit an absent observation for compatibility, but treat it conservatively rather than deriving activity from `UsageLine`. +3. Replace `UsageSnapshot` in `SmartRefreshPolicy` with a canonical activity comparison. Sort metrics by stable ID, reject duplicate IDs in debug builds, and compare reason categories separately so one result can report more than one cause. +4. Return a structured policy decision containing the resulting cadence and baseline/unchanged/changed classification. Allow App diagnostics to log only the provider ID and reason categories. +5. Map observations inside each provider from its decoded upstream model. Normalize values at that boundary and keep reset dates, configured limits, units, localized labels, and other presentation fields out of the observation. +6. Feed the structured decision through the existing `QuotaViewModel` scheduling path without changing interval selection, provider isolation, opt-in gates, or failure handling. +7. Expand the supported slow choices to every whole minute from one through 60 and the fast choices to five-second steps from 10 through 60. Keep the existing ranges, defaults, mode visibility, persistence keys, and completion-driven rescheduling behavior. +8. Expand Core tests with a table of semantic and non-semantic mutations. Add provider fixture tests for exact observation mapping, interval-option and persistence tests, and App tests for the resulting slow/fast schedule. +9. Run the full validation gate from the `writing-code` skill only after this spec is reviewed and implementation is explicitly approved. + +## Risks + +- The activity observation duplicates a small amount of data already used to render quota lines. That duplication is intentional: presentation fields are not a reliable contract for scheduling behavior. +- An omitted or incomplete provider observation can make Smart mode miss activity. Focused mapping tests for every production provider mitigate this, and future providers must test their observation alongside quota mapping. +- Stable metric IDs become scheduling compatibility keys. Providers must keep them nonlocalized and stable when display labels change. +- Provider-owned normalization may miss changes below the precision that provider exposes. This is preferable to fast-mode churn from insignificant floating-point or formatting noise. +- Treating `unknown`-to-known availability as baseline establishment avoids fast refresh on newly discovered capability, but it delays rapid follow-up until a real known-state transition or metric change occurs. +- Credit balances can decrease during active use and increase after a purchase or grant. Both are real credit changes and intentionally activate fast mode. +- More slider stops improve control but make keyboard and pointer traversal longer. Visible values, discrete stepping, and accessibility announcements must keep the selected interval clear.