diff --git a/Sources/CodexBar/SpendDashboardController.swift b/Sources/CodexBar/SpendDashboardController.swift index 429c91a4c4..872a361fcb 100644 --- a/Sources/CodexBar/SpendDashboardController.swift +++ b/Sources/CodexBar/SpendDashboardController.swift @@ -963,6 +963,19 @@ final class SpendDashboardController { let confirmedEmptySourceIDs: Set } + private struct LoadedInputScope: Equatable { + let bucketTimeZoneIdentifier: String + let historyDays: Int + + init( + configuration: SpendDashboardConfiguration, + input: SpendDashboardModel.ProviderInput) + { + self.bucketTimeZoneIdentifier = configuration.bucketCalendar.timeZone.identifier + self.historyDays = input.snapshot.historyDays + } + } + private enum LoadPhase: Sendable { case ordinary case forcing @@ -1000,6 +1013,7 @@ final class SpendDashboardController { private let nowProvider: @Sendable () -> Date private var loadTask: Task? private var loadedInputs: [SpendDashboardModel.ProviderInput] = [] + private var loadedInputScopes: [String: LoadedInputScope] = [:] private var loadedAt = Date() private var lastSuccessfulConfiguration: SpendDashboardConfiguration? private var phase = LoadPhase.ordinary @@ -1060,6 +1074,9 @@ final class SpendDashboardController { if !invalidatedSourceIDs.isEmpty { self.loadedInputs.removeAll { invalidatedSourceIDs.contains($0.id) } + for sourceID in invalidatedSourceIDs { + self.loadedInputScopes.removeValue(forKey: sourceID) + } self.failedSourceCount = 0 self.rebuildModel() } @@ -1074,6 +1091,7 @@ final class SpendDashboardController { !configuration.providerIDs.isEmpty || configuration.openCodexUsageLogsEnabled else { self.loadedInputs = [] + self.loadedInputScopes = [:] self.failedSourceCount = 0 self.isRefreshing = false self.lastSuccessfulConfiguration = configuration @@ -1119,6 +1137,11 @@ final class SpendDashboardController { let cachedIDs = Set(result.inputs.map(\.id)) self.loadedInputs.removeAll { cachedIDs.contains($0.id) } self.loadedInputs.append(contentsOf: result.inputs) + for input in result.inputs { + self.loadedInputScopes[input.id] = LoadedInputScope( + configuration: request.configuration, + input: input) + } self.loadedAt = request.now self.failedSourceCount = result.failedSourceCount self.refreshRetainedCodexDisplayNames(request.configuration.codexAccountDisplayNames) @@ -1247,19 +1270,44 @@ final class SpendDashboardController { let codexDisplayNames = request.configuration.codexAccountDisplayNames self.refreshRetainedCodexDisplayNames(codexDisplayNames) var nextInputs = result.inputs + var nextInputScopes = Dictionary(uniqueKeysWithValues: nextInputs.map { input in + (input.id, LoadedInputScope(configuration: request.configuration, input: input)) + }) + let unsafeSourceIDs = invalidatedSourceIDs + .union(result.invalidatedSourceIDs) + .union(confirmedEmptySourceIDs) + let incompleteCodexScopes = nextInputs.reduce(into: [String: LoadedInputScope]()) { scopes, input in + guard input.provider == .codex, + !input.snapshot.historyCoverageIsEstablished + else { return } + scopes[input.id] = LoadedInputScope(configuration: request.configuration, input: input) + } + if !incompleteCodexScopes.isEmpty { + let retainedInputs = self.loadedInputs.filter { + incompleteCodexScopes[$0.id] == self.loadedInputScopes[$0.id] && + !unsafeSourceIDs.contains($0.id) && + $0.provider == .codex && + $0.snapshot.historyCoverageIsEstablished + }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) } + let retainedSourceIDs = Set(retainedInputs.map(\.id)) + nextInputs.removeAll { retainedSourceIDs.contains($0.id) } + nextInputs.append(contentsOf: retainedInputs) + } if !result.failedSourceIDs.isEmpty { let freshIDs = Set(nextInputs.map(\.id)) - let unsafeSourceIDs = invalidatedSourceIDs - .union(result.invalidatedSourceIDs) - .union(confirmedEmptySourceIDs) - nextInputs.append(contentsOf: self.loadedInputs.filter { + let retainedInputs = self.loadedInputs.filter { result.failedSourceIDs.contains($0.id) && !unsafeSourceIDs.contains($0.id) && !freshIDs.contains($0.id) - }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) }) + }.map { Self.relabelCodexInput($0, displayNamesByID: codexDisplayNames) } + nextInputs.append(contentsOf: retainedInputs) + for input in retainedInputs { + nextInputScopes[input.id] = self.loadedInputScopes[input.id] + } } self.configuration = request.configuration self.loadedInputs = nextInputs + self.loadedInputScopes = nextInputScopes self.loadedAt = request.now self.lastSuccessfulConfiguration = request.configuration self.failedSourceCount = result.failedSourceCount diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 9fcf20e433..a7b571b212 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -120,6 +120,16 @@ extension UsageStore { } func publishTokenSnapshot(_ snapshot: CostUsageTokenSnapshot, for provider: UsageProvider) { + // A bounded Codex refresh can succeed with partial rows while catch-up remains pending. + // Keep the same-scope established snapshot until the scanner publishes another established + // result; account and history-window changes fail the current-publication lookup below. + if provider == .codex, + !snapshot.historyCoverageIsEstablished, + self.tokenSnapshotPublicationForCurrentProviderConfig(for: provider)? + .snapshot?.historyCoverageIsEstablished == true + { + return + } self.tokenSnapshots[provider.instanceID] = snapshot self.publishTokenSnapshotState(snapshot, for: provider) } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index cc58477c52..e7f5ee9207 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -968,6 +968,11 @@ public struct CostUsageFetcher: Sendable { } guard !reports.isEmpty else { return nil } + // `previous` is an exact report captured before the current bounded refresh became + // pending. Its rows remain established even though native catch-up is still active; + // `staleSnapshotUpdatedAt` keeps refresh scheduling and stale presentation explicit. + let displayedHistoryCoverageIsEstablished = nativeHistoryCoverageIsEstablished + || staleSnapshotUpdatedAt != nil // updatedAt keeps the caches' real (oldest) scan time; stamping the hydration time // would let stale token rows inherit app-start freshness (#1964). lastRefreshAt // drives TTL suppression and stays native-only: a merged load must never delay a @@ -978,7 +983,7 @@ public struct CostUsageFetcher: Sendable { now: now, historyDays: clampedHistoryDays, calendar: options.calendar, - historyCoverageIsEstablished: Self.codexHistoryCoverageIsEstablished(options: options), + historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished, costProvenance: .listPriceEstimate, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, diff --git a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift index 880bbc3474..441fe50fcb 100644 --- a/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift +++ b/Sources/CodexBarCore/Generated/CodexParserHash.generated.swift @@ -1,5 +1,5 @@ // Generated by Scripts/regenerate-codex-parser-hash.sh. Do not edit by hand. enum CodexParserHash { - static let value = "8050a4faf4fddb96" + static let value = "a8843ee5c69a90fc" } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift index 4af8c32169..2618488c3c 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageCacheModels.swift @@ -212,13 +212,18 @@ struct CostUsageCodexPreviousReport: Codable, Equatable { var timeZoneIdentifier: String? var roots: [String: Int64]? - init?(report: CostUsageDailyReport, cache: CostUsageCache) { + init?( + report: CostUsageDailyReport, + cache: CostUsageCache, + reportSinceKey: String, + reportUntilKey: String) + { guard !report.data.isEmpty else { return nil } self.data = report.data.map(Entry.init) self.summary = report.summary.map(Summary.init) self.updatedAtUnixMs = cache.lastScanUnixMs - self.scanSinceKey = cache.scanSinceKey - self.scanUntilKey = cache.scanUntilKey + self.scanSinceKey = reportSinceKey + self.scanUntilKey = reportUntilKey self.timeZoneIdentifier = cache.timeZoneIdentifier self.roots = cache.roots } diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift index 49cb1fec1f..c9c2289ba2 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner.swift @@ -5176,8 +5176,11 @@ enum CostUsageScanner { return previous } - let sourceCache: CostUsageCache? = if !currentScanIsPending, - options.forceRescan, + // A routine bounded refresh can turn an established cache back into pending while it + // validates a growing active tail. Snapshot the established report before any refresh, + // not only explicit rescans, so presentation can remain stable until catch-up converges. + let sourceCache: CostUsageCache? = if plan.shouldRefresh, + !currentScanIsPending, !cache.days.isEmpty { cache @@ -5197,7 +5200,11 @@ enum CostUsageScanner { modelsDevCatalog: plan.modelsDevCatalog, modelsDevCacheRoot: options.cacheRoot, priorityTurns: plan.priorityTurns) - return CostUsageCodexPreviousReport(report: report, cache: sourceCache) + return CostUsageCodexPreviousReport( + report: report, + cache: sourceCache, + reportSinceKey: range.sinceKey, + reportUntilKey: range.untilKey) } static func codexPreviousReport( diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift index e9001f077c..50761fb6f0 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageStore+CodexCache.swift @@ -867,10 +867,11 @@ extension CostUsageStore { else { return nil } let range = CostUsageScanner.CostUsageDayRange(since: since, until: until, calendar: calendar) let report = CostUsageScanner.buildCodexReportFromCache(cache: cache, range: range) - guard var previous = CostUsageCodexPreviousReport(report: report, cache: cache) else { return nil } - previous.scanSinceKey = reportWindow?.sinceKey ?? cache.scanSinceKey - previous.scanUntilKey = reportWindow?.untilKey ?? cache.scanUntilKey - return previous + return CostUsageCodexPreviousReport( + report: report, + cache: cache, + reportSinceKey: sinceKey, + reportUntilKey: untilKey) } private static func fileAggregates(_ usage: CostUsageFileUsage) -> [CostUsageStoreDayAggregate] { diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index e12cf0d19e..c93e225aa5 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -260,6 +260,109 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(cached?.daily.map(\.date) == ["2026-04-08"]) } + @Test + func `bounded narrow tail refresh retains only its requested cached window`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let olderDay = try env.makeLocalNoon(year: 2026, month: 2, day: 7) + try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: olderDay, + filename: "older.jsonl", + tokens: 11) + let sessionURL = try Self.writeCodexSessionFile( + homeRoot: env.codexHomeRoot, + env: env, + day: day, + filename: "active-tail.jsonl", + tokens: 42) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let established = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 365, + includePiSessions: false, + scannerOptions: options) + let establishedCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + #expect(established.historyCoverageIsEstablished) + #expect(established.last30DaysTokens == 53) + #expect(establishedCache.codexScanCatchUpPending != true) + + let appendedAt = day.addingTimeInterval(10) + let appendedLine = try env.jsonl([[ + "type": "event_msg", + "timestamp": env.isoString(for: appendedAt), + "payload": [ + "type": "token_count", + "info": [ + "last_token_usage": [ + "input_tokens": 84, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + "model": "openai/gpt-5.4", + ], + ], + ]]) + let handle = try FileHandle(forWritingTo: sessionURL) + try handle.seekToEnd() + try handle.write(contentsOf: Data(appendedLine.utf8)) + try handle.close() + try FileManager.default.setAttributes([.modificationDate: appendedAt], ofItemAtPath: sessionURL.path) + + options.maxCodexScanDurationPerRefresh = .leastNonzeroMagnitude + let partial = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: appendedAt, + historyDays: 30, + includePiSessions: false, + scannerOptions: options) + let pendingCache = CostUsageStoreAccess.read(cacheRoot: env.cacheRoot) + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: appendedAt, + historyDays: 30, + includePiSessions: false, + scannerOptions: options) + let narrowSince = try #require(options.calendar.date(byAdding: .day, value: -29, to: day)) + let wideSince = try #require(options.calendar.date(byAdding: .day, value: -364, to: day)) + let narrowRange = CostUsageScanner.CostUsageDayRange( + since: narrowSince, + until: appendedAt, + calendar: options.calendar) + let wideRange = CostUsageScanner.CostUsageDayRange( + since: wideSince, + until: appendedAt, + calendar: options.calendar) + let rootsFingerprint = CostUsageScanner.codexRootsFingerprint(options: options) + let previous = try #require(pendingCache.codexPreviousReport) + + #expect(!partial.historyCoverageIsEstablished) + #expect(partial.last30DaysTokens == 42) + #expect(pendingCache.codexScanCatchUpPending == true) + #expect(previous.report.data.map(\.date) == ["2026-04-08"]) + #expect(previous.scanSinceKey == narrowRange.sinceKey) + #expect(previous.scanUntilKey == narrowRange.untilKey) + #expect(CostUsageScanner.codexPreviousReport( + cache: pendingCache, + range: narrowRange, + rootsFingerprint: rootsFingerprint) != nil) + #expect(CostUsageScanner.codexPreviousReport( + cache: pendingCache, + range: wideRange, + rootsFingerprint: rootsFingerprint) == nil) + #expect(cached?.snapshot.historyCoverageIsEstablished == true) + #expect(cached?.snapshot.last30DaysTokens == 42) + #expect(cached?.staleSnapshotUpdatedAt == established.updatedAt) + #expect(cached?.lastRefreshAt == nil) + } + @Test func `cached codex token snapshot keeps the cache scan time as updatedAt`() async throws { let env = try CostUsageTestEnvironment() @@ -702,12 +805,13 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(travelled == nil) } + @discardableResult private static func writeCodexSessionFile( homeRoot: URL, env: CostUsageTestEnvironment, day: Date, filename: String, - tokens: Int) throws + tokens: Int) throws -> URL { let comps = Calendar.current.dateComponents([.year, .month, .day], from: day) let dir = homeRoot @@ -741,6 +845,7 @@ struct CostUsageFetcherCacheSnapshotTests { ], ], ]).write(to: url, atomically: true, encoding: .utf8) + return url } private static func writePiCodexSessionFile( diff --git a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift index 41b082c9f7..efaa0e4704 100644 --- a/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift +++ b/Tests/CodexBarTests/CostUsagePerformanceGateTests.swift @@ -1091,7 +1091,11 @@ struct CostUsagePerformanceGateTests { rebuildingCache.timeZoneIdentifier = options.calendar.timeZone.identifier rebuildingCache.roots = priorCache.roots rebuildingCache.codexScanCatchUpPending = true - rebuildingCache.codexPreviousReport = CostUsageCodexPreviousReport(report: priorReport, cache: priorCache) + rebuildingCache.codexPreviousReport = CostUsageCodexPreviousReport( + report: priorReport, + cache: priorCache, + reportSinceKey: range.sinceKey, + reportUntilKey: range.untilKey) CostUsageStoreAccess.replace(cacheRoot: env.cacheRoot, cache: rebuildingCache) var report = CostUsageScanner.loadDailyReport( provider: .codex, diff --git a/Tests/CodexBarTests/CostUsageStoreTests.swift b/Tests/CodexBarTests/CostUsageStoreTests.swift index 6b4b87a67e..8d54bb4bc0 100644 --- a/Tests/CodexBarTests/CostUsageStoreTests.swift +++ b/Tests/CodexBarTests/CostUsageStoreTests.swift @@ -445,7 +445,9 @@ extension CostUsageStoreTests { modelsUsed: nil, modelBreakdowns: nil), ], summary: nil), - cache: cache) + cache: cache, + reportSinceKey: "2026-08-01", + reportUntilKey: "2026-08-01") func save(_ cache: CostUsageCache) { _ = store.syncSaveCodexCache( @@ -1569,7 +1571,9 @@ extension CostUsageStoreTests { modelsUsed: nil, modelBreakdowns: nil), ], summary: nil), - cache: cache) + cache: cache, + reportSinceKey: "2026-06-01", + reportUntilKey: "2026-07-01") let result = store.syncSaveCodexCache( cache, diff --git a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift index f41c554983..d38195942f 100644 --- a/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift +++ b/Tests/CodexBarTests/SpendDashboardCachedPresentationTests.swift @@ -1,5 +1,6 @@ import CodexBarCore import Foundation +import os.lock import Testing @testable import CodexBar @@ -106,6 +107,113 @@ struct SpendDashboardCachedPresentationTests { #expect(controller.model.groups.isEmpty) } + @Test + func `successful incomplete Codex refresh keeps the retained established total`() async { + let gate = SpendDashboardCachedLoaderGate() + let configuration = Self.configuration(account: "account|cache") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input( + id: "codex:account", + cost: 9, + historyCoverageIsEstablished: false)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.failedSourceCount == 0) + #expect(controller.model.groups.first?.totalCost == 3) + #expect(Set(controller.model.groups.flatMap(\.providers).map(\.id)) == ["codex:account"]) + } + + @Test + func `incomplete Codex refresh rejects a retained total from another bucket time zone`() async { + let gate = SpendDashboardCachedLoaderGate() + let utc = Self.configuration(account: "account|cache", bucketTimeZoneIdentifier: "UTC") + let pacific = Self.configuration( + account: "account|cache", + bucketTimeZoneIdentifier: "America/Los_Angeles") + let activeConfiguration = OSAllocatedUnfairLock(initialState: utc) + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request( + configuration: activeConfiguration.withLock { $0 }, + force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: utc) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.model.groups.first?.totalCost == 3) + + activeConfiguration.withLock { $0 = pacific } + controller.update(configuration: pacific) + await gate.resume(at: 0, result: .init(inputs: [], failedSourceIDs: [])) + await Self.waitForPendingCount(1, gate: gate) + await gate.resume(at: 0, result: .init( + inputs: [Self.input( + id: "codex:account", + cost: 9, + historyCoverageIsEstablished: false)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.failedSourceCount == 0) + #expect(controller.configuration?.bucketCalendar.timeZone.identifier == "America/Los_Angeles") + #expect(controller.model.groups.first?.timeZone.identifier == "America/Los_Angeles") + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.providers.first?.totalCost == nil) + } + + @Test + func `incomplete Codex refresh rejects a retained total from another history window`() async { + let gate = SpendDashboardCachedLoaderGate() + let configuration = Self.configuration(account: "account|cache") + let controller = SpendDashboardController( + requestBuilder: { mode in + Self.request(configuration: configuration, force: mode.forcesLoader) + }, + cachedLoader: { _ in + SpendDashboardLoadResult( + inputs: [Self.input(id: "codex:account", cost: 3, historyDays: 30)], + failedSourceIDs: []) + }, + loader: { request in await gate.load(request) }) + + controller.update(configuration: configuration) + await Self.waitForPendingCount(1, gate: gate) + #expect(controller.model.groups.first?.totalCost == 3) + + await gate.resume(at: 0, result: .init( + inputs: [Self.input( + id: "codex:account", + cost: 9, + historyDays: SpendDashboardSource.scanDays, + historyCoverageIsEstablished: false)], + failedSourceIDs: [])) + await Self.waitUntil { !controller.isRefreshing } + + #expect(controller.failedSourceCount == 0) + #expect(controller.model.groups.first?.totalCost == nil) + #expect(controller.model.groups.first?.providers.first?.totalCost == nil) + } + @Test func `cached Codex totals stay bound to their account cache`() async { let first = Self.scanRequest(id: "first", cacheIdentity: "first-cache") @@ -172,11 +280,15 @@ struct SpendDashboardCachedPresentationTests { private nonisolated static let fixtureNow = Date(timeIntervalSince1970: 1_784_179_200) - private static func configuration(account: String) -> SpendDashboardConfiguration { + private static func configuration( + account: String, + bucketTimeZoneIdentifier: String = "") -> SpendDashboardConfiguration + { SpendDashboardConfiguration( costUsageEnabled: true, providerIDs: [UsageProvider.codex.rawValue], - codexAccountIdentities: [account]) + codexAccountIdentities: [account], + bucketTimeZoneIdentifier: bucketTimeZoneIdentifier) } private static func request( @@ -209,7 +321,9 @@ struct SpendDashboardCachedPresentationTests { private nonisolated static func input( id: String? = nil, - cost: Double) -> SpendDashboardModel.ProviderInput + cost: Double, + historyDays: Int = 30, + historyCoverageIsEstablished: Bool = true) -> SpendDashboardModel.ProviderInput { let entry = CostUsageDailyReport.Entry( date: "2026-07-15", @@ -224,6 +338,8 @@ struct SpendDashboardCachedPresentationTests { sessionCostUSD: nil, last30DaysTokens: 10, last30DaysCostUSD: cost, + historyDays: historyDays, + historyCoverageIsEstablished: historyCoverageIsEstablished, daily: [entry], updatedAt: Self.fixtureNow) return SpendDashboardModel.ProviderInput( diff --git a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift index c2641ff963..a83c7b2b9b 100644 --- a/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift +++ b/Tests/CodexBarTests/UsageStoreCodexCostCatchUpTests.swift @@ -6,6 +6,48 @@ import Testing @MainActor @Suite(.serialized) struct UsageStoreCodexCostCatchUpTests { + @Test + func `incomplete refresh cannot replace an established same-scope snapshot`() throws { + let store = try Self.makeStore(suite: "retains-established") + store.publishTokenSnapshot(Self.tokenSnapshot(cost: 3, now: Date()), for: .codex) + let establishedRevision = store.tokenSnapshotPublicationRevision(for: .codex) + + store.publishTokenSnapshot( + Self.tokenSnapshot( + cost: 9, + now: Date().addingTimeInterval(1), + historyCoverageIsEstablished: false), + for: .codex) + + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 3) + #expect(store.tokenSnapshot(for: .codex)?.historyCoverageIsEstablished == true) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == establishedRevision) + + store.publishTokenSnapshot( + Self.tokenSnapshot(cost: 4, now: Date().addingTimeInterval(2)), + for: .codex) + + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 4) + #expect(store.tokenSnapshotPublicationRevision(for: .codex) == establishedRevision + 1) + } + + @Test + func `incomplete refresh does not retain an established snapshot from another scope`() throws { + let store = try Self.makeStore(suite: "scope-change") + store.publishTokenSnapshot(Self.tokenSnapshot(cost: 3, now: Date()), for: .codex) + + store.settings.costUsageHistoryDays = 7 + store.publishTokenSnapshot( + Self.tokenSnapshot( + cost: 9, + now: Date().addingTimeInterval(1), + historyCoverageIsEstablished: false), + for: .codex) + + #expect(store.tokenSnapshot(for: .codex)?.last30DaysCostUSD == 9) + #expect(store.tokenSnapshot(for: .codex)?.historyCoverageIsEstablished == false) + } + @Test func `bounded catch-up automatically publishes only the final stable snapshot`() async throws { let store = try Self.makeStore(suite: "publishes-final") @@ -328,12 +370,17 @@ struct UsageStoreCodexCostCatchUpTests { environmentBase: [:]) } - private static func tokenSnapshot(cost: Double, now: Date) -> CostUsageTokenSnapshot { + private static func tokenSnapshot( + cost: Double, + now: Date, + historyCoverageIsEstablished: Bool = true) -> CostUsageTokenSnapshot + { CostUsageTokenSnapshot( sessionTokens: 10, sessionCostUSD: cost, last30DaysTokens: 10, last30DaysCostUSD: cost, + historyCoverageIsEstablished: historyCoverageIsEstablished, daily: [CostUsageDailyReport.Entry( date: "2026-07-30", inputTokens: 4,