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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions codex-rs/tui/src/chatwidget/auth_profile_popups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
37 changes: 31 additions & 6 deletions codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
62 changes: 61 additions & 1 deletion codex-rs/tui/src/chatwidget/tests/status_and_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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));

Expand All @@ -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<_>>(),
vec![("codex", Some(20.0)), ("codex_model", Some(7.0))]
);

chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0)));
let display = chat
Expand Down Expand Up @@ -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;
Expand Down
Loading