From cf7b74913366562ec24d7babcf076ea22c9dd201 Mon Sep 17 00:00:00 2001 From: Aaron Date: Thu, 23 Jul 2026 14:02:18 -0700 Subject: [PATCH 1/2] fix: overlay authoritative OpenCode Go web windows onto local usage --- .../OpenCodeGoProviderDescriptor.swift | 77 +++++- .../OpenCodeGo/OpenCodeGoUsageSnapshot.swift | 25 ++ .../OpenCodeGoWebOverlayTests.swift | 254 ++++++++++++++++++ docs/opencode.md | 4 + 4 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index bca4fafed5..c118f0f818 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -84,33 +84,66 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { let id: String = "opencodego.local" let kind: ProviderFetchKind = .localProbe + typealias LocalSnapshotLoader = @Sendable (ProviderFetchContext) throws -> OpenCodeGoUsageSnapshot + typealias WebUsageOverlayFetcher = @Sendable (ProviderFetchContext, String) async throws + -> OpenCodeGoUsageSnapshot? + + private let localSnapshotLoader: LocalSnapshotLoader + private let webUsageOverlayFetcher: WebUsageOverlayFetcher + + init( + localSnapshotLoader: @escaping LocalSnapshotLoader = { context in + try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) + }, + webUsageOverlayFetcher: @escaping WebUsageOverlayFetcher = Self.liveWebUsageOverlay) + { + self.localSnapshotLoader = localSnapshotLoader + self.webUsageOverlayFetcher = webUsageOverlayFetcher + } + func isAvailable(_: ProviderFetchContext) async -> Bool { true } func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { - let snapshot = try await self.snapshot(context: context) + let (snapshot, overlaid) = try await self.snapshot(context: context) return self.makeResult( usage: snapshot.toUsageSnapshot(), - sourceLabel: "local") + sourceLabel: overlaid ? "local+web" : "local") } func shouldFallback(on error: Error, context _: ProviderFetchContext) -> Bool { error is OpenCodeGoLocalUsageError } - private func snapshot(context: ProviderFetchContext) async throws -> OpenCodeGoUsageSnapshot { - let snapshot = try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) - guard context.includeOptionalUsage, - context.settings?.opencodego?.cookieSource != .off + private func snapshot(context: ProviderFetchContext) async throws -> (OpenCodeGoUsageSnapshot, Bool) { + let snapshot = try self.localSnapshotLoader(context) + guard context.settings?.opencodego?.cookieSource != .off, + let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { - return snapshot + return (snapshot, false) } - guard let cookieHeader = Self.cachedOrManualCookieHeader(context: context) else { - return snapshot + // The server knows the real billing-cycle anchors; the local monthly window is only an + // estimate anchored at the earliest local row. Overlay the authoritative web windows + // whenever a session cookie is already available (never a fresh browser import here). + // URLSession reports task cancellation as URLError.cancelled, so normalize it here to + // keep a cancelled refresh from completing with a successful local-only result. + let webSnapshot: OpenCodeGoUsageSnapshot? + do { + webSnapshot = try await self.webUsageOverlayFetcher(context, cookieHeader) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } + if let webSnapshot { + return (snapshot.applyingWebUsage(webSnapshot), true) } + guard context.includeOptionalUsage else { + return (snapshot, false) + } let workspaceOverride = context.settings?.opencodego?.workspaceID ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] let zenBalanceTask = Task { @@ -126,7 +159,31 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } } let zenBalance = try await OpenCodeGoUsageFetcher.completedOptionalZenBalance(from: zenBalanceTask) - return snapshot.withZenBalanceUSD(zenBalance) + return (snapshot.withZenBalanceUSD(zenBalance), false) + } + + static func liveWebUsageOverlay( + context: ProviderFetchContext, + cookieHeader: String) async throws -> OpenCodeGoUsageSnapshot? + { + let workspaceOverride = context.settings?.opencodego?.workspaceID + ?? context.env["CODEXBAR_OPENCODEGO_WORKSPACE_ID"] + do { + return try await OpenCodeGoUsageFetcher.fetchUsage( + cookieHeader: cookieHeader, + timeout: context.webTimeout, + workspaceIDOverride: workspaceOverride, + includeZenBalance: context.includeOptionalUsage) + } catch is CancellationError { + throw CancellationError() + } catch let error as URLError where error.code == .cancelled { + throw CancellationError() + } catch { + if Task.isCancelled { + throw CancellationError() + } + return nil + } } private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift index dd865b87f6..6f0adc4ebe 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoUsageSnapshot.swift @@ -138,6 +138,31 @@ public struct OpenCodeGoUsageSnapshot: Sendable { return copy } + /// Replaces the locally estimated usage windows with the server-reported (authoritative) + /// ones while keeping local-only data such as the daily cost history. The local monthly + /// window is anchored at the earliest local row, which can drift far from the real billing + /// cycle (wrong percentage and reset countdown), so whenever web usage is available its + /// percentages and reset countdowns win. A balance-only web response carries no windows + /// and must not clobber the local estimate. + public func applyingWebUsage(_ web: OpenCodeGoUsageSnapshot) -> OpenCodeGoUsageSnapshot { + guard !web.isBalanceOnly else { + return self.withZenBalanceUSD(web.zenBalanceUSD ?? self.zenBalanceUSD) + } + return OpenCodeGoUsageSnapshot( + hasWeeklyUsage: web.hasWeeklyUsage, + hasMonthlyUsage: web.hasMonthlyUsage, + rollingUsagePercent: web.rollingUsagePercent, + weeklyUsagePercent: web.weeklyUsagePercent, + monthlyUsagePercent: web.monthlyUsagePercent, + rollingResetInSec: web.rollingResetInSec, + weeklyResetInSec: web.weeklyResetInSec, + monthlyResetInSec: web.monthlyResetInSec, + zenBalanceUSD: web.zenBalanceUSD ?? self.zenBalanceUSD, + renewsAt: web.renewsAt ?? self.renewsAt, + daily: self.daily, + updatedAt: self.updatedAt) + } + public func withDaily(_ daily: [CostUsageDailyReport.Entry]) -> OpenCodeGoUsageSnapshot { var copy = self copy.daily = daily diff --git a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift new file mode 100644 index 0000000000..b4cf733e5e --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift @@ -0,0 +1,254 @@ +import Foundation +import Testing +@testable import CodexBarCore + +struct OpenCodeGoWebOverlayTests { + private static let updatedAt = Date(timeIntervalSince1970: 1_784_836_525) + private static let renewsAt = Date(timeIntervalSince1970: 1_786_550_400) + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [Value] = [] + + func append(_ value: Value) { + self.lock.lock() + defer { self.lock.unlock() } + self.storage.append(value) + } + + var values: [Value] { + self.lock.lock() + defer { self.lock.unlock() } + return self.storage + } + } + + private struct StubClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ClaudeUsageError.parseFailed("stub") + } + + func debugRawProbe(model _: String) async -> String { + "stub" + } + + func detectVersion() -> String? { + nil + } + } + + private static func dailyEntry() -> CostUsageDailyReport.Entry { + CostUsageDailyReport.Entry( + date: "2026-07-20", + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + requestCount: 748, + costUSD: 11.52, + modelsUsed: nil, + modelBreakdowns: nil) + } + + /// Mirrors the mis-anchored local estimate: earliest local row far before the real billing + /// cycle, so the monthly window sums more than the $60 plan limit and clamps to 100%. + private static func localEstimate(zenBalanceUSD: Double? = nil) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 49.4, + monthlyUsagePercent: 100, + rollingResetInSec: 18000, + weeklyResetInSec: 266_400, + monthlyResetInSec: 266_400, + zenBalanceUSD: zenBalanceUSD, + daily: [self.dailyEntry()], + updatedAt: self.updatedAt) + } + + private static func webUsage(zenBalanceUSD: Double? = nil) -> OpenCodeGoUsageSnapshot { + OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 0, + weeklyUsagePercent: 52, + monthlyUsagePercent: 64, + rollingResetInSec: 18000, + weeklyResetInSec: 266_400, + monthlyResetInSec: 1_539_000, + zenBalanceUSD: zenBalanceUSD, + renewsAt: self.renewsAt, + updatedAt: self.updatedAt.addingTimeInterval(2)) + } + + private func makeContext( + includeOptionalUsage: Bool = true, + settings: ProviderSettingsSnapshot? = nil) -> ProviderFetchContext + { + ProviderFetchContext( + runtime: .app, + sourceMode: .auto, + includeCredits: false, + includeOptionalUsage: includeOptionalUsage, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: [:], + settings: settings, + fetcher: UsageFetcher(environment: [:]), + claudeFetcher: StubClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private func makeManualCookieSettings() -> ProviderSettingsSnapshot { + ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .manual, + manualCookieHeader: "auth=test", + workspaceID: nil)) + } + + @Test + func `overlay replaces estimated windows with server values and keeps local daily`() { + let merged = Self.localEstimate().applyingWebUsage(Self.webUsage(zenBalanceUSD: 42.5)) + + #expect(merged.rollingUsagePercent == 0) + #expect(merged.weeklyUsagePercent == 52) + #expect(merged.monthlyUsagePercent == 64) + #expect(merged.monthlyResetInSec == 1_539_000) + #expect(merged.hasWeeklyUsage) + #expect(merged.hasMonthlyUsage) + #expect(merged.zenBalanceUSD == 42.5) + #expect(merged.renewsAt == Self.renewsAt) + #expect(merged.daily.count == 1) + #expect(merged.daily.first?.costUSD == 11.52) + #expect(merged.updatedAt == Self.updatedAt) + #expect(!merged.isBalanceOnly) + } + + @Test + func `overlay keeps local zen balance when web usage has none`() { + let merged = Self.localEstimate(zenBalanceUSD: 7.25).applyingWebUsage(Self.webUsage()) + + #expect(merged.zenBalanceUSD == 7.25) + #expect(merged.monthlyUsagePercent == 64) + } + + @Test + func `overlay keeps local renewal date when web usage has none`() { + let local = Self.localEstimate() + let merged = local.applyingWebUsage(Self.webUsage()) + + #expect(merged.renewsAt == Self.renewsAt) + let webWithoutRenewal = OpenCodeGoUsageSnapshot( + hasMonthlyUsage: true, + rollingUsagePercent: 1, + weeklyUsagePercent: 2, + monthlyUsagePercent: 3, + rollingResetInSec: 1, + weeklyResetInSec: 2, + monthlyResetInSec: 3, + renewsAt: nil, + updatedAt: Self.updatedAt) + #expect(local.applyingWebUsage(webWithoutRenewal).renewsAt == nil) + } + + @Test + func `balance only web response keeps local windows and adopts balance`() { + let web = OpenCodeGoUsageSnapshot.zenBalanceOnly(balanceUSD: 42.5, updatedAt: Self.updatedAt) + let merged = Self.localEstimate().applyingWebUsage(web) + + #expect(merged.monthlyUsagePercent == 100) + #expect(merged.monthlyResetInSec == 266_400) + #expect(merged.zenBalanceUSD == 42.5) + #expect(merged.daily.count == 1) + #expect(!merged.isBalanceOnly) + } + + @Test + func `overlaid snapshot projects server monthly window into usage snapshot`() { + let merged = Self.localEstimate().applyingWebUsage(Self.webUsage()) + let usage = merged.toUsageSnapshot() + + #expect(usage.primary?.usedPercent == 0) + #expect(usage.secondary?.usedPercent == 52) + #expect(usage.tertiary?.usedPercent == 64) + #expect(usage.tertiary?.resetsAt == Self.updatedAt.addingTimeInterval(1_539_000)) + #expect(usage.opencodegoUsage?.daily.count == 1) + #expect(usage.extraRateWindows?.contains { $0.id == "renewal" } == true) + } + + @Test + func `local strategy overlays authoritative web usage when a cookie is configured`() async throws { + let observedCookies = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + observedCookies.append(cookieHeader) + return Self.webUsage(zenBalanceUSD: 42.5) + }) + + let result = try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + + #expect(result.sourceLabel == "local+web") + #expect(observedCookies.values == ["auth=test"]) + #expect(result.usage.tertiary?.usedPercent == 64) + #expect(result.usage.secondary?.usedPercent == 52) + #expect(result.usage.opencodegoUsage?.daily.count == 1) + #expect(result.usage.providerCost?.used == 42.5) + } + + @Test + func `local strategy keeps local estimate when web overlay is unavailable`() async throws { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in nil }) + + let result = try await strategy.fetch(self.makeContext( + includeOptionalUsage: false, + settings: self.makeManualCookieSettings())) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + } + + @Test + func `local strategy does not consult web usage when cookies are disabled`() async throws { + let webCalls = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + webCalls.append(cookieHeader) + return Self.webUsage() + }) + let settings = ProviderSettingsSnapshot.make(opencodego: .init( + cookieSource: .off, + manualCookieHeader: nil, + workspaceID: nil)) + + let result = try await strategy.fetch(self.makeContext(settings: settings)) + + #expect(webCalls.values.isEmpty) + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + } + + @Test + func `local strategy propagates cancellation from the web overlay`() async { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw CancellationError() }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + } + } + + @Test + func `local strategy propagates url session cancellation from the web overlay`() async { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw URLError(.cancelled) }) + + await #expect(throws: CancellationError.self) { + try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) + } + } +} diff --git a/docs/opencode.md b/docs/opencode.md index 886f00e60a..1a8c80c3e6 100644 --- a/docs/opencode.md +++ b/docs/opencode.md @@ -32,6 +32,10 @@ read_when: - OpenCode Go unscoped Auto mode tries quota windows and daily cost history derived from local `opencode-go` assistant costs first, then falls back to web usage when local history is unavailable. Auto stays web-first when a token account, manual cookie, or workspace override scopes the request, because local history is device-wide. +- The local monthly window is an estimate anchored at the earliest local row and can drift from the real billing + cycle. When a cached or manual session cookie is available, the local strategy overlays the server-reported + rolling/weekly/monthly percentages and reset countdowns (plus Zen balance) onto the local snapshot, keeping the + local daily cost history. This path never triggers a fresh browser import. - OpenCode Go cost history chart: `opencode.ai` has no daily-granularity endpoint, so per-day cost/request buckets come from local `opencode-go` assistant costs in `opencode.db`, keyed by device-local calendar day. Successful web usage remains workspace-scoped and is never blended with device-wide local costs, so it does not show cost history. From fa05d655ba624e310c2bd60d5cca38ea67e5520a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 09:13:59 -0700 Subject: [PATCH 2/2] fix: evict invalid OpenCode Go cookie cache --- .../OpenCodeGoProviderDescriptor.swift | 36 ++++++++-- .../OpenCodeGoWebOverlayTests.swift | 68 +++++++++++++++++++ 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift index c118f0f818..ef52e0bf32 100644 --- a/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/OpenCodeGo/OpenCodeGoProviderDescriptor.swift @@ -91,6 +91,11 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { private let localSnapshotLoader: LocalSnapshotLoader private let webUsageOverlayFetcher: WebUsageOverlayFetcher + private struct OverlayCookie { + let header: String + let cachedEntry: CookieHeaderCache.Entry? + } + init( localSnapshotLoader: @escaping LocalSnapshotLoader = { context in try OpenCodeGoLocalUsageReader().fetch(historyDays: context.costUsageHistoryDays) @@ -119,7 +124,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { private func snapshot(context: ProviderFetchContext) async throws -> (OpenCodeGoUsageSnapshot, Bool) { let snapshot = try self.localSnapshotLoader(context) guard context.settings?.opencodego?.cookieSource != .off, - let cookieHeader = Self.cachedOrManualCookieHeader(context: context) + let cookie = Self.cachedOrManualCookie(context: context) else { return (snapshot, false) } @@ -131,11 +136,20 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { // keep a cancelled refresh from completing with a successful local-only result. let webSnapshot: OpenCodeGoUsageSnapshot? do { - webSnapshot = try await self.webUsageOverlayFetcher(context, cookieHeader) + webSnapshot = try await self.webUsageOverlayFetcher(context, cookie.header) + } catch OpenCodeGoUsageError.invalidCredentials { + #if os(macOS) + if let cached = cookie.cachedEntry { + _ = CookieHeaderCache.clearIfCurrent(provider: .opencodego, expected: cached) + } + #endif + return (snapshot, false) } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { throw CancellationError() + } catch { + return (snapshot, false) } if let webSnapshot { return (snapshot.applyingWebUsage(webSnapshot), true) @@ -149,7 +163,7 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { let zenBalanceTask = Task { do { return try await OpenCodeGoUsageFetcher.fetchOptionalZenBalance( - cookieHeader: cookieHeader, + cookieHeader: cookie.header, timeout: context.webTimeout, workspaceIDOverride: workspaceOverride) } catch is CancellationError { @@ -174,6 +188,8 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { timeout: context.webTimeout, workspaceIDOverride: workspaceOverride, includeZenBalance: context.includeOptionalUsage) + } catch OpenCodeGoUsageError.invalidCredentials { + throw OpenCodeGoUsageError.invalidCredentials } catch is CancellationError { throw CancellationError() } catch let error as URLError where error.code == .cancelled { @@ -186,14 +202,20 @@ struct OpenCodeGoLocalUsageFetchStrategy: ProviderFetchStrategy { } } - private static func cachedOrManualCookieHeader(context: ProviderFetchContext) -> String? { + private static func cachedOrManualCookie(context: ProviderFetchContext) -> OverlayCookie? { if let settings = context.settings?.opencodego, settings.cookieSource == .manual { - return OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) + guard let header = OpenCodeWebCookieSupport.requestCookieHeader(from: settings.manualCookieHeader) else { + return nil + } + return OverlayCookie(header: header, cachedEntry: nil) } #if os(macOS) - guard let cached = CookieHeaderCache.load(provider: .opencodego) else { return nil } - return OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + let observation = CookieHeaderCache.observeForConditionalMutation(provider: .opencodego) + guard let cached = observation.entry, + let header = OpenCodeWebCookieSupport.requestCookieHeader(from: cached.cookieHeader) + else { return nil } + return OverlayCookie(header: header, cachedEntry: cached) #else return nil #endif diff --git a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift index b4cf733e5e..817ee64e67 100644 --- a/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift +++ b/Tests/CodexBarTests/OpenCodeGoWebOverlayTests.swift @@ -2,6 +2,7 @@ import Foundation import Testing @testable import CodexBarCore +@Suite(.serialized) struct OpenCodeGoWebOverlayTests { private static let updatedAt = Date(timeIntervalSince1970: 1_784_836_525) private static let renewsAt = Date(timeIntervalSince1970: 1_786_550_400) @@ -251,4 +252,71 @@ struct OpenCodeGoWebOverlayTests { try await strategy.fetch(self.makeContext(settings: self.makeManualCookieSettings())) } } + + #if os(macOS) + @Test + func `local strategy evicts cached cookie after authentication failure`() async throws { + try await self.withCachedCookie { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw OpenCodeGoUsageError.invalidCredentials }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + #expect(CookieHeaderCache.load(provider: .opencodego) == nil) + } + } + + @Test + func `local strategy retains cached cookie after transport failure`() async throws { + try await self.withCachedCookie { + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, _ in throw URLError(.timedOut) }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local") + #expect(result.usage.tertiary?.usedPercent == 100) + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "auth=cached-session") + } + } + + @Test + func `local strategy reuses valid cached cookie`() async throws { + try await self.withCachedCookie { + let observedCookies = Recorder() + let strategy = OpenCodeGoLocalUsageFetchStrategy( + localSnapshotLoader: { _ in Self.localEstimate() }, + webUsageOverlayFetcher: { _, cookieHeader in + observedCookies.append(cookieHeader) + return Self.webUsage() + }) + + let result = try await strategy.fetch(self.makeContext(includeOptionalUsage: false)) + + #expect(result.sourceLabel == "local+web") + #expect(result.usage.tertiary?.usedPercent == 64) + #expect(observedCookies.values == ["auth=cached-session"]) + #expect(CookieHeaderCache.load(provider: .opencodego)?.cookieHeader == "auth=cached-session") + } + } + + private func withCachedCookie(_ operation: () async throws -> T) async rethrows -> T { + let service = "com.steipete.codexbar.tests.opencodego-overlay.\(UUID().uuidString)" + return try await KeychainCacheStore.withServiceOverrideForTesting(service) { + try await KeychainCacheStore.withImplicitTestStoreForTesting { + CookieHeaderCache.resetDisplayCacheForTesting() + defer { CookieHeaderCache.resetDisplayCacheForTesting() } + CookieHeaderCache.store( + provider: .opencodego, + cookieHeader: "auth=cached-session", + sourceLabel: "Chrome") + return try await operation() + } + } + } + #endif }