diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index e1d0e74703..5adf8ad9b7 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -313,7 +313,9 @@ public struct CostUsageFetcher: Sendable { let scoped = CostUsageScanner.codexCache(cache, scopedTo: roots) let progressKey = self.codexScanProgressKey(cache: cache, scopedFiles: scoped.files) - let hasIncompleteFile = scoped.files.values.contains { $0.codexScanComplete == false } + let hasIncompleteFile = scoped.files.values.contains { + $0.codexScanComplete == false || $0.hasBufferedCodexForkRetryLines + } let pending = cache.codexScanCatchUpPending == true || hasIncompleteFile return CodexScanCatchUpStatus( pending: pending, @@ -332,6 +334,33 @@ public struct CostUsageFetcher: Sendable { return !status.pending && status.progressKey != "scope-mismatch" } + private static let establishedEmptyCodexDailyReport = CostUsageDailyReport( + data: [], + summary: CostUsageDailyReport.Summary( + totalInputTokens: 0, + totalOutputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + totalTokens: 0, + totalCostUSD: 0)) + + private static func codexCachedHistoryCoverageIsEstablished( + cache: CostUsageCache, + range: CostUsageScanner.CostUsageDayRange, + rootsFingerprint: [String: Int64]) -> Bool + { + guard cache.lastScanUnixMs > 0, + cache.timeZoneIdentifier == range.calendar.timeZone.identifier, + cache.roots == rootsFingerprint, + cache.codexScanCatchUpPending != true, + !cache.files.values.contains(where: { + $0.codexScanComplete == false || $0.hasBufferedCodexForkRetryLines + }), + !CostUsageScanner.requestedWindowExpandsCache(range: range, cache: cache) + else { return false } + return true + } + private static func resolvedScannerOptions( _ override: CostUsageScanner.Options?, provider: UsageProvider, @@ -723,7 +752,9 @@ public struct CostUsageFetcher: Sendable { guard cache.timeZoneIdentifier == options.calendar.timeZone.identifier, cache.roots == rootsFingerprint, cache.codexScanCatchUpPending != true, - !cache.files.values.contains(where: { $0.codexScanComplete == false }), + !cache.files.values.contains(where: { + $0.codexScanComplete == false || $0.hasBufferedCodexForkRetryLines + }), let cachedSince = cache.scanSinceKey, let cachedUntil = cache.scanUntilKey else { return nil } @@ -809,6 +840,10 @@ public struct CostUsageFetcher: Sendable { var scanTimes: [Date] = [] var piMerged = false var staleSnapshotUpdatedAt: Date? + let nativeHistoryCoverageIsEstablished = Self.codexCachedHistoryCoverageIsEstablished( + cache: cache, + range: range, + rootsFingerprint: rootsFingerprint) if let previous = CostUsageScanner.codexPreviousReport( cache: cache, @@ -852,6 +887,18 @@ public struct CostUsageFetcher: Sendable { } } + // A completed scan can legitimately have no rows (a fresh account or a quiet + // window). Keep that established-empty state across app restarts instead of + // collapsing it back to "unavailable" merely because the cache has no day map. + if reports.isEmpty, nativeHistoryCoverageIsEstablished { + reports.append(Self.establishedEmptyCodexDailyReport) + if cache.lastScanUnixMs > 0 { + let scanAt = Date(timeIntervalSince1970: TimeInterval(cache.lastScanUnixMs) / 1000) + nativeScanAt = scanAt + scanTimes.append(scanAt) + } + } + if includePiSessions, shouldMergePiUsage, let piResult = PiSessionCostScanner.loadCachedDailyReportResult( @@ -1042,10 +1089,13 @@ public struct CostUsageFetcher: Sendable { ? CostUsageTokenSnapshot.entry(in: daily.data, forLocalDayContaining: now, calendar: calendar) : CostUsageTokenSnapshot.latestEntry(in: daily.data) let hasHistoricalRows = !daily.data.isEmpty + let establishedEmptyHistory = historyCoverageIsEstablished && daily.data.isEmpty let sessionTokens: Int? = if let sessionEntry { sessionEntry.totalTokens } else if hasHistoricalRows { 0 + } else if establishedEmptyHistory { + 0 } else { nil } @@ -1053,6 +1103,8 @@ public struct CostUsageFetcher: Sendable { sessionEntry.costUSD } else if hasHistoricalRows { 0 + } else if establishedEmptyHistory { + 0 } else { nil } @@ -1063,14 +1115,16 @@ public struct CostUsageFetcher: Sendable { let totalFromEntries = daily.data.compactMap(\.costUSD).reduce(0, +) let allEntriesCarryCost = !daily.data.isEmpty && daily.data.allSatisfy { $0.costUSD != nil } let last30DaysCostUSD = totalFromSummary - ?? (allEntriesCarryCost ? totalFromEntries : nil) + ?? (allEntriesCarryCost + ? totalFromEntries + : establishedEmptyHistory ? 0 : nil) let totalTokensFromSummary = daily.summary?.totalTokens let totalTokensFromEntries = daily.data.compactMap(\.totalTokens).reduce(0, +) let allEntriesCarryTokens = !daily.data.isEmpty && daily.data.allSatisfy { $0.totalTokens != nil } let last30DaysTokens = totalTokensFromSummary ?? (allEntriesCarryTokens ? totalTokensFromEntries - : nil) + : establishedEmptyHistory ? 0 : nil) return CostUsageTokenSnapshot( sessionTokens: sessionTokens, diff --git a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift index d80e999bf3..3513635453 100644 --- a/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift @@ -75,6 +75,111 @@ struct CostUsageFetcherCacheSnapshotTests { #expect(activity?.daily.isEmpty == true) } + @Test + func `cached codex token snapshot preserves a completed empty history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let scanTime = now.addingTimeInterval(-60) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(scanTime.timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-04-07" + cache.scanUntilKey = "2026-04-09" + cache.timeZoneIdentifier = options.calendar.timeZone.identifier + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + CostUsageStoreAccess.replace( + cacheRoot: env.cacheRoot, + cache: cache, + calendar: options.calendar) + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: now, + historyDays: 1, + includePiSessions: false, + scannerOptions: options) + + #expect(cached?.snapshot.sessionTokens == 0) + #expect(cached?.snapshot.sessionCostUSD == 0) + #expect(cached?.snapshot.last30DaysTokens == 0) + #expect(cached?.snapshot.last30DaysCostUSD == 0) + #expect(cached?.snapshot.historyCoverageIsEstablished == true) + #expect(cached?.lastRefreshAt == scanTime) + } + + @Test + func `cached codex token snapshot refuses an empty history while catch up is pending`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(now.addingTimeInterval(-60).timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-04-07" + cache.scanUntilKey = "2026-04-09" + cache.timeZoneIdentifier = options.calendar.timeZone.identifier + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.codexScanCatchUpPending = true + CostUsageStoreAccess.replace( + cacheRoot: env.cacheRoot, + cache: cache, + calendar: options.calendar) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: now, + historyDays: 1, + includePiSessions: false, + scannerOptions: options) + + #expect(cached == nil) + } + + @Test + func `cached codex token snapshot refuses an empty history with buffered fork retries`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let now = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + let line = CostUsageScanner.CodexBufferedFastLine( + lineIndex: 1, + ordinal: nil, + line: .interAgentCommunication(triggerTurn: false)) + let filePath = env.codexSessionsRoot.appendingPathComponent("fork.jsonl").path + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(now.addingTimeInterval(-60).timeIntervalSince1970 * 1000) + cache.scanSinceKey = "2026-04-07" + cache.scanUntilKey = "2026-04-09" + cache.timeZoneIdentifier = options.calendar.timeZone.identifier + cache.roots = CostUsageScanner.codexRootsFingerprint(options: options) + cache.files[filePath] = CostUsageScanner.makeFileUsage( + mtimeUnixMs: cache.lastScanUnixMs, + size: 1, + days: [:], + parsedBytes: 1, + codexScanComplete: true, + codexBufferedUnresolvedForkLines: [line]) + CostUsageStoreAccess.replace( + cacheRoot: env.cacheRoot, + cache: cache, + calendar: options.calendar) + + let cached = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: now, + historyDays: 1, + includePiSessions: false, + scannerOptions: options) + + #expect(cached == nil) + } + @Test func `cached codex token snapshot loads from existing cache without rescanning`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageFetcherTests.swift b/Tests/CodexBarTests/CostUsageFetcherTests.swift index 014fceaf06..2691ad92e8 100644 --- a/Tests/CodexBarTests/CostUsageFetcherTests.swift +++ b/Tests/CodexBarTests/CostUsageFetcherTests.swift @@ -56,6 +56,31 @@ struct CostUsageFetcherTests { } extension CostUsageFetcherTests { + @Test + func `completed empty codex scan publishes known zero totals`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + var options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot) + options.refreshMinIntervalSeconds = 0 + + let snapshot = try await CostUsageFetcher.loadTokenSnapshot( + provider: .codex, + now: day, + historyDays: 1, + includePiSessions: false, + scannerOptions: options) + + #expect(snapshot.historyCoverageIsEstablished) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.last30DaysTokens == 0) + #expect(snapshot.last30DaysCostUSD == 0) + } + @Test func `codex history coverage follows pending catch up`() async throws { let env = try CostUsageTestEnvironment() diff --git a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift index df4c35deb8..8263556927 100644 --- a/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift +++ b/Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift @@ -191,6 +191,36 @@ struct CostUsageTokenSnapshotDaySelectionTests { #expect(snapshot.last30DaysTokens == 0) } + @Test + func `token snapshot reports known zero for an established empty history`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let snapshot = CostUsageFetcher.tokenSnapshot( + from: CostUsageDailyReport(data: [], summary: nil), + now: now, + historyCoverageIsEstablished: true) + + #expect(snapshot.sessionCostUSD == 0) + #expect(snapshot.sessionTokens == 0) + #expect(snapshot.last30DaysCostUSD == 0) + #expect(snapshot.last30DaysTokens == 0) + #expect(snapshot.historyCoverageIsEstablished) + } + + @Test + func `token snapshot keeps an unestablished empty history unavailable`() throws { + let now = try Self.localNoon(year: 2026, month: 5, day: 18) + let snapshot = CostUsageFetcher.tokenSnapshot( + from: CostUsageDailyReport(data: [], summary: nil), + now: now, + historyCoverageIsEstablished: false) + + #expect(snapshot.sessionCostUSD == nil) + #expect(snapshot.sessionTokens == nil) + #expect(snapshot.last30DaysCostUSD == nil) + #expect(snapshot.last30DaysTokens == nil) + #expect(!snapshot.historyCoverageIsEstablished) + } + @Test func `token snapshot does not report a partial cost from mixed present and missing rows`() throws { let now = try Self.localNoon(year: 2026, month: 5, day: 18) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 3f08cc9ebe..e239cd8bf5 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1353,19 +1353,19 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 708, + line: 737, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 783, + line: 814, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 858, + line: 905, anchor: "provider: .codex,", expectedProviderIDs: ["codex"], reason: "This provider-specific core branch passes its already-selected identity to a shared helper."), @@ -3385,7 +3385,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 532, + line: 561, anchor: "if provider == .codex {", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -3393,7 +3393,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 560, + line: 589, anchor: "provider == .claude || (provider == .codex && options.shouldMergePiUsage)", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 5, @@ -3401,7 +3401,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 607, + line: 636, anchor: "options.provider == .codex || options.provider == .claude", expectedProviderIDs: ["claude", "codex"], expectedReferenceCount: 2, @@ -3409,7 +3409,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 634, + line: 663, anchor: "guard provider == .codex || provider == .claude else { return nil }", expectedProviderIDs: ["claude", "codex", "openai"], expectedReferenceCount: 5, @@ -3417,7 +3417,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1115, + line: 1169, anchor: "if provider == .vertexai {", expectedProviderIDs: ["claude", "vertexai"], expectedReferenceCount: 2, @@ -3425,7 +3425,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact cost scanner dispatch selects a provider-owned transcript, cache, or pricing format."), AllowedProviderConstruct( path: "Sources/CodexBarCore/CostUsageFetcher.swift", - line: 1470, + line: 1524, anchor: "if provider == .cursor {", expectedProviderIDs: ["cursor"], expectedReferenceCount: 1,