From fce10e009d2b56536d01145a809b8e862dbb671d Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 08:52:18 -0700 Subject: [PATCH] tui: keep cleared Fast tier from reappearing after side-thread resume (#23121) ## Why After turning Fast mode off in the TUI, returning from a side thread could make `Fast` appear again in the main chat widget. The opt-out itself was still persisted; the display was being rebuilt from stale cached `ThreadSessionState` data, which made it look like Fast had been re-enabled. Fixes #23104. ## What changed - Keep the active thread's cached `service_tier` in sync whenever the user persists a service-tier selection. - Update both the primary-thread snapshot and the thread event store so restored TUI state reflects the current tier. - Add a focused regression test for clearing a cached Fast tier. ## Manual repro 1. Start a TUI session where `Fast` is enabled by default. 2. Run `/fast` and turn Fast mode off. Confirm `Fast` disappears from the chat widget display. 3. Re-enter thread navigation via either path: - Run `/side test`, then return to the main thread. - Run `/agent`, enter a child thread, then return to the main thread. 4. Before this fix, `Fast` reappears in the main chat widget display even though the opt-out was already persisted. 5. After this fix, `Fast` stays cleared. ## Verification - `cargo test -p codex-tui app::thread_session_state::tests::service_tier_sync_updates_active_cached_session -- --exact` --- codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app/thread_session_state.rs | 69 ++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 9271089c43..efb9a26c63 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1303,8 +1303,10 @@ impl App { } AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); - let profile = self.active_profile.as_deref(); self.config.service_tier = service_tier.clone(); + self.sync_active_thread_service_tier_to_cached_session() + .await; + let profile = self.active_profile.as_deref(); let edits = crate::config_update::build_service_tier_selection_edits( profile, service_tier.as_deref(), diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4037a085eb..6f2b3ec7f1 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -8,6 +8,30 @@ use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; impl App { + pub(super) async fn sync_active_thread_service_tier_to_cached_session(&mut self) { + let Some(active_thread_id) = self.active_thread_id else { + return; + }; + + let service_tier = self.chat_widget.current_service_tier().map(str::to_string); + let update_session = |session: &mut ThreadSessionState| { + session.service_tier = service_tier.clone(); + }; + + if self.primary_thread_id == Some(active_thread_id) + && let Some(session) = self.primary_session_configured.as_mut() + { + update_session(session); + } + + if let Some(channel) = self.thread_event_channels.get(&active_thread_id) { + let mut store = channel.store.lock().await; + if let Some(session) = store.session.as_mut() { + update_session(session); + } + } + } + pub(super) async fn sync_active_thread_permission_settings_to_cached_session(&mut self) { let Some(active_thread_id) = self.active_thread_id else { return; @@ -125,6 +149,7 @@ mod tests { use crate::test_support::test_path_buf; use codex_app_server_protocol::AskForApproval; use codex_config::types::ApprovalsReviewer; + use codex_protocol::config_types::ServiceTier; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; @@ -316,6 +341,50 @@ mod tests { assert_eq!(store_session, Some(expected_session)); } + #[tokio::test] + async fn service_tier_sync_updates_active_cached_session() { + let mut app = make_test_app().await; + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000406").expect("valid thread"); + let session = ThreadSessionState { + service_tier: Some(ServiceTier::Fast.request_value().to_string()), + ..test_thread_session(thread_id, test_path_buf("/tmp/main")) + }; + + app.primary_thread_id = Some(thread_id); + app.active_thread_id = Some(thread_id); + app.primary_session_configured = Some(session.clone()); + app.thread_event_channels.insert( + thread_id, + ThreadEventChannel::new_with_session(/*capacity*/ 4, session.clone(), Vec::new()), + ); + app.chat_widget.handle_thread_session(session); + app.chat_widget.set_service_tier(/*service_tier*/ None); + + app.sync_active_thread_service_tier_to_cached_session() + .await; + + let expected_session = ThreadSessionState { + service_tier: None, + ..test_thread_session(thread_id, test_path_buf("/tmp/main")) + }; + assert_eq!( + app.primary_session_configured, + Some(expected_session.clone()) + ); + + let store_session = app + .thread_event_channels + .get(&thread_id) + .expect("thread channel") + .store + .lock() + .await + .session + .clone(); + assert_eq!(store_session, Some(expected_session)); + } + #[tokio::test] async fn thread_read_fallback_uses_active_permission_settings() { let mut app = make_test_app().await;