diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 7269fdacda..a2ec69ed9d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2788,15 +2788,18 @@ impl ThreadRequestProcessor { developer_instructions, personality, ); - self.load_and_apply_persisted_resume_metadata( - &thread_history, - &mut request_overrides, - &mut typesafe_overrides, - ) - .await; + let has_explicit_model_resume_override = + has_model_resume_override(request_overrides.as_ref(), &typesafe_overrides); + let persisted_metadata = self + .load_and_apply_persisted_resume_metadata( + &thread_history, + &mut request_overrides, + &mut typesafe_overrides, + ) + .await; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self + let mut config = match self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await @@ -2808,6 +2811,13 @@ impl ThreadRequestProcessor { return Ok(()); } }; + if !has_explicit_model_resume_override + && persisted_metadata + .as_ref() + .is_some_and(|metadata| metadata.reasoning_effort.is_none()) + { + config.model_reasoning_effort = None; + } let response_history = thread_history.clone(); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index efdd2c9eed..352d96409f 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -59,10 +59,14 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::Settings; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ImageGenerationEndEvent; @@ -529,21 +533,30 @@ async fn thread_resume_preserves_persisted_approvals_reviewer() -> Result<()> { } #[tokio::test] -async fn thread_resume_preserves_acknowledged_approvals_reviewer_update() -> Result<()> { +async fn thread_resume_preserves_acknowledged_model_effort_and_approvals_reviewer_update() +-> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config_toml = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config_toml.replace( + "model = \"gpt-5.4\"", + "model = \"gpt-5.4\"\nmodel_reasoning_effort = \"high\"", + ), + )?; let thread_id = { let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp - .send_thread_start_request(ThreadStartParams { + .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("gpt-5.4".to_string()), ..Default::default() }) @@ -580,6 +593,8 @@ async fn thread_resume_preserves_acknowledged_approvals_reviewer_update() -> Res let update_id = mcp .send_thread_settings_update_request(ThreadSettingsUpdateParams { thread_id: thread.id.clone(), + model: Some("gpt-5.2-codex".to_string()), + effort: Some(ReasoningEffort::Ultra), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), ..Default::default() }) @@ -601,7 +616,60 @@ async fn thread_resume_preserves_acknowledged_approvals_reviewer_update() -> Res let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) - .without_auto_env() + .build() + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + model, + reasoning_effort, + approvals_reviewer, + .. + } = to_response::(resume_resp)?; + + assert_eq!(model, "gpt-5.2-codex"); + assert_eq!(reasoning_effort, Some(ReasoningEffort::Ultra)); + assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview); + + let update_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread_id.clone(), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }) + .await?; + let update_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(update_id)), + ) + .await??; + let _: ThreadSettingsUpdateResponse = to_response(update_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + drop(mcp); + + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) .build() .await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -617,10 +685,10 @@ async fn thread_resume_preserves_acknowledged_approvals_reviewer_update() -> Res ) .await??; let ThreadResumeResponse { - approvals_reviewer, .. + reasoning_effort, .. } = to_response::(resume_resp)?; - assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview); + assert_eq!(reasoning_effort, None); Ok(()) } diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index ac4201de9f..1bf81168d7 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -37,7 +37,10 @@ pub fn rollout_item_affects_thread_metadata(item: &RolloutItem) -> bool { match item { RolloutItem::SessionMeta(_) | RolloutItem::TurnContext(_) => true, RolloutItem::EventMsg( - EventMsg::TokenCount(_) | EventMsg::UserMessage(_) | EventMsg::ThreadGoalUpdated(_), + EventMsg::TokenCount(_) + | EventMsg::UserMessage(_) + | EventMsg::ThreadGoalUpdated(_) + | EventMsg::ThreadSettingsApplied(_), ) => true, RolloutItem::EventMsg(EventMsg::ItemCompleted(event)) if matches!(event.item, TurnItem::UserMessage(_)) => @@ -114,6 +117,16 @@ fn apply_event_msg(metadata: &mut ThreadMetadata, event: &EventMsg) { set_preview_if_empty(metadata, Some(objective.to_string())); } } + EventMsg::ThreadSettingsApplied(event) => { + let settings = &event.thread_settings; + metadata.model = Some(settings.model.clone()); + metadata.model_provider = settings.model_provider_id.clone(); + metadata.reasoning_effort = settings.reasoning_effort.clone(); + metadata.cwd = settings.cwd.clone().into_path_buf(); + metadata.sandbox_policy = + serde_json::to_string(&settings.permission_profile).unwrap_or_default(); + metadata.approval_mode = enum_to_string(&settings.approval_policy); + } _ => {} } } @@ -151,10 +164,16 @@ pub(crate) fn enum_to_string(value: &T) -> String { #[cfg(test)] mod tests { use super::apply_rollout_item; + use super::rollout_item_affects_thread_metadata; use crate::model::ThreadMetadata; use chrono::DateTime; use chrono::Utc; use codex_protocol::ThreadId; + use codex_protocol::config_types::ApprovalsReviewer; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::ReasoningSummary; + use codex_protocol::config_types::Settings; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ContentItem; @@ -173,6 +192,8 @@ mod tests { use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; use codex_protocol::protocol::ThreadHistoryMode; + use codex_protocol::protocol::ThreadSettingsAppliedEvent; + use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::UserMessageEvent; @@ -530,6 +551,54 @@ mod tests { assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::High)); } + #[test] + fn thread_settings_applied_updates_resume_metadata() { + let mut metadata = metadata_for_test(); + let permission_profile = PermissionProfile::workspace_write(); + let cwd = std::env::current_dir() + .expect("current directory") + .join("updated/workspace"); + let item = RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( + ThreadSettingsAppliedEvent { + thread_settings: ThreadSettingsSnapshot { + model: "gpt-5.2-codex".to_string(), + model_provider_id: "updated-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: permission_profile.clone(), + active_permission_profile: None, + cwd: cwd.clone().try_into().expect("absolute settings cwd"), + reasoning_effort: Some(ReasoningEffort::Ultra), + reasoning_summary: Some(ReasoningSummary::Auto), + personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Ultra), + developer_instructions: None, + }, + }, + }, + }, + )); + + assert!(rollout_item_affects_thread_metadata(&item)); + apply_rollout_item(&mut metadata, &item, "test-provider"); + + assert_eq!(metadata.model.as_deref(), Some("gpt-5.2-codex")); + assert_eq!(metadata.model_provider, "updated-provider"); + assert_eq!(metadata.reasoning_effort, Some(ReasoningEffort::Ultra)); + assert_eq!(metadata.cwd, cwd); + assert_eq!(metadata.approval_mode, "never"); + assert_eq!( + metadata.sandbox_policy, + serde_json::to_string(&permission_profile) + .expect("permission profile should serialize") + ); + } + #[test] fn session_meta_does_not_set_model_or_reasoning_effort() { let mut metadata = metadata_for_test(); diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index 0d6ea80f2a..76f8727c8b 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -773,7 +773,9 @@ fn stored_thread_from_state( .and_then(|metadata| metadata.model_provider.clone()) .unwrap_or_else(|| "test".to_string()), model: metadata.and_then(|metadata| metadata.model.clone()), - reasoning_effort: metadata.and_then(|metadata| metadata.reasoning_effort.clone()), + reasoning_effort: metadata + .and_then(|metadata| metadata.reasoning_effort.clone()) + .flatten(), created_at: metadata .and_then(|metadata| metadata.created_at) .unwrap_or_else(Utc::now), diff --git a/codex-rs/thread-store/src/local/update_thread_metadata.rs b/codex-rs/thread-store/src/local/update_thread_metadata.rs index 580df1dc61..a6ea76e7e0 100644 --- a/codex-rs/thread-store/src/local/update_thread_metadata.rs +++ b/codex-rs/thread-store/src/local/update_thread_metadata.rs @@ -266,7 +266,7 @@ async fn apply_metadata_update( metadata.model = Some(model); } if let Some(reasoning_effort) = patch.reasoning_effort { - metadata.reasoning_effort = Some(reasoning_effort); + metadata.reasoning_effort = reasoning_effort; } if let Some(created_at) = patch.created_at { metadata.created_at = created_at; @@ -717,6 +717,7 @@ fn rollout_path_is_archived(store: &LocalThreadStore, path: &Path) -> bool { #[cfg(test)] mod tests { use codex_protocol::models::PermissionProfile; + use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadHistoryMode; use pretty_assertions::assert_eq; use serde_json::Value; @@ -1008,7 +1009,7 @@ mod tests { } #[tokio::test] - async fn update_thread_metadata_sets_permission_profile() { + async fn update_thread_metadata_updates_permission_profile_and_reasoning_effort() { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); let runtime = codex_state::StateRuntime::init( @@ -1027,6 +1028,7 @@ mod tests { thread_id, patch: ThreadMetadataPatch { permission_profile: Some(PermissionProfile::Disabled), + reasoning_effort: Some(Some(ReasoningEffort::Ultra)), ..Default::default() }, include_archived: false, @@ -1035,6 +1037,19 @@ mod tests { .expect("set permission profile"); assert_eq!(thread.permission_profile, PermissionProfile::Disabled); + let thread = store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + reasoning_effort: Some(None), + ..Default::default() + }, + include_archived: false, + }) + .await + .expect("clear reasoning effort"); + + assert_eq!(thread.reasoning_effort, None); let metadata = runtime .get_thread(thread_id) .await @@ -1045,6 +1060,7 @@ mod tests { metadata.sandbox_policy, serde_json::to_string(&permission_profile).expect("serialize profile") ); + assert_eq!(metadata.reasoning_effort, None); } #[tokio::test] diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 4b53990828..54ef305c07 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -240,7 +240,7 @@ impl ThreadMetadataSync { update.cwd = Some(turn_ctx.cwd.clone().into_path_buf()); } update.model = Some(turn_ctx.model.clone()); - update.reasoning_effort = turn_ctx.effort.clone(); + update.reasoning_effort = Some(turn_ctx.effort.clone()); update.approval_mode = Some(turn_ctx.approval_policy); update.permission_profile = Some(turn_ctx.permission_profile()); } @@ -269,6 +269,16 @@ impl ThreadMetadataSync { } } } + RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) => { + let settings = &event.thread_settings; + self.cwd_seen = true; + update.model = Some(settings.model.clone()); + update.model_provider = Some(settings.model_provider_id.clone()); + update.reasoning_effort = Some(settings.reasoning_effort.clone()); + update.cwd = Some(settings.cwd.clone().into_path_buf()); + update.approval_mode = Some(settings.approval_policy); + update.permission_profile = Some(settings.permission_profile.clone()); + } RolloutItem::SessionMeta(_) | RolloutItem::EventMsg(_) | RolloutItem::ResponseItem(_) @@ -374,7 +384,15 @@ fn git_info_patch_from_observation(git_info: GitInfo) -> GitInfoPatch { mod tests { use std::sync::Arc; + use codex_protocol::config_types::ApprovalsReviewer; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::ReasoningSummary; + use codex_protocol::config_types::Settings; use codex_protocol::items::UserMessageItem; + use codex_protocol::models::PermissionProfile; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::SessionMeta; @@ -383,6 +401,8 @@ mod tests { use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use codex_protocol::protocol::ThreadSettingsAppliedEvent; + use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::UserInput; @@ -551,6 +571,74 @@ mod tests { assert!(update.patch.advance_recency_at.is_some()); } + #[test] + fn thread_settings_applied_updates_live_metadata() { + let thread_id = ThreadId::new(); + let mut sync = ThreadMetadataSync::for_resume(&resume_params(thread_id, Vec::new())); + let permission_profile = PermissionProfile::workspace_write(); + let cwd = std::env::current_dir() + .expect("current directory") + .join("updated/workspace"); + + let mut item = RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied( + ThreadSettingsAppliedEvent { + thread_settings: ThreadSettingsSnapshot { + model: "gpt-5.2-codex".to_string(), + model_provider_id: "updated-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: permission_profile.clone(), + active_permission_profile: None, + cwd: cwd.clone().try_into().expect("absolute settings cwd"), + reasoning_effort: Some(ReasoningEffort::Ultra), + reasoning_summary: Some(ReasoningSummary::Auto), + personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.2-codex".to_string(), + reasoning_effort: Some(ReasoningEffort::Ultra), + developer_instructions: None, + }, + }, + }, + }, + )); + + let update = sync + .observe_appended_items(&[item.clone()]) + .expect("thread settings metadata update"); + + assert_eq!(update.patch.model.as_deref(), Some("gpt-5.2-codex")); + assert_eq!( + update.patch.model_provider.as_deref(), + Some("updated-provider") + ); + assert_eq!( + update.patch.reasoning_effort, + Some(Some(ReasoningEffort::Ultra)) + ); + assert_eq!(update.patch.cwd, Some(cwd)); + assert_eq!(update.patch.approval_mode, Some(AskForApproval::Never)); + assert_eq!(update.patch.permission_profile, Some(permission_profile)); + + let RolloutItem::EventMsg(EventMsg::ThreadSettingsApplied(event)) = &mut item else { + panic!("thread settings applied item"); + }; + event.thread_settings.reasoning_effort = None; + event + .thread_settings + .collaboration_mode + .settings + .reasoning_effort = None; + + let update = sync + .observe_appended_items(&[item]) + .expect("thread settings clear metadata update"); + assert_eq!(update.patch.reasoning_effort, Some(None)); + } + #[test] fn resume_history_waits_for_append_before_flushing_metadata() { let thread_id = ThreadId::new(); diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 4e688eca6f..317170442c 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -543,7 +543,12 @@ pub struct ThreadMetadataPatch { /// Latest observed model. pub model: Option, /// Latest observed reasoning effort. - pub reasoning_effort: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "optional_option" + )] + pub reasoning_effort: ClearableField, /// Creation timestamp when known. pub created_at: Option>, /// Last update timestamp for this metadata observation. @@ -741,6 +746,7 @@ mod tests { fn thread_metadata_patch_round_trips_optional_clears() { let patch = ThreadMetadataPatch { name: Some(None), + reasoning_effort: Some(None), thread_source: Some(None), agent_nickname: Some(None), agent_role: Some(None), @@ -750,6 +756,7 @@ mod tests { let value = serde_json::to_value(&patch).expect("serialize patch"); assert_eq!(value["name"], json!(null)); + assert_eq!(value["reasoning_effort"], json!(null)); assert_eq!(value["thread_source"], json!(null)); assert_eq!(value["agent_nickname"], json!(null)); assert_eq!(value["agent_role"], json!(null)); @@ -758,6 +765,7 @@ mod tests { let decoded: ThreadMetadataPatch = serde_json::from_value(value).expect("deserialize patch"); assert_eq!(decoded.name, Some(None)); + assert_eq!(decoded.reasoning_effort, Some(None)); assert_eq!(decoded.thread_source, Some(None)); assert_eq!(decoded.agent_nickname, Some(None)); assert_eq!(decoded.agent_role, Some(None)); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 554df264e2..cffd90edb8 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -925,8 +925,12 @@ impl App { (chat_widget, None) } SessionSelection::Resume(target_session) => { + let model_settings = config_persistence::resume_model_settings_for_overrides( + &config, + &harness_overrides, + ); let resumed = app_server - .resume_thread(config.clone(), target_session.thread_id) + .resume_thread(config.clone(), target_session.thread_id, model_settings) .await .map_err(|err| session_start_error("resume", &target_session, err))?; let init = crate::chatwidget::ChatWidgetInit { diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 80ed84d01e..6021a5e6a3 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -5,6 +5,7 @@ //! loop. use super::*; +use codex_config::ConfigLayerSource; #[cfg(target_os = "windows")] use codex_utils_approval_presets::ApprovalPreset; @@ -25,6 +26,36 @@ async fn build_config_on_runtime_worker( } } +pub(super) fn resume_model_settings_for_overrides( + config: &Config, + harness_overrides: &ConfigOverrides, +) -> crate::app_server_session::ResumeModelSettings { + let has_layer_override = config + .config_layer_stack + .layers_high_to_low() + .into_iter() + .any(|layer| { + matches!( + &layer.name, + ConfigLayerSource::SessionFlags + | ConfigLayerSource::User { + profile: Some(_), + .. + } + ) && ["model", "model_provider", "model_reasoning_effort"] + .iter() + .any(|key| layer.config.get(*key).is_some()) + }); + if harness_overrides.model.is_some() + || harness_overrides.model_provider.is_some() + || has_layer_override + { + crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig + } else { + crate::app_server_session::ResumeModelSettings::RestoreFromThread + } +} + impl App { pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { let mut overrides = self.harness_overrides.clone(); @@ -734,10 +765,89 @@ impl App { } pub(super) fn on_update_reasoning_effort(&mut self, effort: Option) { + let clear_ephemeral_plan_effort = effort != Some(ReasoningEffortConfig::Ultra) + && self.chat_widget.config_ref().plan_mode_reasoning_effort + == Some(ReasoningEffortConfig::Ultra) + && self.config.plan_mode_reasoning_effort != Some(ReasoningEffortConfig::Ultra); // TODO(aibrahim): Remove this and don't use config as a state object. // Instead, explicitly pass the stored collaboration mode's effort into new sessions. self.config.model_reasoning_effort = effort.clone(); - self.chat_widget.set_reasoning_effort(effort); + self.chat_widget.set_reasoning_effort(effort.clone()); + if clear_ephemeral_plan_effort { + self.chat_widget.set_plan_mode_reasoning_effort(effort); + } + } + + pub(super) fn on_update_plan_mode_reasoning_effort( + &mut self, + effort: Option, + ) { + let clear_ephemeral_default_effort = effort != Some(ReasoningEffortConfig::Ultra) + && self + .chat_widget + .current_collaboration_mode() + .reasoning_effort() + == Some(ReasoningEffortConfig::Ultra) + && self.config.model_reasoning_effort != Some(ReasoningEffortConfig::Ultra); + self.config.plan_mode_reasoning_effort = effort.clone(); + self.chat_widget + .set_plan_mode_reasoning_effort(effort.clone()); + if clear_ephemeral_default_effort { + self.chat_widget.set_reasoning_effort(effort); + } + } + + pub(super) fn on_apply_advanced_reasoning( + &mut self, + model: &str, + effort: ReasoningEffortConfig, + ) -> Option { + let default_effort = self.default_reasoning_effort_for_conversation_model(model); + if let Some(default_effort) = default_effort.as_ref() { + self.config.model = Some(model.to_string()); + self.config.model_reasoning_effort = Some(default_effort.clone()); + } + self.chat_widget.set_model(model); + self.chat_widget.set_reasoning_effort(Some(effort.clone())); + self.chat_widget + .set_plan_mode_reasoning_effort(Some(effort)); + default_effort + } + + fn default_reasoning_effort_for_conversation_model( + &self, + model: &str, + ) -> Option { + let configured_effort = self + .config + .model_reasoning_effort + .as_ref() + .filter(|effort| **effort != ReasoningEffortConfig::Ultra); + let preset = self + .model_catalog + .try_list_models() + .ok()? + .into_iter() + .find(|preset| preset.model == model)?; + let supported = &preset.supported_reasoning_efforts; + + configured_effort + .filter(|effort| supported.iter().any(|option| option.effort == **effort)) + .cloned() + .or_else(|| { + (preset.default_reasoning_effort != ReasoningEffortConfig::Ultra) + .then_some(preset.default_reasoning_effort) + }) + .or_else(|| { + supported + .iter() + .find(|option| option.effort != ReasoningEffortConfig::Ultra) + .map(|option| option.effort.clone()) + }) + } + + pub(super) fn resume_model_settings(&self) -> crate::app_server_session::ResumeModelSettings { + resume_model_settings_for_overrides(&self.config, &self.harness_overrides) } pub(super) fn on_update_personality(&mut self, personality: Personality) { @@ -1059,12 +1169,19 @@ fn windows_toml_from_json(value: &serde_json::Value) -> Option { #[cfg(test)] mod tests { + use std::sync::Arc; + use super::*; use crate::app::test_support::app_enabled_in_effective_config; use crate::app::test_support::make_test_app; use crate::legacy_core::config::edit::ConfigEdit; use crate::test_support::PathBufExt; + use codex_config::ConfigLayerEntry; + use codex_config::ConfigLayerStack; use codex_protocol::models::PermissionProfile; + use codex_protocol::openai_models::ReasoningEffortPreset; + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; use pretty_assertions::assert_eq; use tempfile::tempdir; @@ -1086,6 +1203,249 @@ mod tests { ); } + #[tokio::test] + async fn conversation_reasoning_uses_compatible_default_for_new_threads() { + for (configured_effort, expected_default_effort) in [ + (ReasoningEffortConfig::Low, ReasoningEffortConfig::Low), + ( + ReasoningEffortConfig::Custom("unsupported".to_string()), + ReasoningEffortConfig::Medium, + ), + (ReasoningEffortConfig::Ultra, ReasoningEffortConfig::Medium), + ] { + let mut app = make_test_app().await; + app.config.model = Some("gpt-5.4".to_string()); + app.config.model_reasoning_effort = Some(configured_effort.clone()); + app.chat_widget + .set_reasoning_effort(Some(configured_effort)); + + let default_effort = + app.on_apply_advanced_reasoning("gpt-5.4", ReasoningEffortConfig::Ultra); + let new_thread_config = app.fresh_session_config(); + + assert_eq!(default_effort, Some(expected_default_effort.clone())); + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + assert_eq!( + ( + new_thread_config.model.as_deref(), + new_thread_config.model_reasoning_effort, + ), + (Some("gpt-5.4"), Some(expected_default_effort)) + ); + } + } + + #[tokio::test] + async fn conversation_reasoning_keeps_previous_default_for_ultra_only_model() { + let mut app = make_test_app().await; + app.config.model = Some("gpt-5.4".to_string()); + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); + let mut preset = app + .model_catalog + .try_list_models() + .expect("model catalog is infallible") + .into_iter() + .find(|preset| preset.model == "gpt-5.4") + .expect("gpt-5.4 preset"); + preset.model = "ultra-only".to_string(); + preset.default_reasoning_effort = ReasoningEffortConfig::Ultra; + preset.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }]; + app.model_catalog = Arc::new(ModelCatalog::new(vec![preset])); + + let default_effort = + app.on_apply_advanced_reasoning("ultra-only", ReasoningEffortConfig::Ultra); + let new_thread_config = app.fresh_session_config(); + + assert_eq!(default_effort, None); + assert_eq!(app.chat_widget.current_model(), "ultra-only"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + assert_eq!( + ( + new_thread_config.model.as_deref(), + new_thread_config.model_reasoning_effort, + ), + (Some("gpt-5.4"), Some(ReasoningEffortConfig::Low)) + ); + } + + #[tokio::test] + async fn conversation_reasoning_updates_active_plan_without_changing_plan_default() { + let mut app = make_test_app().await; + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); + app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::High); + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + app.chat_widget + .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + let default_effort = + app.on_apply_advanced_reasoning("gpt-5.4", ReasoningEffortConfig::Ultra); + + assert_eq!(default_effort, Some(ReasoningEffortConfig::Low)); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + assert_eq!( + ( + app.config.model_reasoning_effort.clone(), + app.config.plan_mode_reasoning_effort.clone(), + ), + ( + Some(ReasoningEffortConfig::Low), + Some(ReasoningEffortConfig::High), + ) + ); + } + + #[tokio::test] + async fn leaving_conversation_ultra_in_default_clears_the_ephemeral_plan_effort() { + let mut app = make_test_app().await; + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); + app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::High); + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + app.chat_widget + .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + + app.on_apply_advanced_reasoning("gpt-5.4", ReasoningEffortConfig::Ultra); + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Medium) + ); + assert_eq!( + app.config.plan_mode_reasoning_effort, + Some(ReasoningEffortConfig::High) + ); + } + + #[tokio::test] + async fn leaving_conversation_ultra_in_plan_clears_the_ephemeral_default_effort() { + let mut app = make_test_app().await; + app.config.model_reasoning_effort = Some(ReasoningEffortConfig::Low); + app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::High); + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + app.chat_widget + .set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + app.on_apply_advanced_reasoning("gpt-5.4", ReasoningEffortConfig::Ultra); + app.on_update_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Medium)); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Medium) + ); + assert_eq!( + app.config.model_reasoning_effort, + Some(ReasoningEffortConfig::Low) + ); + } + + #[tokio::test] + async fn resume_model_settings_preserves_only_explicit_model_overrides() { + let mut app = make_test_app().await; + + assert_eq!( + app.resume_model_settings(), + crate::app_server_session::ResumeModelSettings::RestoreFromThread + ); + let profile_path = test_path_buf("/tmp/work.config.toml").abs(); + let profile = "work" + .parse::() + .expect("valid profile name"); + for (key, expected) in [ + ( + "model", + crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, + ), + ( + "model_provider", + crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, + ), + ( + "model_reasoning_effort", + crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, + ), + ( + "sandbox_mode", + crate::app_server_session::ResumeModelSettings::RestoreFromThread, + ), + ] { + let config = TomlValue::Table(toml::map::Map::from_iter([( + key.to_string(), + TomlValue::String("value".to_string()), + )])); + app.config.config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + config.clone(), + )], + Default::default(), + Default::default(), + ) + .expect("session flags layer stack"); + assert_eq!(app.resume_model_settings(), expected); + + app.config.config_layer_stack = ConfigLayerStack::default().with_user_config_profile( + &profile_path, + Some(&profile), + config, + ); + assert_eq!(app.resume_model_settings(), expected); + } + + app.config.config_layer_stack = ConfigLayerStack::default().with_user_config( + &profile_path, + TomlValue::Table(toml::map::Map::from_iter([( + "model_reasoning_effort".to_string(), + TomlValue::String("high".to_string()), + )])), + ); + assert_eq!( + app.resume_model_settings(), + crate::app_server_session::ResumeModelSettings::RestoreFromThread + ); + + app.harness_overrides.model_provider = Some("custom-provider".to_string()); + assert_eq!( + app.resume_model_settings(), + crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig + ); + } + #[tokio::test] async fn refresh_in_memory_config_from_disk_loads_latest_apps_state() -> Result<()> { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 8713537fbc..a5d408d4ca 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -173,7 +173,11 @@ impl App { if let Some(thread_id) = self.chat_widget.thread_id() { self.refresh_in_memory_config_from_disk_best_effort("forking the thread") .await; - match app_server.fork_thread(self.config.clone(), thread_id).await { + let mut fork_config = self.config.clone(); + fork_config.model = Some(self.chat_widget.current_model().to_string()); + fork_config.model_reasoning_effort = + self.chat_widget.current_reasoning_effort(); + match app_server.fork_thread(fork_config, thread_id).await { Ok(forked) => { self.shutdown_current_thread(app_server).await; match self @@ -1026,6 +1030,42 @@ impl App { AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } + AppEvent::OpenAdvancedReasoningPopup { model } => { + self.chat_widget.open_advanced_reasoning_popup(model); + } + AppEvent::ApplyAdvancedReasoning { model, effort } => { + let default_effort = + self.on_apply_advanced_reasoning(model.as_str(), effort.clone()); + if let Some(mut params) = + self.active_thread_model_setting_update_params(model.clone()) + { + params.effort = Some(effort.clone()); + self.send_thread_settings_update(app_server, params).await; + } + self.sync_active_thread_service_tier_to_cached_session() + .await; + + if let Some(default_effort) = default_effort.as_ref() + && let Err(err) = crate::config_update::write_config_batch( + app_server.request_handle(), + crate::config_update::build_model_selection_edits( + model.as_str(), + Some(default_effort), + ), + ) + .await + { + let error = format_config_error(&err); + tracing::error!(error = %error, "failed to persist conversation model"); + self.chat_widget + .add_error_message(format!("Failed to save default model: {error}")); + } else { + self.chat_widget.add_info_message( + format!("Model changed to {model} {effort} for this conversation"), + /*hint*/ None, + ); + } + } AppEvent::OpenPlanReasoningScopePrompt { model, effort } => { self.chat_widget .open_plan_reasoning_scope_prompt(model, effort); @@ -1803,8 +1843,7 @@ impl App { self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden); } AppEvent::UpdatePlanModeReasoningEffort(effort) => { - self.config.plan_mode_reasoning_effort = effort.clone(); - self.chat_widget.set_plan_mode_reasoning_effort(effort); + self.on_update_plan_mode_reasoning_effort(effort); self.sync_active_thread_plan_mode_reasoning_setting(app_server) .await; } diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index b4bbbd231a..5c2e174651 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -269,7 +269,7 @@ impl App { } let (session, turns, live_attached) = match app_server - .resume_thread(self.config.clone(), thread_id) + .resume_thread(self.config.clone(), thread_id, self.resume_model_settings()) .await { Ok(started) => (started.session, started.turns, true), @@ -789,7 +789,11 @@ impl App { self.chat_widget.rollout_path().as_deref(), ); match app_server - .resume_thread(resume_config.clone(), target_session.thread_id) + .resume_thread( + resume_config.clone(), + target_session.thread_id, + self.resume_model_settings(), + ) .await { Ok(resumed) => { diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d8d63575bb..04c2e8505b 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1,5 +1,7 @@ //! App-level orchestration tests for the TUI. +#[path = "tests/advanced_reasoning_tests.rs"] +mod advanced_reasoning_tests; mod model_catalog; mod plugin_catalog; mod session_summary; diff --git a/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs new file mode 100644 index 0000000000..662a16bfc1 --- /dev/null +++ b/codex-rs/tui/src/app/tests/advanced_reasoning_tests.rs @@ -0,0 +1,90 @@ +use super::*; +use app_test_support::create_fake_rollout; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn fork_current_session_preserves_conversation_ultra() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.config.sqlite_home = codex_home.path().to_path_buf(); + let source_thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(app.config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + app.chat_widget.handle_thread_session(ThreadSessionState { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Ultra), + ..test_thread_session(source_thread_id, test_path_buf("/tmp/project")) + }); + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?; + + let control = + Box::pin(app.handle_event(&mut tui, &mut app_server, AppEvent::ForkCurrentSession)).await?; + + assert!(matches!(control, AppRunControl::Continue)); + assert_ne!(app.chat_widget.thread_id(), Some(source_thread_id)); + assert_eq!(app.chat_widget.current_model(), "gpt-5.4"); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); + app_server.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn switching_from_ultra_thread_restores_configured_plan_effort() { + let mut app = make_test_app().await; + app.config.plan_mode_reasoning_effort = Some(ReasoningEffortConfig::High); + app.chat_widget + .set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let ultra_session = ThreadSessionState { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Ultra), + ..test_thread_session(ThreadId::new(), test_path_buf("/tmp/ultra")) + }; + let normal_session = ThreadSessionState { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::Medium), + ..test_thread_session(ThreadId::new(), test_path_buf("/tmp/normal")) + }; + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(ultra_session), + turns: Vec::new(), + events: Vec::new(), + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: Some(normal_session), + turns: Vec::new(), + events: Vec::new(), + input_state: None, + }, + /*resume_restored_queue*/ false, + ); + app.chat_widget + .handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + assert_eq!( + app.chat_widget.active_collaboration_mode_kind(), + ModeKind::Plan + ); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); +} diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index b149fd53f3..a353711072 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -1201,7 +1201,7 @@ impl App { } match app_server - .resume_thread(self.config.clone(), thread_id) + .resume_thread(self.config.clone(), thread_id, self.resume_model_settings()) .await { Ok(started) => { @@ -1325,6 +1325,10 @@ impl App { let suppress_replay_notices = replay_filter::snapshot_has_pending_interactive_request(&snapshot); if let Some(session) = snapshot.session { + if session.reasoning_effort != Some(ReasoningEffortConfig::Ultra) { + self.chat_widget + .set_plan_mode_reasoning_effort(self.config.plan_mode_reasoning_effort.clone()); + } if self.side_threads.contains_key(&session.thread_id) { self.chat_widget.handle_side_thread_session(session); } else if suppress_replay_notices { diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index 84dcebe1a8..1f96b1750e 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -153,7 +153,7 @@ impl App { } } - async fn send_thread_settings_update( + pub(super) async fn send_thread_settings_update( &mut self, app_server: &mut AppServerSession, params: ThreadSettingsUpdateParams, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fa2c9f29fc..553a58d8d4 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -741,6 +741,17 @@ pub(crate) enum AppEvent { model: ModelPreset, }, + /// Open the explicit Max/Ultra reasoning selection popup for a model. + OpenAdvancedReasoningPopup { + model: ModelPreset, + }, + + /// Apply an advanced reasoning effort to the active conversation without changing defaults. + ApplyAdvancedReasoning { + model: String, + effort: ReasoningEffort, + }, + /// Open the Plan-mode reasoning scope prompt for the selected model/effort. OpenPlanReasoningScopePrompt { model: String, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index ab29dfd598..1afa758908 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -190,6 +190,15 @@ pub(crate) enum ThreadParamsMode { Remote, } +/// Determines where model settings come from when resuming a thread. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ResumeModelSettings { + /// Sends the current config's model, provider, and reasoning effort as explicit overrides. + OverrideFromCurrentConfig, + /// Omits those overrides so app-server restores the settings saved with the thread. + RestoreFromThread, +} + impl ThreadParamsMode { fn model_provider_from_config(self, config: &Config) -> Option { match self { @@ -484,9 +493,14 @@ impl AppServerSession { &mut self, config: Config, thread_id: ThreadId, + model_settings: ResumeModelSettings, ) -> Result { let request_id = self.next_request_id(); - let session_config = self.session_config_with_effective_service_tier(&config); + let session_config = if model_settings == ResumeModelSettings::RestoreFromThread { + config.clone() + } else { + self.session_config_with_effective_service_tier(&config) + }; let response: ThreadResumeResponse = self .client .request_typed(ClientRequest::ThreadResume { @@ -496,6 +510,7 @@ impl AppServerSession { thread_id, self.thread_params_mode(), self.remote_cwd_override.as_deref(), + model_settings, ), }) .await @@ -1437,6 +1452,7 @@ fn thread_resume_params_from_config( thread_id: ThreadId, thread_params_mode: ThreadParamsMode, remote_cwd_override: Option<&std::path::Path>, + model_settings: ResumeModelSettings, ) -> ThreadResumeParams { let permissions = permissions_selection_from_config(&config, thread_params_mode); let sandbox = permissions @@ -1448,10 +1464,26 @@ fn thread_resume_params_from_config( ) }) .flatten(); + let mut config_overrides = config_request_overrides_from_config(&config); + if model_settings == ResumeModelSettings::RestoreFromThread + && let Some(overrides) = config_overrides.as_mut() + { + overrides.remove("model_reasoning_effort"); + if overrides.is_empty() { + config_overrides = None; + } + } + let (model, model_provider) = match model_settings { + ResumeModelSettings::OverrideFromCurrentConfig => ( + config.model.clone(), + thread_params_mode.model_provider_from_config(&config), + ), + ResumeModelSettings::RestoreFromThread => (None, None), + }; ThreadResumeParams { thread_id: thread_id.to_string(), - model: config.model.clone(), - model_provider: thread_params_mode.model_provider_from_config(&config), + model, + model_provider, service_tier: service_tier_override_from_config(&config), cwd: thread_cwd_from_config(&config, thread_params_mode, remote_cwd_override), runtime_workspace_roots: Some(config.workspace_roots.clone()), @@ -1459,7 +1491,7 @@ fn thread_resume_params_from_config( approvals_reviewer: approvals_reviewer_override_from_config(&config), sandbox, permissions, - config: config_request_overrides_from_config(&config), + config: config_overrides, developer_instructions: with_terminal_visualization_instructions( &config, /*control_instructions*/ None, ), @@ -1767,6 +1799,7 @@ mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; + use app_test_support::create_fake_rollout; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; @@ -1779,6 +1812,7 @@ mod tests { use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; + use codex_protocol::openai_models::ModelServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -2023,6 +2057,7 @@ mod tests { thread_id, ThreadParamsMode::Remote, /*remote_cwd_override*/ None, + ResumeModelSettings::OverrideFromCurrentConfig, ); let fork = thread_fork_params_from_config( config, @@ -2140,6 +2175,7 @@ mod tests { thread_id, ThreadParamsMode::Remote, Some(remote_cwd.as_path()), + ResumeModelSettings::OverrideFromCurrentConfig, ); let fork = thread_fork_params_from_config( config, @@ -2191,6 +2227,7 @@ mod tests { thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, + ResumeModelSettings::OverrideFromCurrentConfig, ); let fork = thread_fork_params_from_config( config, @@ -2217,6 +2254,88 @@ mod tests { assert_eq!(fork.config, Some(expected_config)); } + #[tokio::test] + async fn thread_resume_params_can_restore_persisted_model_settings() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config.model = Some("configured-model".to_string()); + config.model_provider_id = "configured-provider".to_string(); + config.model_reasoning_effort = Some(ReasoningEffort::Ultra); + config.model_reasoning_summary = Some(ReasoningSummary::Detailed); + + let params = thread_resume_params_from_config( + config, + ThreadId::new(), + ThreadParamsMode::Embedded, + /*remote_cwd_override*/ None, + ResumeModelSettings::RestoreFromThread, + ); + + assert_eq!(params.model, None); + assert_eq!(params.model_provider, None); + assert_eq!( + params.config, + Some(HashMap::from([ + ( + "model_reasoning_summary".to_string(), + serde_json::Value::String("detailed".to_string()), + ), + ( + "personality".to_string(), + serde_json::Value::String("pragmatic".to_string()), + ), + ( + "web_search".to_string(), + serde_json::Value::String("cached".to_string()), + ), + ])) + ); + } + + #[tokio::test] + async fn persisted_resume_does_not_forward_implicit_service_tier() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&codex_home).await; + config.model = Some("gpt-5.4".to_string()); + config.service_tier = None; + config + .features + .enable(Feature::FastMode) + .expect("enable fast mode"); + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create source rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + let mut preset = crate::test_support::TEST_MODEL_PRESETS + .iter() + .find(|preset| preset.model == "gpt-5.4") + .expect("gpt-5.4 test preset") + .clone(); + preset.service_tiers = vec![ModelServiceTier { + id: ServiceTier::Fast.request_value().to_string(), + name: "fast".to_string(), + description: "Fast tier".to_string(), + }]; + preset.default_service_tier = Some(ServiceTier::Fast.request_value().to_string()); + app_server.available_models = vec![preset]; + + let resumed = app_server + .resume_thread(config, thread_id, ResumeModelSettings::RestoreFromThread) + .await?; + + assert_eq!(resumed.session.service_tier, None); + app_server.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn config_request_overrides_preserve_implicit_personality_default() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -2278,6 +2397,7 @@ mod tests { thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, + ResumeModelSettings::OverrideFromCurrentConfig, ); let control_fork = thread_fork_params_from_config( config.clone(), @@ -2307,6 +2427,7 @@ mod tests { thread_id, ThreadParamsMode::Embedded, /*remote_cwd_override*/ None, + ResumeModelSettings::OverrideFromCurrentConfig, ); let treatment_fork = thread_fork_params_from_config( config, diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index b7d218c5bc..c8ace98397 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -100,22 +100,39 @@ impl ChatWidget { let description = (!preset.description.is_empty()).then_some(preset.description.clone()); let model = preset.model.clone(); - let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( - model.as_str(), - Some(preset.default_reasoning_effort.clone()), - ); - let actions = self.model_selection_actions( - model.clone(), - Some(preset.default_reasoning_effort.clone()), - should_prompt_plan_mode_scope, - ); + let requires_advanced_selection = + Self::is_advanced_reasoning_effort(&preset.default_reasoning_effort) + || preset + .supported_reasoning_efforts + .iter() + .any(|option| Self::is_advanced_reasoning_effort(&option.effort)); + let actions: Vec = if requires_advanced_selection { + let preset_for_action = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenReasoningPopup { + model: preset_for_action.clone(), + }); + })] + } else { + let should_prompt_plan_mode_scope = self + .should_prompt_plan_mode_reasoning_scope( + model.as_str(), + Some(preset.default_reasoning_effort.clone()), + ); + self.model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort.clone()), + should_prompt_plan_mode_scope, + ) + }; SelectionItem { name: model.clone(), description, is_current: model.as_str() == current_model, is_default: preset.is_default, actions, - dismiss_on_select: true, + dismiss_on_select: !requires_advanced_selection, + dismiss_parent_on_child_accept: requires_advanced_selection, ..Default::default() } }) @@ -225,20 +242,24 @@ impl ChatWidget { .as_ref() .and_then(|effort| self.ultra_reasoning_concurrency_warning(effort)); vec![Box::new(move |tx| { - if should_prompt_plan_mode_scope { + if effort_for_action == Some(ReasoningEffortConfig::Ultra) { + tx.send(AppEvent::ApplyAdvancedReasoning { + model: model_for_action.clone(), + effort: ReasoningEffortConfig::Ultra, + }); + } else if should_prompt_plan_mode_scope { tx.send(AppEvent::OpenPlanReasoningScopePrompt { model: model_for_action.clone(), effort: effort_for_action.clone(), }); - return; + } else { + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action.clone(), + }); } - - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action.clone())); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action.clone(), - }); if let Some(warning) = warning.clone() { tx.send(AppEvent::InsertHistoryCell(Box::new( history_cell::new_warning_event(warning), @@ -372,10 +393,13 @@ impl ChatWidget { }); } - /// Open a popup to choose the reasoning effort (stage 2) for the given model. + /// Open a popup to choose the standard reasoning effort for the given model. + /// + /// Max and Ultra require an explicit second step so expensive efforts cannot + /// be selected accidentally while moving through the normal effort scale. pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort = preset.default_reasoning_effort; - let supported = preset.supported_reasoning_efforts; + let default_effort = preset.default_reasoning_effort.clone(); + let supported = &preset.supported_reasoning_efforts; let in_plan_mode = self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; @@ -400,15 +424,18 @@ impl ChatWidget { || preset.model.starts_with("gpt-5.1-codex-max") || preset.model.starts_with("gpt-5.2"); - let mut choices: Vec = supported + let mut all_choices: Vec = supported .iter() .map(|option| option.effort.clone()) .collect(); - if choices.is_empty() { - choices.push(default_effort.clone()); + if all_choices.is_empty() { + all_choices.push(default_effort.clone()); } + let (choices, advanced_choices): (Vec<_>, Vec<_>) = all_choices + .into_iter() + .partition(|effort| !Self::is_advanced_reasoning_effort(effort)); - if choices.len() == 1 { + if choices.len() == 1 && advanced_choices.is_empty() { let selected_effort = choices.first().cloned(); let selected_model = preset.model; if self @@ -427,9 +454,7 @@ impl ChatWidget { let default_choice = choices .contains(&default_effort) - .then(|| default_effort.clone()) - .or_else(|| choices.first().cloned()) - .or(Some(default_effort)); + .then(|| default_effort.clone()); let model_slug = preset.model.to_string(); let is_current_model = self.current_model() == preset.model.as_str(); @@ -443,7 +468,7 @@ impl ChatWidget { self.effective_reasoning_effort() } } else { - default_choice.clone() + default_choice.clone().or_else(|| choices.first().cloned()) }; let selection_choice = highlight_choice.clone().or_else(|| default_choice.clone()); let initial_selected_idx = choices @@ -452,7 +477,6 @@ impl ChatWidget { let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.clone(); - let warning = self.ultra_reasoning_concurrency_warning(&effort); let mut effort_label = Self::reasoning_effort_label(&effort); if Some(choice) == default_choice.as_ref() { effort_label.push_str(" (default)"); @@ -476,32 +500,16 @@ impl ChatWidget { None }; - let model_for_action = model_slug.clone(); let choice_effort = Some(effort); let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( model_slug.as_str(), choice_effort.clone(), ); - let actions: Vec = vec![Box::new(move |tx| { - if should_prompt_plan_mode_scope { - tx.send(AppEvent::OpenPlanReasoningScopePrompt { - model: model_for_action.clone(), - effort: choice_effort.clone(), - }); - } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort.clone())); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: choice_effort.clone(), - }); - if let Some(warning) = warning.clone() { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_warning_event(warning), - ))); - } - } - })]; + let actions = self.model_selection_actions( + model_slug.clone(), + choice_effort, + should_prompt_plan_mode_scope, + ); items.push(SelectionItem { name: effort_label, @@ -514,6 +522,36 @@ impl ChatWidget { }); } + if !advanced_choices.is_empty() { + let advanced_label = advanced_choices + .iter() + .map(Self::reasoning_effort_label) + .collect::>() + .join(" and "); + let verb = if advanced_choices.len() == 1 { + "consumes" + } else { + "consume" + }; + let preset_for_action = preset; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAdvancedReasoningPopup { + model: preset_for_action.clone(), + }); + })]; + items.push(SelectionItem { + name: "More reasoning…".to_string(), + description: Some(format!("{advanced_label} {verb} usage limits faster")), + is_current: is_current_model + && highlight_choice + .as_ref() + .is_some_and(Self::is_advanced_reasoning_effort), + actions, + dismiss_parent_on_child_accept: true, + ..Default::default() + }); + } + let mut header = ColumnRenderable::new(); header.push(Line::from( format!("Select Reasoning Level for {model_slug}").bold(), @@ -528,6 +566,76 @@ impl ChatWidget { }); } + /// Open the explicit Max/Ultra effort picker for the given model. + pub(crate) fn open_advanced_reasoning_popup(&mut self, preset: ModelPreset) { + let mut choices = preset + .supported_reasoning_efforts + .iter() + .map(|option| option.effort.clone()) + .filter(Self::is_advanced_reasoning_effort) + .collect::>(); + if choices.is_empty() + && Self::is_advanced_reasoning_effort(&preset.default_reasoning_effort) + { + choices.push(preset.default_reasoning_effort.clone()); + } + choices.sort_by_key(|effort| matches!(effort, ReasoningEffortConfig::Ultra)); + if choices.is_empty() { + return; + } + + let model_slug = preset.model.to_string(); + let is_current_model = self.current_model() == preset.model.as_str(); + let highlight_choice = is_current_model + .then(|| self.effective_reasoning_effort()) + .flatten(); + let mut items = Vec::new(); + for effort in choices { + let description = match &effort { + ReasoningEffortConfig::Max => { + "For difficult problems when quality matters more than speed · higher usage" + } + ReasoningEffortConfig::Ultra => { + "For demanding work using multiple agents · highest usage" + } + _ => unreachable!("advanced choices are limited to Max and Ultra"), + }; + let should_prompt_plan_mode_scope = self + .should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), Some(effort.clone())); + let actions = self.model_selection_actions( + model_slug.clone(), + Some(effort.clone()), + should_prompt_plan_mode_scope, + ); + + items.push(SelectionItem { + name: Self::reasoning_effort_label(&effort), + description: Some(description.to_string()), + is_current: is_current_model && Some(&effort) == highlight_choice.as_ref(), + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Advanced Reasoning".bold())); + header.push(Line::from("⚠ Consumes usage limits faster".cyan())); + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(super) fn is_advanced_reasoning_effort(effort: &ReasoningEffortConfig) -> bool { + matches!( + effort, + ReasoningEffortConfig::Max | ReasoningEffortConfig::Ultra + ) + } + pub(super) fn reasoning_effort_label(effort: &ReasoningEffortConfig) -> String { match effort { ReasoningEffortConfig::None => "None".to_string(), diff --git a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs index b611281d0b..b247ca9e65 100644 --- a/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs +++ b/codex-rs/tui/src/chatwidget/reasoning_shortcuts.rs @@ -10,7 +10,8 @@ //! current model preset's default, and it walks only efforts advertised by the //! active model. Unsupported efforts anchor to the model default, or the first //! advertised effort when the default is absent, before stepping through the -//! advertised order. +//! advertised order. Raising never silently crosses into Max or Ultra; those +//! efforts require the explicit advanced-reasoning picker. use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ModelPreset; @@ -109,6 +110,34 @@ impl ChatWidget { return true; }; + if direction == ReasoningShortcutDirection::Raise + && Self::is_advanced_reasoning_effort(&next_effort) + { + let advanced_label = choices + .iter() + .filter(|effort| Self::is_advanced_reasoning_effort(effort)) + .map(Self::reasoning_effort_label) + .collect::>() + .join(" and "); + let verb = if advanced_label.contains(" and ") { + "are" + } else { + "is" + }; + let model_path = if current_model.starts_with("codex-auto-") { + current_model + } else { + format!("All models → {current_model}") + }; + self.add_info_message( + format!( + "{advanced_label} {verb} available under /model → {model_path} → More reasoning…" + ), + /*hint*/ None, + ); + return true; + } + if self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan { let warning = self.ultra_reasoning_concurrency_warning(&next_effort); self.app_event_tx @@ -136,11 +165,13 @@ impl ChatWidget { } fn reasoning_choices(preset: &ModelPreset) -> Vec { - let mut choices: Vec = preset + let (mut choices, mut advanced_choices): (Vec<_>, Vec<_>) = preset .supported_reasoning_efforts .iter() .map(|option| option.effort.clone()) - .collect(); + .partition(|effort| !ChatWidget::is_advanced_reasoning_effort(effort)); + advanced_choices.sort_by_key(|effort| matches!(effort, ReasoningEffortConfig::Ultra)); + choices.extend(advanced_choices); if choices.is_empty() { choices.push(preset.default_reasoning_effort.clone()); } diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 177700d4f4..782aa7e1d0 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -83,6 +83,9 @@ impl ChatWidget { Some(session.reasoning_effort.clone()), /*developer_instructions*/ None, ); + if session.reasoning_effort == Some(ReasoningEffortConfig::Ultra) { + self.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::Ultra)); + } match session.collaboration_mode.as_deref() { Some(collaboration_mode) => { self.set_effective_collaboration_mode(collaboration_mode.clone()); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 269606098f..e5688e92f2 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -345,7 +345,6 @@ impl ChatWidget { ) } - #[allow(dead_code)] // Used in tests pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { &self.current_collaboration_mode } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap new file mode 100644 index 0000000000..ad57955bcc --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__advanced_only_reasoning_selection_popup.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Select Reasoning Level for gpt-5.4 + +› 1. More reasoning… Ultra consumes usage limits faster + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap new file mode 100644 index 0000000000..926a0b1b71 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_advanced_reasoning_selection_popup.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: popup +--- + Advanced Reasoning + ⚠ Consumes usage limits faster + + 1. Max For difficult problems when quality matters more than + speed · higher usage +› 2. Ultra (current) For demanding work using multiple agents · highest usage + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 6027772004..1b91548f11 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -6,8 +6,8 @@ expression: popup 1. Low Fast responses with lighter reasoning 2. Medium (default) Balances speed and reasoning depth for everyday tasks - 3. Max Maximum available reasoning -› 4. High (current) Greater reasoning depth for complex problems - 5. Extra high Extra high reasoning depth for complex problems +› 3. High (current) Greater reasoning depth for complex problems + 4. Extra high Extra high reasoning depth for complex problems + 5. More reasoning… Max and Ultra consume usage limits faster Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 8126cc89b1..37a3bd8a07 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -75,6 +75,43 @@ async fn resumed_initial_messages_render_history() { ); } +#[tokio::test] +async fn restored_conversation_ultra_remains_selected_after_switching_to_plan() { + let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + chat.set_plan_mode_reasoning_effort(Some(ReasoningEffortConfig::High)); + + chat.handle_thread_session(crate::session_state::ThreadSessionState { + thread_id: ThreadId::new(), + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-5.4".to_string(), + model_provider_id: "test-provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/home/user/project").abs(), + runtime_workspace_roots: Vec::new(), + instruction_source_paths: Vec::new(), + reasoning_effort: Some(ReasoningEffortConfig::Ultra), + collaboration_mode: None, + personality: None, + message_history: None, + network_proxy: None, + rollout_path: None, + }); + chat.handle_key_event(KeyEvent::from(KeyCode::BackTab)); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::Ultra) + ); +} + #[tokio::test] async fn replayed_user_messages_seed_composer_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 82c4ef50b5..780648c106 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -427,6 +427,52 @@ async fn reasoning_shortcut_in_plan_mode_updates_plan_override_without_prompt_or ); } +#[tokio::test] +async fn advanced_reasoning_selection_in_plan_mode_uses_expected_scope() { + for effort in [ReasoningEffortConfig::Ultra, ReasoningEffortConfig::Max] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.thread_id = Some(ThreadId::new()); + chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); + let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) + .expect("expected plan collaboration mode"); + chat.set_collaboration_mask(plan_mask); + let _ = drain_insert_history(&mut rx); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: effort.clone(), + description: "Advanced reasoning".to_string(), + }]; + chat.open_advanced_reasoning_popup(preset); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + if effort == ReasoningEffortConfig::Ultra { + assert!(events.iter().any(|event| matches!( + event, + AppEvent::ApplyAdvancedReasoning { + model, + effort: ReasoningEffortConfig::Ultra, + } if model == "gpt-5.4" + ))); + assert!(events.iter().all(|event| !matches!( + event, + AppEvent::OpenPlanReasoningScopePrompt { .. } + | AppEvent::PersistPlanModeReasoningEffort(_) + | AppEvent::PersistModelSelection { .. } + ))); + } else { + assert!(events.iter().any(|event| matches!( + event, + AppEvent::OpenPlanReasoningScopePrompt { + model, + effort: Some(ReasoningEffortConfig::Max), + } if model == "gpt-5.4" + ))); + } + } +} + #[tokio::test] async fn plan_mode_reasoning_override_is_marked_current_in_reasoning_popup() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index a3560dac4f..72daaeeadb 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3196,12 +3196,40 @@ async fn model_reasoning_selection_popup_snapshot() { description: "Maximum available reasoning".to_string(), }, ); + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }); chat.open_reasoning_popup(preset); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("model_reasoning_selection_popup", popup); } +#[tokio::test] +async fn model_advanced_reasoning_selection_popup_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_reasoning_effort(Some(ReasoningEffortConfig::Ultra)); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts.extend([ + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Max, + description: "Maximum available reasoning".to_string(), + }, + ]); + chat.open_advanced_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("model_advanced_reasoning_selection_popup", popup); +} + #[tokio::test] async fn model_reasoning_selection_popup_applies_custom_effort() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; @@ -3262,11 +3290,21 @@ async fn select_ultra_with_multi_agent_thread_limit(max_threads: usize) -> (bool chat.handle_key_event(KeyEvent::from(KeyCode::Down)); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let advanced_preset = std::iter::from_fn(|| rx.try_recv().ok()).find_map(|event| match event { + AppEvent::OpenAdvancedReasoningPopup { model } => Some(model), + _ => None, + }); + chat.open_advanced_reasoning_popup(advanced_preset.expect("advanced reasoning popup")); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + let mut selected_ultra = false; let mut warnings = Vec::new(); while let Ok(event) = rx.try_recv() { match event { - AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Ultra)) => { + AppEvent::ApplyAdvancedReasoning { + effort: ReasoningEffortConfig::Ultra, + .. + } => { selected_ultra = true; } AppEvent::InsertHistoryCell(cell) => { @@ -3299,6 +3337,38 @@ async fn ultra_reasoning_selection_skips_warning_below_threshold() { assert_eq!(below_threshold, (true, Vec::new())); } +#[tokio::test] +async fn max_reasoning_selection_persists_model_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: ReasoningEffortConfig::Max, + description: "Maximum reasoning".to_string(), + }]; + chat.open_advanced_reasoning_popup(preset); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Max)) + ))); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::PersistModelSelection { + model, + effort: Some(ReasoningEffortConfig::Max), + } if model == "gpt-5.4" + ))); + assert!( + events + .iter() + .all(|event| !matches!(event, AppEvent::ApplyAdvancedReasoning { .. })) + ); +} + #[tokio::test] async fn model_reasoning_selection_popup_extra_high_warning_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; @@ -3421,6 +3491,96 @@ async fn reasoning_shortcut_is_ignored_with_model_popup_open() { ); } +#[tokio::test] +async fn reasoning_up_shortcut_does_not_silently_enter_advanced_effort() { + for (model, model_path) in [ + ("gpt-5.4", "All models → gpt-5.4"), + ("codex-auto-test", "codex-auto-test"), + ] { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.id = model.to_string(); + preset.model = model.to_string(); + preset.display_name = model.to_string(); + preset.supported_reasoning_efforts.extend([ + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Max, + description: "Maximum reasoning".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }, + ]); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); + chat.set_model(model); + + for effort in [ReasoningEffortConfig::XHigh, ReasoningEffortConfig::Max] { + chat.set_reasoning_effort(Some(effort)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().all(|event| !matches!( + event, + AppEvent::UpdateReasoningEffort(_) | AppEvent::ApplyAdvancedReasoning { .. } + ))); + let messages = events + .into_iter() + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.display_lines(/*width*/ 140))) + } + _ => None, + }) + .collect::>(); + assert_eq!( + messages, + vec![format!( + "• Max and Ultra are available under /model → {model_path} → More reasoning…\n" + )] + ); + } + } +} + +#[tokio::test] +async fn reasoning_down_shortcut_can_leave_advanced_effort() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts.extend([ + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }, + ReasoningEffortPreset { + effort: ReasoningEffortConfig::Max, + description: "Maximum reasoning".to_string(), + }, + ]); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); + + for (current, expected) in [ + (ReasoningEffortConfig::Ultra, ReasoningEffortConfig::Max), + (ReasoningEffortConfig::Max, ReasoningEffortConfig::XHigh), + ] { + chat.set_reasoning_effort(Some(current)); + chat.handle_key_event(KeyEvent::new(KeyCode::Char(','), KeyModifiers::ALT)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().any(|event| matches!( + event, + AppEvent::UpdateReasoningEffort(Some(effort)) if effort == &expected + ))); + assert!( + events + .iter() + .all(|event| !matches!(event, AppEvent::PersistModelSelection { .. })) + ); + } +} + #[tokio::test] async fn reasoning_popup_shows_extra_high_with_space() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; @@ -3488,6 +3648,60 @@ async fn single_reasoning_option_skips_selection() { ); } +#[tokio::test] +async fn advanced_only_reasoning_option_requires_explicit_selection() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.default_reasoning_effort = ReasoningEffortConfig::Ultra; + preset.supported_reasoning_efforts = vec![ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }]; + chat.open_reasoning_popup(preset); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("advanced_only_reasoning_selection_popup", popup); + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().all(|event| !matches!( + event, + AppEvent::UpdateReasoningEffort(_) + | AppEvent::ApplyAdvancedReasoning { .. } + | AppEvent::PersistModelSelection { .. } + ))); +} + +#[tokio::test] +async fn auto_model_advertising_advanced_effort_opens_reasoning_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.id = "codex-auto-test".to_string(); + preset.model = "codex-auto-test".to_string(); + preset.display_name = "codex-auto-test".to_string(); + preset.default_reasoning_effort = ReasoningEffortConfig::Medium; + preset + .supported_reasoning_efforts + .push(ReasoningEffortPreset { + effort: ReasoningEffortConfig::Ultra, + description: "Ultra reasoning".to_string(), + }); + chat.open_model_popup_with_presets(vec![preset]); + + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); + assert!(events.iter().all(|event| !matches!( + event, + AppEvent::UpdateReasoningEffort(_) + | AppEvent::ApplyAdvancedReasoning { .. } + | AppEvent::PersistModelSelection { .. } + ))); + assert!( + events + .iter() + .any(|event| matches!(event, AppEvent::OpenReasoningPopup { .. })) + ); +} + #[tokio::test] async fn feedback_selection_popup_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;