Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions Sources/CodexBarCore/CostUsageFetcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1042,17 +1089,22 @@ 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
}
let sessionCostUSD: Double? = if let sessionEntry {
sessionEntry.costUSD
} else if hasHistoricalRows {
0
} else if establishedEmptyHistory {
0
} else {
nil
}
Expand All @@ -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,
Expand Down
105 changes: 105 additions & 0 deletions Tests/CodexBarTests/CostUsageFetcherCacheSnapshotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
25 changes: 25 additions & 0 deletions Tests/CodexBarTests/CostUsageFetcherTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
30 changes: 30 additions & 0 deletions Tests/CodexBarTests/CostUsageTokenSnapshotDaySelectionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 9 additions & 9 deletions Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand Down Expand Up @@ -3385,47 +3385,47 @@ 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,
expectedReferenceFingerprint: ["codex@0"],
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,
expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@10", "codex@15", "codex@27"],
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,
expectedReferenceFingerprint: ["claude@0", "codex@0"],
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,
expectedReferenceFingerprint: ["claude@0", "codex@0", "codex@4", "codex@15", "openai@15"],
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,
expectedReferenceFingerprint: ["vertexai@0", "claude@2"],
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,
Expand Down
Loading