Skip to content
Merged
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ Known evidence gaps:

## [Unreleased]

### Fixed

- Auth-profile auto-switch now resumes the interrupted turn on the new profile.
When a turn fails on a rate-limited profile and no usage reset is available,
the agent switches to a healthier profile and automatically re-runs the failed
request, instead of switching silently and forcing the user to re-type `go`.
- Auto-switch selection preserves each cached profile's server-reported reset
time, so exhausted profiles are ranked by their real reset instead of being
treated as reset-unknown. When every alternate profile is also rate-limited,
the agent no longer switches onto an exhausted profile and instead reports the
earliest reset time so the user knows when a profile becomes usable again.

## [0.1.77] - 2026-07-23

Tag: `rust-v0.1.77`
Expand Down
62 changes: 62 additions & 0 deletions codex-rs/core/src/usage_profile_health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,68 @@ mod tests {
assert_eq!(None, earliest_exhausted_reset_at(&snapshot, 500));
}

/// Health derived from a single primary 5h window at `used_percent`, resetting at `resets_at`.
fn health_from_5h(used_percent: f64, resets_at: Option<i64>) -> UsageProfileHealth {
usage_health_for_snapshots(
&[snapshot(
Some(window(used_percent, MINUTES_PER_5_HOURS, resets_at)),
None,
)],
&config(),
Some(FIVE_HOUR_LIMIT_LABEL),
/*is_fresh*/ true,
)
}

#[test]
fn highest_available_picks_best_remaining_and_skips_exhausted() {
// `dead` is exhausted (must be skipped), `low` has 20% headroom, `high` has 70%.
// HighestAvailable must select `high` even though `low`/`dead` come first in order.
let health_by_profile = BTreeMap::from([
("dead".to_string(), health_from_5h(100.0, Some(900))),
("low".to_string(), health_from_5h(80.0, Some(100))),
("high".to_string(), health_from_5h(30.0, Some(200))),
]);

let selection = choose_profile_for_auto_switch(
&config(),
&["dead".to_string(), "low".to_string(), "high".to_string()],
&health_by_profile,
);

assert_eq!(selection.selected_profile.as_deref(), Some("high"));
assert_eq!(
selection.reason,
UsageProfileSelectionReason::SelectedHealthyProfile
);
}

#[test]
fn all_exhausted_snapshots_report_earliest_reset_gracefully() {
// Every candidate is exhausted: no profile is selectable, and the soonest reset
// (900, earlier than 2_000) is surfaced so the caller can tell the user when a
// profile becomes usable again.
let health_by_profile = BTreeMap::from([
("later".to_string(), health_from_5h(100.0, Some(2_000))),
("soonest".to_string(), health_from_5h(100.0, Some(900))),
]);

let selection = choose_profile_for_auto_switch(
&config(),
&["later".to_string(), "soonest".to_string()],
&health_by_profile,
);

assert_eq!(
selection,
UsageProfileSelection {
selected_profile: None,
retry_at: Some(900),
reason: UsageProfileSelectionReason::NoAvailableProfiles,
}
);
}

