Make advanced reasoning selection explicit in the TUI (#32746)

## Why

`Max` and `Ultra` consume usage limits faster than standard reasoning levels, so they should not be selected accidentally while navigating the normal effort scale.

## What changed

- Move `Max` and `Ultra` behind a `More reasoning…` entry with a dedicated warning and descriptions.
- Keep the reasoning shortcuts from silently increasing into advanced efforts.
- Apply `Ultra` to the active conversation without changing defaults for new threads, while preserving it across mode switches and thread resumes.
- Record applied thread settings in thread metadata, including explicit clearing of reasoning effort, so resumed threads restore their latest model settings unless the user supplied an override.

## Testing

- Add TUI coverage for the advanced picker, shortcuts, Plan mode, configuration defaults, and resumed conversations.
- Add app-server and thread-store coverage for persisting, clearing, and restoring model and reasoning settings.

GitOrigin-RevId: 6708c7c2e8d38f491bf63000ea34f83476afdfe6
This commit is contained in:
Felipe Coury
2026-07-13 09:40:51 +00:00
committed by copyberry
parent 2a4942c081
commit 769a5de257
27 changed files with 1447 additions and 92 deletions
@@ -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();
@@ -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::<ThreadResumeResponse>(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::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(approvals_reviewer, ApprovalsReviewer::AutoReview);
assert_eq!(reasoning_effort, None);
Ok(())
}
+70 -1
View File
@@ -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<T: Serialize>(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();
+3 -1
View File
@@ -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),
@@ -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]
@@ -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();
+9 -1
View File
@@ -543,7 +543,12 @@ pub struct ThreadMetadataPatch {
/// Latest observed model.
pub model: Option<String>,
/// Latest observed reasoning effort.
pub reasoning_effort: Option<ReasoningEffort>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "optional_option"
)]
pub reasoning_effort: ClearableField<ReasoningEffort>,
/// Creation timestamp when known.
pub created_at: Option<DateTime<Utc>>,
/// 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));
+5 -1
View File
@@ -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 {
+361 -1
View File
@@ -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<Config> {
let mut overrides = self.harness_overrides.clone();
@@ -734,10 +765,89 @@ impl App {
}
pub(super) fn on_update_reasoning_effort(&mut self, effort: Option<ReasoningEffortConfig>) {
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<ReasoningEffortConfig>,
) {
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<ReasoningEffortConfig> {
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<ReasoningEffortConfig> {
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<WindowsToml> {
#[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::<codex_config::ProfileV2Name>()
.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;
+42 -3
View File
@@ -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;
}
+6 -2
View File
@@ -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) => {
+2
View File
@@ -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;
@@ -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)
);
}
+5 -1
View File
@@ -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 {
+1 -1
View File
@@ -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,
+11
View File
@@ -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,
+125 -4
View File
@@ -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<String> {
match self {
@@ -484,9 +493,14 @@ impl AppServerSession {
&mut self,
config: Config,
thread_id: ThreadId,
model_settings: ResumeModelSettings,
) -> Result<AppServerStartedThread> {
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,
+160 -52
View File
@@ -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<SelectionAction> = 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<ReasoningEffortConfig> = supported
let mut all_choices: Vec<ReasoningEffortConfig> = 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<SelectionItem> = 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<SelectionAction> = 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::<Vec<_>>()
.join(" and ");
let verb = if advanced_choices.len() == 1 {
"consumes"
} else {
"consume"
};
let preset_for_action = preset;
let actions: Vec<SelectionAction> = 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::<Vec<_>>();
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(),
@@ -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::<Vec<_>>()
.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<ReasoningEffortConfig> {
let mut choices: Vec<ReasoningEffortConfig> = 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());
}
@@ -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());
-1
View File
@@ -345,7 +345,6 @@ impl ChatWidget {
)
}
#[allow(dead_code)] // Used in tests
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
&self.current_collaboration_mode
}
@@ -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
@@ -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
@@ -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
@@ -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;
@@ -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::<Vec<_>>();
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;
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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;