diff --git a/CHANGELOG.md b/CHANGELOG.md index ff83dc287b..8b068577b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/codex-rs/core/src/usage_profile_health.rs b/codex-rs/core/src/usage_profile_health.rs index ce110008f4..26b603d94a 100644 --- a/codex-rs/core/src/usage_profile_health.rs +++ b/codex-rs/core/src/usage_profile_health.rs @@ -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) -> 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([ diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4bc695d10f..825c31bfd0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -662,6 +662,9 @@ pub(crate) struct ChatWidget { warning_display_state: WarningDisplayState, rate_limit_switch_prompt: RateLimitSwitchPromptState, last_auth_profile_auto_switch_trigger: Option, + /// 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, pending_auth_profile_auto_switch_trigger: Option, auth_profile_auto_switch_cooldowns: BTreeMap, diff --git a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs index cbfe6c9b36..f5d4308019 100644 --- a/codex-rs/tui/src/chatwidget/auth_profile_popups.rs +++ b/codex-rs/tui/src/chatwidget/auth_profile_popups.rs @@ -1065,6 +1065,7 @@ mod tests { RateLimitWindowDisplay { used_percent, resets_at: None, + resets_at_unix: None, window_minutes: Some(window_minutes), } } diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 1fd796a9be..3758174e6c 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -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(), diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index d5a1bfc7ce..5377a363de 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -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; @@ -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; @@ -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, @@ -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) -> 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::::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( 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 8189dbee7c..513c90552b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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::>(); + 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::>(); + 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; diff --git a/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs b/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs index e1bce7da6a..ce3f68972a 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage_limit_reset_tests/automatic.rs @@ -527,3 +527,100 @@ async fn verified_reset_resumes_failed_turn_exactly_once() { "failed turn must resume only once" ); } + +#[tokio::test] +async fn automatic_weekly_switch_requeues_failed_turn_for_resume_on_new_profile() { + // Regression: on the automatic weekly-exhaustion path, when no reset is available and the + // agent auto-switches to a healthier profile, the interrupted turn must be re-queued so it + // resumes automatically on the new profile (the `SwitchAuthProfile { resume_queued_input }` + // handler drains the queue after applying the profile override). Previously the switch fired + // but the failed turn was silently dropped, forcing the user to re-type `go`. + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + super::status_and_layout::save_test_auth_profile(&chat, "work"); + super::status_and_layout::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()]; + chat.config.usage_self_heal.enabled = true; + super::status_and_layout::configure_test_session(&mut chat); + set_canonical_reset_provider(&mut chat); + chat.config.usage_limit.auto_reset_enabled = true; + + // `personal` has fresh, healthy weekly usage, so it is a valid smart-selection target. + chat.on_auth_profile_rate_limit_snapshots( + Some("personal".to_string()), + vec![non_exhausted_weekly_snapshot()], + ); + // Banked credits present so the exhausted snapshot does not proactively auto-switch: the + // automatic reset path takes precedence until it turns out no usable reset applies. + chat.on_rate_limit_reset_credits(Some(exact_reset_summary())); + while rx.try_recv().is_ok() {} + + chat.submit_user_message(UserMessage::from("resume me on personal")); + assert_user_turn_text(next_submit_op(&mut op_rx), "resume me on personal"); + chat.on_task_started(); + chat.on_rate_limit_snapshot(Some(exhausted_weekly_snapshot())); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .all(|event| !matches!(event, AppEvent::SwitchAuthProfile { .. })), + "auto-reset must retain precedence before the turn actually fails" + ); + chat.on_rate_limit_error( + RateLimitErrorKind::UsageLimit, + "Weekly usage limit reached.".to_string(), + ); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()).any(|event| matches!( + event, + AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::AutoResetCheck { generation: 1 }, + target: RateLimitRefreshTarget::Selected, + } + )), + "the failed weekly turn must hand off to the automatic reset check first" + ); + + // Force the same-window fallback so the reset check yields no usable reset and the agent + // falls back to an auth-profile switch. + chat.usage_limit_auto_reset_key = Some(format!( + "{:?}:codex:weekly:{:?}", + chat.config.selected_auth_profile, + Some(123) + )); + chat.finish_usage_limit_auto_reset_check(1, Ok(())); + + let switches = std::iter::from_fn(|| rx.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::SwitchAuthProfile { + profile, + resume_queued_input, + .. + } => Some((profile, resume_queued_input)), + _ => None, + }) + .collect::>(); + assert_eq!(switches, vec![(Some("personal".to_string()), true)]); + + // The interrupted turn is re-queued (not sent yet); the pending switch drains it on the new + // profile once applied. No self-heal retry competes for the same turn. + assert_eq!( + chat.queued_user_message_texts(), + vec!["resume me on personal".to_string()] + ); + assert_eq!(chat.pending_usage_self_heal_retry_id(), None); + assert!( + op_rx.try_recv().is_err(), + "failed turn must not resubmit before the profile switch is applied" + ); + + // Simulate the switch handler draining the queue once the profile override is applied: the + // interrupted turn re-runs exactly once, on the new profile. + chat.pending_auth_profile_auto_switch_trigger = None; + assert!(chat.maybe_send_next_queued_input()); + assert_user_turn_text(next_submit_op(&mut op_rx), "resume me on personal"); + assert!( + op_rx.try_recv().is_err(), + "interrupted turn resumes exactly once" + ); +} diff --git a/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs b/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs index bab58084b9..84ef5584f4 100644 --- a/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs +++ b/codex-rs/tui/src/chatwidget/usage_limit_reset/automatic.rs @@ -203,6 +203,12 @@ impl ChatWidget { pub(super) fn fallback_auth_profile_switch_after_reset_unavailable(&mut self) { if self.try_auth_profile_switch_after_reset_unavailable() { + // A switch to a healthier profile is underway. Re-queue the interrupted turn so it + // resumes automatically on the new profile once the switch is applied, instead of + // being silently dropped (which forced the user to re-type `go`). The pending + // `SwitchAuthProfile { resume_queued_input: true }` drains the queue after the + // profile override op is submitted, so the turn re-runs on the healthy profile. + self.requeue_failed_turn_at_front(); return; } let retry_delay = self.maybe_schedule_usage_self_heal_retry( diff --git a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs index 5f7db40a9b..ea9bbb7541 100644 --- a/codex-rs/tui/src/chatwidget/usage_profile_broker.rs +++ b/codex-rs/tui/src/chatwidget/usage_profile_broker.rs @@ -27,6 +27,17 @@ pub(super) struct UsageProfileSwitchTarget { pub(super) trigger_key: String, } +/// Outcome of resolving an auto-switch target for an exhausted window. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum UsageProfileSwitchOutcome { + /// A healthier alternate profile is available; switch to it. + Switch(UsageProfileSwitchTarget), + /// No alternate profile has available usage right now. `earliest_reset_at` carries the + /// soonest known reset (unix seconds) across the exhausted candidates, when any is known, + /// so callers can tell the user when a profile is expected to become usable again. + NoEligibleProfile { earliest_reset_at: Option }, +} + pub(super) fn exhausted_auto_switch_window( snapshot: &RateLimitSnapshot, config: &AuthProfileAutoSwitchConfig, @@ -82,10 +93,10 @@ pub(super) fn auth_profile_auto_switch_target( recently_failed_profiles: &HashSet, limit_id: &str, window: &UsageProfileAutoSwitchWindow, -) -> Option { +) -> UsageProfileSwitchOutcome { let ordered = ordered_auth_profiles_for_auto_switch(&config.profiles, saved_profiles); let candidates = auth_profile_auto_switch_candidates(selected_auth_profile, &ordered); - let profile = match config.strategy { + let selection = match config.strategy { AuthProfileAutoSwitchStrategy::HighestAvailable | AuthProfileAutoSwitchStrategy::Ordered => healthiest_auth_profile_for_auto_switch( config, @@ -94,11 +105,16 @@ pub(super) fn auth_profile_auto_switch_target( &candidates, window, ), - }?; - Some(UsageProfileSwitchTarget { - profile, - trigger_key: auto_switch_trigger_key(limit_id, window), - }) + }; + match selection.selected_profile { + Some(profile) => UsageProfileSwitchOutcome::Switch(UsageProfileSwitchTarget { + profile, + trigger_key: auto_switch_trigger_key(limit_id, window), + }), + None => UsageProfileSwitchOutcome::NoEligibleProfile { + earliest_reset_at: selection.retry_at, + }, + } } fn ordered_auth_profiles_for_auto_switch( @@ -159,7 +175,7 @@ fn healthiest_auth_profile_for_auto_switch( recently_failed_profiles: &HashSet, candidates: &[String], window: &UsageProfileAutoSwitchWindow, -) -> Option { +) -> usage_profile_health::UsageProfileSelection { let freshness = Duration::from_secs(config.heartbeat_freshness_secs); let mut health_by_profile = BTreeMap::new(); @@ -180,7 +196,7 @@ fn healthiest_auth_profile_for_auto_switch( health_by_profile.insert(profile.clone(), health); } - choose_profile_for_auto_switch(config, candidates, &health_by_profile).selected_profile + choose_profile_for_auto_switch(config, candidates, &health_by_profile) } fn auth_profile_usage_health_for_auto_switch( @@ -294,7 +310,10 @@ fn display_rate_limit_window( UsageProfileRateLimitWindow { used_percent: window.used_percent, window_minutes: window.window_minutes, - resets_at: None, + // Preserve the server-reported reset time so an exhausted cached profile reports its + // `retry_at` (used for the earliest-reset message and to avoid re-picking a profile + // until it resets) instead of an anonymous exhausted state. + resets_at: window.resets_at_unix, } } diff --git a/codex-rs/tui/src/chatwidget/usage_self_heal.rs b/codex-rs/tui/src/chatwidget/usage_self_heal.rs index ce15eadc4f..5d3e0f0498 100644 --- a/codex-rs/tui/src/chatwidget/usage_self_heal.rs +++ b/codex-rs/tui/src/chatwidget/usage_self_heal.rs @@ -93,6 +93,22 @@ impl ChatWidget { } pub(super) fn resume_after_usage_limit_reset(&mut self) -> bool { + if !self.requeue_failed_turn_at_front() { + return false; + } + self.maybe_send_next_queued_input() + } + + /// Move the interrupted turn back to the front of the input queue WITHOUT sending it, and + /// clear the self-heal retry bookkeeping that turn owned. Returns whether a turn was + /// re-queued. + /// + /// Callers that want the turn to run immediately follow this with + /// `maybe_send_next_queued_input`. On the auth-profile auto-switch path the send is + /// deliberately left to the pending `SwitchAuthProfile { resume_queued_input: true }` + /// handler, which drains the queue *after* it submits the profile override — sending here + /// would race the switch and re-run the turn on the still-exhausted profile. + pub(super) fn requeue_failed_turn_at_front(&mut self) -> bool { let Some(submitted) = self.usage_self_heal.last_submitted_turn.take() else { return false; }; @@ -109,7 +125,7 @@ impl ChatWidget { .queued_user_message_history_records .push_front(submitted.history_record); self.refresh_pending_input_preview(); - self.maybe_send_next_queued_input() + true } pub(super) fn maybe_schedule_usage_self_heal_retry( diff --git a/codex-rs/tui/src/status/mod.rs b/codex-rs/tui/src/status/mod.rs index de3e558314..fd4a66f00c 100644 --- a/codex-rs/tui/src/status/mod.rs +++ b/codex-rs/tui/src/status/mod.rs @@ -24,6 +24,7 @@ pub(crate) use card::new_status_output_with_rate_limits; pub(crate) use card::new_status_output_with_rate_limits_handle; pub(crate) use helpers::compose_agents_summary; pub(crate) use helpers::format_directory_display; +pub(crate) use helpers::format_reset_timestamp; pub(crate) use helpers::format_tokens_compact; pub(crate) use helpers::plan_type_display_name; pub(crate) use rate_limits::RATE_LIMIT_STALE_THRESHOLD_MINUTES; diff --git a/codex-rs/tui/src/status/rate_limits.rs b/codex-rs/tui/src/status/rate_limits.rs index 0fe78d54aa..0e74f088ff 100644 --- a/codex-rs/tui/src/status/rate_limits.rs +++ b/codex-rs/tui/src/status/rate_limits.rs @@ -71,6 +71,10 @@ pub(crate) struct RateLimitWindowDisplay { pub used_percent: f64, /// Human-readable local reset time. pub resets_at: Option, + /// Raw reset timestamp (unix seconds) as reported by the server, preserved so downstream + /// consumers (auth-profile auto-switch selection, exhausted-profile reset tracking) can + /// compare reset times without re-parsing the localized `resets_at` label. + pub resets_at_unix: Option, /// Window length in minutes when provided by the server. pub window_minutes: Option, } @@ -86,6 +90,7 @@ impl RateLimitWindowDisplay { Self { used_percent: f64::from(window.used_percent), resets_at, + resets_at_unix: window.resets_at, window_minutes: window.window_duration_mins, } } @@ -428,6 +433,7 @@ mod tests { RateLimitWindowDisplay { used_percent, resets_at: Some("soon".to_string()), + resets_at_unix: None, window_minutes: Some(300), } } @@ -487,11 +493,13 @@ mod tests { primary: Some(RateLimitWindowDisplay { used_percent: 20.0, resets_at: Some("soon".to_string()), + resets_at_unix: None, window_minutes: Some(60), }), secondary: Some(RateLimitWindowDisplay { used_percent: 40.0, resets_at: Some("later".to_string()), + resets_at_unix: None, window_minutes: Some(2 * 60), }), credits: None, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 0f6a1454b0..f9fcd061fe 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -59,6 +59,7 @@ fn stale_monthly_limit_marks_fresh_rolling_snapshot_stale() { primary: Some(RateLimitWindowDisplay { used_percent: 20.0, resets_at: Some("soon".to_string()), + resets_at_unix: None, window_minutes: Some(300), }), secondary: None,