From 5bfe2f2cd4aece48bf075769109ac6716639c330 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:50:54 +0700 Subject: [PATCH 1/4] Port upstream 0.69.0: reset Codex weekly baseline on plan change --- .../src-tauri/src/commands/providers.rs | 52 +++++ rust/src/providers/codex/weekly_reset.rs | 48 ++++- .../codex/weekly_reset/diagnostics.rs | 2 + .../src/providers/codex/weekly_reset/tests.rs | 183 ++++++++++++++++++ 4 files changed, 275 insertions(+), 10 deletions(-) diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index 103d47b869..e9ca1490ff 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -698,6 +698,11 @@ pub(super) fn codex_reset_backfill( if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { return; } + // A subscription change starts a new quota baseline: reset times from the + // previous plan are not evidence for the new one. Unknown plans never block. + if snapshot.provider_id == "codex" && codex_plan_changed(cached, snapshot) { + return; + } // Backfill each slot from the corresponding cached slot. backfill_slot_window( @@ -715,6 +720,20 @@ pub(super) fn codex_reset_backfill( } } +/// True only when both snapshots report a known plan and the plans differ. +fn codex_plan_changed(cached: &ProviderUsageSnapshot, fresh: &ProviderUsageSnapshot) -> bool { + let normalize = |plan: &Option| { + plan.as_deref() + .map(str::trim) + .filter(|plan| !plan.is_empty()) + .map(str::to_lowercase) + }; + matches!( + (normalize(&cached.plan_name), normalize(&fresh.plan_name)), + (Some(cached), Some(fresh)) if cached != fresh + ) +} + /// Backfill `resets_at` and `reset_description` on a fresh window from the /// cached window whose reset is still in the future. `used_percent` is never /// overwritten (upstream: "fresh used_percent untouched"). @@ -1391,6 +1410,39 @@ mod reset_backfill_tests { assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); } + #[test] + fn codex_backfill_skips_when_known_plans_differ() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = Some("Plus".into()); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = Some("Pro".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "plan change baseline"); + } + + #[test] + fn codex_backfill_keeps_baseline_for_same_or_unknown_plan() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + for (cached_plan, fresh_plan) in [ + (Some("Plus"), Some(" plus ")), + (Some("Plus"), None), + (None, Some("Pro")), + (Some("Plus"), Some(" ")), + ] { + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = cached_plan.map(str::to_string); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = fresh_plan.map(str::to_string); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!( + fresh.primary.resets_at.as_deref(), + Some(future.as_str()), + "{cached_plan:?} -> {fresh_plan:?}" + ); + } + } + #[test] fn f6_skips_non_codex_provider() { let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index c27a3158eb..e427b95294 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -244,6 +244,13 @@ pub(super) fn initial_decision( exact_oauth: bool, observed_at: DateTime, ) -> InitialDecision { + if exact_oauth && plan_changed(state, current) { + // A new subscription has its own quota baseline, not evidence of a + // reset on the previous plan: drop the stored weekly window, pending + // candidate, and credit inventory so the old plan cannot be pinned. + log_reset_diagnostic("planBaseline", "reset", ResetDiagnosticReason::PlanChanged); + *state = AccountState::default(); + } if let Some(candidate) = state.candidate.clone() { match delayed_candidate_decision( state, @@ -325,7 +332,12 @@ pub(super) fn confirmation_decision( if confirmation_weekly.used_percent > RESET_THRESHOLD { return ConfirmationDecision::Publish; } - if initial_weekly.used_percent > RESET_THRESHOLD { + // A near-zero reading is only trusted when both observations report the + // same plan; a plan flip between them is not a confirmation. + if initial_weekly.used_percent > RESET_THRESHOLD + || normalized_plan(initial.login_method.as_deref()) + != normalized_plan(confirmation.login_method.as_deref()) + { return ConfirmationDecision::Preserve; } @@ -696,16 +708,32 @@ fn supported_delayed_boundary(previous: &RateWindow, current: &RateWindow) -> bo distance.abs() < STABLE_BOUNDARY_TOLERANCE_SECONDS || distance >= RESET_TOLERANCE_SECONDS } +fn normalized_plan(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase) +} + +/// True only when the stored and fresh plans are both known and differ; +/// an unknown plan never resets the baseline. +fn plan_changed(state: &AccountState, current: &UsageSnapshot) -> bool { + if current.updated_at <= state.published_at { + return false; + } + match ( + normalized_plan(state.plan.as_deref()), + normalized_plan(current.login_method.as_deref()), + ) { + (Some(previous), Some(current)) => previous != current, + _ => false, + } +} + fn plans_match(previous: Option<&str>, left: &UsageSnapshot, right: &UsageSnapshot) -> bool { - let normalize = |value: Option<&str>| { - value - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_lowercase) - }; - let previous = normalize(previous); - let left = normalize(left.login_method.as_deref()); - let right = normalize(right.login_method.as_deref()); + let previous = normalized_plan(previous); + let left = normalized_plan(left.login_method.as_deref()); + let right = normalized_plan(right.login_method.as_deref()); previous.is_some() && previous == left && left == right } diff --git a/rust/src/providers/codex/weekly_reset/diagnostics.rs b/rust/src/providers/codex/weekly_reset/diagnostics.rs index 9ce305e8e3..c870b60a28 100644 --- a/rust/src/providers/codex/weekly_reset/diagnostics.rs +++ b/rust/src/providers/codex/weekly_reset/diagnostics.rs @@ -9,6 +9,7 @@ pub(super) enum ResetDiagnosticReason { InconsistentResetBoundary, UnsupportedResetBoundary, PlanMismatch, + PlanChanged, MissingCreditInventory, ChangedCreditInventory, EvidenceVersionMismatch, @@ -33,6 +34,7 @@ impl ResetDiagnosticReason { Self::InconsistentResetBoundary => "inconsistentResetBoundary", Self::UnsupportedResetBoundary => "unsupportedResetBoundary", Self::PlanMismatch => "planMismatch", + Self::PlanChanged => "planChanged", Self::MissingCreditInventory => "missingCreditInventory", Self::ChangedCreditInventory => "changedCreditInventory", Self::EvidenceVersionMismatch => "evidenceVersionMismatch", diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index 224ab8c355..3e71208b06 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -527,3 +527,186 @@ fn rolling_weekly_confirmation_keeps_inventory_and_expiry_guards() { "candidate expired" ); } + +fn plan_snapshot(plan: Option<&str>, used: f64, captured_minutes: i64) -> UsageSnapshot { + let mut snapshot = snapshot(used, 9, captured_minutes); + snapshot.login_method = plan.map(str::to_string); + snapshot +} + +/// Plus subscription with a stale 80% weekly baseline that resets in one day. +fn plus_baseline() -> AccountState { + let mut previous = snapshot(80.0, 1, 0); + previous.login_method = Some("ChatGPT Plus".to_string()); + AccountState { + published_weekly: previous.secondary.clone(), + published_at: previous.updated_at, + plan: previous.login_method.clone(), + credit_inventory: Some(inventory("credit-a")), + candidate: None, + } +} + +#[test] +fn plan_upgrade_starts_a_new_baseline_and_publishes_the_new_plan() { + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Pro"), 0.0, 11); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + assert!(state.published_weekly.is_none()); + assert!(state.credit_inventory.is_none()); + assert!(state.candidate.is_none()); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Publish + ); +} + +#[test] +fn same_plan_near_zero_reading_keeps_the_previous_weekly_pinned() { + // Identical to the upgrade scenario, but the plan did not change: the old + // weekly window stays pinned until the confirmation is trustworthy. + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let initial = plan_snapshot(Some("ChatGPT Plus"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Plus"), 0.0, 11); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + assert!(state.published_weekly.is_some()); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve + ); +} + +#[test] +fn plan_upgrade_does_not_pin_the_previous_plan_weekly_window() { + let mut state = plus_baseline(); + let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + let preserved = preserve_weekly(&state, current.clone()); + let used = |snapshot: &UsageSnapshot| snapshot.secondary.as_ref().map(|w| w.used_percent); + assert_eq!(used(&preserved), Some(5.0)); + assert_eq!(used(&preserved), used(¤t)); +} + +#[test] +fn plan_change_discards_a_pending_candidate() { + let mut state = plus_baseline(); + state.candidate = Some(DelayedCandidate { + evidence_version: EVIDENCE_VERSION, + first_observed_at: now(), + created_at: now(), + snapshot_updated_at: now(), + weekly: RateWindow::new(0.0), + plan: Some("ChatGPT Plus".to_string()), + inventory: inventory("credit-a"), + }); + let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + assert!(state.candidate.is_none()); +} + +#[test] +fn same_unknown_stale_or_non_oauth_plans_keep_the_baseline() { + let cases: [(&str, Option<&str>, bool); 4] = [ + ( + "same plan with case and spacing", + Some(" chatgpt plus "), + true, + ), + ("unknown fresh plan", None, true), + ("blank fresh plan", Some(" "), true), + ("not exact OAuth", Some("ChatGPT Pro"), false), + ]; + for (name, plan, exact_oauth) in cases { + let mut state = plus_baseline(); + let current = plan_snapshot(plan, 5.0, 10); + initial_decision(&mut state, ¤t, None, exact_oauth, now()); + assert!(state.published_weekly.is_some(), "{name}"); + assert!(state.credit_inventory.is_some(), "{name}"); + } + + let mut unknown_stored = plus_baseline(); + unknown_stored.plan = None; + let fresh = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + initial_decision(&mut unknown_stored, &fresh, None, true, now()); + assert!( + unknown_stored.published_weekly.is_some(), + "unknown stored plan" + ); + + let mut older = plus_baseline(); + let stale = plan_snapshot(Some("ChatGPT Pro"), 5.0, -1); + initial_decision(&mut older, &stale, None, true, now()); + assert!(older.published_weekly.is_some(), "older observation"); +} + +#[test] +fn near_zero_confirmation_must_report_the_initial_plan() { + let inv = inventory("credit-a"); + for confirmation_plan in [Some("ChatGPT Plus"), None] { + for has_baseline in [false, true] { + let mut state = if has_baseline { + baseline() + } else { + AccountState::default() + }; + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(confirmation_plan, 0.0, 11); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve, + "{confirmation_plan:?} baseline {has_baseline}" + ); + assert!(state.candidate.is_none()); + } + } +} + +#[test] +fn nonzero_confirmation_can_publish_its_own_plan() { + let mut state = AccountState::default(); + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Plus"), 5.0, 11); + assert_eq!( + confirmation_decision(&mut state, &initial, None, &confirmation, None, true, now()), + ConfirmationDecision::Publish + ); +} From 1864c8424134e30863e9587cf2d385d5d9208974 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:18:52 +0700 Subject: [PATCH 2/4] Address thermo review --- rust/src/providers/codex/weekly_reset.rs | 4 +++- .../src/providers/codex/weekly_reset/tests.rs | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/rust/src/providers/codex/weekly_reset.rs b/rust/src/providers/codex/weekly_reset.rs index e427b95294..928fe589b7 100644 --- a/rust/src/providers/codex/weekly_reset.rs +++ b/rust/src/providers/codex/weekly_reset.rs @@ -641,7 +641,9 @@ pub(super) fn commit_publication( if let Some(weekly) = weekly(snapshot) { state.published_weekly = Some(weekly.clone()); state.published_at = snapshot.updated_at; - state.plan = snapshot.login_method.clone(); + if normalized_plan(snapshot.login_method.as_deref()).is_some() { + state.plan = snapshot.login_method.clone(); + } state.credit_inventory = inventory; state.candidate = None; } diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index 3e71208b06..66c7f3de9c 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -670,6 +670,29 @@ fn same_unknown_stale_or_non_oauth_plans_keep_the_baseline() { assert!(older.published_weekly.is_some(), "older observation"); } +#[test] +fn unknown_plan_publication_preserves_the_last_known_plan() { + for unknown_plan in [None, Some(" ")] { + let mut state = plus_baseline(); + let inventory = inventory("credit-a"); + let unknown = plan_snapshot(unknown_plan, 50.0, 10); + assert_eq!( + initial_decision(&mut state, &unknown, Some(&inventory), true, now()), + InitialDecision::Publish + ); + commit_publication(&mut state, &unknown, Some(inventory)); + assert_eq!(state.plan.as_deref(), Some("ChatGPT Plus")); + + let changed = plan_snapshot(Some("ChatGPT Pro"), 50.0, 11); + assert_eq!( + initial_decision(&mut state, &changed, None, true, now()), + InitialDecision::Publish + ); + assert!(state.published_weekly.is_none()); + assert!(state.credit_inventory.is_none()); + } +} + #[test] fn near_zero_confirmation_must_report_the_initial_plan() { let inv = inventory("credit-a"); From 953d739fb4283c0aa4eb7e65d7d58e13d397eb14 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Wed, 30 Sep 2026 22:20:55 +0700 Subject: [PATCH 3/4] Address thermo review --- .../src-tauri/src/commands/providers.rs | 258 +----------------- .../src/commands/providers/reset_backfill.rs | 248 +++++++++++++++++ 2 files changed, 251 insertions(+), 255 deletions(-) create mode 100644 apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers.rs b/apps/desktop-tauri/src-tauri/src/commands/providers.rs index e9ca1490ff..6c3b10d552 100644 --- a/apps/desktop-tauri/src-tauri/src/commands/providers.rs +++ b/apps/desktop-tauri/src-tauri/src/commands/providers.rs @@ -9,6 +9,8 @@ use codexbar::core::HookUsageWindow; use serde::Serialize; use std::sync::Arc; +mod reset_backfill; + const MAX_CONCURRENT_PROVIDER_FETCHES: usize = 8; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -594,7 +596,7 @@ async fn refresh_provider( .find(|c| c.provider_id == snapshot.provider_id && c.error.is_none()) .cloned(); let mut snapshot = snapshot; - codex_reset_backfill(&mut snapshot, cached.as_ref()); + reset_backfill::codex_reset_backfill(&mut snapshot, cached.as_ref()); upsert_provider_cache(&mut guard.provider_cache, snapshot.clone()); if fresh_snapshot { guard @@ -678,101 +680,6 @@ fn dispatch_usage_updated_hook( ); } -/// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing -/// `resets_at` / `reset_description` on fresh Codex windows from the cached -/// lane data when the cached reset is still future. z.ai five-hour cached -/// resets use the same plausibility bound as the provider parser, so an -/// impossible rejected reset cannot be restored from the cache. Fresh -/// `used_percent` is untouched; only reset metadata is backfilled. -/// -/// This remains provider-scoped by design (upstream: "Provider-specific by -/// design"): only Codex and z.ai carry the relevant bounded reset semantics. -/// -/// Applies to the bridge snapshot before publishing so every surface (tray, -/// CLI, frontend) sees the backfilled reset instead of a missing one. -pub(super) fn codex_reset_backfill( - snapshot: &mut ProviderUsageSnapshot, - cached: Option<&ProviderUsageSnapshot>, -) { - let Some(cached) = cached else { return }; - if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { - return; - } - // A subscription change starts a new quota baseline: reset times from the - // previous plan are not evidence for the new one. Unknown plans never block. - if snapshot.provider_id == "codex" && codex_plan_changed(cached, snapshot) { - return; - } - - // Backfill each slot from the corresponding cached slot. - backfill_slot_window( - &snapshot.provider_id, - &mut snapshot.primary, - &cached.primary, - ); - if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { - backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); - } - // Tertiary (monthly/other): the Codex bridge doesn't normally populate this, - // but the slot exists for forward-compat. Backfill when available. - if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { - backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); - } -} - -/// True only when both snapshots report a known plan and the plans differ. -fn codex_plan_changed(cached: &ProviderUsageSnapshot, fresh: &ProviderUsageSnapshot) -> bool { - let normalize = |plan: &Option| { - plan.as_deref() - .map(str::trim) - .filter(|plan| !plan.is_empty()) - .map(str::to_lowercase) - }; - matches!( - (normalize(&cached.plan_name), normalize(&fresh.plan_name)), - (Some(cached), Some(fresh)) if cached != fresh - ) -} - -/// Backfill `resets_at` and `reset_description` on a fresh window from the -/// cached window whose reset is still in the future. `used_percent` is never -/// overwritten (upstream: "fresh used_percent untouched"). -fn backfill_slot_window( - provider_id: &str, - fresh: &mut bridge::RateWindowSnapshot, - cached: &bridge::RateWindowSnapshot, -) { - if fresh.resets_at.is_some() { - return; - } - let Some(cached_reset) = &cached.resets_at else { - return; - }; - // Only backfill when the cached reset is still future — a stale reset is - // worse than a missing one. - let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { - return; - }; - let now = chrono::Utc::now(); - if cached_dt <= now { - return; - } - // A missing z.ai five-hour reset can mean the provider rejected an - // impossible future timestamp. Do not let equally impossible cached - // evidence undo that rejection, but preserve a plausible cached reset. - if provider_id == "zai" - && fresh.window_minutes == Some(300) - && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) - { - return; - } - fresh.resets_at = Some(cached_reset.clone()); - fresh.reset_description = fresh - .reset_description - .clone() - .or_else(|| cached.reset_description.clone()); -} - #[cfg(test)] pub(super) fn preserve_last_good_transient_failure( guard: &mut AppState, @@ -1320,162 +1227,3 @@ mod predictive_warning_tests { } } } - -#[cfg(test)] -mod reset_backfill_tests { - use super::*; - use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; - - fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { - RateWindowSnapshot { - used_percent: used, - remaining_percent: 100.0 - used, - window_minutes: Some(300), - resets_at: resets_at.map(String::from), - reset_description: None, - is_exhausted: false, - is_informational: false, - reserve_percent: None, - reserve_description: None, - reserve_will_last_to_reset: false, - reserve_eta_seconds: None, - } - } - - fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { - ProviderUsageSnapshot { - provider_id: "codex".into(), - display_name: "Codex".into(), - primary, - primary_label: None, - secondary: None, - secondary_label: None, - model_specific: None, - tertiary: None, - tertiary_label: None, - extra_rate_windows: Vec::new(), - inventory: Vec::new(), - display_details: Vec::new(), - cost: None, - plan_name: None, - account_email: None, - subscription: None, - source_label: String::new(), - has_successful_claude_cli_quota: false, - updated_at: "2026-01-01T00:00:00Z".into(), - error: None, - error_state: codexbar::core::ProviderStateKind::Ready, - pace: None, - account_organization: None, - tray_status_label: None, - fetch_duration_ms: None, - wayfinder_usage: None, - session_equivalent_forecast: None, - } - } - - #[test] - fn f6_backfills_future_cached_reset() { - // Cached has a future resets_at; fresh has none → backfilled. - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&future))); - let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); - // used_percent is NOT overwritten. - assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); - } - - #[test] - fn f6_does_not_backfill_stale_cached_reset() { - // Cached reset is in the past → not backfilled. - let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&past))); - let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert!( - fresh.primary.resets_at.is_none(), - "stale reset not backfilled" - ); - } - - #[test] - fn f6_does_not_overwrite_existing_resets_at() { - // Fresh already has resets_at → cached not applied. - let future1 = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339(); - let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); - let cached = codex_snapshot(win(50.0, Some(&future2))); - let mut fresh = codex_snapshot(win(30.0, Some(&future1))); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); - } - - #[test] - fn codex_backfill_skips_when_known_plans_differ() { - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let mut cached = codex_snapshot(win(80.0, Some(&future))); - cached.plan_name = Some("Plus".into()); - let mut fresh = codex_snapshot(win(5.0, None)); - fresh.plan_name = Some("Pro".into()); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert!(fresh.primary.resets_at.is_none(), "plan change baseline"); - } - - #[test] - fn codex_backfill_keeps_baseline_for_same_or_unknown_plan() { - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - for (cached_plan, fresh_plan) in [ - (Some("Plus"), Some(" plus ")), - (Some("Plus"), None), - (None, Some("Pro")), - (Some("Plus"), Some(" ")), - ] { - let mut cached = codex_snapshot(win(80.0, Some(&future))); - cached.plan_name = cached_plan.map(str::to_string); - let mut fresh = codex_snapshot(win(5.0, None)); - fresh.plan_name = fresh_plan.map(str::to_string); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!( - fresh.primary.resets_at.as_deref(), - Some(future.as_str()), - "{cached_plan:?} -> {fresh_plan:?}" - ); - } - } - - #[test] - fn f6_skips_non_codex_provider() { - let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); - let mut cached = codex_snapshot(win(50.0, Some(&future))); - cached.provider_id = "claude".into(); - let mut fresh = codex_snapshot(win(30.0, None)); - fresh.provider_id = "claude".into(); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); - } - - #[test] - fn zai_five_hour_backfill_rejects_impossible_cached_reset() { - for (offset, should_backfill) in [ - (chrono::Duration::hours(1), true), - (chrono::Duration::hours(10), false), - ] { - let future = (chrono::Utc::now() + offset).to_rfc3339(); - let mut cached = codex_snapshot(win(50.0, Some(&future))); - cached.provider_id = "zai".into(); - let mut fresh = codex_snapshot(win(30.0, None)); - fresh.provider_id = "zai".into(); - fresh.primary.reset_description = Some("5-hour".into()); - codex_reset_backfill(&mut fresh, Some(&cached)); - assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); - assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); - } - } - - #[test] - fn f6_skips_when_no_cached_snapshot() { - let mut fresh = codex_snapshot(win(30.0, None)); - codex_reset_backfill(&mut fresh, None); - assert!(fresh.primary.resets_at.is_none()); - } -} diff --git a/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs new file mode 100644 index 0000000000..1bd5642e88 --- /dev/null +++ b/apps/desktop-tauri/src-tauri/src/commands/providers/reset_backfill.rs @@ -0,0 +1,248 @@ +use crate::commands::bridge::{ProviderUsageSnapshot, RateWindowSnapshot}; + +/// F6 (upstream 0.48.0 UsageStore+CodexResetBackfill): backfill missing +/// `resets_at` / `reset_description` on fresh Codex windows from the cached +/// lane data when the cached reset is still future. z.ai five-hour cached +/// resets use the same plausibility bound as the provider parser, so an +/// impossible rejected reset cannot be restored from the cache. Fresh +/// `used_percent` is untouched; only reset metadata is backfilled. +/// +/// This remains provider-scoped by design (upstream: "Provider-specific by +/// design"): only Codex and z.ai carry the relevant bounded reset semantics. +/// +/// Applies to the bridge snapshot before publishing so every surface (tray, +/// CLI, frontend) sees the backfilled reset instead of a missing one. +pub(super) fn codex_reset_backfill( + snapshot: &mut ProviderUsageSnapshot, + cached: Option<&ProviderUsageSnapshot>, +) { + let Some(cached) = cached else { return }; + if !matches!(snapshot.provider_id.as_str(), "codex" | "zai") { + return; + } + // A subscription change starts a new quota baseline: reset times from the + // previous plan are not evidence for the new one. Unknown plans never block. + if snapshot.provider_id == "codex" && codex_plan_changed(cached, snapshot) { + return; + } + + // Backfill each slot from the corresponding cached slot. + backfill_slot_window( + &snapshot.provider_id, + &mut snapshot.primary, + &cached.primary, + ); + if let (Some(fresh), Some(cached_sec)) = (&mut snapshot.secondary, &cached.secondary) { + backfill_slot_window(&snapshot.provider_id, fresh, cached_sec); + } + // Codex rarely populates tertiary windows, but keep this forward-compatible. + if let (Some(fresh), Some(cached_ter)) = (&mut snapshot.tertiary, &cached.tertiary) { + backfill_slot_window(&snapshot.provider_id, fresh, cached_ter); + } +} + +/// True only when both snapshots report a known plan and the plans differ. +fn codex_plan_changed(cached: &ProviderUsageSnapshot, fresh: &ProviderUsageSnapshot) -> bool { + let normalize = |plan: &Option| { + plan.as_deref() + .map(str::trim) + .filter(|plan| !plan.is_empty()) + .map(str::to_lowercase) + }; + matches!( + (normalize(&cached.plan_name), normalize(&fresh.plan_name)), + (Some(cached), Some(fresh)) if cached != fresh + ) +} + +/// Backfill `resets_at` and `reset_description` on a fresh window from the +/// cached window whose reset is still in the future. `used_percent` is never +/// overwritten (upstream: "fresh used_percent untouched"). +fn backfill_slot_window( + provider_id: &str, + fresh: &mut RateWindowSnapshot, + cached: &RateWindowSnapshot, +) { + if fresh.resets_at.is_some() { + return; + } + let Some(cached_reset) = &cached.resets_at else { + return; + }; + // A stale reset is worse than a missing one. + let Ok(cached_dt) = chrono::DateTime::parse_from_rfc3339(cached_reset) else { + return; + }; + let now = chrono::Utc::now(); + if cached_dt <= now { + return; + } + // A missing z.ai reset can represent a rejected timestamp; do not restore + // equally implausible cached evidence. + if provider_id == "zai" + && fresh.window_minutes == Some(300) + && cached_dt > now + chrono::Duration::minutes(5 * 60 + 1) + { + return; + } + fresh.resets_at = Some(cached_reset.clone()); + fresh.reset_description = fresh + .reset_description + .clone() + .or_else(|| cached.reset_description.clone()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::bridge::RateWindowSnapshot; + + fn win(used: f64, resets_at: Option<&str>) -> RateWindowSnapshot { + RateWindowSnapshot { + used_percent: used, + remaining_percent: 100.0 - used, + window_minutes: Some(300), + resets_at: resets_at.map(String::from), + reset_description: None, + is_exhausted: false, + is_informational: false, + reserve_percent: None, + reserve_description: None, + reserve_will_last_to_reset: false, + reserve_eta_seconds: None, + } + } + + fn codex_snapshot(primary: RateWindowSnapshot) -> ProviderUsageSnapshot { + ProviderUsageSnapshot { + provider_id: "codex".into(), + display_name: "Codex".into(), + primary, + primary_label: None, + secondary: None, + secondary_label: None, + model_specific: None, + tertiary: None, + tertiary_label: None, + extra_rate_windows: Vec::new(), + inventory: Vec::new(), + display_details: Vec::new(), + cost: None, + plan_name: None, + account_email: None, + subscription: None, + source_label: String::new(), + has_successful_claude_cli_quota: false, + updated_at: "2026-01-01T00:00:00Z".into(), + error: None, + error_state: codexbar::core::ProviderStateKind::Ready, + pace: None, + account_organization: None, + tray_status_label: None, + fetch_duration_ms: None, + wayfinder_usage: None, + session_equivalent_forecast: None, + } + } + + #[test] + fn f6_backfills_future_cached_reset() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&future))); + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.as_deref(), Some(future.as_str())); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + + #[test] + fn f6_does_not_backfill_stale_cached_reset() { + let past = (chrono::Utc::now() - chrono::Duration::hours(2)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&past))); + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!( + fresh.primary.resets_at.is_none(), + "stale reset not backfilled" + ); + } + + #[test] + fn f6_does_not_overwrite_existing_resets_at() { + let future1 = (chrono::Utc::now() + chrono::Duration::hours(3)).to_rfc3339(); + let future2 = (chrono::Utc::now() + chrono::Duration::hours(5)).to_rfc3339(); + let cached = codex_snapshot(win(50.0, Some(&future2))); + let mut fresh = codex_snapshot(win(30.0, Some(&future1))); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.as_deref(), Some(future1.as_str())); + } + + #[test] + fn codex_backfill_skips_when_known_plans_differ() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = Some("Plus".into()); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = Some("Pro".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "plan change baseline"); + } + + #[test] + fn codex_backfill_keeps_baseline_for_same_or_unknown_plan() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + for (cached_plan, fresh_plan) in [ + (Some("Plus"), Some(" plus ")), + (Some("Plus"), None), + (None, Some("Pro")), + (Some("Plus"), Some(" ")), + ] { + let mut cached = codex_snapshot(win(80.0, Some(&future))); + cached.plan_name = cached_plan.map(str::to_string); + let mut fresh = codex_snapshot(win(5.0, None)); + fresh.plan_name = fresh_plan.map(str::to_string); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!( + fresh.primary.resets_at.as_deref(), + Some(future.as_str()), + "{cached_plan:?} -> {fresh_plan:?}" + ); + } + } + + #[test] + fn f6_skips_non_codex_provider() { + let future = (chrono::Utc::now() + chrono::Duration::hours(2)).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "claude".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "claude".into(); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert!(fresh.primary.resets_at.is_none(), "non-codex skip"); + } + + #[test] + fn zai_five_hour_backfill_rejects_impossible_cached_reset() { + for (offset, should_backfill) in [ + (chrono::Duration::hours(1), true), + (chrono::Duration::hours(10), false), + ] { + let future = (chrono::Utc::now() + offset).to_rfc3339(); + let mut cached = codex_snapshot(win(50.0, Some(&future))); + cached.provider_id = "zai".into(); + let mut fresh = codex_snapshot(win(30.0, None)); + fresh.provider_id = "zai".into(); + fresh.primary.reset_description = Some("5-hour".into()); + codex_reset_backfill(&mut fresh, Some(&cached)); + assert_eq!(fresh.primary.resets_at.is_some(), should_backfill); + assert!((fresh.primary.used_percent - 30.0).abs() < f64::EPSILON); + } + } + + #[test] + fn f6_skips_when_no_cached_snapshot() { + let mut fresh = codex_snapshot(win(30.0, None)); + codex_reset_backfill(&mut fresh, None); + assert!(fresh.primary.resets_at.is_none()); + } +} From 9ef221d42b6c5c6edfb1a1a66d324f42998b9f81 Mon Sep 17 00:00:00 2001 From: RCD <90105158+Finesssee@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:46:10 +0700 Subject: [PATCH 4/4] Port upstream 0.69.0: port plan transition publication tests Move the plan-change weekly-reset tests into weekly_reset/tests/plan_change.rs so tests.rs stays under 1000 lines after merging #629, and port the upstream CodexPlanTransitionPublicationTests cases that were missing: - a new plan without a weekly window cannot borrow the old plan's window; - the new plan's own usage and reset become the stored baseline at 0 % and 5 %; - same-plan (any case or spacing) or blank-plan near-zero readings keep the old evidence. --- .../src/providers/codex/weekly_reset/tests.rs | 208 +----------- .../codex/weekly_reset/tests/plan_change.rs | 321 ++++++++++++++++++ 2 files changed, 323 insertions(+), 206 deletions(-) create mode 100644 rust/src/providers/codex/weekly_reset/tests/plan_change.rs diff --git a/rust/src/providers/codex/weekly_reset/tests.rs b/rust/src/providers/codex/weekly_reset/tests.rs index fe44d8f240..6848b8ff83 100644 --- a/rust/src/providers/codex/weekly_reset/tests.rs +++ b/rust/src/providers/codex/weekly_reset/tests.rs @@ -1,6 +1,8 @@ use super::*; use chrono::TimeZone; +mod plan_change; + #[test] fn reset_diagnostic_codes_are_fixed_and_redacted() { let codes = [ @@ -649,212 +651,6 @@ fn rolling_weekly_confirmation_keeps_inventory_and_expiry_guards() { ); } -fn plan_snapshot(plan: Option<&str>, used: f64, captured_minutes: i64) -> UsageSnapshot { - let mut snapshot = snapshot(used, 9, captured_minutes); - snapshot.login_method = plan.map(str::to_string); - snapshot -} - -/// Plus subscription with a stale 80% weekly baseline that resets in one day. -fn plus_baseline() -> AccountState { - let mut previous = snapshot(80.0, 1, 0); - previous.login_method = Some("ChatGPT Plus".to_string()); - AccountState { - published_weekly: previous.secondary.clone(), - published_at: previous.updated_at, - plan: previous.login_method.clone(), - credit_inventory: Some(inventory("credit-a")), - candidate: None, - } -} - -#[test] -fn plan_upgrade_starts_a_new_baseline_and_publishes_the_new_plan() { - let mut state = plus_baseline(); - let inv = inventory("credit-a"); - let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); - let confirmation = plan_snapshot(Some("ChatGPT Pro"), 0.0, 11); - assert_eq!( - initial_decision(&mut state, &initial, Some(&inv), true, now()), - InitialDecision::RequiresConfirmation - ); - assert!(state.published_weekly.is_none()); - assert!(state.credit_inventory.is_none()); - assert!(state.candidate.is_none()); - assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), - ConfirmationDecision::Publish - ); -} - -#[test] -fn same_plan_near_zero_reading_keeps_the_previous_weekly_pinned() { - // Identical to the upgrade scenario, but the plan did not change: the old - // weekly window stays pinned until the confirmation is trustworthy. - let mut state = plus_baseline(); - let inv = inventory("credit-a"); - let initial = plan_snapshot(Some("ChatGPT Plus"), 0.0, 10); - let confirmation = plan_snapshot(Some("ChatGPT Plus"), 0.0, 11); - assert_eq!( - initial_decision(&mut state, &initial, Some(&inv), true, now()), - InitialDecision::RequiresConfirmation - ); - assert!(state.published_weekly.is_some()); - assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), - ConfirmationDecision::Preserve - ); -} - -#[test] -fn plan_upgrade_does_not_pin_the_previous_plan_weekly_window() { - let mut state = plus_baseline(); - let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); - assert_eq!( - initial_decision(&mut state, ¤t, None, true, now()), - InitialDecision::Publish - ); - let preserved = preserve_weekly(&state, current.clone()); - let used = |snapshot: &UsageSnapshot| snapshot.secondary.as_ref().map(|w| w.used_percent); - assert_eq!(used(&preserved), Some(5.0)); - assert_eq!(used(&preserved), used(¤t)); -} - -#[test] -fn plan_change_discards_a_pending_candidate() { - let mut state = plus_baseline(); - state.candidate = Some(DelayedCandidate { - evidence_version: EVIDENCE_VERSION, - first_observed_at: now(), - created_at: now(), - snapshot_updated_at: now(), - weekly: RateWindow::new(0.0), - plan: Some("ChatGPT Plus".to_string()), - inventory: inventory("credit-a"), - }); - let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); - assert_eq!( - initial_decision(&mut state, ¤t, None, true, now()), - InitialDecision::Publish - ); - assert!(state.candidate.is_none()); -} - -#[test] -fn same_unknown_stale_or_non_oauth_plans_keep_the_baseline() { - let cases: [(&str, Option<&str>, bool); 4] = [ - ( - "same plan with case and spacing", - Some(" chatgpt plus "), - true, - ), - ("unknown fresh plan", None, true), - ("blank fresh plan", Some(" "), true), - ("not exact OAuth", Some("ChatGPT Pro"), false), - ]; - for (name, plan, exact_oauth) in cases { - let mut state = plus_baseline(); - let current = plan_snapshot(plan, 5.0, 10); - initial_decision(&mut state, ¤t, None, exact_oauth, now()); - assert!(state.published_weekly.is_some(), "{name}"); - assert!(state.credit_inventory.is_some(), "{name}"); - } - - let mut unknown_stored = plus_baseline(); - unknown_stored.plan = None; - let fresh = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); - initial_decision(&mut unknown_stored, &fresh, None, true, now()); - assert!( - unknown_stored.published_weekly.is_some(), - "unknown stored plan" - ); - - let mut older = plus_baseline(); - let stale = plan_snapshot(Some("ChatGPT Pro"), 5.0, -1); - initial_decision(&mut older, &stale, None, true, now()); - assert!(older.published_weekly.is_some(), "older observation"); -} - -#[test] -fn unknown_plan_publication_preserves_the_last_known_plan() { - for unknown_plan in [None, Some(" ")] { - let mut state = plus_baseline(); - let inventory = inventory("credit-a"); - let unknown = plan_snapshot(unknown_plan, 50.0, 10); - assert_eq!( - initial_decision(&mut state, &unknown, Some(&inventory), true, now()), - InitialDecision::Publish - ); - commit_publication(&mut state, &unknown, Some(inventory)); - assert_eq!(state.plan.as_deref(), Some("ChatGPT Plus")); - - let changed = plan_snapshot(Some("ChatGPT Pro"), 50.0, 11); - assert_eq!( - initial_decision(&mut state, &changed, None, true, now()), - InitialDecision::Publish - ); - assert!(state.published_weekly.is_none()); - assert!(state.credit_inventory.is_none()); - } -} - -#[test] -fn near_zero_confirmation_must_report_the_initial_plan() { - let inv = inventory("credit-a"); - for confirmation_plan in [Some("ChatGPT Plus"), None] { - for has_baseline in [false, true] { - let mut state = if has_baseline { - baseline() - } else { - AccountState::default() - }; - let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); - let confirmation = plan_snapshot(confirmation_plan, 0.0, 11); - assert_eq!( - confirmation_decision( - &mut state, - &initial, - Some(&inv), - &confirmation, - Some(&inv), - true, - now(), - ), - ConfirmationDecision::Preserve, - "{confirmation_plan:?} baseline {has_baseline}" - ); - assert!(state.candidate.is_none()); - } - } -} - -#[test] -fn nonzero_confirmation_can_publish_its_own_plan() { - let mut state = AccountState::default(); - let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); - let confirmation = plan_snapshot(Some("ChatGPT Plus"), 5.0, 11); - assert_eq!( - confirmation_decision(&mut state, &initial, None, &confirmation, None, true, now()), - ConfirmationDecision::Publish - ); -} - /// Upstream `persisted stale baseline recovers after delayed reset /// confirmation across relaunch` for fixed and rolling boundaries, driven /// through the decisions `CodexApi::fetch_usage` makes and the persisted diff --git a/rust/src/providers/codex/weekly_reset/tests/plan_change.rs b/rust/src/providers/codex/weekly_reset/tests/plan_change.rs new file mode 100644 index 0000000000..2a22edb68e --- /dev/null +++ b/rust/src/providers/codex/weekly_reset/tests/plan_change.rs @@ -0,0 +1,321 @@ +//! Plan-change baseline tests (upstream 0.69.0 #4088, +//! `CodexPlanTransitionPublicationTests`). + +use super::*; + +fn plan_snapshot(plan: Option<&str>, used: f64, captured_minutes: i64) -> UsageSnapshot { + let mut snapshot = snapshot(used, 9, captured_minutes); + snapshot.login_method = plan.map(str::to_string); + snapshot +} + +/// Plus subscription with a stale 80% weekly baseline that resets in one day. +fn plus_baseline() -> AccountState { + let mut previous = snapshot(80.0, 1, 0); + previous.login_method = Some("ChatGPT Plus".to_string()); + AccountState { + published_weekly: previous.secondary.clone(), + published_at: previous.updated_at, + plan: previous.login_method.clone(), + credit_inventory: Some(inventory("credit-a")), + candidate: None, + } +} + +#[test] +fn plan_upgrade_starts_a_new_baseline_and_publishes_the_new_plan() { + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Pro"), 0.0, 11); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + assert!(state.published_weekly.is_none()); + assert!(state.credit_inventory.is_none()); + assert!(state.candidate.is_none()); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Publish + ); +} + +#[test] +fn same_plan_near_zero_reading_keeps_the_previous_weekly_pinned() { + // Identical to the upgrade scenario, but the plan did not change: the old + // weekly window stays pinned until the confirmation is trustworthy. + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let initial = plan_snapshot(Some("ChatGPT Plus"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Plus"), 0.0, 11); + assert_eq!( + initial_decision(&mut state, &initial, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation + ); + assert!(state.published_weekly.is_some()); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve + ); +} + +#[test] +fn plan_upgrade_does_not_pin_the_previous_plan_weekly_window() { + let mut state = plus_baseline(); + let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + let preserved = preserve_weekly(&state, current.clone()); + let used = |snapshot: &UsageSnapshot| snapshot.secondary.as_ref().map(|w| w.used_percent); + assert_eq!(used(&preserved), Some(5.0)); + assert_eq!(used(&preserved), used(¤t)); +} + +#[test] +fn plan_change_discards_a_pending_candidate() { + let mut state = plus_baseline(); + state.candidate = Some(DelayedCandidate { + evidence_version: EVIDENCE_VERSION, + first_observed_at: now(), + created_at: now(), + snapshot_updated_at: now(), + weekly: RateWindow::new(0.0), + plan: Some("ChatGPT Plus".to_string()), + inventory: inventory("credit-a"), + }); + let current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + assert!(state.candidate.is_none()); +} + +#[test] +fn same_unknown_stale_or_non_oauth_plans_keep_the_baseline() { + let cases: [(&str, Option<&str>, bool); 4] = [ + ( + "same plan with case and spacing", + Some(" chatgpt plus "), + true, + ), + ("unknown fresh plan", None, true), + ("blank fresh plan", Some(" "), true), + ("not exact OAuth", Some("ChatGPT Pro"), false), + ]; + for (name, plan, exact_oauth) in cases { + let mut state = plus_baseline(); + let current = plan_snapshot(plan, 5.0, 10); + initial_decision(&mut state, ¤t, None, exact_oauth, now()); + assert!(state.published_weekly.is_some(), "{name}"); + assert!(state.credit_inventory.is_some(), "{name}"); + } + + let mut unknown_stored = plus_baseline(); + unknown_stored.plan = None; + let fresh = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + initial_decision(&mut unknown_stored, &fresh, None, true, now()); + assert!( + unknown_stored.published_weekly.is_some(), + "unknown stored plan" + ); + + let mut older = plus_baseline(); + let stale = plan_snapshot(Some("ChatGPT Pro"), 5.0, -1); + initial_decision(&mut older, &stale, None, true, now()); + assert!(older.published_weekly.is_some(), "older observation"); +} + +#[test] +fn unknown_plan_publication_preserves_the_last_known_plan() { + for unknown_plan in [None, Some(" ")] { + let mut state = plus_baseline(); + let inventory = inventory("credit-a"); + let unknown = plan_snapshot(unknown_plan, 50.0, 10); + assert_eq!( + initial_decision(&mut state, &unknown, Some(&inventory), true, now()), + InitialDecision::Publish + ); + commit_publication(&mut state, &unknown, Some(inventory)); + assert_eq!(state.plan.as_deref(), Some("ChatGPT Plus")); + + let changed = plan_snapshot(Some("ChatGPT Pro"), 50.0, 11); + assert_eq!( + initial_decision(&mut state, &changed, None, true, now()), + InitialDecision::Publish + ); + assert!(state.published_weekly.is_none()); + assert!(state.credit_inventory.is_none()); + } +} + +#[test] +fn near_zero_confirmation_must_report_the_initial_plan() { + let inv = inventory("credit-a"); + for confirmation_plan in [Some("ChatGPT Plus"), None] { + for has_baseline in [false, true] { + let mut state = if has_baseline { + baseline() + } else { + AccountState::default() + }; + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(confirmation_plan, 0.0, 11); + assert_eq!( + confirmation_decision( + &mut state, + &initial, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve, + "{confirmation_plan:?} baseline {has_baseline}" + ); + assert!(state.candidate.is_none()); + } + } +} + +#[test] +fn nonzero_confirmation_can_publish_its_own_plan() { + let mut state = AccountState::default(); + let initial = plan_snapshot(Some("ChatGPT Pro"), 0.0, 10); + let confirmation = plan_snapshot(Some("ChatGPT Plus"), 5.0, 11); + assert_eq!( + confirmation_decision(&mut state, &initial, None, &confirmation, None, true, now()), + ConfirmationDecision::Publish + ); +} + +/// Upstream `new plan cannot borrow missing weekly usage from the old plan`: a +/// changed plan without a weekly window publishes as-is instead of showing the +/// previous plan's weekly window. The same plan still keeps the old window. +#[test] +fn new_plan_cannot_borrow_missing_weekly_usage_from_the_old_plan() { + let mut state = plus_baseline(); + let mut current = plan_snapshot(Some("ChatGPT Pro"), 5.0, 10); + current.secondary = None; + assert_eq!( + initial_decision(&mut state, ¤t, None, true, now()), + InitialDecision::Publish + ); + assert!(preserve_weekly(&state, current.clone()).secondary.is_none()); + commit_publication(&mut state, ¤t, None); + assert!(state.published_weekly.is_none()); + + let mut same_plan = plus_baseline(); + let mut same = plan_snapshot(Some("ChatGPT Plus"), 5.0, 10); + same.secondary = None; + assert_eq!( + initial_decision(&mut same_plan, &same, None, true, now()), + InitialDecision::Preserve + ); + let shown = preserve_weekly(&same_plan, same); + assert_eq!( + shown.secondary.map(|weekly| weekly.used_percent), + Some(80.0) + ); +} + +/// Upstream `new token plan replaces previous plan quota baseline` for 0 % and +/// 5 %: the new plan publishes its own usage and reset, which become the +/// stored baseline, and no reset candidate survives. +#[test] +fn new_plan_replaces_the_previous_plan_quota_baseline() { + for used in [0.0, 5.0] { + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let current = plan_snapshot(Some("ChatGPT Pro"), used, 10); + let confirmation = plan_snapshot(Some("ChatGPT Pro"), used, 11); + let published = match initial_decision(&mut state, ¤t, Some(&inv), true, now()) { + InitialDecision::Publish => current.clone(), + InitialDecision::RequiresConfirmation => { + assert_eq!( + confirmation_decision( + &mut state, + ¤t, + Some(&inv), + &confirmation, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Publish, + "{used}% confirmation" + ); + confirmation.clone() + } + InitialDecision::Preserve => panic!("{used}%: previous plan baseline pinned"), + }; + commit_publication(&mut state, &published, Some(inv)); + let weekly = state.published_weekly.as_ref().expect("new baseline"); + assert_eq!(weekly.used_percent, used); + assert_eq!( + weekly.resets_at, + current + .secondary + .as_ref() + .and_then(|window| window.resets_at) + ); + assert_eq!(state.plan.as_deref(), Some("ChatGPT Pro")); + assert!(state.candidate.is_none()); + } +} + +/// Upstream `same or unknown token plan cannot discard previous quota +/// evidence`: a near-zero reading with the same (any case or spacing) or a +/// blank plan still needs a later confirmation, so the old weekly stays shown. +#[test] +fn same_or_unknown_plan_cannot_discard_previous_quota_evidence() { + for plan in ["ChatGPT Plus", " CHATGPT PLUS ", ""] { + let mut state = plus_baseline(); + let inv = inventory("credit-a"); + let current = plan_snapshot(Some(plan), 0.0, 10); + assert_eq!( + initial_decision(&mut state, ¤t, Some(&inv), true, now()), + InitialDecision::RequiresConfirmation, + "{plan:?}" + ); + assert_eq!( + confirmation_decision( + &mut state, + ¤t, + Some(&inv), + ¤t, + Some(&inv), + true, + now(), + ), + ConfirmationDecision::Preserve, + "{plan:?}" + ); + let shown = preserve_weekly(&state, current); + assert_eq!( + shown.secondary.map(|weekly| weekly.used_percent), + Some(80.0), + "{plan:?}" + ); + } +}