mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -345,7 +345,6 @@ impl ChatWidget {
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Used in tests
|
||||
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
|
||||
&self.current_collaboration_mode
|
||||
}
|
||||
|
||||
+9
@@ -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
|
||||
+12
@@ -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
|
||||
+3
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user