From 4e8145e779b1259a6c3073402ff1c5f924a465db Mon Sep 17 00:00:00 2001 From: Daniel930902 Date: Thu, 22 Oct 2026 16:52:59 +0800 Subject: [PATCH 1/2] Add opt-in OpenCode local history on the shared incremental store Reimplements #4574 in the shape requested in its close comment: one shared, fingerprint-gated OpenCodeLocalHistoryStore replaces the separate full-load reader. - Bounded memory: the store retains compact per-turn entries and windows them into day rows at read time; nothing pre-windowed is held. - Cache-only publication: decoded entries persist in the store's own SQLite database (never Codex cost-usage.sqlite, never the OpenCode Go reader's in-memory cache), and cached publications read that durable cache without touching the OpenCode database at all. - Fingerprint gating: scans re-read the database only when its size/mtime/WAL fingerprint changed, keeping the tightened scan I/O budget intact. The ambient merge keeps the #3273 contract: opt-in and default-off, OpenAI-provider assistant records only, stable-ID v1/v2 deduplication, recorded cost surfaced as-is, provenance-labelled sourceRecorded/mixed, and permanent exclusion from scoped CODEX_HOME scans, managed accounts, and the Spend Dashboard. The lane/scope/account-switch behavior from #4574 carries over unchanged. --- .../CodexBar/CostHistoryChartMenuView.swift | 24 +- Sources/CodexBar/MenuCardView+Costs.swift | 22 +- .../PreferencesProviderDetailView.swift | 9 +- .../PreferencesSpendDashboardPane.swift | 1 + .../Codex/CodexProviderImplementation.swift | 17 + .../Codex/UsageStore+CodexAccountState.swift | 15 +- .../Resources/ar.lproj/Localizable.strings | 2 + .../Resources/ca.lproj/Localizable.strings | 2 + .../Resources/de.lproj/Localizable.strings | 2 + .../Resources/en.lproj/Localizable.strings | 2 + .../Resources/es.lproj/Localizable.strings | 2 + .../Resources/fa.lproj/Localizable.strings | 2 + .../Resources/fr.lproj/Localizable.strings | 2 + .../Resources/gl.lproj/Localizable.strings | 2 + .../Resources/id.lproj/Localizable.strings | 2 + .../Resources/it.lproj/Localizable.strings | 2 + .../Resources/ja.lproj/Localizable.strings | 2 + .../Resources/ko.lproj/Localizable.strings | 2 + .../Resources/nl.lproj/Localizable.strings | 2 + .../Resources/pl.lproj/Localizable.strings | 2 + .../Resources/pt-BR.lproj/Localizable.strings | 2 + .../Resources/ru.lproj/Localizable.strings | 2 + .../Resources/sv.lproj/Localizable.strings | 2 + .../Resources/th.lproj/Localizable.strings | 2 + .../Resources/tr.lproj/Localizable.strings | 2 + .../Resources/uk.lproj/Localizable.strings | 2 + .../Resources/vi.lproj/Localizable.strings | 2 + .../zh-Hans.lproj/Localizable.strings | 2 + .../zh-Hant.lproj/Localizable.strings | 2 + Sources/CodexBar/SettingsStore+Defaults.swift | 11 + .../CodexBar/SettingsStore+TokenCost.swift | 17 +- Sources/CodexBar/SettingsStore.swift | 2 + Sources/CodexBar/SettingsStoreState.swift | 1 + Sources/CodexBar/SpendDashboardModel.swift | 4 + .../UsageStore+CodexCostCatchUp.swift | 2 +- .../CodexBar/UsageStore+MenuCardModel.swift | 4 +- .../UsageStore+SpendDashboardTokenCost.swift | 1 + Sources/CodexBar/UsageStore+TokenCost.swift | 28 +- Sources/CodexBar/UsageStore.swift | 5 +- Sources/CodexBarCLI/CLIRenderer.swift | 1 + Sources/CodexBarCore/CostProvenance.swift | 34 +- Sources/CodexBarCore/CostUsageFetcher.swift | 114 ++- Sources/CodexBarCore/CostUsageModels.swift | 47 +- .../OpenCode/OpenCodeLocalHistoryStore.swift | 662 ++++++++++++++ .../OpenCodeLocalHistoryMergeTests.swift | 599 ++++++++++++ .../OpenCodeLocalHistorySettingsTests.swift | 203 +++++ .../OpenCodeLocalHistoryStoreTests.swift | 852 ++++++++++++++++++ .../ProviderArchitectureGatekeeperTests.swift | 4 +- 48 files changed, 2677 insertions(+), 48 deletions(-) create mode 100644 Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalHistoryStore.swift create mode 100644 Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift create mode 100644 Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift create mode 100644 Tests/CodexBarTests/OpenCodeLocalHistoryStoreTests.swift diff --git a/Sources/CodexBar/CostHistoryChartMenuView.swift b/Sources/CodexBar/CostHistoryChartMenuView.swift index fcc41abffdf..5c4b5aa61da 100644 --- a/Sources/CodexBar/CostHistoryChartMenuView.swift +++ b/Sources/CodexBar/CostHistoryChartMenuView.swift @@ -67,6 +67,7 @@ struct CostHistoryChartMenuView: View { } private let provider: UsageProvider + private let costProvenance: CostProvenance private let daily: [DailyEntry] private let totalCostUSD: Double? private let currencyCode: String @@ -96,9 +97,11 @@ struct CostHistoryChartMenuView: View { sessions: [CostUsageSessionBreakdown] = [], hidePersonalInfo: Bool, width: CGFloat, + costProvenance: CostProvenance = .listPriceEstimate, onMetricChanged: (@MainActor (ChartMetric) -> Void)? = nil) { self.provider = provider + self.costProvenance = costProvenance self.daily = daily self.totalCostUSD = totalCostUSD self.currencyCode = currencyCode @@ -338,7 +341,7 @@ struct CostHistoryChartMenuView: View { if self.totalCostUSD != nil || incompleteCount > 0 { VStack(alignment: .leading, spacing: 2) { Text(String( - format: incompleteCount > 0 ? L("Est. subtotal (%@): %@") : L("Est. total (%@): %@"), + format: self.costTotalLabelKey(incompleteCount: incompleteCount), self.windowLabel, self.totalCostUSD.map(self.costString) ?? "—") + UsageFormatter.incompleteUsageSuffix(incompleteCount)) @@ -348,7 +351,7 @@ struct CostHistoryChartMenuView: View { .truncationMode(.head) .frame(height: Self.detailPrimaryLineHeight, alignment: .leading) if let disclaimer = UsageFormatter.incompleteUsageNote(incompleteCount) - ?? Self.estimateDisclaimer(provider: self.provider) + ?? Self.estimateDisclaimer(provider: self.provider, provenance: self.costProvenance) { Text(disclaimer) .help(disclaimer) @@ -410,7 +413,22 @@ struct CostHistoryChartMenuView: View { } } - static func estimateDisclaimer(provider: UsageProvider) -> String? { + // Provider-specific by design: only Codex history can carry source-recorded OpenCode + // costs, so only its chart total label and disclaimer follow provenance like the + // menu-card hint does. + private func costTotalLabelKey(incompleteCount: Int) -> String { + guard self.provider == .codex, self.costProvenance == .sourceRecorded else { + return incompleteCount > 0 ? "Est. subtotal (%@): %@" : "Est. total (%@): %@" + } + return incompleteCount > 0 ? "Recorded subtotal (%@): %@" : "Recorded total (%@): %@" + } + + static func estimateDisclaimer(provider: UsageProvider, provenance: CostProvenance = .listPriceEstimate) -> String? { + if provider == .codex, provenance == .sourceRecorded || provenance == .mixed { + return provenance == .mixed + ? L("Estimated from token usage plus source-recorded OpenCode cost · not a subscription bill") + : L("Source-recorded cost from OpenCode local history · not a subscription bill") + } guard let hint = ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.chartEstimateDisclaimer else { return nil } diff --git a/Sources/CodexBar/MenuCardView+Costs.swift b/Sources/CodexBar/MenuCardView+Costs.swift index 33947a4b57f..64e7fc5f98b 100644 --- a/Sources/CodexBar/MenuCardView+Costs.swift +++ b/Sources/CodexBar/MenuCardView+Costs.swift @@ -291,7 +291,7 @@ extension UsageMenuCardView.Model { let todayIncompleteCount = snapshot.summary(forLastDays: 1, calendar: calendar).incompleteRequestCount let err = (error?.isEmpty ?? true) ? nil : error let hints = [ - Self.tokenUsageHint(provider: provider), + Self.tokenUsageHint(provider: provider, provenance: snapshot.costProvenance), UsageFormatter.incompleteUsageNote(incompleteCount), tokensOnly ? Self.tokenHistoryCoverageHint(snapshot) : nil, ] @@ -339,8 +339,10 @@ extension UsageMenuCardView.Model { UsageFormatter.tokenCountString(totalTokens)) + suffix } - static func tokenUsageHint(provider: UsageProvider) -> String? { - let lines = Self.tokenUsageHintLines(provider: provider) + static func tokenUsageHint( + provider: UsageProvider, + provenance: CostProvenance = .listPriceEstimate) -> String? { + let lines = Self.tokenUsageHintLines(provider: provider, provenance: provenance) return lines.isEmpty ? nil : lines.joined(separator: "\n") } @@ -361,8 +363,18 @@ extension UsageMenuCardView.Model { : L("Local token history is unavailable or incomplete.") } - static func tokenUsageHintLines(provider: UsageProvider) -> [String] { - ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.menuHintLines.map { hint in + static func tokenUsageHintLines( + provider: UsageProvider, + provenance: CostProvenance = .listPriceEstimate) -> [String] + { + // Provider-specific by design: only Codex history can mix source-recorded OpenCode + // costs into an otherwise list-price estimate, so only that hint follows provenance. + if provider == .codex, provenance == .sourceRecorded || provenance == .mixed { + return [provenance == .mixed + ? L("Estimated from token usage plus source-recorded OpenCode cost · not a subscription bill") + : L("Source-recorded cost from OpenCode local history · not a subscription bill")] + } + return ProviderDescriptorRegistry.descriptor(for: provider).tokenCost.menuHintLines.map { hint in switch hint { case let .localized(key): L(key) case .estimate: UsageFormatter.costEstimateHint(provider: provider) diff --git a/Sources/CodexBar/PreferencesProviderDetailView.swift b/Sources/CodexBar/PreferencesProviderDetailView.swift index e8f2a5116a3..a927772c93f 100644 --- a/Sources/CodexBar/PreferencesProviderDetailView.swift +++ b/Sources/CodexBar/PreferencesProviderDetailView.swift @@ -160,11 +160,14 @@ struct ProviderDetailView: View { Section { ProviderTokenUsageInlineView(provider: self.provider, tokenUsage: tokenUsage) } header: { - Text(L(self.store.settings.codexLocalSessionCostLedgerEnabled ? "This Mac" : "Local usage")) + Text(L(self.store.settings.codexLocalSessionCostLedgerEnabled + || self.store.settings.codexIncludeOpenCodeLocalHistory + ? "This Mac" : "Local usage")) } footer: { SettingsSectionFooter(L(self.store.settings.codexLocalSessionCostLedgerEnabled - ? "Local usage is shared across accounts on this Mac." - : "Local usage comes from the current Codex profile.")) + || self.store.settings.codexIncludeOpenCodeLocalHistory + ? "Local usage is shared across accounts on this Mac." + : "Local usage comes from the current Codex profile.")) } } if let diagnostic = self.openAIWebDiagnostic { diff --git a/Sources/CodexBar/PreferencesSpendDashboardPane.swift b/Sources/CodexBar/PreferencesSpendDashboardPane.swift index afc06452ec1..b2d3b0ecd19 100644 --- a/Sources/CodexBar/PreferencesSpendDashboardPane.swift +++ b/Sources/CodexBar/PreferencesSpendDashboardPane.swift @@ -79,6 +79,7 @@ func spendDashboardProvenanceText(_ provenance: CostProvenance) -> String { switch provenance { case .listPriceEstimate: L("List-price equivalent") case .vendorMetered: L("Plan metered") + case .sourceRecorded: L("Source recorded") case .mixed: L("Metered and list-price") case .unknown: L("Spend unavailable") } diff --git a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift index ab1b84f49ba..f8f0d4813f3 100644 --- a/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift +++ b/Sources/CodexBar/Providers/Codex/CodexProviderImplementation.swift @@ -94,6 +94,23 @@ struct CodexProviderImplementation: ProviderImplementation { onChange: nil, onAppDidBecomeActive: nil, onAppearWhenEnabled: nil), + ProviderSettingsToggleDescriptor( + id: "codex-opencode-local-history", + title: "Include OpenCode local history (OpenAI)", + subtitle: [ + "Optional, off by default.", + "Merges machine-local OpenCode usage for OpenAI-provider assistant records from ~/.local/share/opencode/opencode.db into this Mac's ambient Codex history.", + "Reads usage metadata only (read-only SQLite: model, tokens, recorded cost); prompts, responses and auth state are never read.", + "OpenCode records $0 cost for subscription usage; recorded dollars are shown as-is and never treated as billing.", + "Rows are attributed to the model only, never to an account, and are excluded from managed accounts, scoped CODEX_HOME scans and the Spend Dashboard.", + ].joined(separator: " "), + binding: context.binding(\.codexIncludeOpenCodeLocalHistory), + statusText: nil, + actions: [], + isVisible: nil, + onChange: nil, + onAppDidBecomeActive: nil, + onAppearWhenEnabled: nil), ProviderSettingsToggleDescriptor( id: "codex-historical-tracking", title: "Historical tracking", diff --git a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift index 97f25023db7..9ddc99eec8a 100644 --- a/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift +++ b/Sources/CodexBar/Providers/Codex/UsageStore+CodexAccountState.swift @@ -31,7 +31,20 @@ struct CodexAccountScopedRefreshGuard: Equatable { @MainActor extension UsageStore { func accountScopedTokenSnapshot(for provider: UsageProvider) -> CostUsageTokenSnapshot? { - guard provider == .codex, !self.settings.codexLocalSessionCostLedgerEnabled else { + guard provider == .codex else { + return self.tokenSnapshotPublications[provider.instanceID]?.snapshot + } + if self.settings.codexIncludeOpenCodeLocalHistory { + // Ambient history is account-agnostic: an ambient publication stays valid across + // account switches (config-revision churn) as long as the ambient scope itself is + // still selected — but a pre-toggle scoped publication must not leak account rows + // into the machine-local lane, so the scope signature has to match. + guard let publication = self.tokenSnapshotPublications[provider.instanceID], + publication.scopeSignature == self.tokenSnapshotScopeSignature(for: provider) + else { return nil } + return publication.snapshot + } + guard !self.settings.codexLocalSessionCostLedgerEnabled else { return self.tokenSnapshotPublications[provider.instanceID]?.snapshot } return self.tokenSnapshotForCurrentProviderConfig(for: provider)?.snapshot diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index 05c816c74d9..5dca252c7a2 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1654,6 +1654,8 @@ "Usage snapshot unavailable" = "ملخص الاستخدام غير متاح"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "تعذر على CodexBar إعداد ملخص استخدام قابل للمشاركة. حدّث الاستخدام والإنفاق ثم حاول مرة أخرى."; "Incomplete" = "غير مكتمل"; +"Recorded total (%@): %@" = "إجمالي مسجل (%@): %@"; +"Recorded subtotal (%@): %@" = "إجمالي فرعي مسجل (%@): %@"; "Est. subtotal (%@): %@" = "الإجمالي الفرعي التقديري (%@): %@"; "Excluded requests with missing final usage: %d" = "الطلبات المستبعدة لعدم توفر بيانات الاستخدام النهائية: %d"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index ae9b64337c7..37544d79654 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1654,6 +1654,8 @@ "Usage snapshot unavailable" = "Instantània d'ús no disponible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar no ha pogut preparar una instantània d'ús per compartir. Actualitza Ús i despesa i torna-ho a provar."; "Incomplete" = "Incomplet"; +"Recorded total (%@): %@" = "Total registrat (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal registrat (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimat (%@): %@"; "Excluded requests with missing final usage: %d" = "Sol·licituds excloses sense dades d’ús finals: %d"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 175e0993982..61c94911ab4 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1652,6 +1652,8 @@ "Usage snapshot unavailable" = "Nutzungsübersicht nicht verfügbar"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar konnte keine teilbare Nutzungsübersicht erstellen. Aktualisieren Sie „Nutzung & Ausgaben“ und versuchen Sie es erneut."; "Incomplete" = "Unvollständig"; +"Recorded total (%@): %@" = "Erfasster Gesamtbetrag (%@): %@"; +"Recorded subtotal (%@): %@" = "Erfasster Teilbetrag (%@): %@"; "Est. subtotal (%@): %@" = "Geschätzte Zwischensumme (%@): %@"; "Excluded requests with missing final usage: %d" = "Ausgeschlossene Anfragen ohne abschließende Nutzungsdaten: %d"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index f2b1df16697..11cf1d255ef 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1653,6 +1653,8 @@ "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again."; "Incomplete" = "Incomplete"; +"Recorded total (%@): %@" = "Recorded total (%@): %@"; +"Recorded subtotal (%@): %@" = "Recorded subtotal (%@): %@"; "Est. subtotal (%@): %@" = "Est. subtotal (%@): %@"; "Excluded requests with missing final usage: %d" = "Excluded requests with missing final usage: %d"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 91d44b65a76..68bd5e78cb9 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1648,6 +1648,8 @@ "Usage snapshot unavailable" = "Instantánea de uso no disponible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar no pudo preparar una instantánea de uso para compartir. Actualiza Uso y gasto y vuelve a intentarlo."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Total registrado (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal registrado (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimado (%@): %@"; "Excluded requests with missing final usage: %d" = "Solicitudes excluidas sin datos de uso finales: %d"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 1f553fd130d..8537fa9115b 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1654,6 +1654,8 @@ "Usage snapshot unavailable" = "نمای کلی استفاده در دسترس نیست"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar نتوانست نمای کلی استفاده قابل اشتراک‌گذاری را آماده کند. استفاده و هزینه را تازه‌سازی کنید و دوباره امتحان کنید."; "Incomplete" = "ناقص"; +"Recorded total (%@): %@" = "مجموع ثبت‌شده (%@): %@"; +"Recorded subtotal (%@): %@" = "جمع جزئی ثبت‌شده (%@): %@"; "Est. subtotal (%@): %@" = "جمع جزئی تخمینی (%@): %@"; "Excluded requests with missing final usage: %d" = "درخواست‌های حذف‌شده به دلیل نبود داده‌های مصرف نهایی: %d"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index 2f522c09a91..09292bc4081 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1649,6 +1649,8 @@ "Usage snapshot unavailable" = "Aperçu de l’utilisation indisponible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar n’a pas pu préparer un aperçu de l’utilisation à partager. Actualisez Utilisation et dépenses, puis réessayez."; "Incomplete" = "Incomplet"; +"Recorded total (%@): %@" = "Total enregistré (%@) : %@"; +"Recorded subtotal (%@): %@" = "Sous-total enregistré (%@) : %@"; "Est. subtotal (%@): %@" = "Sous-total estimé (%@) : %@"; "Excluded requests with missing final usage: %d" = "Requêtes exclues sans données d’utilisation finales : %d"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index b9a32765231..50e99cfb49c 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1649,6 +1649,8 @@ "Usage snapshot unavailable" = "Resumo do uso non dispoñible"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar non puido preparar un resumo do uso para compartir. Actualiza Uso e gasto e téntao de novo."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Total rexistrado (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal rexistrado (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimado (%@): %@"; "Excluded requests with missing final usage: %d" = "Solicitudes excluídas sen datos de uso finais: %d"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 9160e8d36f6..32a92fd4943 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1654,6 +1654,8 @@ "Usage snapshot unavailable" = "Ringkasan penggunaan tidak tersedia"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar tidak dapat menyiapkan ringkasan penggunaan untuk dibagikan. Segarkan Penggunaan & pengeluaran, lalu coba lagi."; "Incomplete" = "Tidak lengkap"; +"Recorded total (%@): %@" = "Total tercatat (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal tercatat (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal perkiraan (%@): %@"; "Excluded requests with missing final usage: %d" = "Permintaan yang dikecualikan karena data penggunaan akhir tidak tersedia: %d"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 1e6459718ec..20163458399 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1654,6 +1654,8 @@ "Usage snapshot unavailable" = "Riepilogo utilizzo non disponibile"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar non ha potuto preparare un riepilogo dell’utilizzo da condividere. Aggiorna Utilizzo e spesa e riprova."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Totale registrato (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotale registrato (%@): %@"; "Est. subtotal (%@): %@" = "Subtotale stimato (%@): %@"; "Excluded requests with missing final usage: %d" = "Richieste escluse senza dati di utilizzo finali: %d"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index d60fb24dcba..5de7f65731b 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1650,6 +1650,8 @@ "Usage snapshot unavailable" = "使用状況の概要を利用できません"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar で共有用の使用状況の概要を準備できませんでした。「使用状況と支出」を更新して、もう一度お試しください。"; "Incomplete" = "不完全"; +"Recorded total (%@): %@" = "記録された合計 (%@): %@"; +"Recorded subtotal (%@): %@" = "記録された小計 (%@): %@"; "Est. subtotal (%@): %@" = "推定小計 (%@): %@"; "Excluded requests with missing final usage: %d" = "最終的な使用量が未記録のため除外されたリクエスト: %d"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index f11c1793732..0f54a554a76 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1619,6 +1619,8 @@ "Usage snapshot unavailable" = "사용량 개요를 사용할 수 없음"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar에서 공유할 사용량 개요를 준비하지 못했습니다. 사용량 및 지출을 새로 고친 후 다시 시도하세요."; "Incomplete" = "불완전"; +"Recorded total (%@): %@" = "기록된 합계 (%@): %@"; +"Recorded subtotal (%@): %@" = "기록된 소계 (%@): %@"; "Est. subtotal (%@): %@" = "예상 소계 (%@): %@"; "Excluded requests with missing final usage: %d" = "최종 사용량이 없어 제외된 요청: %d"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 39db8f3ff71..a68b98b602a 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1649,6 +1649,8 @@ "Usage snapshot unavailable" = "Gebruiksoverzicht niet beschikbaar"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar kon geen deelbaar gebruiksoverzicht maken. Vernieuw Gebruik en uitgaven en probeer het opnieuw."; "Incomplete" = "Onvolledig"; +"Recorded total (%@): %@" = "Vastgelegd totaal (%@): %@"; +"Recorded subtotal (%@): %@" = "Vastgelegd subtotaal (%@): %@"; "Est. subtotal (%@): %@" = "Geschat subtotaal (%@): %@"; "Excluded requests with missing final usage: %d" = "Uitgesloten verzoeken zonder definitieve gebruiksgegevens: %d"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index ac72fb94aec..820c3f526d1 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1653,6 +1653,8 @@ "Usage snapshot unavailable" = "Podsumowanie użycia niedostępne"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar nie może przygotować podsumowania użycia do udostępnienia. Odśwież Zużycie i wydatki, a następnie spróbuj ponownie."; "Incomplete" = "Niekompletne"; +"Recorded total (%@): %@" = "Zarejestrowana łącznie (%@): %@"; +"Recorded subtotal (%@): %@" = "Zarejestrowana częściowo (%@): %@"; "Est. subtotal (%@): %@" = "Szacowana suma częściowa (%@): %@"; "Excluded requests with missing final usage: %d" = "Wykluczone żądania bez końcowych danych o użyciu: %d"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index ce20d9e34ba..3875e3077f8 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1650,6 +1650,8 @@ "Usage snapshot unavailable" = "Resumo de uso indisponível"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "O CodexBar não conseguiu preparar um resumo de uso para compartilhar. Atualize Uso e gastos e tente novamente."; "Incomplete" = "Incompleto"; +"Recorded total (%@): %@" = "Total registrado (%@): %@"; +"Recorded subtotal (%@): %@" = "Subtotal registrado (%@): %@"; "Est. subtotal (%@): %@" = "Subtotal estimado (%@): %@"; "Excluded requests with missing final usage: %d" = "Solicitações excluídas sem dados finais de uso: %d"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index 39554dc4087..94eb5a367a1 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1651,6 +1651,8 @@ "Usage snapshot unavailable" = "Сводка использования недоступна"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar не удалось подготовить сводку использования для отправки. Обновите раздел «Использование и расходы» и повторите попытку."; "Incomplete" = "Неполные данные"; +"Recorded total (%@): %@" = "Записанный итог (%@): %@"; +"Recorded subtotal (%@): %@" = "Записанный подытог (%@): %@"; "Est. subtotal (%@): %@" = "Оценка подытога (%@): %@"; "Excluded requests with missing final usage: %d" = "Исключённые запросы без итоговых данных об использовании: %d"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index 4594bfe14e9..cda492de35e 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1649,6 +1649,8 @@ "Usage snapshot unavailable" = "Användningsöversikt är inte tillgänglig"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar kunde inte förbereda en delbar användningsöversikt. Uppdatera Användning och utgifter och försök igen."; "Incomplete" = "Ofullständigt"; +"Recorded total (%@): %@" = "Registrerad total (%@): %@"; +"Recorded subtotal (%@): %@" = "Registrerad delsumma (%@): %@"; "Est. subtotal (%@): %@" = "Uppskattad delsumma (%@): %@"; "Excluded requests with missing final usage: %d" = "Uteslutna förfrågningar utan slutliga användningsdata: %d"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index c97bc12dcfa..5df2917fa59 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1654,6 +1654,8 @@ "Usage snapshot unavailable" = "ภาพรวมการใช้งานไม่พร้อมใช้งาน"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar ไม่สามารถเตรียมภาพรวมการใช้งานสำหรับแชร์ได้ รีเฟรชการใช้งานและค่าใช้จ่ายแล้วลองอีกครั้ง"; "Incomplete" = "ไม่ครบถ้วน"; +"Recorded total (%@): %@" = "ยอดรวมที่บันทึก (%@): %@"; +"Recorded subtotal (%@): %@" = "ยอดรวมย่อยที่บันทึก (%@): %@"; "Est. subtotal (%@): %@" = "ยอดรวมย่อยโดยประมาณ (%@): %@"; "Excluded requests with missing final usage: %d" = "คำขอที่ไม่นับรวมเนื่องจากไม่มีข้อมูลการใช้งานขั้นสุดท้าย: %d"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index efdb1aced12..6e518f8d836 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1653,6 +1653,8 @@ "Usage snapshot unavailable" = "Kullanım özeti kullanılamıyor"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar paylaşılabilir bir kullanım özeti hazırlayamadı. Kullanım ve harcamayı yenileyip tekrar deneyin."; "Incomplete" = "Eksik"; +"Recorded total (%@): %@" = "Kaydedilen toplam (%@): %@"; +"Recorded subtotal (%@): %@" = "Kaydedilen ara toplam (%@): %@"; "Est. subtotal (%@): %@" = "Tahmini ara toplam (%@): %@"; "Excluded requests with missing final usage: %d" = "Nihai kullanım verileri eksik olduğu için hariç tutulan istekler: %d"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 67eee23a109..b33091424ea 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1650,6 +1650,8 @@ "Usage snapshot unavailable" = "Огляд використання недоступний"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar не вдалося підготувати огляд використання для поширення. Оновіть розділ «Використання й витрати» та повторіть спробу."; "Incomplete" = "Неповні дані"; +"Recorded total (%@): %@" = "Записаний підсумок (%@): %@"; +"Recorded subtotal (%@): %@" = "Записаний проміжний підсумок (%@): %@"; "Est. subtotal (%@): %@" = "Орієнтовний підсумок (%@): %@"; "Excluded requests with missing final usage: %d" = "Виключено запити без остаточних даних про використання: %d"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index c149f88de41..bca93dae2bb 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1651,6 +1651,8 @@ "Usage snapshot unavailable" = "Không có tổng quan mức sử dụng"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar không thể chuẩn bị tổng quan mức sử dụng để chia sẻ. Hãy làm mới Mức sử dụng và chi phí rồi thử lại."; "Incomplete" = "Chưa đầy đủ"; +"Recorded total (%@): %@" = "Tổng đã ghi nhận (%@): %@"; +"Recorded subtotal (%@): %@" = "Tổng phụ đã ghi nhận (%@): %@"; "Est. subtotal (%@): %@" = "Tổng phụ ước tính (%@): %@"; "Excluded requests with missing final usage: %d" = "Yêu cầu bị loại trừ do thiếu dữ liệu sử dụng cuối cùng: %d"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 3c9578657a9..7eeea87e04f 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1665,6 +1665,8 @@ "Usage snapshot unavailable" = "用量概览不可用"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar 无法准备可分享的用量概览。请刷新“用量和支出”后重试。"; "Incomplete" = "不完整"; +"Recorded total (%@): %@" = "记录总额 (%@): %@"; +"Recorded subtotal (%@): %@" = "记录小计 (%@): %@"; "Est. subtotal (%@): %@" = "估算小计(%@):%@"; "Excluded requests with missing final usage: %d" = "因缺少最终用量而排除的请求:%d"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 6c192c15e66..13205fddc1c 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1681,6 +1681,8 @@ "Usage snapshot unavailable" = "用量總覽無法使用"; "CodexBar couldn't prepare a shareable usage snapshot. Refresh Usage & Spend and try again." = "CodexBar 無法準備可分享的用量總覽。請重新整理「使用量與支出」後再試一次。"; "Incomplete" = "不完整"; +"Recorded total (%@): %@" = "記錄總額 (%@): %@"; +"Recorded subtotal (%@): %@" = "記錄小計 (%@): %@"; "Est. subtotal (%@): %@" = "估算小計(%@):%@"; "Excluded requests with missing final usage: %d" = "因缺少最終用量而排除的請求:%d"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 9cb78053bb6..d8933e9a2e8 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -556,6 +556,17 @@ extension SettingsStore { } } + /// Opt-in, default-off: merge machine-local OpenCode history for OpenAI-provider assistant + /// records into the ambient Codex menu/CLI history through the shared incremental store. + /// Never affects managed Usage & Spend rows or scoped CODEX_HOME scans (#3273 contract). + var codexIncludeOpenCodeLocalHistory: Bool { + get { self.defaultsState.codexIncludeOpenCodeLocalHistory } + set { + self.setDefault(\.codexIncludeOpenCodeLocalHistory, newValue, key: "codexIncludeOpenCodeLocalHistory") + self.noteBackgroundWorkSettingsChanged() + } + } + var costUsageHistoryDays: Int { get { self.costReportingPeriod.days(now: Date(), calendar: self.costUsageBucketCalendar) } set { self.costReportingPeriod = .rolling(days: max(1, min(365, newValue))) } diff --git a/Sources/CodexBar/SettingsStore+TokenCost.swift b/Sources/CodexBar/SettingsStore+TokenCost.swift index 8aece924ffe..2976277867c 100644 --- a/Sources/CodexBar/SettingsStore+TokenCost.swift +++ b/Sources/CodexBar/SettingsStore+TokenCost.swift @@ -3,18 +3,29 @@ import Foundation extension SettingsStore { func costSummaryShowsInline(for provider: UsageProvider) -> Bool { - // Provider-specific by design: Codex's local ledger can enable its summary without the global scanner. + // Provider-specific by design: Codex's local ledger and OpenCode local history can + // enable its summary without the global scanner. let isEnabled = self.costUsageEnabled || - (provider == .codex && self.codexLocalSessionCostLedgerEnabled) + (provider == .codex && + (self.codexLocalSessionCostLedgerEnabled || self.codexIncludeOpenCodeLocalHistory)) return isEnabled && self.costSummaryDisplayStyle.showsInlineSummary } func costSummaryShowsSubmenu(for provider: UsageProvider) -> Bool { - self.isCostUsageEffectivelyEnabled(for: provider) && + self.isHistoryLaneEffectivelyEnabled(for: provider) && self.costSummaryDisplayStyle.showsCostSubmenu } + /// Opt-in OpenCode local history activates the ambient Codex menu-history lane on its + /// own, so users whose only source is OpenCode need no second toggle to see the merged + /// history. Spend Dashboard gating never consults this lane (#3273 contract). + func isHistoryLaneEffectivelyEnabled(for provider: UsageProvider) -> Bool { + // Provider-specific by design: the OpenCode opt-in enables Codex's ambient history lane alone. + self.isCostUsageEffectivelyEnabled(for: provider) || + (provider == .codex && self.codexIncludeOpenCodeLocalHistory) + } + func applyTokenCostDefaultIfNeeded() { // Tests cover detection directly; skip filesystem-driven auto-enablement to keep startup deterministic. guard !Self.isRunningTests else { return } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index fb92bc2381e..0a72124d275 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -645,6 +645,8 @@ extension SettingsStore { costUsageEnabled: costUsageEnabled, codexLocalSessionCostLedgerEnabled: userDefaults.object( forKey: "codexLocalSessionCostLedgerEnabled") as? Bool ?? false, + codexIncludeOpenCodeLocalHistory: userDefaults.object( + forKey: "codexIncludeOpenCodeLocalHistory") as? Bool ?? false, costReportingPeriod: costReportingPeriod, costUsageBucketTimeZoneIdentifier: costUsageBucketTimeZoneIdentifier, openCodexUsageLogsEnabled: userDefaults.object(forKey: "openCodexUsageLogsEnabled") as? Bool ?? false, diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index c07027709a9..50e5f31a9fb 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -60,6 +60,7 @@ struct SettingsDefaultsState { var copilotSeatCreditEntitlementRaw: String var costUsageEnabled: Bool var codexLocalSessionCostLedgerEnabled: Bool + var codexIncludeOpenCodeLocalHistory: Bool var costReportingPeriod: CostReportingPeriod var costUsageBucketTimeZoneIdentifier: String var openCodexUsageLogsEnabled: Bool diff --git a/Sources/CodexBar/SpendDashboardModel.swift b/Sources/CodexBar/SpendDashboardModel.swift index b694fad2c04..a52133adc2b 100644 --- a/Sources/CodexBar/SpendDashboardModel.swift +++ b/Sources/CodexBar/SpendDashboardModel.swift @@ -652,6 +652,10 @@ struct SpendDashboardModel: Equatable, Sendable { sawVendorMeteredProvenance = true case .listPriceEstimate: sawEstimate = true + case .sourceRecorded: + // OpenCode local history never reaches the Spend Dashboard; treat any + // such snapshot conservatively like legacy unknown provenance. + sawEstimate = true case .mixed: sawVendorMeteredProvenance = true sawEstimate = true diff --git a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift index 1a0a289f2fa..fb033e59f9b 100644 --- a/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift +++ b/Sources/CodexBar/UsageStore+CodexCostCatchUp.swift @@ -305,7 +305,7 @@ extension UsageStore { && self.settings.providerConfigRevision(for: .codex) == context.providerConfigRevision && self.settings.costUsageSettingsRevision == context.costUsageSettingsRevision && self.settings.costUsageHistoryDays == context.historyDays - && self.settings.isCostUsageEffectivelyEnabled(for: .codex) + && self.settings.isHistoryLaneEffectivelyEnabled(for: .codex) && self.isEnabled(.codex) && self.tokenCostScope(for: .codex).codexHomePath == context.codexHomePath && self.tokenSnapshotScopeSignature(for: .codex) == context.scopeSignature diff --git a/Sources/CodexBar/UsageStore+MenuCardModel.swift b/Sources/CodexBar/UsageStore+MenuCardModel.swift index 53e3bf1a283..093f3a1c40e 100644 --- a/Sources/CodexBar/UsageStore+MenuCardModel.swift +++ b/Sources/CodexBar/UsageStore+MenuCardModel.swift @@ -144,13 +144,13 @@ extension UsageStore { limitsAvailability: isSettings && !isLive ? nil : self.knownLimitsAvailability(for: provider), usageBarsShowUsed: self.settings.usageBarsShowUsed, resetTimeDisplayStyle: self.settings.resetTimeDisplayStyle, - tokenCostUsageEnabled: self.settings.isCostUsageEffectivelyEnabled(for: provider), + tokenCostUsageEnabled: self.settings.isHistoryLaneEffectivelyEnabled(for: provider), tokenCostIsRefreshing: !isSettings && self.tokenCostRefreshIsActive(for: provider), codexLocalSessionCostLedgerEnabled: self.settings.codexLocalSessionCostLedgerEnabled, // Settings exposes available costs regardless of the menu's display style. costSummaryInlineEnabled: isSettings || self.settings.costSummaryShowsInline(for: provider), tokenCostMenuSectionEnabled: isSettings - ? self.settings.isCostUsageEffectivelyEnabled(for: provider) + ? self.settings.isHistoryLaneEffectivelyEnabled(for: provider) : descriptor.tokenCost.showsCostMenuSection && self.settings.costSummaryShowsSubmenu(for: provider), costComparisonPeriodsEnabled: !isSettings && self.settings.costComparisonPeriodsEnabled, showOptionalCreditsAndExtraUsage: optionalUsageOverride ?? self.settings.showOptionalCreditsAndExtraUsage, diff --git a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift index ceb1ecc1d97..3314821477e 100644 --- a/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift +++ b/Sources/CodexBar/UsageStore+SpendDashboardTokenCost.swift @@ -128,6 +128,7 @@ extension UsageStore { historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider), + includeOpenCodeLocalHistory: false, reportContext: .spendDashboard) let snapshot = result.snapshot try Task.checkCancellation() diff --git a/Sources/CodexBar/UsageStore+TokenCost.swift b/Sources/CodexBar/UsageStore+TokenCost.swift index 0efcc51d149..fc84c26f9de 100644 --- a/Sources/CodexBar/UsageStore+TokenCost.swift +++ b/Sources/CodexBar/UsageStore+TokenCost.swift @@ -134,6 +134,15 @@ extension UsageStore { return !piIsCostSource } + /// Provider-specific by design: only the ambient Codex ledger may merge machine-local + /// OpenCode history; scoped homes and every other provider stay excluded, and the source + /// is opt-in and default-off (#3273 contract). + func shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for provider: UsageProvider) -> Bool { + guard provider == .codex else { return false } + if self.tokenCostScope(for: provider).codexHomePath != nil { return false } + return self.settings.codexIncludeOpenCodeLocalHistory + } + func piRowsScopeSignature(for provider: UsageProvider) -> String? { guard provider == .claude || (provider == .codex && self.tokenCostScope(for: provider).codexHomePath == nil) @@ -149,6 +158,7 @@ extension UsageStore { historyDays: Int, cursorCookieHeaderOverride: String? = nil, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, reportContext: CostUsageReportContext = .regular) async throws -> CostUsageTokenResult { if let override = self._test_tokenUsageResultLoaderOverride { @@ -200,6 +210,7 @@ extension UsageStore { cursorCookieHeaderOverride: cursorCookieHeaderOverride, allowPricingRefresh: allowPricingRefresh, includePiSessions: effectiveIncludePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, piSessionProcessContexts: piSessionProcessContexts, bypassScannerDebounce: true, calendar: self.settings.costUsageBucketCalendar, @@ -387,7 +398,7 @@ extension UsageStore { @discardableResult func hydrateCachedTokenSnapshots(now: Date = Date()) -> Task? { // Provider-specific by design: only the Codex local ledger hydrates a cached snapshot before the first scan. - guard self.settings.isCostUsageEffectivelyEnabled(for: .codex) else { return nil } + guard self.settings.isHistoryLaneEffectivelyEnabled(for: .codex) else { return nil } guard self.settings.enabledProvidersOrdered(metadataByProvider: self.providerMetadata).contains(.codex) else { return nil } @@ -422,6 +433,7 @@ extension UsageStore { codexHomePath: scope.codexHomePath, historyDays: historyDays, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex), calendar: self.settings.costUsageBucketCalendar, environment: self.environmentBase) .map { @@ -442,7 +454,7 @@ extension UsageStore { self.tokenAccountingScopeIsCurrent(result.accounting, for: .codex), self.settings.providerConfigRevision(for: .codex) == providerConfigRevision, self.settings.costUsageSettingsRevision == costUsageSettingsRevision, - self.settings.isCostUsageEffectivelyEnabled(for: .codex), + self.settings.isHistoryLaneEffectivelyEnabled(for: .codex), self.isEnabled(.codex), self.tokenCostScope(for: .codex).signature == scope.signature, self.settings.costUsageHistoryDays == historyDays, @@ -495,7 +507,11 @@ extension UsageStore { guard provider == .codex else { return (nil, provider.rawValue) } - if self.settings.codexLocalSessionCostLedgerEnabled { + // The local ledger and the OpenCode local history opt-in both describe machine-local + // ambient history, so either selects the ambient home like the ledger always has; + // without this the opt-in would scan a managed home whose scoped rows can never + // include OpenCode history, leaving the toggle ineffective (#3273 contract). + if self.settings.codexLocalSessionCostLedgerEnabled || self.settings.codexIncludeOpenCodeLocalHistory { return (nil, "codex:ambient") } let activeSource = self.settings.codexActiveSource @@ -547,6 +563,10 @@ extension UsageStore { { let scope = self.tokenCostScope(for: provider) var base = "\(scope.signature)|historyDays=\(historyDays)" + // Provider-specific by design: only Codex's ambient ledger carries the opt-in OpenCode local source. + if provider == .codex { + base += "|openCodeLocal=\(self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: provider) ? "1" : "0")" + } if self.usesPiHistoryScope(provider) { base += "|piHistoryGeneration=\(self.piHistoryScopeGeneration)" } @@ -664,7 +684,7 @@ extension UsageStore { guard self.providerPublicationRevisionIsCurrent(scope.publicationRevision, for: provider), self.settings.providerConfigRevision(for: provider) == scope.providerConfigRevision, self.settings.costUsageSettingsRevision == scope.costSettingsRevision, - self.settings.isCostUsageEffectivelyEnabled(for: provider), + self.settings.isHistoryLaneEffectivelyEnabled(for: provider), self.isEnabled(provider), self.settings.costUsageHistoryDays == scope.historyDays else { diff --git a/Sources/CodexBar/UsageStore.swift b/Sources/CodexBar/UsageStore.swift index 44877fea999..2bf943cf464 100644 --- a/Sources/CodexBar/UsageStore.swift +++ b/Sources/CodexBar/UsageStore.swift @@ -1458,7 +1458,7 @@ extension UsageStore { return } - guard self.settings.isCostUsageEffectivelyEnabled(for: provider), self.isEnabled(provider) else { + guard self.settings.isHistoryLaneEffectivelyEnabled(for: provider), self.isEnabled(provider) else { self.resetTokenUsageState(for: provider) return } @@ -1534,7 +1534,8 @@ extension UsageStore { codexHomePath: costScope.codexHomePath, historyDays: historyDays, cursorCookieHeaderOverride: cursorCookieHeaderOverride, - includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider)) + includePiSessions: self.shouldIncludePiSessionsInTokenSnapshot(for: provider), + includeOpenCodeLocalHistory: self.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: provider)) try self.commitTokenUsageResult(result, context: refreshContext) } catch { guard self.tokenRefreshPublicationDisposition( diff --git a/Sources/CodexBarCLI/CLIRenderer.swift b/Sources/CodexBarCLI/CLIRenderer.swift index 8f807f4c0c0..6d9e452ae4e 100644 --- a/Sources/CodexBarCLI/CLIRenderer.swift +++ b/Sources/CodexBarCLI/CLIRenderer.swift @@ -596,6 +596,7 @@ enum CLIRenderer { let provenance: String? = switch history.costProvenance { case .vendorMetered: "reported" case .listPriceEstimate: "estimated" + case .sourceRecorded: "recorded" case .mixed: "includes estimates" case .unknown: nil } diff --git a/Sources/CodexBarCore/CostProvenance.swift b/Sources/CodexBarCore/CostProvenance.swift index e4c6a88c90c..18336eaf363 100644 --- a/Sources/CodexBarCore/CostProvenance.swift +++ b/Sources/CodexBarCore/CostProvenance.swift @@ -6,7 +6,11 @@ public enum CostProvenance: String, Sendable, Equatable, Codable { case listPriceEstimate /// Vendor-reported metered spend (for example Cursor plan deductions). case vendorMetered - /// Window mixes list-price rows with vendor-metered rows. + /// Cost recorded by the source client (for example OpenCode's local history) and + /// surfaced as-is; no list-price estimation was applied to these rows. + case sourceRecorded + /// Window mixes list-price rows with rows that carry non-estimated costs + /// (vendor-metered or source-recorded). case mixed case unknown @@ -16,29 +20,29 @@ public enum CostProvenance: String, Sendable, Equatable, Codable { /// Narrow a snapshot-level provenance to the costs actually present in a window. /// Daily vendor-reported rows stay vendor-metered even when `meteredCostUSD` is absent. + /// `hasListPricedCosts` distinguishes list-priced cost from source-recorded cost inside + /// the window; when absent the window-cost bit is used as a legacy approximation. public static func forWindow( snapshot: CostProvenance, hasWindowCosts: Bool, - includesMetered: Bool) -> CostProvenance + includesMetered: Bool, + includesSourceRecorded: Bool = false, + hasListPricedCosts: Bool? = nil) -> CostProvenance { + let windowHasListPricedCosts = hasListPricedCosts ?? hasWindowCosts switch snapshot { case .vendorMetered: - includesMetered || hasWindowCosts ? .vendorMetered : .unknown + return includesMetered || hasWindowCosts ? .vendorMetered : .unknown case .mixed: - switch (includesMetered, hasWindowCosts) { - case (true, true): - .mixed - case (true, false): - .vendorMetered - case (false, true): - .listPriceEstimate - case (false, false): - .unknown - } + if includesMetered { return hasWindowCosts ? .mixed : .vendorMetered } + if includesSourceRecorded { return windowHasListPricedCosts ? .mixed : .sourceRecorded } + return hasWindowCosts ? .listPriceEstimate : .unknown case .listPriceEstimate: - hasWindowCosts ? .listPriceEstimate : .unknown + return hasWindowCosts ? .listPriceEstimate : .unknown + case .sourceRecorded: + return hasWindowCosts ? .sourceRecorded : .unknown case .unknown: - .unknown + return .unknown } } } diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index a869bdfed8e..b3c9560f99f 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -129,6 +129,7 @@ public struct CostUsageFetcher: Sendable { codexHomePath: String? = nil, historyDays: Int = 30, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, calendar: Calendar? = nil, requireCompleteHistory: Bool = false, environment: [String: String] = ProcessInfo.processInfo.environment) async -> CachedCodexTokenSnapshotResult? @@ -139,6 +140,7 @@ public struct CostUsageFetcher: Sendable { historyDays: historyDays, allowScopedCodexHome: requireCompleteHistory, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, requireCompleteHistory: requireCompleteHistory, scannerOptions: self.scannerOptions(calendar: calendar), environment: environment) @@ -214,6 +216,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], bypassScannerDebounce: Bool = false, @@ -232,6 +235,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, @@ -252,6 +256,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], bypassScannerDebounce: Bool, @@ -282,10 +287,11 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, - scannerOptions: options, + scannerOptions: self.scannerOptions(calendar: calendar), reportContext: reportContext) } @@ -431,6 +437,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, bypassScannerDebounce: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], @@ -453,6 +460,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: refreshPricingInBackground, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, @@ -476,6 +484,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: Bool = true, refreshPricingInBackground: Bool = true, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, bypassScannerDebounce: Bool = false, piWorkingDirectories: [URL] = [], piSessionProcessContexts: [PiSessionProcessContext] = [], @@ -687,6 +696,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, scannerOptions: overrideScannerOptions, @@ -722,6 +732,10 @@ public struct CostUsageFetcher: Sendable { let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) // Provider-specific by design: scoped Codex homes exclude ambient Pi sessions from managed-profile totals. let shouldMergePiUsage = provider != .codex || scopedCodexHomePath?.isEmpty != false + // Provider-specific by design: machine-local OpenCode history only joins the ambient + // Codex ledger; scoped homes and non-Codex providers never merge it (#3273 contract). + let shouldMergeOpenCodeLocalUsage = includeOpenCodeLocalHistory && + provider == .codex && Self.codexHistoryIsAmbient(codexHomePath: codexHomePath, environment: environment) await Self.refreshPricingIfAllowed( options: PricingRefreshOptions( provider: provider, @@ -763,6 +777,7 @@ public struct CostUsageFetcher: Sendable { allowVertexClaudeFallback: allowVertexClaudeFallback, includePiSessions: includePiSessions, shouldMergePiUsage: shouldMergePiUsage, + shouldMergeOpenCodeLocalUsage: shouldMergeOpenCodeLocalUsage, scanOptions: scanOptions, environment: environment, piOptions: piOptions, @@ -795,6 +810,7 @@ public struct CostUsageFetcher: Sendable { allowPricingRefresh: allowPricingRefresh, refreshPricingInBackground: false, includePiSessions: includePiSessions, + includeOpenCodeLocalHistory: includeOpenCodeLocalHistory, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, scannerOptions: options, @@ -812,7 +828,7 @@ public struct CostUsageFetcher: Sendable { historyCoverageIsEstablished: scanResult.inclusive.historyCoverageIsEstablished, historyScanIsPartial: scanResult.native.historyCoverageIsEstablished && !scanResult.inclusive.historyCoverageIsEstablished, - costProvenance: .listPriceEstimate, + costProvenance: scanResult.costProvenance, projects: scanResult.inclusive.projects, sessions: scanResult.inclusive.sessions, detailsAreComplete: scanResult.inclusive.detailsAreComplete, @@ -851,12 +867,26 @@ public struct CostUsageFetcher: Sendable { let inclusive: LocalTokenScanReport let native: LocalTokenScanReport let piScope: String? + /// Provenance of the inclusive report's costs; the native projection stays list-price. + let costProvenance: CostProvenance + } + + /// Provider-specific by design: machine-local OpenCode history joins only scans whose + /// Codex home was not explicitly selected, whether through the `codexHomePath` argument + /// or the process `CODEX_HOME` the native scanner honors. Managed profiles, scoped CLI + /// homes, and other providers never see machine-wide OpenCode rows (#3273 contract). + static func codexHistoryIsAmbient(codexHomePath: String?, environment: [String: String]) -> Bool { + if let path = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty { + return false + } + return environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false } private struct LocalTokenScanOptions: Sendable { let allowVertexClaudeFallback: Bool let includePiSessions: Bool let shouldMergePiUsage: Bool + let shouldMergeOpenCodeLocalUsage: Bool let scanOptions: CostUsageScanner.Options @ProcessEnvironment private(set) var environment: [String: String] let piOptions: PiSessionCostScanner.Options @@ -989,9 +1019,43 @@ public struct CostUsageFetcher: Sendable { [daily, piScanResult.report], calendar: options.scanOptions.calendar) } + var openCodeLocalReport: CostUsageDailyReport? + // Mixed provenance claims a list-price estimate is included, so it must rest on an + // actual native cost — unpriced native rows (unknown model) contribute tokens only. + let listPriceHistoryPresent = daily.data.contains { $0.costUSD != nil } + if options.shouldMergeOpenCodeLocalUsage { + // Provider-specific by design: this optional merge reads machine-local OpenCode + // history for OpenAI-provider assistant records into the ambient Codex ledger. + // Scoped homes never reach this branch, and read failures skip the merge so a + // damaged OpenCode database cannot break the native Codex scan. + if let openCode = try? OpenCodeLocalHistoryStore( + cacheRoot: options.scanOptions.cacheRoot, + environment: options.environment) + .loadDailyReport(since: since, until: now, calendar: options.scanOptions.calendar) + { + daily = CostUsageDailyReport.merged( + [daily, openCode], + calendar: options.scanOptions.calendar) + openCodeLocalReport = openCode + } + } + if let openCodeLocalReport { + // Provider-specific by design: OpenCode rows carry no Codex project or session + // attribution, so they join the unattributed bucket the Pi merge established. + // Session breakdowns stay native (they cannot attribute these rows). + if let project = Self.unknownProjectBreakdown(from: openCodeLocalReport) { + projects.append(project) + sessions = [] + } + } if provider == .codex { projects = Self.mergedProjectBreakdowns(projects) } + // Recorded OpenCode costs must not surface under a pure list-price claim: + // mixed when estimated rows coexist, sourceRecorded when only OpenCode rows exist. + let inclusiveCostProvenance: CostProvenance = openCodeLocalReport != nil + ? (listPriceHistoryPresent ? .mixed : .sourceRecorded) + : .listPriceEstimate return LocalTokenScanResult( inclusive: LocalTokenScanReport( daily: daily, @@ -1001,7 +1065,8 @@ public struct CostUsageFetcher: Sendable { historyCoverageIsEstablished: native.historyCoverageIsEstablished && piScanIsComplete, detailsAreComplete: native.detailsAreComplete && piScanIsComplete && piScope == nil), native: native, - piScope: piScope) + piScope: piScope, + costProvenance: inclusiveCostProvenance) } } @@ -1372,6 +1437,7 @@ public struct CostUsageFetcher: Sendable { historyDays: Int = 30, allowScopedCodexHome: Bool = false, includePiSessions: Bool = true, + includeOpenCodeLocalHistory: Bool = false, includeProjectAndSessionBreakdowns: Bool = true, requireCompleteHistory: Bool = false, scannerOptions overrideScannerOptions: CostUsageScanner.Options? = nil, @@ -1382,6 +1448,11 @@ public struct CostUsageFetcher: Sendable { let scopedCodexHomePath = codexHomePath?.trimmingCharacters(in: .whitespacesAndNewlines) guard scopedCodexHomePath?.isEmpty != false || allowScopedCodexHome else { return nil } let piHistoryRequested = includePiSessions && scopedCodexHomePath?.isEmpty != false + // Provider-specific by design: machine-local OpenCode history only joins the ambient + // Codex cache publication; explicitly scoped homes (argument or CODEX_HOME) never + // request it. + let openCodeLocalHistoryRequested = includeOpenCodeLocalHistory && + Self.codexHistoryIsAmbient(codexHomePath: codexHomePath, environment: environment) let processContexts: [PiSessionProcessContext] = if piHistoryRequested, piScannerOptions == nil { await LocalAgentSessionScanner().piSessionProcessContexts(environment: environment) } else { @@ -1525,6 +1596,26 @@ public struct CostUsageFetcher: Sendable { } } + // Mirror the foreground scan: only a native row with an actual cost carries the + // list-price half of a mixed provenance claim. + let listPriceReportsPresent = reports.contains { report in + report.data.contains { $0.costUSD != nil } + } + var openCodeLocalReport: CostUsageDailyReport? + if openCodeLocalHistoryRequested { + // Provider-specific by design: this optional merge reads machine-local OpenCode + // history for OpenAI-provider assistant records into the cached ambient Codex + // publication so catch-up converges on the same rows the foreground scan shows. + if let openCode = OpenCodeLocalHistoryStore( + cacheRoot: options.cacheRoot, + environment: environment) + .cachedDailyReport(since: since, until: now, calendar: options.calendar) + { + reports.append(openCode) + openCodeLocalReport = openCode + } + } + try check() guard !reports.isEmpty else { return nil } // `previous` is an exact report captured before the current bounded refresh became @@ -1536,6 +1627,21 @@ public struct CostUsageFetcher: Sendable { // 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 // rescan on the strength of another source's scan. + if let openCodeLocalReport { + // Provider-specific by design: OpenCode rows carry no Codex project or session + // attribution, so they join the unattributed bucket the Pi merge established. + if let project = Self.unknownProjectBreakdown(from: openCodeLocalReport) { + projects.append(project) + sessions = [] + } + } + // Recorded OpenCode costs must not surface under a pure list-price claim; + // mirror the foreground scan's provenance so cache and live views agree. + let cachedCostProvenance: CostProvenance = if openCodeLocalReport != nil { + listPriceReportsPresent ? .mixed : .sourceRecorded + } else { + .listPriceEstimate + } return CachedCodexTokenSnapshotResult( snapshot: Self.tokenSnapshot( from: CostUsageDailyReport.merged(reports, calendar: options.calendar), @@ -1543,7 +1649,7 @@ public struct CostUsageFetcher: Sendable { historyDays: clampedHistoryDays, calendar: options.calendar, historyCoverageIsEstablished: displayedHistoryCoverageIsEstablished, - costProvenance: .listPriceEstimate, + costProvenance: cachedCostProvenance, projects: Self.mergedProjectBreakdowns(projects), sessions: sessions, detailsAreComplete: nativeDetailsAreComplete && !piHistoryRequested, diff --git a/Sources/CodexBarCore/CostUsageModels.swift b/Sources/CodexBarCore/CostUsageModels.swift index 3ebbd239304..8ee3f9ce7c9 100644 --- a/Sources/CodexBarCore/CostUsageModels.swift +++ b/Sources/CodexBarCore/CostUsageModels.swift @@ -287,7 +287,11 @@ public struct CostUsageTokenSnapshot: Sendable, Equatable { provenance: CostProvenance.forWindow( snapshot: self.costProvenance, hasWindowCosts: !costs.isEmpty, - includesMetered: windowMetered != nil), + includesMetered: windowMetered != nil, + includesSourceRecorded: entries.contains { $0.costIsSourceRecorded }, + hasListPricedCosts: entries.contains { + $0.containsListPricedCost || ($0.costUSD != nil && !$0.costIsSourceRecorded) + }), meteredCostUSD: windowMetered, incompleteRequestCount: CostUsageIncompleteRequests.sum(entries.map(\.incompleteRequestCount))) } @@ -549,6 +553,17 @@ public struct CostUsageDailyReport: Sendable, Codable { public let pricedRequestCount: Int? public let unmeteredRequestCount: Int? public let estimatedRequestCount: Int? + /// True when this day's cost was recorded by the source client (for example OpenCode + /// local history) rather than estimated from token counts. Persists through caches so + /// window provenance stays truthful; older caches decode as plain estimated rows. + public let costIsSourceRecorded: Bool + /// True when this day mixes source-recorded cost with non-recorded (list-price + /// estimated or vendor-metered) cost — the one case where the recorded flag alone + /// would mislabel the whole day's cost as recorded. Pure days stay false: their cost + /// kind is already conveyed by `costIsSourceRecorded` and `costUSD`, which also keeps + /// single-report merges identity-preserving. Older caches decode as false and + /// refresh on the next scan. + public let containsListPricedCost: Bool package var hasOnlyIncompleteRequests: Bool { self.incompleteRequestCount > 0 && self.totalTokens == nil && self.costUSD == nil @@ -624,6 +639,8 @@ public struct CostUsageDailyReport: Sendable, Codable { case pricedRequestCount case unmeteredRequestCount case estimatedRequestCount + case costIsSourceRecorded + case containsListPricedCost } public init(from decoder: Decoder) throws { @@ -653,6 +670,10 @@ public struct CostUsageDailyReport: Sendable, Codable { self.pricedRequestCount = try container.decodeIfPresent(Int.self, forKey: .pricedRequestCount) self.unmeteredRequestCount = try container.decodeIfPresent(Int.self, forKey: .unmeteredRequestCount) self.estimatedRequestCount = try container.decodeIfPresent(Int.self, forKey: .estimatedRequestCount) + self.costIsSourceRecorded = + try container.decodeIfPresent(Bool.self, forKey: .costIsSourceRecorded) ?? false + self.containsListPricedCost = + try container.decodeIfPresent(Bool.self, forKey: .containsListPricedCost) ?? false } public init( @@ -670,7 +691,9 @@ public struct CostUsageDailyReport: Sendable, Codable { unpricedRequestCount: Int? = nil, unmeteredRequestCount: Int? = nil, estimatedRequestCount: Int? = nil, - pricedRequestCount: Int? = nil) + pricedRequestCount: Int? = nil, + costIsSourceRecorded: Bool = false, + containsListPricedCost: Bool = false) { self.date = date self.inputTokens = inputTokens @@ -687,6 +710,8 @@ public struct CostUsageDailyReport: Sendable, Codable { self.unmeteredRequestCount = unmeteredRequestCount self.estimatedRequestCount = estimatedRequestCount self.pricedRequestCount = pricedRequestCount + self.costIsSourceRecorded = costIsSourceRecorded + self.containsListPricedCost = containsListPricedCost } public func encode(to encoder: Encoder) throws { @@ -706,6 +731,12 @@ public struct CostUsageDailyReport: Sendable, Codable { try container.encodeIfPresent(self.pricedRequestCount, forKey: .pricedRequestCount) try container.encodeIfPresent(self.unmeteredRequestCount, forKey: .unmeteredRequestCount) try container.encodeIfPresent(self.estimatedRequestCount, forKey: .estimatedRequestCount) + if self.costIsSourceRecorded { + try container.encode(true, forKey: .costIsSourceRecorded) + } + if self.containsListPricedCost { + try container.encode(true, forKey: .containsListPricedCost) + } } private static func decodeModelsUsed(from container: KeyedDecodingContainer) -> [String]? { @@ -986,6 +1017,8 @@ extension CostUsageDailyReport { var costUSD: Double = 0 var sawCost = false var costIsComplete = true + var anyCostIsSourceRecorded = false + var anyCostIsListPriced = false var modelsUsed: Set = [] var breakdowns: [String: BreakdownAccumulator] = [:] @@ -1036,6 +1069,12 @@ extension CostUsageDailyReport { } } } + self.anyCostIsSourceRecorded = self.anyCostIsSourceRecorded || entry.costIsSourceRecorded + // Re-merges of already-merged reports must keep the mixed-day bit: a merged day + // folds recorded and priced rows into one entry whose own flag propagates here. + self.anyCostIsListPriced = self.anyCostIsListPriced + || entry.containsListPricedCost + || (entry.costUSD != nil && !entry.costIsSourceRecorded) if let modelsUsed = entry.modelsUsed { self.modelsUsed.formUnion(modelsUsed) } @@ -1079,7 +1118,9 @@ extension CostUsageDailyReport { unpricedRequestCount: includeCoverage ? self.coverage.exact?.unpriced : nil, unmeteredRequestCount: includeCoverage ? self.coverage.exact?.unmetered : nil, estimatedRequestCount: includeCoverage ? self.coverage.exact?.estimated : nil, - pricedRequestCount: includeCoverage ? self.coverage.exact?.priced : nil) + pricedRequestCount: includeCoverage ? self.coverage.exact?.priced : nil, + costIsSourceRecorded: self.anyCostIsSourceRecorded, + containsListPricedCost: self.anyCostIsListPriced && self.anyCostIsSourceRecorded) } func resolvedCostUSD() -> Double? { diff --git a/Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalHistoryStore.swift b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalHistoryStore.swift new file mode 100644 index 00000000000..59cff254beb --- /dev/null +++ b/Sources/CodexBarCore/Providers/OpenCode/OpenCodeLocalHistoryStore.swift @@ -0,0 +1,662 @@ +import Foundation + +#if canImport(SQLite3) +import SQLite3 +#elseif canImport(CSQLite3) +import CSQLite3 +#endif + +/// The shared machine-local OpenCode history for assistant records whose provider is exactly +/// `openai`, consumed by every path of the ambient Codex ledger (#3273). +/// +/// Architecture — the reasons PR #4574 was closed are the contract here: +/// - **One durable cache, fingerprint gated.** The decoded per-day entries persist in this +/// store's own SQLite database; a scan re-reads the OpenCode database only when its +/// fingerprint (size, mtime, WAL size, WAL mtime) changes. Nothing is shared with the +/// OpenCode Go reader's in-memory cache, so the two histories never evict each other. +/// - **Cache-only publication.** Cached publications read the persisted entries and never +/// touch the OpenCode database, keeping the tightened cost-ledger I/O budget intact. +/// - **Windowing at read time.** The store retains compact per-day entries, not a +/// pre-windowed report, and aggregates for the requested window on each read. +/// +/// Source contract (see issue #3273): +/// - Reads usage metadata only, with `SQLITE_OPEN_READONLY`. Prompts, responses, tool output +/// and `auth.json` are never read; rows are never attributed to an account. +/// - Selects only assistant records with an exact `providerID == "openai"` match. +/// - Counts valid `step-finish` parts when present and falls back to message-level totals +/// only when no valid parts exist, mirroring the OpenCode Go reader's precedence. +/// - Deduplicates by stable message IDs: OpenCode v2 copies legacy `message` rows into +/// `session_message` under the same IDs, so v2 rows only contribute IDs the legacy table +/// does not already hold. No fuzzy timestamp or token matching is attempted. +/// - Preserves recorded token dimensions. Recorded cost is surfaced as-is (OpenCode records +/// `$0` for subscription usage); dollars are never recomputed or reinterpreted here. +public struct OpenCodeLocalHistoryStore: Sendable { + /// Versioned so a schema change ships as a new filename and older builds keep working. + public static let cacheDatabaseFilename = "opencode-local-history-v1.sqlite" + + private let databaseURL: URL + private let cacheURL: URL + + public init( + cacheRoot: URL? = nil, + homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser, + environment: [String: String] = ProcessInfo.processInfo.environment) + { + let dataRoot = Self.xdgDataRoot(homeDirectory: homeDirectory, environment: environment) + let openCodeDirectory = dataRoot.appendingPathComponent("opencode", isDirectory: true) + self.databaseURL = Self.primaryDatabaseURL(dataDirectory: openCodeDirectory, environment: environment) + self.cacheURL = Self.resolvedCacheRoot(cacheRoot) + .appendingPathComponent(Self.cacheDatabaseFilename, isDirectory: false) + } + + /// The store's own cache directory, mirroring the OpenCodex usage cache layout. A scan's + /// cache root wins when supplied; production falls back to the CodexBar caches directory. + static func resolvedCacheRoot(_ cacheRoot: URL?) -> URL { + if let cacheRoot { + return cacheRoot + } + return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? + .appendingPathComponent("CodexBar", isDirectory: true) + .appendingPathComponent("opencode-local-history", isDirectory: true) + ?? FileManager.default.temporaryDirectory + .appendingPathComponent("opencode-local-history", isDirectory: true) + } + + public init(databaseURL: URL, cacheURL: URL) { + self.databaseURL = databaseURL + self.cacheURL = cacheURL + } + + // MARK: - Reads + + /// A windowed report for the scan path. The OpenCode database is opened only when its + /// fingerprint changed since the persisted entries were decoded; otherwise the cached + /// entries are used as-is. `nil` means there is no matching history. Only genuine + /// SQLite read failures throw; a missing or empty OpenCode installation never disrupts + /// the native Codex scan. + public func loadDailyReport(since: Date, until: Date, calendar: Calendar) throws -> CostUsageDailyReport? { + guard FileManager.default.fileExists(atPath: self.databaseURL.path) else { + try? self.removeAllCachedEntries() + return nil + } + let cached = try self.readCachedEntries() + if let cached, cached.matchesCurrentDatabase(at: self.databaseURL) { + return Self.windowedReport(entries: cached.entries, since: since, until: until, calendar: calendar) + } + guard let entries = try self.decodeEntries(), !entries.isEmpty else { + try? self.removeAllCachedEntries() + return nil + } + try? self.persist(entries: entries) + return Self.windowedReport(entries: entries, since: since, until: until, calendar: calendar) + } + + /// A windowed report for cached publications: reads only the persisted entries — no + /// database access at all, not even a fingerprint stat (#3273 cache-only publication). + /// `nil` when nothing is cached yet; the next scan refreshes the cache and its + /// fingerprint gate decides whether the persisted entries are still current. + public func cachedDailyReport(since: Date, until: Date, calendar: Calendar) -> CostUsageDailyReport? { + guard let cached = try? self.readCachedEntries() else { return nil } + return Self.windowedReport(entries: cached.entries, since: since, until: until, calendar: calendar) + } + + /// Removes persisted entries. Called when the setting turns off so stale rows can never + /// surface again. + public func removeAllCachedEntries() throws { + try CacheDatabase.withDatabase(at: self.cacheURL) { db in + try CacheDatabase.execute(db, "DELETE FROM entries") + try CacheDatabase.execute(db, "DELETE FROM meta WHERE key = 'fingerprint'") + } + } + + // MARK: - Entry model + + /// One decoded assistant turn. Compact by design: the durable cache holds these, and the + /// in-process copy exists only between a scan and its aggregation. + struct Entry: Codable, Equatable, Sendable { + let createdMs: Int64 + let costUSD: Double + /// The model OpenCode recorded for the turn; empty when unattributed. + let modelID: String + let totalTokens: Int? + let inputTokens: Int? + let outputTokens: Int? + let reasoningTokens: Int? + let cacheReadTokens: Int? + let cacheCreationTokens: Int? + } + + struct CachedEntries: Equatable, Sendable { + let entries: [Entry] + let fingerprint: DatabaseFingerprint + + func matchesCurrentDatabase(at url: URL) -> Bool { + guard let current = DatabaseFingerprint.current(for: url) else { return false } + return current == self.fingerprint + } + } + + // MARK: - Windowing + + /// Buckets entries into calendar-day rows keyed by the reporting calendar, matching how + /// native Codex history is keyed, so they merge consistently with it. Windowing happens + /// here, at read time, from compact entries — the store never retains a pre-windowed + /// report. + static func windowedReport( + entries: [Entry], + since: Date, + until: Date, + calendar: Calendar) -> CostUsageDailyReport? + { + // Normalize the lower bound to the reporting day's start: the foreground scan passes + // a `now`-anchored window, and a clock-time bound would drop earlier-today turns. + let sinceStartMs = Int64(calendar.startOfDay(for: since).timeIntervalSince1970 * 1000) + let untilMs = Int64(until.timeIntervalSince1970 * 1000) + var dayEntries: [String: CostUsageDailyReport.Entry] = [:] + for entry in entries { + guard entry.createdMs >= sinceStartMs, entry.createdMs <= untilMs else { continue } + let date = Date(timeIntervalSince1970: TimeInterval(entry.createdMs) / 1000) + let key = CostUsageScanner.CostUsageDayRange.dayKey(from: date, calendar: calendar) + let day = dayEntries[key] ?? CostUsageDailyReport.Entry( + date: key, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + requestCount: 0, + costUSD: 0, + modelsUsed: [], + modelBreakdowns: [], + costIsSourceRecorded: true) + let breakdown = Self.breakdown(for: entry, existing: day.modelBreakdowns ?? []) + dayEntries[key] = CostUsageDailyReport.Entry( + date: key, + inputTokens: Self.sum(day.inputTokens, entry.inputTokens), + outputTokens: Self.sum(day.outputTokens, entry.outputTokens), + cacheReadTokens: Self.sum(day.cacheReadTokens, entry.cacheReadTokens), + cacheCreationTokens: Self.sum(day.cacheCreationTokens, entry.cacheCreationTokens), + reasoningTokens: Self.sum(day.reasoningTokens, entry.reasoningTokens), + totalTokens: Self.totalSum(day.totalTokens, entry.totalTokens), + requestCount: (day.requestCount ?? 0) + 1, + costUSD: (day.costUSD ?? 0) + entry.costUSD, + modelsUsed: Array((day.modelsUsed ?? []) + [breakdown.modelName]).sorted(), + modelBreakdowns: (day.modelBreakdowns ?? []) + .filter { $0.modelName != breakdown.modelName } + [breakdown], + // The cost comes from OpenCode's own records, not list-price estimation. + costIsSourceRecorded: true) + } + guard !dayEntries.isEmpty else { return nil } + return CostUsageDailyReport( + data: dayEntries.values.sorted { $0.date < $1.date }, + summary: nil) + } + + private static func breakdown( + for entry: Entry, + existing: [CostUsageDailyReport.ModelBreakdown]) -> CostUsageDailyReport.ModelBreakdown + { + let trimmed = entry.modelID.trimmingCharacters(in: .whitespacesAndNewlines) + let baseModel = trimmed.isEmpty ? "unknown" : trimmed + // Keep the source visible (#3273 contract): rows stay identifiable as OpenCode + // local history in UI model breakdowns and CLI JSON instead of silently merging + // into native model totals. + let model = "\(baseModel) (OpenCode local)" + if let match = existing.first(where: { $0.modelName == model }) { + return CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: (match.costUSD ?? 0) + entry.costUSD, + totalTokens: Self.totalSum(match.totalTokens, entry.totalTokens), + requestCount: (match.requestCount ?? 0) + 1, + inputTokens: Self.sum(match.inputTokens, entry.inputTokens), + outputTokens: Self.sum(match.outputTokens, entry.outputTokens), + cacheReadTokens: Self.sum(match.cacheReadTokens, entry.cacheReadTokens), + cacheCreationTokens: Self.sum(match.cacheCreationTokens, entry.cacheCreationTokens), + reasoningTokens: Self.sum(match.reasoningTokens, entry.reasoningTokens)) + } + return CostUsageDailyReport.ModelBreakdown( + modelName: model, + costUSD: entry.costUSD, + totalTokens: entry.totalTokens, + requestCount: 1, + inputTokens: entry.inputTokens, + outputTokens: entry.outputTokens, + cacheReadTokens: entry.cacheReadTokens, + cacheCreationTokens: entry.cacheCreationTokens, + reasoningTokens: entry.reasoningTokens) + } + + /// The day's total is conservatively unknown when any contributing turn lacks one, + /// mirroring how native Codex history aggregates days with mixed token evidence. + private static func totalSum(_ lhs: Int?, _ rhs: Int?) -> Int? { + guard let l = lhs, let r = rhs else { return nil } + let (total, overflow) = l.addingReportingOverflow(r) + return overflow ? nil : total + } + + private static func sum(_ lhs: Int?, _ rhs: Int?) -> Int? { + switch (lhs, rhs) { + case let (l?, r?): + let (total, overflow) = l.addingReportingOverflow(r) + return overflow ? nil : total + case let (l?, nil): return l + case let (nil, r?): return r + case (nil, nil): return nil + } + } + + // MARK: - Durable cache + + private func readCachedEntries() throws -> CachedEntries? { + guard FileManager.default.fileExists(atPath: self.cacheURL.path) else { return nil } + return try CacheDatabase.withDatabase(at: self.cacheURL) { db in + guard let payload = try CacheDatabase.stringValue(db, "SELECT payload FROM entries LIMIT 1"), + let data = Data(base64Encoded: payload), + let entries = try? JSONDecoder().decode([Entry].self, from: data), + let fingerprintJSON = try CacheDatabase.stringValue( + db, + "SELECT value FROM meta WHERE key = 'fingerprint'"), + let fingerprintData = Data(base64Encoded: fingerprintJSON), + let fingerprint = try? JSONDecoder().decode(DatabaseFingerprint.self, from: fingerprintData) + else { return nil } + return CachedEntries(entries: entries, fingerprint: fingerprint) + } + } + + private func persist(entries: [Entry]) throws { + let payload = try JSONEncoder().encode(entries).base64EncodedString() + let fingerprint = DatabaseFingerprint.current(for: self.databaseURL) + try CacheDatabase.withDatabase(at: self.cacheURL) { db in + try CacheDatabase.execute(db, "BEGIN IMMEDIATE") + try CacheDatabase.execute(db, "DELETE FROM entries") + try CacheDatabase.execute( + db, + "INSERT INTO entries (ordinal, payload) VALUES (0, '\(payload)')") + try CacheDatabase.execute(db, "DELETE FROM meta WHERE key = 'fingerprint'") + if let fingerprint { + let json = try JSONEncoder().encode(fingerprint).base64EncodedString() + try CacheDatabase.execute( + db, + "INSERT INTO meta (key, value) VALUES ('fingerprint', '\(json)')") + } + try CacheDatabase.execute(db, "COMMIT") + } + } + + // MARK: - Database fingerprint + + /// Mirrors the OpenCode Go reader's fingerprint: the main database plus its WAL sidecar, + /// so WAL appends invalidate the cache without a checkpoint. + struct DatabaseFingerprint: Codable, Equatable, Sendable { + let path: String + let size: UInt64 + let modificationDate: Date + let walSize: UInt64? + let walModificationDate: Date? + + static func current(for url: URL) -> Self? { + let url = url.resolvingSymlinksInPath() + guard let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? UInt64, + let mtime = attrs[.modificationDate] as? Date + else { return nil } + let walAttrs = try? FileManager.default.attributesOfItem(atPath: url.path + "-wal") + return Self( + path: url.path, + size: size, + modificationDate: mtime, + walSize: walAttrs?[.size] as? UInt64, + walModificationDate: walAttrs?[.modificationDate] as? Date) + } + } + + // MARK: - Environment resolution + + /// Resolves the XDG data root the way OpenCode's `xdg-basedir` dependency does: an + /// absolute `XDG_DATA_HOME` wins; relative or missing values fall back to `~/.local/share`. + static func xdgDataRoot(homeDirectory: URL, environment: [String: String]) -> URL { + if let raw = Self.cleaned(environment["XDG_DATA_HOME"]), + raw.hasPrefix("/") + { + return URL(fileURLWithPath: raw, isDirectory: true) + } + return homeDirectory + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + } + + /// Resolves the single database OpenCode itself would use, mirroring upstream + /// `database.path()`: `OPENCODE_DB` selects exactly one database (`:memory:` is in-memory + /// history an on-disk reader cannot see; absolute paths win as-is; relative names resolve + /// inside the data directory) and is never substituted with another database. Without an + /// explicit selection, upstream derives the channel from a build-time constant, which an + /// external reader cannot observe — release channels and the + /// `OPENCODE_DISABLE_CHANNEL_DB` opt-out all select the plain database, so that is the + /// contract's selection here. Non-release channel databases stay reachable through + /// `OPENCODE_DB`, exactly like pointing OpenCode itself at them. + static func primaryDatabaseURL( + dataDirectory: URL, + environment: [String: String]) -> URL + { + if let override = Self.cleaned(environment["OPENCODE_DB"]) { + if override == ":memory:" { + return dataDirectory.appendingPathComponent("unreachable-memory-db", isDirectory: false) + } + if override.hasPrefix("/") { + return URL(fileURLWithPath: override) + } + return dataDirectory.appendingPathComponent(override, isDirectory: false) + } + return dataDirectory.appendingPathComponent("opencode.db", isDirectory: false) + } + + private static func cleaned(_ value: String?) -> String? { + guard let value else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} + +#if canImport(SQLite3) || canImport(CSQLite3) + +// MARK: - SQLite helpers + +enum CacheDatabase { + static func withDatabase(at url: URL, _ body: (OpaquePointer?) throws -> T) throws -> T { + let directory = url.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var db: OpaquePointer? + let openResult = sqlite3_open_v2(url.path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) + guard openResult == SQLITE_OK, db != nil else { + let failure = OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: openResult) + if let db { + _ = sqlite3_close(db) + } + throw failure + } + defer { _ = sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + try Self.execute(db, """ + CREATE TABLE IF NOT EXISTS entries ( + ordinal INTEGER PRIMARY KEY, + payload TEXT NOT NULL + ) + """) + try Self.execute(db, """ + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + return try body(db) + } + + static func execute(_ db: OpaquePointer?, _ sql: String) throws { + guard sqlite3_exec(db, sql, nil, nil, nil) == SQLITE_OK else { + throw OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: sqlite3_errcode(db)) + } + } + + static func stringValue(_ db: OpaquePointer?, _ sql: String) throws -> String? { + var stmt: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) + guard prepareResult == SQLITE_OK else { + throw OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: prepareResult) + } + defer { _ = sqlite3_finalize(stmt) } + let step = sqlite3_step(stmt) + guard step == SQLITE_ROW else { return nil } + guard let text = sqlite3_column_text(stmt, 0) else { return nil } + return String(cString: text) + } +} + +struct OpenCodeLocalHistorySQLiteFailure: Error { + let code: Int32 + let message: String + + init(db: OpaquePointer?, resultCode: Int32) { + self.code = resultCode + self.message = db.map { String(cString: sqlite3_errmsg($0)) } ?? "sqlite failure" + } +} + +// MARK: - OpenCode database decoding + +extension OpenCodeLocalHistoryStore { + /// Reads the matched rows from the OpenCode database. `nil` when the database exists but + /// holds no matching rows; only genuine SQLite read failures throw. + func decodeEntries() throws -> [Entry]? { + do { + return try self.decodeEntries(immutable: false) + } catch let failure as OpenCodeLocalHistorySQLiteFailure { + // A clean WAL shutdown can remove both sidecars while leaving the database header + // in WAL mode. Immutable mode reads that idle main file without recreating the + // sidecars; active WALs stay on the normal path. + guard failure.code == SQLITE_CANTOPEN, Self.walSidecarsAreMissing(at: self.databaseURL) else { + throw failure + } + return try self.decodeEntries(immutable: true) + } + } + + private static func walSidecarsAreMissing(at url: URL) -> Bool { + let wal = url.appendingPathExtension("wal").path + let shm = url.appendingPathExtension("shm").path + return !FileManager.default.fileExists(atPath: wal) && !FileManager.default.fileExists(atPath: shm) + } + + private func decodeEntries(immutable: Bool) throws -> [Entry]? { + var db: OpaquePointer? + let filename = immutable + ? "\(self.databaseURL.absoluteURL.absoluteString)?immutable=1" + : self.databaseURL.path + let flags = immutable ? SQLITE_OPEN_READONLY | SQLITE_OPEN_URI : SQLITE_OPEN_READONLY + let openResult = sqlite3_open_v2(filename, &db, flags, nil) + guard openResult == SQLITE_OK, db != nil else { + let failure = OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: openResult) + if let db { + _ = sqlite3_close(db) + } + throw failure + } + defer { _ = sqlite3_close(db) } + sqlite3_busy_timeout(db, 250) + + let hasMessage = Self.tableExists(db, "message") + let hasParts = Self.tableExists(db, "part") + let hasSessionMessages = Self.tableExists(db, "session_message") + guard hasMessage || hasSessionMessages else { + // An idle WAL database with removed sidecars fails table detection with CANTOPEN + // before any query runs. Probe readability so that case surfaces as a failure the + // immutable fallback can handle, while a readable database without our tables + // reads as empty history rather than an error. + try Self.assertReadable(db) + return nil + } + + var statements: [String] = [] + if hasMessage { + statements.append(hasParts ? Self.messageAndPartUsageSQL : Self.messageOnlyUsageSQL) + } + if hasSessionMessages { + statements.append(Self.sessionMessageUsageSQL(hasLegacyMessages: hasMessage)) + } + let sql = statements.joined(separator: "\nUNION ALL\n") + + var stmt: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, sql, -1, &stmt, nil) + guard prepareResult == SQLITE_OK else { + throw OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: prepareResult) + } + defer { _ = sqlite3_finalize(stmt) } + + var entries: [Entry] = [] + while true { + let step = sqlite3_step(stmt) + if step == SQLITE_DONE { + break + } + guard step == SQLITE_ROW else { + throw OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: step) + } + let createdMs = sqlite3_column_int64(stmt, 0) + let cost = sqlite3_column_double(stmt, 1) + guard createdMs > 0, cost >= 0, cost.isFinite else { continue } + let model = sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "" + let tokens = sqlite3_column_text(stmt, 3).flatMap { json in + try? JSONDecoder().decode(TokenCounts.self, from: Data(String(cString: json).utf8)) + } + let resolved = tokens.flatMap { $0.isValid ? $0 : nil } + entries.append(Entry( + createdMs: createdMs, + costUSD: cost, + modelID: model, + totalTokens: resolved?.resolvedTotal, + inputTokens: resolved?.input, + outputTokens: resolved?.output, + reasoningTokens: resolved?.reasoning, + cacheReadTokens: resolved?.cache?.read, + cacheCreationTokens: resolved?.cache?.write)) + } + return entries + } + + private static func tableExists(_ db: OpaquePointer?, _ table: String) -> Bool { + var stmt: OpaquePointer? + let sql = "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '\(table)'" + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return false } + defer { _ = sqlite3_finalize(stmt) } + return sqlite3_step(stmt) == SQLITE_ROW + } + + private static func assertReadable(_ db: OpaquePointer?) throws { + var stmt: OpaquePointer? + let prepareResult = sqlite3_prepare_v2(db, "SELECT 1 FROM sqlite_master LIMIT 1", -1, &stmt, nil) + guard prepareResult == SQLITE_OK else { + throw OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: prepareResult) + } + defer { _ = sqlite3_finalize(stmt) } + let step = sqlite3_step(stmt) + guard step == SQLITE_ROW || step == SQLITE_DONE else { + throw OpenCodeLocalHistorySQLiteFailure(db: db, resultCode: step) + } + } + + /// Legacy `message` rows for the selected provider, as a reusable projection. + private static let providerMessagesSQL = """ + SELECT + id AS messageID, + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost, + json_type(data, '$.cost') IN ('integer', 'real') AS hasCost, + COALESCE(json_extract(data, '$.modelID'), '') AS modelID, + CASE WHEN json_type(data, '$.tokens') = 'object' + THEN json_extract(data, '$.tokens') END AS tokens + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'openai' + AND json_extract(data, '$.role') = 'assistant' + """ + + /// Message-level totals only, for databases without a `part` table. + private static let messageOnlyUsageSQL = """ + SELECT createdMs, cost, modelID, tokens + FROM (\(Self.providerMessagesSQL)) + WHERE hasCost + """ + + /// Valid `step-finish` parts win; the message fallback contributes only when a message + /// has no valid part, so a message is never counted twice. + private static let messageAndPartUsageSQL = """ + WITH provider_messages AS (\(Self.providerMessagesSQL)) + SELECT + CAST(COALESCE(json_extract(p.data, '$.time.created'), p.time_created, m.createdMs) AS INTEGER) + AS createdMs, + CAST(json_extract(p.data, '$.cost') AS REAL) AS cost, + m.modelID AS modelID, + CASE WHEN json_type(p.data, '$.tokens') = 'object' + THEN json_extract(p.data, '$.tokens') END AS tokens + FROM part p + JOIN provider_messages m ON m.messageID = p.message_id + WHERE json_valid(p.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + UNION ALL + SELECT createdMs, cost, modelID, tokens + FROM provider_messages m + WHERE hasCost + AND NOT EXISTS ( + SELECT 1 + FROM part p + WHERE p.message_id = m.messageID + AND json_valid(p.data) + AND json_extract(p.data, '$.type') = 'step-finish' + AND json_type(p.data, '$.cost') IN ('integer', 'real') + ) + """ + + /// OpenCode v2 writes new turns to `session_message` and copies older turns there under + /// the same IDs, so v2 rows only add IDs the legacy `message` table does not already hold. + private static func sessionMessageUsageSQL(hasLegacyMessages: Bool) -> String { + let legacyExclusion = hasLegacyMessages + ? "\n AND NOT EXISTS (SELECT 1 FROM message m WHERE m.id = s.id)" + : "" + return """ + SELECT + CAST(COALESCE(json_extract(s.data, '$.time.created'), s.time_created) AS INTEGER) AS createdMs, + CAST(json_extract(s.data, '$.cost') AS REAL) AS cost, + COALESCE(json_extract(s.data, '$.model.id'), '') AS modelID, + CASE WHEN json_type(s.data, '$.tokens') = 'object' + THEN json_extract(s.data, '$.tokens') END AS tokens + FROM session_message s + WHERE s.type = 'assistant' + AND json_valid(s.data) + AND json_extract(s.data, '$.model.providerID') = 'openai' + AND json_type(s.data, '$.cost') IN ('integer', 'real')\(legacyExclusion) + """ + } + + private struct TokenCounts: Decodable { + struct Cache: Decodable { + let read: Int? + let write: Int? + } + + let total: Int? + let input: Int? + let output: Int? + let reasoning: Int? + let cache: Cache? + + private var components: [Int?] { + [self.input, self.output, self.reasoning, self.cache?.read, self.cache?.write] + } + + var resolvedTotal: Int? { + // OpenCode separates output from reasoning and input from cache counts. + self.total ?? (self.components.allSatisfy { $0 != nil } + ? Self.checkedSum(self.components.compactMap(\.self)) : nil) + } + + var isValid: Bool { + (self.components + [self.total]).allSatisfy { ($0 ?? 0) >= 0 } && self.resolvedTotal != nil + } + + private static func checkedSum(_ values: [Int]) -> Int? { + var total = 0 + for value in values { + let (partial, overflow) = total.addingReportingOverflow(value) + guard !overflow else { return nil } + total = partial + } + return total + } + } +} + +#else + +extension OpenCodeLocalHistoryStore { + func decodeEntries() throws -> [Entry]? { nil } +} + +#endif diff --git a/Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift b/Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift new file mode 100644 index 00000000000..45310e14fb4 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeLocalHistoryMergeTests.swift @@ -0,0 +1,599 @@ +import CodexBarCore +import Foundation +import SQLite3 +import Testing + +@testable import CodexBarCore + +/// Gating tests for the opt-in machine-local OpenCode history source (#3273 contract): +/// it may only join the ambient Codex ledger, never scoped homes, other providers, or a +/// default-off configuration. +@Suite(.serialized) +struct OpenCodeLocalHistoryMergeTests { + @Test + func `ambient codex scan merges opt-in opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect(merged.snapshot.last30DaysTokens == 35) + // Recorded $0 cost (subscription usage) is preserved as-is, never reinterpreted. + #expect(merged.snapshot.last30DaysCostUSD == 0) + // Rows keep a visible source label instead of silently merging into native totals. + let model = try #require(merged.snapshot.daily.first?.modelsUsed?.first) + #expect(model.hasSuffix("(OpenCode local)")) + // Grouped accounting carries the rows: the unattributed bucket holds the OpenCode + // daily data, and session breakdowns stay native (empty here, no native sessions). + let unattributed = try #require( + merged.snapshot.projects.first { $0.name == CostUsageProjectBreakdown.unknownProjectName }) + #expect(unattributed.daily.first?.totalTokens == 35) + #expect(merged.snapshot.sessions.isEmpty) + // Only recorded rows are present, so the snapshot must not claim list-price estimation. + #expect(merged.snapshot.costProvenance == .sourceRecorded) + } + + @Test + func `mixed history separates recorded costs from the list-price claim`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + try Self.writeNativeCodexSession(env: env, day: day, filename: "mixed-native.jsonl", tokens: 100) + + var scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + scannerOptions.refreshMinIntervalSeconds = 0 + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "HOME": env.root.path, + ], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + // Native (estimated) and OpenCode (recorded) rows both contribute. + #expect(merged.snapshot.last30DaysTokens == 135) + // Grouped totals keep both: the native project and the unattributed bucket. + let nativeProject = try #require(merged.snapshot.projects.first { + $0.name != CostUsageProjectBreakdown.unknownProjectName + }) + #expect(nativeProject.totalTokens == 100) + let unattributed = try #require( + merged.snapshot.projects.first { $0.name == CostUsageProjectBreakdown.unknownProjectName }) + #expect(unattributed.totalTokens == 35) + // Session breakdowns cannot attribute OpenCode rows and stay native. + #expect(merged.snapshot.sessions.isEmpty) + // The window mixes estimated and recorded costs; a pure list-price claim would be false. + #expect(merged.snapshot.costProvenance == .mixed) + let window = merged.snapshot.summary(forLastDays: 1, calendar: scannerOptions.calendar) + #expect(window.provenance == .mixed) + // Per-day entries keep the recorded marker so cached windows stay truthful too. + #expect(merged.snapshot.daily.contains { $0.costIsSourceRecorded }) + } + + @Test + func `narrow window of a mixed snapshot stays source recorded`() 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: 4, day: 5) + // OpenCode recorded cost lands on the recent day; the priced native session is + // two days older, so the snapshot is mixed but today's window is recorded-only. + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + try Self.writeNativeCodexSession( + env: env, + day: olderDay, + filename: "older-priced-native.jsonl", + tokens: 100) + + var scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + scannerOptions.refreshMinIntervalSeconds = 0 + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "HOME": env.root.path, + ], + now: day, + historyDays: 7, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + // The full snapshot is mixed: list-priced native rows and recorded OpenCode rows. + #expect(merged.snapshot.costProvenance == .mixed) + // A window covering both days is mixed too. + #expect(merged.snapshot.summary(forLastDays: 7, calendar: scannerOptions.calendar).provenance == .mixed) + // But the one-day window holds only the recorded row: no list-price amount exists + // in it, so claiming a mixed estimate would be false. + let today = merged.snapshot.summary(forLastDays: 1, calendar: scannerOptions.calendar) + #expect(today.provenance == .sourceRecorded) + #expect(today.totalCostUSD != nil) + } + + @Test + func `unpriced native history keeps provenance source recorded`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + // A native model with no list price contributes tokens only; its rows carry no cost, + // so a mixed provenance would claim an estimate that is not actually included. + try Self.writeNativeCodexSession( + env: env, + day: day, + filename: "unpriced-native.jsonl", + tokens: 100, + model: "unknown-unpriced-model") + + var scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + scannerOptions.refreshMinIntervalSeconds = 0 + let merged = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "HOME": env.root.path, + ], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + // Tokens still merge from both sources. + #expect(merged.snapshot.last30DaysTokens == 135) + // But the cost claim stays recorded-only: no native list-price amount exists. + #expect(merged.snapshot.costProvenance == .sourceRecorded) + let window = merged.snapshot.summary(forLastDays: 1, calendar: scannerOptions.calendar) + #expect(window.provenance == .sourceRecorded) + } + + @Test + func `ambient codex scan stays native when the source is off`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let native = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: false, + scannerOptions: scannerOptions) + + #expect((native.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `scoped codex home never merges opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let scopedHome = env.root.appendingPathComponent("scoped-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: scopedHome, withIntermediateDirectories: true) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: scopedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let scoped = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + codexHomePath: scopedHome.path, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect((scoped.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `environment selected codex home never merges opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let selectedHome = env.root.appendingPathComponent("selected-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: selectedHome, withIntermediateDirectories: true) + + let scannerOptions = CostUsageScanner.Options( + codexSessionsRoot: selectedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let scoped = try await CostUsageFetcher.loadTokenResult( + provider: .codex, + environment: [ + "OPENCODE_DB": databaseURL.path, + "CODEX_HOME": selectedHome.path, + ], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect((scoped.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `claude scans never merge opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let scannerOptions = CostUsageScanner.Options( + claudeProjectsRoots: [env.claudeProjectsRoot], + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let claude = try await CostUsageFetcher.loadTokenResult( + provider: .claude, + environment: ["OPENCODE_DB": databaseURL.path], + now: day, + historyDays: 1, + allowPricingRefresh: false, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: scannerOptions) + + #expect((claude.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `cached codex publication merges opt-in opencode local history`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + + let options = CostUsageScanner.Options( + codexSessionsRoot: env.codexSessionsRoot, + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + let scanTime = day.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) + + // The cached publication reads only the OpenCode store's durable cache, so a scan + // populates it first — exactly how production reaches the cached path after the + // foreground scan has already persisted entries. + _ = try OpenCodeLocalHistoryStore( + cacheRoot: env.cacheRoot, + environment: ["OPENCODE_DB": databaseURL.path]) + .loadDailyReport(since: day.addingTimeInterval(-86400), until: day, calendar: options.calendar) + + let merged = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: options, + environment: ["OPENCODE_DB": databaseURL.path]) + #expect(merged?.snapshot.last30DaysTokens == 35) + // Catch-up publication converges on the same grouped accounting and provenance. + #expect(merged?.snapshot.costProvenance == .sourceRecorded) + #expect(merged?.snapshot.projects.contains { + $0.name == CostUsageProjectBreakdown.unknownProjectName + } == true) + #expect(merged?.snapshot.sessions.isEmpty == true) + + let native = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + includePiSessions: false, + scannerOptions: options, + environment: ["OPENCODE_DB": databaseURL.path]) + #expect(native?.snapshot.last30DaysTokens == 0) + #expect(native?.snapshot.costProvenance == .listPriceEstimate) + } + + @Test + func `cached scoped codex publication stays native`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let scopedHome = env.root.appendingPathComponent("scoped-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: scopedHome, withIntermediateDirectories: true) + + let options = CostUsageScanner.Options( + codexSessionsRoot: scopedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(day.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 scoped = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + codexHomePath: scopedHome.path, + historyDays: 1, + allowScopedCodexHome: true, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: options, + environment: ["OPENCODE_DB": databaseURL.path]) + #expect((scoped?.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `cached publication honors environment selected codex homes`() async throws { + let env = try CostUsageTestEnvironment() + defer { env.cleanup() } + let day = try env.makeLocalNoon(year: 2026, month: 4, day: 8) + let databaseURL = try Self.makeOpenCodeDatabase(root: env.root, day: day) + let selectedHome = env.root.appendingPathComponent("selected-codex-home", isDirectory: true) + try FileManager.default.createDirectory(at: selectedHome, withIntermediateDirectories: true) + + let options = CostUsageScanner.Options( + codexSessionsRoot: selectedHome.appendingPathComponent("sessions", isDirectory: true), + cacheRoot: env.cacheRoot, + codexTraceDatabaseURL: env.root.appendingPathComponent("missing-traces.sqlite")) + var cache = CostUsageCache() + cache.lastScanUnixMs = Int64(day.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 scoped = await CostUsageFetcher.loadCachedCodexTokenSnapshotResult( + now: day, + historyDays: 1, + includePiSessions: false, + includeOpenCodeLocalHistory: true, + scannerOptions: options, + environment: [ + "OPENCODE_DB": databaseURL.path, + "CODEX_HOME": selectedHome.path, + ]) + #expect((scoped?.snapshot.last30DaysTokens ?? 0) == 0) + } + + @Test + func `entries persisted before this feature decode without the recorded marker`() throws { + // A pre-feature cache entry carries every field this feature reads, plus none of the + // new ones. Upgrading must keep that stored data loading instead of discarding it. + let legacyJSON = """ + { + "date": "2026-04-08", + "inputTokens": 30, + "outputTokens": 5, + "totalTokens": 35, + "requestCount": 1, + "costUSD": 0.5, + "modelsUsed": ["gpt-5.1-codex"], + "modelBreakdowns": [ + {"modelName": "gpt-5.1-codex", "costUSD": 0.5, "totalTokens": 35, "requestCount": 1} + ] + } + """ + let entry = try JSONDecoder().decode( + CostUsageDailyReport.Entry.self, from: Data(legacyJSON.utf8)) + #expect(entry.totalTokens == 35) + #expect(entry.costUSD == 0.5) + #expect(entry.costIsSourceRecorded == false) + // Re-encoding round-trips the new field so refreshed caches stay self-describing. + let reencoded = try JSONDecoder().decode( + CostUsageDailyReport.Entry.self, from: JSONEncoder().encode(entry)) + #expect(reencoded == entry) + // A recorded entry must survive its own encode/decode round trip. + let recorded = CostUsageDailyReport.Entry( + date: "2026-04-08", + inputTokens: 30, + outputTokens: 5, + totalTokens: 35, + requestCount: 1, + costUSD: 0, + modelsUsed: ["gpt-5.1-codex (OpenCode local)"], + modelBreakdowns: [ + CostUsageDailyReport.ModelBreakdown( + modelName: "gpt-5.1-codex (OpenCode local)", + costUSD: 0, + totalTokens: 35, + requestCount: 1), + ], + costIsSourceRecorded: true) + let recordedData = try JSONEncoder().encode(recorded) + let recordedJSON = try #require(String(data: recordedData, encoding: .utf8)) + #expect(recordedJSON.contains("\"costIsSourceRecorded\":true")) + let decodedRecorded = try JSONDecoder().decode(CostUsageDailyReport.Entry.self, from: recordedData) + #expect(decodedRecorded.costIsSourceRecorded == true) + #expect(decodedRecorded == recorded) + } + + /// Writes one native Codex JSONL session with the given token count, so mixed-history + /// tests can assert how estimated and recorded rows combine. + private static func writeNativeCodexSession( + env: CostUsageTestEnvironment, + day: Date, + filename: String, + tokens: Int, + model: String = "gpt-5.4") throws + { + var partition = Calendar(identifier: .gregorian) + partition.timeZone = TimeZone(identifier: "UTC") ?? .current + let components = partition.dateComponents([.year, .month, .day], from: day) + let directory = env.codexSessionsRoot + .appendingPathComponent(String(format: "%04d", components.year ?? 1970), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.month ?? 1), isDirectory: true) + .appendingPathComponent(String(format: "%02d", components.day ?? 1), isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try env.jsonl([ + [ + "type": "session_meta", + "timestamp": env.isoString(for: day.addingTimeInterval(-2)), + "payload": [ + "id": "opencode-merge-\(filename)", + "cwd": env.root.appendingPathComponent("merge-project", isDirectory: true).path, + ], + ], + [ + "type": "turn_context", + "timestamp": env.isoString(for: day.addingTimeInterval(-1)), + "payload": ["model": model, "turn_id": "opencode-merge-turn"], + ], + [ + "type": "event_msg", + "timestamp": env.isoString(for: day), + "payload": [ + "type": "token_count", + "info": [ + "model": model, + "last_token_usage": [ + "input_tokens": tokens, + "cached_input_tokens": 0, + "output_tokens": 0, + ], + ], + ], + ], + ]).write( + to: directory.appendingPathComponent(filename, isDirectory: false), + atomically: true, + encoding: .utf8) + } + + /// Builds an OpenCode SQLite database with one `openai` assistant record (35 tokens, + /// recorded $0 cost) plus one `nchc` record that must never be selected. + private static func makeOpenCodeDatabase(root: URL, day: Date) throws -> URL { + let databaseURL = root.appendingPathComponent("opencode-local.db", isDirectory: false) + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + let createdMs = Int64(day.timeIntervalSince1970 * 1000) + try Self.insertRow( + db: db, + json: Self.json([ + "providerID": "openai", + "role": "assistant", + "modelID": "gpt-5.1-codex", + "cost": 0, + "time": ["created": createdMs], + "tokens": ["input": 30, "output": 5, "total": 35]]), + createdMs: createdMs) + try Self.insertRow( + db: db, + json: Self.json([ + "providerID": "nchc", + "role": "assistant", + "modelID": "glm-5.3-max", + "cost": 9, + "time": ["created": createdMs], + "tokens": ["input": 900, "output": 90, "total": 990]]), + createdMs: createdMs) + return databaseURL + } + + private static func insertRow(db: OpaquePointer?, json: String, createdMs: Int64) throws { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func json(_ payload: [String: Any]) -> String { + let data = try? JSONSerialization.data(withJSONObject: payload) + return String(data: data ?? Data(), encoding: .utf8) ?? "{}" + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private enum SQLiteTestError: Error { + case open + case prepare + case step + case exec + } +} diff --git a/Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift b/Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift new file mode 100644 index 00000000000..d3317da1c18 --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeLocalHistorySettingsTests.swift @@ -0,0 +1,203 @@ +import CodexBarCore +import Foundation +import Testing +@testable import CodexBar + +/// Lane-activation tests for the opt-in OpenCode local history source (#3273 contract): +/// enabling it must surface the ambient Codex menu-history lane on its own, while the +/// shared cost-usage gate — and every Spend Dashboard path that consults it — stays +/// untouched for users who never opted in. +@MainActor +struct OpenCodeLocalHistorySettingsTests { + @Test + func `opencode opt-in activates the ambient codex history lane`() throws { + let settings = testSettingsStore(suiteName: "OpenCodeLocalHistorySettingsTests-lane") + // Default-off: the ambient lane follows the shared cost-usage gate. + #expect(!settings.costUsageEnabled) + #expect(!settings.codexLocalSessionCostLedgerEnabled) + #expect(!settings.codexIncludeOpenCodeLocalHistory) + #expect(!settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + + // Opting into OpenCode local history alone activates the ambient Codex + // menu-history lane, so a user whose only source is OpenCode needs no second + // toggle to see the merged history. + settings.codexIncludeOpenCodeLocalHistory = true + #expect(settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + // The shared gate stays off — other providers and the Spend Dashboard paths + // that consult it never activate through this opt-in. + #expect(!settings.isCostUsageEffectivelyEnabled(for: .codex)) + #expect(!settings.isCostUsageEffectivelyEnabled(for: .claude)) + + // The classic toggles keep working on their own. + settings.codexIncludeOpenCodeLocalHistory = false + settings.codexLocalSessionCostLedgerEnabled = true + #expect(settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + settings.codexLocalSessionCostLedgerEnabled = false + #expect(!settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + } + + @Test + func `codex settings expose the opencode opt-in toggle default off`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-toggle") + let context = fixture.settingsContext(provider: .codex) + let toggle = try #require(CodexProviderImplementation().settingsToggles(context: context) + .first(where: { $0.id == "codex-opencode-local-history" })) + #expect(!toggle.binding.wrappedValue) + toggle.binding.wrappedValue = true + #expect(fixture.settings.codexIncludeOpenCodeLocalHistory) + #expect(fixture.settings.isHistoryLaneEffectivelyEnabled(for: .codex)) + } + + @Test + func `menu card exposes the codex cost section from the opencode opt-in alone`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-menu") + // Default-off with no other cost source: the ambient history lane stays hidden. + #expect(!fixture.settings.costUsageEnabled) + #expect(!fixture.settings.codexLocalSessionCostLedgerEnabled) + #expect(!fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostUsageEnabled) + #expect(!fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostMenuSectionEnabled) + + // The opt-in alone surfaces the menu-history lane for a user whose only source is + // OpenCode — no second cost toggle required. + fixture.settings.codexIncludeOpenCodeLocalHistory = true + #expect(fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostUsageEnabled) + #expect(fixture.store.menuCardInput(for: .codex, context: .menu).tokenCostMenuSectionEnabled) + #expect(fixture.store.menuCardInput(for: .codex, context: .settings).tokenCostMenuSectionEnabled) + + // Other providers never activate through this opt-in. + #expect(!fixture.store.menuCardInput(for: .claude, context: .menu).tokenCostUsageEnabled) + #expect(!fixture.store.menuCardInput(for: .claude, context: .menu).tokenCostMenuSectionEnabled) + } + + @Test + func `inline summary style renders from the opencode opt-in alone`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-inline") + fixture.settings.costSummaryDisplayStyle = .inlineSummary + // Default-off with no other cost source: the inline lane stays hidden. + #expect(!fixture.settings.costSummaryShowsInline(for: .codex)) + + // The opt-in alone renders the inline summary — the only presentation under this + // style, since .inlineSummary disables the submenu. + fixture.settings.codexIncludeOpenCodeLocalHistory = true + #expect(fixture.settings.costSummaryShowsInline(for: .codex)) + #expect(!fixture.settings.costSummaryShowsSubmenu(for: .codex)) + #expect(fixture.store.menuCardInput(for: .codex, context: .menu).costSummaryInlineEnabled) + + // Other providers never activate through this opt-in. + #expect(!fixture.settings.costSummaryShowsInline(for: .claude)) + } + + @Test + func `opencode opt-in selects the ambient scope over the managed account home`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-scope") + fixture.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + fixture.settings.codexActiveSource = .managedAccount(id: UUID()) + defer { fixture.settings._test_activeManagedCodexRemoteHomePath = nil } + + // Default-off: the managed home stays selected and the merge stays gated off. + let managedScope = fixture.store.tokenCostScope(for: .codex) + #expect(managedScope.codexHomePath == "/tmp/managed-codex-home") + #expect(!fixture.store.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex)) + + // The opt-in selects the ambient home — the machine-local scope its rows live in — + // mirroring the local ledger, so the merge gate opens instead of scanning a home + // whose scoped rows can never include OpenCode history. + fixture.settings.codexIncludeOpenCodeLocalHistory = true + let ambientScope = fixture.store.tokenCostScope(for: .codex) + #expect(ambientScope.codexHomePath == nil) + #expect(ambientScope.signature == "codex:ambient") + #expect(fixture.store.shouldIncludeOpenCodeLocalHistoryInTokenSnapshot(for: .codex)) + } + + @Test + func `ambient opencode publication survives codex account switches`() throws { + let fixture = try self.makeSettingsFixture(suite: "OpenCodeLocalHistorySettingsTests-account") + let store = fixture.store + + // The opt-in selects the ambient scope; a publication under that scope installs. + store.settings.codexIncludeOpenCodeLocalHistory = true + store.installCachedTokenSnapshot(Self.snapshot(cost: 1), for: .codex) + #expect(store.accountScopedTokenSnapshot(for: .codex) != nil) + + // An account switch bumps the provider-config revision, but ambient history is + // account-agnostic: the still-current ambient publication must not be discarded + // or the This Mac section would vanish behind a rescan. + let revisionBefore = store.settings.providerConfigRevision(for: .codex) + store.settings.codexActiveSource = .managedAccount(id: UUID()) + #expect(store.settings.providerConfigRevision(for: .codex) != revisionBefore) + #expect(store.accountScopedTokenSnapshot(for: .codex) != nil) + + // A pre-toggle scoped publication must not leak account rows into the ambient lane. + store.settings.codexIncludeOpenCodeLocalHistory = false + store.settings._test_activeManagedCodexRemoteHomePath = "/tmp/managed-codex-home" + store.settings.codexActiveSource = .managedAccount(id: UUID()) + store.installCachedTokenSnapshot(Self.snapshot(cost: 2), for: .codex) + store.settings.codexIncludeOpenCodeLocalHistory = true + #expect(store.accountScopedTokenSnapshot(for: .codex) == nil) + } + + private static func snapshot(cost: Double) -> CostUsageTokenSnapshot { + CostUsageTokenSnapshot( + sessionTokens: 10, + sessionCostUSD: cost, + last30DaysTokens: 10, + last30DaysCostUSD: cost, + daily: [], + updatedAt: Date()) + } + + private func makeSettingsFixture(suite: String) throws -> Fixture { + let settings = testSettingsStore(suiteName: suite) + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + settings._test_managedCodexAccountStoreURL = root.appendingPathComponent("accounts.json") + let environment = ["HOME": root.path, "CODEX_HOME": root.appendingPathComponent("codex").path] + let store = UsageStore( + fetcher: UsageFetcher(environment: environment), + browserDetection: BrowserDetection(homeDirectory: root.path, cacheTTL: 0), + settings: settings, + startupBehavior: .testing, + environmentBase: environment) + return Fixture(settings: settings, store: store) + } + + private struct Fixture { + let settings: SettingsStore + let store: UsageStore + private let state = ProviderSettingsContextState() + + @MainActor + func settingsContext(provider: UsageProvider) -> ProviderSettingsContext { + let settings = self.settings + let store = self.store + let state = self.state + return ProviderSettingsContext( + provider: provider, + settings: settings, + store: store, + statusText: { id in state.statusByID[id] }, + setStatusText: { id, text in + if let text { + state.statusByID[id] = text + } else { + state.statusByID.removeValue(forKey: id) + } + }, + lastAppActiveRunAt: { id in state.lastRunAtByID[id] }, + setLastAppActiveRunAt: { id, date in + if let date { + state.lastRunAtByID[id] = date + } else { + state.lastRunAtByID.removeValue(forKey: id) + } + }, + requestConfirmation: { _ in }, + runLoginFlow: {}) + } + } + + private final class ProviderSettingsContextState { + var statusByID: [String: String] = [:] + var lastRunAtByID: [String: Date] = [:] + } +} diff --git a/Tests/CodexBarTests/OpenCodeLocalHistoryStoreTests.swift b/Tests/CodexBarTests/OpenCodeLocalHistoryStoreTests.swift new file mode 100644 index 00000000000..bd38567bfce --- /dev/null +++ b/Tests/CodexBarTests/OpenCodeLocalHistoryStoreTests.swift @@ -0,0 +1,852 @@ +import Foundation +import Testing + +@testable import CodexBarCore + +#if canImport(SQLite3) +import SQLite3 + +struct OpenCodeLocalHistoryStoreTests { + @Test + func `selects only openai provider records`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1.5, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "glm-5.3-max", providerID: "nchc") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "claude-sonnet-4-5", providerID: "opencode-go") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "gpt-5.1", providerID: "openrouter") + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(result.data.count == 1) + #expect(entry.costUSD == 1.5) + #expect(entry.requestCount == 1) + #expect(entry.modelsUsed == ["gpt-5.1-codex (OpenCode local)"]) + } + + @Test + func `step finish parts win over message fallback without double counting`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + let withParts = try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 5, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertStepFinishPart( + databaseURL: env.databaseURL, messageID: withParts, createdMs: Self.ms(now), cost: 2, + tokens: ["input": 10, "output": 5, "reasoning": 1, "cache": ["read": 4, "write": 0]]) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 3, + model: "gpt-5.1-codex", providerID: "openai") + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 5) + #expect(entry.requestCount == 2) + #expect(entry.inputTokens == 10) + #expect(entry.outputTokens == 5) + #expect(entry.reasoningTokens == 1) + #expect(entry.cacheReadTokens == 4) + #expect(entry.cacheCreationTokens == 0) + // The message-level fallback row has no token metadata, so the day's aggregate total + // is conservatively unknown even though the component dimensions merge additively. + #expect(entry.totalTokens == nil) + } + + @Test + func `v2 session messages add new turns without double counting migrated ones`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL, includeSessionMessages: true) + let now = Date(timeIntervalSince1970: 1_772_798_400) + let migratedID = try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 3, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertSessionMessage( + databaseURL: env.databaseURL, id: migratedID, createdMs: Self.ms(now), cost: 3, + model: "gpt-5.1-codex") + try Self.insertSessionMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 2, + model: "gpt-5.2", tokens: ["input": 7, "output": 3, "total": 10]) + try Self.insertSessionMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 9, + model: "glm-5.3-max", providerID: "nchc") + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 5) + #expect(entry.requestCount == 2) + #expect(entry.modelsUsed?.sorted() == ["gpt-5.1-codex (OpenCode local)", "gpt-5.2 (OpenCode local)"]) + } + + @Test + func `v2 only database without legacy tables is read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createV2OnlyDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertSessionMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", tokens: ["input": 2, "output": 1, "total": 3]) + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 1) + #expect(entry.requestCount == 1) + #expect(entry.totalTokens == 3) + } + + @Test + func `recorded zero cost keeps token history`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 0, + model: "gpt-5.1-codex", providerID: "openai", + tokens: ["input": 100, "output": 50, "total": 150]) + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 0) + #expect(entry.totalTokens == 150) + } + + @Test + func `missing and non numeric cost rows are skipped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: nil, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertRawMessage( + databaseURL: env.databaseURL, + json: "{\"providerID\":\"openai\",\"role\":\"assistant\",\"cost\":\"free\"}") + + let result = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let entry = try #require(result?.data.first) + #expect(entry.requestCount == 1) + #expect(entry.costUSD == 1) + } + + @Test + func `negative or overflowing token counts are dropped`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "negative", providerID: "openai", + tokens: ["input": -5, "output": 1, "total": -4]) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "overflow", providerID: "openai", + tokens: ["input": Int.max, "output": 1, "total": Int.max]) + + let result = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let entry = try #require(result?.data.first) + #expect(entry.requestCount == 2) + #expect(entry.totalTokens == nil) + #expect(entry.modelsUsed?.sorted() == ["negative (OpenCode local)", "overflow (OpenCode local)"]) + } + + @Test + func `rows outside the requested window are excluded`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + try Self.insertMessage( + databaseURL: env.databaseURL, + createdMs: Self.ms(now.addingTimeInterval(-48 * 3600)), cost: 8, + model: "gpt-5.1-codex", providerID: "openai") + + let result = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let entry = try #require(result?.data.first) + #expect(entry.requestCount == 1) + #expect(entry.costUSD == 1) + } + + @Test + func `missing database or empty history returns nil`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + + #expect(try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: Date.distantPast, until: Date(), calendar: .current) == nil) + + try Self.createDatabase(at: env.databaseURL) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: 1_772_798_400_000, cost: 9, + model: "glm-5.3-max", providerID: "nchc") + + #expect(try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: Date.distantPast, until: Date(), calendar: .current) == nil) + } + + @Test + func `idle wal database without sidecars is still read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 4, + model: "gpt-5.1-codex", providerID: "openai") + try Self.configureIdleWAL(at: env.databaseURL) + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 4) + } + + @Test + func `active wal frames are read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + + var writer = try Self.OpenWriter(url: env.databaseURL) + defer { writer.close() } + try writer.exec("PRAGMA journal_mode = WAL;") + try writer.insertMessage(createdMs: Self.ms(now), cost: 6, model: "gpt-5.1-codex", providerID: "openai") + // Keep the write transaction uncommitted... committed but un-checkpointed frames must be visible. + try writer.exec("PRAGMA wal_checkpoint(PASSIVE);") + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + let entry = try #require(result.data.first) + #expect(entry.costUSD == 6) + #expect(entry.requestCount == 1) + } + + @Test + func `absolute OPENCODE_DB override is honored`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 2, + model: "gpt-5.1-codex", providerID: "openai") + + let store = OpenCodeLocalHistoryStore( + cacheRoot: env.root.appendingPathComponent("history-cache", isDirectory: true), + homeDirectory: env.root, + environment: ["OPENCODE_DB": env.databaseURL.path]) + let loaded = try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + #expect(result.data.first?.requestCount == 1) + } + + @Test + func `selection follows the upstream single database contract`() { + let dataDirectory = URL(fileURLWithPath: "/tmp/opencode-data", isDirectory: true) + // No explicit selection: the release database, regardless of environment channel + // hints (upstream derives the channel from a build constant, not the environment). + let plain = OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: dataDirectory, + environment: ["OPENCODE_UPDATE_CHANNEL": "canary"]) + #expect(plain.lastPathComponent == "opencode.db") + + let disabled = OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: dataDirectory, + environment: ["OPENCODE_UPDATE_CHANNEL": "canary", "OPENCODE_DISABLE_CHANNEL_DB": "1"]) + #expect(disabled.lastPathComponent == "opencode.db") + + let stable = OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: dataDirectory, + environment: ["OPENCODE_UPDATE_CHANNEL": "latest"]) + #expect(stable.lastPathComponent == "opencode.db") + + // Explicit selections are never substituted: one database, exactly as requested. + let relative = OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: dataDirectory, + environment: ["OPENCODE_DB": "custom.db"]) + #expect(relative.lastPathComponent == "custom.db") + + let absolute = OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: dataDirectory, + environment: ["OPENCODE_DB": "/elsewhere/open.db"]) + #expect(absolute.path == "/elsewhere/open.db") + + // In-memory history lives off disk; an on-disk reader sees nothing and must not + // silently substitute the stable database. + let memory = OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: dataDirectory, + environment: ["OPENCODE_DB": ":memory:"]) + #expect(memory.lastPathComponent != "opencode.db") + } + + @Test + func `an explicit selection is never substituted with the default database`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + // The default database holds rows, but the explicitly selected one does not. + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + let selectedDirectory = env.root.appendingPathComponent("selected", isDirectory: true) + try FileManager.default.createDirectory(at: selectedDirectory, withIntermediateDirectories: true) + let selectedURL = selectedDirectory.appendingPathComponent("opencode.db", isDirectory: false) + try Self.createDatabase(at: selectedURL) + + let store = OpenCodeLocalHistoryStore( + databaseURL: OpenCodeLocalHistoryStore.primaryDatabaseURL( + dataDirectory: env.databaseURL.deletingLastPathComponent(), + environment: ["OPENCODE_DB": selectedURL.path]), + cacheURL: env.cacheURL) + #expect(try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + } + + @Test + func `earlier today rows survive a clock time lower bound`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + // Pin the calendar so the test is independent of the host time zone: 2026-03-06 + // 15:00 UTC with the row at 10:00 UTC the same day. + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(identifier: "UTC") ?? .current + let now = try #require(utc.date(from: DateComponents( + year: 2026, month: 3, day: 6, hour: 15, minute: 0))) + let morning = try #require(utc.date(from: DateComponents( + year: 2026, month: 3, day: 6, hour: 10, minute: 0))) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(morning), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + // A `now`-anchored one-hour window must still include the morning row once the + // lower bound is normalized to the reporting day's start. + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: now.addingTimeInterval(-3600), until: now, calendar: utc) + let result = try #require(loaded) + #expect(result.data.first?.requestCount == 1) + } + + @Test + func `day keys follow the supplied reporting calendar`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + var utcCalendar = Calendar(identifier: .gregorian) + utcCalendar.timeZone = TimeZone(identifier: "UTC") ?? .current + // 2026-10-16 00:30 UTC: a local-time calendar would file this under October 15. + let boundary = try #require(utcCalendar.date( + from: DateComponents(year: 2026, month: 10, day: 16, hour: 0, minute: 30))) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(boundary), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + let loaded = try OpenCodeLocalHistoryStore( + databaseURL: env.databaseURL, + cacheURL: env.cacheURL) + .loadDailyReport(since: boundary.addingTimeInterval(-3600), until: boundary.addingTimeInterval(3600), calendar: utcCalendar) + let result = try #require(loaded) + #expect(result.data.first?.date == "2026-10-16") + } + + @Test + func `xdg data home relocates the database discovery`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + let xdgRoot = env.root.appendingPathComponent("xdg-data", isDirectory: true) + let xdgDirectory = xdgRoot.appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: xdgDirectory, withIntermediateDirectories: true) + let databaseURL = xdgDirectory.appendingPathComponent("opencode.db", isDirectory: false) + try Self.createDatabase(at: databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + let store = OpenCodeLocalHistoryStore( + cacheRoot: env.root.appendingPathComponent("history-cache", isDirectory: true), + homeDirectory: env.root, + environment: ["XDG_DATA_HOME": xdgRoot.path]) + let loaded = try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let result = try #require(loaded) + #expect(result.data.first?.requestCount == 1) + + // A relative XDG_DATA_HOME is ignored per the XDG spec; discovery falls back to + // ~/.local/share/opencode, which holds no database in this environment. + let relativeStore = OpenCodeLocalHistoryStore( + cacheRoot: env.root.appendingPathComponent("history-cache-2", isDirectory: true), + homeDirectory: env.root, + environment: ["XDG_DATA_HOME": "relative/path"]) + #expect(try relativeStore.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + } + + // MARK: - v2 shared-store cache behavior + + @Test + func `cached publication reads the durable cache without the database`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1.5, + model: "gpt-5.1-codex", providerID: "openai") + + let store = OpenCodeLocalHistoryStore(databaseURL: env.databaseURL, cacheURL: env.cacheURL) + let firstLoaded = try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let first = try #require(firstLoaded) + + // The database disappears (uninstalled, or a scan merely persists first): a cached + // publication still serves the persisted entries — it never opens the database. + try FileManager.default.removeItem(at: env.databaseURL) + let cached = store.cachedDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + #expect(cached?.data.map(\.date) == first.data.map(\.date)) + #expect(cached?.data.first?.costUSD == 1.5) + + // The scan path sees the missing database and clears the cache instead of serving + // rows from an installation that is gone. + #expect(try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + #expect(store.cachedDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + } + + @Test + func `a changed database fingerprint forces one re-read`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + let store = OpenCodeLocalHistoryStore(databaseURL: env.databaseURL, cacheURL: env.cacheURL) + let firstLoaded = try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let first = try #require(firstLoaded) + #expect(first.data.first?.requestCount == 1) + + // A new row changes the fingerprint; the next scan re-reads and sees it. + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 2, + model: "gpt-5.1-codex", providerID: "openai") + let secondLoaded = try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + let second = try #require(secondLoaded) + #expect(second.data.first?.requestCount == 2) + #expect(second.data.first?.costUSD == 3) + } + + @Test + func `removing the cache empties cached publications`() throws { + let env = try Self.makeEnvironment() + defer { try? FileManager.default.removeItem(at: env.root) } + try Self.createDatabase(at: env.databaseURL) + let now = Date(timeIntervalSince1970: 1_772_798_400) + try Self.insertMessage( + databaseURL: env.databaseURL, createdMs: Self.ms(now), cost: 1, + model: "gpt-5.1-codex", providerID: "openai") + + let store = OpenCodeLocalHistoryStore(databaseURL: env.databaseURL, cacheURL: env.cacheURL) + _ = try store.loadDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) + #expect(store.cachedDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) != nil) + + try store.removeAllCachedEntries() + #expect(store.cachedDailyReport( + since: now.addingTimeInterval(-3600), until: now, calendar: .current) == nil) + } +} + +extension OpenCodeLocalHistoryStoreTests { + private struct Environment { + let root: URL + let databaseURL: URL + let cacheURL: URL + } + + private static func makeEnvironment() throws -> Environment { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenCodeLocalHistoryStoreTests-\(UUID().uuidString)", isDirectory: true) + let directory = root + .appendingPathComponent(".local", isDirectory: true) + .appendingPathComponent("share", isDirectory: true) + .appendingPathComponent("opencode", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let cacheURL = root.appendingPathComponent("history-cache", isDirectory: true) + .appendingPathComponent("opencode-local-history-v1.sqlite", isDirectory: false) + return Environment( + root: root, + databaseURL: directory.appendingPathComponent("opencode.db", isDirectory: false), + cacheURL: cacheURL) + } + + private static func createDatabase( + at url: URL, + includeParts: Bool = true, + includeSessionMessages: Bool = false) throws + { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + CREATE TABLE part ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + session_id TEXT NOT NULL, + data TEXT NOT NULL, + time_created INTEGER, + time_updated INTEGER + ); + """) + if !includeParts { + try Self.exec(db: db, sql: "DROP TABLE part") + } + if includeSessionMessages { + try Self.exec( + db: db, + sql: """ + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + """) + } + } + + private static func createV2OnlyDatabase(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + try Self.exec( + db: db, + sql: """ + CREATE TABLE session_message ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + type TEXT NOT NULL, + seq INTEGER NOT NULL, + time_created INTEGER NOT NULL, + time_updated INTEGER NOT NULL, + data TEXT NOT NULL + ); + """) + } + + private static func configureIdleWAL(at url: URL) throws { + var db: OpaquePointer? + guard sqlite3_open(url.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + do { + try Self.exec(db: db, sql: "PRAGMA journal_mode = WAL; PRAGMA wal_checkpoint(TRUNCATE);") + } catch { + sqlite3_close(db) + throw error + } + guard sqlite3_close(db) == SQLITE_OK else { throw SQLiteTestError.close } + + for suffix in ["-wal", "-shm"] { + let sidecarURL = URL(fileURLWithPath: url.path + suffix) + if FileManager.default.fileExists(atPath: sidecarURL.path) { + try FileManager.default.removeItem(at: sidecarURL) + } + } + } + + @discardableResult + private static func insertMessage( + databaseURL: URL, + createdMs: Int64, + cost: Double?, + model: String? = nil, + providerID: String = "openai", + tokens: Any? = nil) throws -> String + { + let messageID = UUID().uuidString + var payload: [String: Any] = [ + "providerID": providerID, + "role": "assistant", + "time": ["created": createdMs], + ] + if let cost { + payload["cost"] = cost + } + if let model { + payload["modelID"] = model + } + if let tokens { + payload["tokens"] = tokens + } + try Self.insertRawMessage(databaseURL: databaseURL, id: messageID, createdMs: createdMs, json: Self.json(payload)) + return messageID + } + + private static func insertRawMessage( + databaseURL: URL, + id: String = UUID().uuidString, + createdMs: Int64 = 0, + json: String) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, id, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, json, -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func insertStepFinishPart( + databaseURL: URL, + messageID: String, + createdMs: Int64, + cost: Double, + tokens: [String: Any] = ["input": 1, "output": 1, "total": 2]) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + let payload: [String: Any] = [ + "type": "step-finish", + "cost": cost, + "tokens": tokens, + ] + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + "INSERT INTO part (id, message_id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, messageID, -1, transient) + sqlite3_bind_text(stmt, 3, "session-1", -1, transient) + sqlite3_bind_text(stmt, 4, Self.json(payload), -1, transient) + sqlite3_bind_int64(stmt, 5, createdMs) + sqlite3_bind_int64(stmt, 6, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func insertSessionMessage( + databaseURL: URL, + id: String = UUID().uuidString, + createdMs: Int64, + cost: Double, + model: String, + providerID: String = "openai", + tokens: [String: Any]? = nil) throws + { + var db: OpaquePointer? + guard sqlite3_open(databaseURL.path, &db) == SQLITE_OK else { throw SQLiteTestError.open } + defer { sqlite3_close(db) } + + var payload: [String: Any] = [ + "model": ["providerID": providerID, "id": model], + "cost": cost, + "time": ["created": createdMs], + ] + if let tokens { + payload["tokens"] = tokens + } + + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + db, + """ + INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) + VALUES (?, ?, 'assistant', 0, ?, ?, ?) + """, + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, id, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_int64(stmt, 3, createdMs) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_text(stmt, 5, Self.json(payload), -1, transient) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + private static func json(_ payload: [String: Any]) -> String { + let data = try? JSONSerialization.data(withJSONObject: payload) + return String(data: data ?? Data(), encoding: .utf8) ?? "{}" + } + + private static func exec(db: OpaquePointer?, sql: String) throws { + var message: UnsafeMutablePointer? + guard sqlite3_exec(db, sql, nil, nil, &message) == SQLITE_OK else { + sqlite3_free(message) + throw SQLiteTestError.exec + } + } + + private static func ms(_ date: Date) -> Int64 { + Int64(date.timeIntervalSince1970 * 1000) + } + + private enum SQLiteTestError: Error { + case close + case open + case prepare + case step + case exec + } + + /// A database connection that stays open across WAL writes, exercising the reader's + /// normal (non-immutable) open path against a live WAL. + private struct OpenWriter { + private var db: OpaquePointer? + + init(url: URL) throws { + guard sqlite3_open(url.path, &self.db) == SQLITE_OK else { throw SQLiteTestError.open } + } + + func exec(_ sql: String) throws { + try OpenCodeLocalHistoryStoreTests.exec(db: self.db, sql: sql) + } + + func insertMessage(createdMs: Int64, cost: Double, model: String, providerID: String) throws { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2( + self.db, + "INSERT INTO message (id, session_id, data, time_created, time_updated) VALUES (?, ?, ?, ?, ?)", + -1, + &stmt, + nil) == SQLITE_OK + else { throw SQLiteTestError.prepare } + defer { sqlite3_finalize(stmt) } + + let payload: [String: Any] = [ + "providerID": providerID, + "role": "assistant", + "modelID": model, + "cost": cost, + "time": ["created": createdMs], + ] + let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) + sqlite3_bind_text(stmt, 1, UUID().uuidString, -1, transient) + sqlite3_bind_text(stmt, 2, "session-1", -1, transient) + sqlite3_bind_text(stmt, 3, OpenCodeLocalHistoryStoreTests.json(payload), -1, transient) + sqlite3_bind_int64(stmt, 4, createdMs) + sqlite3_bind_int64(stmt, 5, createdMs) + guard sqlite3_step(stmt) == SQLITE_DONE else { throw SQLiteTestError.step } + } + + mutating func close() { + if let db { + sqlite3_close(db) + } + self.db = nil + } + } +} + +#endif diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 898db716e1e..50575b997c6 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1202,7 +1202,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+CodexCostCatchUp.swift", - anchor: "&& self.settings.isCostUsageEffectivelyEnabled(for: .codex)", + anchor: "&& self.settings.isHistoryLaneEffectivelyEnabled(for: .codex)", expectedProviderIDs: ["codex"], reason: "This provider-owned integration passes its fixed identity to a shared helper."), SuppressedProviderReference( @@ -1357,7 +1357,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "The Codex-only cache hydration path passes its fixed provider identity to shared state helpers."), SuppressedProviderReference( path: "Sources/CodexBar/UsageStore+TokenCost.swift", - anchor: "self.settings.isCostUsageEffectivelyEnabled(for: .codex),", + anchor: "self.settings.isHistoryLaneEffectivelyEnabled(for: .codex),", expectedProviderIDs: ["codex"], reason: "This provider-specific app branch passes its already-selected identity to a shared helper."), SuppressedProviderReference( From 0c90ce2d218b08a210647224cbba3c6e208a8740 Mon Sep 17 00:00:00 2001 From: Daniel930902 Date: Thu, 22 Oct 2026 17:44:46 +0800 Subject: [PATCH 2/2] Fix Claude account scanner options regression The claudeConfigDirectory restoration prepared account-specific projects roots in `options` but the call still passed a fresh scannerOptions lookup, so a mapped Claude account validated its directory while the scan read ambient history (ClawSweeper P1 on #4702). Pass the prepared options through as main did. --- Sources/CodexBarCore/CostUsageFetcher.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/CodexBarCore/CostUsageFetcher.swift b/Sources/CodexBarCore/CostUsageFetcher.swift index b3c9560f99f..48342b62893 100644 --- a/Sources/CodexBarCore/CostUsageFetcher.swift +++ b/Sources/CodexBarCore/CostUsageFetcher.swift @@ -291,7 +291,7 @@ public struct CostUsageFetcher: Sendable { bypassScannerDebounce: bypassScannerDebounce, piWorkingDirectories: piWorkingDirectories, piSessionProcessContexts: piSessionProcessContexts, - scannerOptions: self.scannerOptions(calendar: calendar), + scannerOptions: options, reportContext: reportContext) }