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`
This commit is contained in:
Eric Traut
2026-05-18 08:52:18 -07:00
committed by GitHub
parent 4ca60ef9ff
commit fce10e009d
2 changed files with 72 additions and 1 deletions

View File

@@ -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(),

View File

@@ -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;