diff --git a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs index 2f47f0e32c..32307d1ee3 100644 --- a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs +++ b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs @@ -441,11 +441,15 @@ impl ChatWidget { .auth_profile_auto_switch .heartbeat_freshness_secs, ); - if self - .auth_profile_usage_snapshots(profile) - .is_some_and(|snapshots| { - auth_profile_usage_snapshots_are_fresh(snapshots, heartbeat_freshness) - }) + let is_selected_profile = self.config.selected_auth_profile.as_deref() == profile; + // Current-profile heartbeats also refresh exact reset-credit metadata, so cached usage + // freshness may suppress only non-selected profiles. + if !is_selected_profile + && self + .auth_profile_usage_snapshots(profile) + .is_some_and(|snapshots| { + auth_profile_usage_snapshots_are_fresh(snapshots, heartbeat_freshness) + }) { return false; } diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index cf1c403efd..ba689f917d 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3248,19 +3248,44 @@ async fn profile_popup_requests_usage_heartbeat_when_selected_usage_is_missing() } #[tokio::test] -async fn profile_popup_skips_usage_heartbeat_when_selected_usage_is_fresh() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn usage_heartbeat_preserves_selected_refresh_throttle_and_non_selected_expiry() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); chat.config.selected_auth_profile = Some("work".to_string()); set_chatgpt_auth(&mut chat); save_popup_chatgpt_auth_profile(&chat, "work", "work@example.com"); + save_popup_chatgpt_auth_profile(&chat, "personal", "personal@example.com"); + chat.on_auth_profile_rate_limit_snapshots( + Some("personal".to_string()), + vec![profile_usage_snapshot( + /*secondary_used_percent*/ 20, /*primary_used_percent*/ 10, + )], + ); chat.on_rate_limit_snapshot(Some(profile_usage_snapshot( - /*secondary_used_percent*/ 20, /*primary_used_percent*/ 10, + /*secondary_used_percent*/ 100, /*primary_used_percent*/ 100, ))); - while rx.try_recv().is_ok() {} - chat.open_profile_popup(); - assert_no_rate_limit_refresh_event(&mut rx); + assert_eq!( + chat.auth_profile_usage_refresh_targets(), + vec![RateLimitRefreshTarget::Named("work".to_string())] + ); + assert_eq!(chat.auth_profile_usage_refresh_targets(), Vec::new()); + + let freshness_secs = i64::try_from( + chat.config + .auth_profile_auto_switch + .heartbeat_freshness_secs, + ) + .unwrap_or(i64::MAX - 1); + chat.auth_profile_rate_limit_snapshots_by_profile + .get_mut(&Some("personal".to_string())) + .and_then(|snapshots| snapshots.get_mut("codex")) + .expect("personal usage snapshot") + .captured_at = Local::now() - chrono::Duration::seconds(freshness_secs.saturating_add(1)); + assert_eq!( + chat.auth_profile_usage_refresh_targets(), + vec![RateLimitRefreshTarget::Named("personal".to_string())] + ); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 379fa42430..5d80476e94 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -981,7 +981,7 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() { } #[tokio::test] -async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() { +async fn sparse_rolling_rate_limit_snapshot_preserves_omitted_snapshot_data() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut usage_limits = snapshot(/*percent*/ 10.0); usage_limits.individual_limit = Some(SpendControlLimitSnapshot { @@ -991,6 +991,20 @@ async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() { resets_at: 1_800_000_000, }); chat.on_rate_limit_snapshot(Some(usage_limits)); + chat.on_rate_limit_snapshot(Some(RateLimitSnapshot { + limit_id: Some("codex_model".to_string()), + limit_name: Some("codex_model".to_string()), + primary: Some(RateLimitWindow { + used_percent: 7, + window_duration_mins: Some(60), + resets_at: Some(1_800_000_100), + }), + secondary: None, + credits: None, + individual_limit: None, + plan_type: None, + rate_limit_reached_type: None, + })); chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0)); @@ -1005,6 +1019,16 @@ async fn rolling_rate_limit_snapshot_preserves_prior_individual_limit() { assert_eq!(individual_limit.used, "8,000"); assert_eq!(individual_limit.limit, "25,000"); assert_eq!(individual_limit.percent_remaining, 68.0); + assert_eq!( + chat.rate_limit_snapshots_by_limit_id + .iter() + .map(|(limit_id, display)| ( + limit_id.as_str(), + display.primary.as_ref().map(|window| window.used_percent), + )) + .collect::>(), + vec![("codex", Some(20.0)), ("codex_model", Some(7.0))] + ); chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0))); let display = chat @@ -1533,6 +1557,42 @@ async fn ordered_auto_switch_strategy_preserves_configured_order() { } } +#[tokio::test] +async fn ordered_auto_switch_selects_first_unknown_before_later_healthy_profile() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + save_test_auth_profile(&chat, "work"); + save_test_auth_profile(&chat, "personal"); + save_test_auth_profile(&chat, "backup"); + chat.config.selected_auth_profile = Some("work".to_string()); + chat.config.auth_profile_auto_switch.enabled = true; + chat.config.auth_profile_auto_switch.strategy = + crate::legacy_core::config::AuthProfileAutoSwitchStrategy::Ordered; + chat.config.auth_profile_auto_switch.profiles = vec![ + "work".to_string(), + "personal".to_string(), + "backup".to_string(), + ]; + configure_test_session(&mut chat); + drain_insert_history(&mut rx); + + chat.on_auth_profile_rate_limit_snapshots( + Some("backup".to_string()), + vec![rate_limit_snapshot_for_window( + /*used_percent*/ 25, /*window_duration_mins*/ 300, /*resets_at*/ 123, + )], + ); + chat.on_rate_limit_snapshot(Some(rate_limit_snapshot_for_window( + /*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 123, + ))); + + match rx.try_recv() { + Ok(AppEvent::SwitchAuthProfile { profile, .. }) => { + assert_eq!(profile, Some("personal".to_string())); + } + other => panic!("expected auth profile switch event, got {other:?}"), + } +} + #[tokio::test] async fn auto_switch_skips_known_exhausted_profile_for_unknown_candidate() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;