#[test]
fn exhausted_candidates_merge_earliest_retry_timestamp() {
let health_by_profile = BTreeMap::from([
Expand Down
3 changes: 3 additions & 0 deletions codex-rs/tui/src/chatwidget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,9 @@ pub(crate) struct ChatWidget {
warning_display_state: WarningDisplayState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
last_auth_profile_auto_switch_trigger: Option<String>,
/// Trigger key of the most recent "no eligible profile" notice, so the info
/// message is emitted once per exhaustion window instead of on every poll.
last_no_eligible_auth_profile_trigger: Option<String>,
pending_auth_profile_auto_switch_trigger: Option<String>,
auth_profile_auto_switch_cooldowns:
BTreeMap<crate::legacy_core::usage_profile_health::UsageProfileCooldownKey, Instant>,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/auth_profile_popups.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,7 @@ mod tests {
RateLimitWindowDisplay {
used_percent,
resets_at: None,
resets_at_unix: None,
window_minutes: Some(window_minutes),
}
}
Expand Down
1 change: 1 addition & 0 deletions codex-rs/tui/src/chatwidget/constructor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ impl ChatWidget {
warning_display_state: WarningDisplayState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
last_auth_profile_auto_switch_trigger: None,
last_no_eligible_auth_profile_trigger: None,
pending_auth_profile_auto_switch_trigger: None,
auth_profile_auto_switch_cooldowns: BTreeMap::new(),
usage_self_heal: UsageSelfHealState::default(),
Expand Down
48 changes: 39 additions & 9 deletions codex-rs/tui/src/chatwidget/rate_limits.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use super::*;
use crate::chatwidget::auth_profile_popups::AUTH_PROFILE_USAGE_HEARTBEAT_FAILURE_BACKOFF;
use crate::chatwidget::usage_profile_broker::UsageProfileAutoSwitchWindow;
use crate::chatwidget::usage_profile_broker::UsageProfileSwitchOutcome;
use crate::chatwidget::usage_profile_broker::auth_profile_auto_switch_target as broker_auth_profile_auto_switch_target;
use crate::chatwidget::usage_profile_broker::auto_switch_trigger_key;
use crate::chatwidget::usage_profile_broker::earliest_exhausted_reset_at;
Expand All @@ -14,6 +15,9 @@ use crate::chatwidget::usage_profile_broker::limit_label_for_window as broker_li
use crate::chatwidget::user_messages::QueueInsertionPosition;
use crate::legacy_core::usage_profile_health::UsageProfileCooldownKey;
use crate::legacy_core::usage_profile_health::cooldown_duration_for_reset;
use crate::status::format_reset_timestamp;
use chrono::DateTime;
use chrono::Utc;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_login::list_auth_profiles;
use tokio::time::MissedTickBehavior;
Expand Down Expand Up @@ -569,7 +573,7 @@ impl ChatWidget {
}
};
let recently_failed = self.recently_failed_auth_profile_usage_heartbeats();
if let Some(target) = broker_auth_profile_auto_switch_target(
match broker_auth_profile_auto_switch_target(
&self.config.auth_profile_auto_switch,
self.config.selected_auth_profile.as_deref(),
&profiles,
Expand All @@ -578,16 +582,42 @@ impl ChatWidget {
limit_id,
window,
) {
self.mark_auth_profile_auto_switch_cooldown(cooldown_key);
return Some((target.profile, target.trigger_key));
UsageProfileSwitchOutcome::Switch(target) => {
self.mark_auth_profile_auto_switch_cooldown(cooldown_key);
Some((target.profile, target.trigger_key))
}
UsageProfileSwitchOutcome::NoEligibleProfile { earliest_reset_at } => {
// Dedupe like the auto-switch trigger: emit the info message once
// per exhaustion window instead of on every rate-limit poll.
if self.last_no_eligible_auth_profile_trigger.as_deref()
!= Some(trigger_key.as_str())
{
self.last_no_eligible_auth_profile_trigger = Some(trigger_key);
self.add_info_message(
Self::no_eligible_auth_profile_message(earliest_reset_at),
/*hint*/ None,
);
}
None
}
}
}

self.add_info_message(
"Auth profile auto-switch is enabled, but no alternate profile with available usage is known."
.to_string(),
/*hint*/ None,
);
None
/// User-facing message when auto-switch is on but no alternate profile has available usage.
/// Includes the soonest known reset so the user knows when a profile becomes usable again.
fn no_eligible_auth_profile_message(earliest_reset_at: Option<i64>) -> String {
const BASE: &str =
"Auth profile auto-switch is enabled, but every alternate profile is also rate-limited";
match earliest_reset_at
.and_then(|reset| DateTime::<Utc>::from_timestamp(reset, 0))
.map(|dt| dt.with_timezone(&Local))
{
Some(reset_local) => format!(
"{BASE}. The earliest profile resets at {}.",
format_reset_timestamp(reset_local, Local::now())
),
None => format!("{BASE} or unavailable."),
}
}

fn auth_profile_auto_switch_cooldown_key(
Expand Down
108 changes: 108 additions & 0 deletions codex-rs/tui/src/chatwidget/tests/status_and_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,114 @@ async fn exhausted_limit_auto_switches_to_profile_with_most_fresh_usage() {
}
}

#[tokio::test]
async fn all_alternate_profiles_exhausted_reports_earliest_reset_without_switching() {
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.profiles = vec![
"work".to_string(),
"personal".to_string(),
"backup".to_string(),
];
configure_test_session(&mut chat);
drain_insert_history(&mut rx);

// Both alternates are exhausted on the 5h window with distinct resets; `backup` resets
// soonest, so its reset must be surfaced.
chat.on_auth_profile_rate_limit_snapshots(
Some("personal".to_string()),
vec![rate_limit_snapshot_for_window(
/*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 5_000,
)],
);
chat.on_auth_profile_rate_limit_snapshots(
Some("backup".to_string()),
vec![rate_limit_snapshot_for_window(
/*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 4_000,
)],
);
drain_insert_history(&mut rx);

chat.on_rate_limit_snapshot(Some(rate_limit_snapshot_for_window(
/*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 3_000,
)));

// No eligible profile: the agent must not switch onto an also-exhausted profile.
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.all(|event| !matches!(event, AppEvent::SwitchAuthProfile { .. })),
"must not switch when every alternate profile is exhausted"
);

// A graceful message is shown, including the soonest reset so the user knows when a
// profile becomes usable again.
let message = events
.into_iter()
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
})
.find(|text| text.contains("every alternate profile is also rate-limited"))
.expect("graceful no-eligible-profile message");
assert!(
message.contains("resets at"),
"message should include the earliest reset time, got: {message}"
);
}

#[tokio::test]
async fn no_eligible_profile_message_is_not_repeated_on_every_poll() {
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");
chat.config.selected_auth_profile = Some("work".to_string());
chat.config.auth_profile_auto_switch.enabled = true;
chat.config.auth_profile_auto_switch.profiles =
vec!["work".to_string(), "personal".to_string()];
configure_test_session(&mut chat);
drain_insert_history(&mut rx);

// Only alternate profile is exhausted, so no switch is possible.
chat.on_auth_profile_rate_limit_snapshots(
Some("personal".to_string()),
vec![rate_limit_snapshot_for_window(
/*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 5_000,
)],
);
drain_insert_history(&mut rx);

// Poll the same exhausted window three times, mirroring the rate-limit poller.
for _ in 0..3 {
chat.on_rate_limit_snapshot(Some(rate_limit_snapshot_for_window(
/*used_percent*/ 100, /*window_duration_mins*/ 300, /*resets_at*/ 3_000,
)));
}

let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
let notice_count = events
.into_iter()
.filter_map(|event| match event {
AppEvent::InsertHistoryCell(cell) => {
Some(lines_to_single_string(&cell.display_lines(/*width*/ 120)))
}
_ => None,
})
.filter(|text| text.contains("every alternate profile is also rate-limited"))
.count();
assert_eq!(
notice_count, 1,
"no-eligible-profile notice must be emitted once per exhaustion window, not per poll"
);
}

#[tokio::test]
async fn ordered_auto_switch_strategy_preserves_configured_order() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
Expand Down
Loading
Loading