Support inline model, reasoning, and speed selection in /model (#54378)

## What changed

Allow `/model [model] --reasoning <level> --speed <tier>` to update the current session without changing saved defaults. Both space-separated and `=` flag values are supported; omitting the model applies settings to the current model. Bare `/model` still opens the picker.

Validate model availability, supported reasoning levels, and speed tiers before applying changes. Reject unknown flags, duplicate settings, and missing values. Apply model, reasoning, and speed together after server acceptance.

Restore each thread's speed when switching threads, and use destination configuration defaults for new sessions instead of carrying over the active session's speed.

## Testing

Extend regression coverage for inline commands, invalid arguments, unchanged saved defaults, settings-only updates to hidden current models, destination speed defaults, and speed restoration on the next turn after switching threads.

GitOrigin-RevId: ad9cecbdb3554e34a6518367364978a37fb68439
This commit is contained in:
Felipe Coury
2026-10-15 19:01:10 +00:00
committed by copyberry
parent 7ae8319917
commit 0884c8047a
23 changed files with 455 additions and 58 deletions
-4
View File
@@ -947,7 +947,6 @@ impl App {
.as_ref()
.filter(|_| remote)
.map(AbsolutePathBuf::to_path_buf);
let preserve_service_tier = remote || cwd.is_none();
let local_cwd = if remote {
self.config.cwd.to_path_buf()
} else {
@@ -969,9 +968,6 @@ impl App {
return None;
}
};
if preserve_service_tier {
config.service_tier = self.chat_widget.configured_service_tier();
}
let trust_cwd = config.cwd.to_path_buf();
if self
.confirm_directory_trust(
+2 -2
View File
@@ -2536,9 +2536,9 @@ impl App {
AppEvent::PersistDaybreakSelection { thread_id, enabled } => {
self.persist_daybreak_selection(app_server, thread_id, enabled).await;
}
AppEvent::SelectSessionModel { model, effort } => {
AppEvent::SelectSessionModel { model, effort, service_tier } => {
self.app_event_tx.send(AppEvent::FollowTranscript);
self.select_session_model(app_server, model, effort).await;
self.select_session_model(app_server, model, effort, service_tier).await;
}
AppEvent::CyberModelAutoReviewNotice => {
self.chat_widget.add_warning_message(
+58 -16
View File
@@ -1,14 +1,17 @@
//! Apply session model choices separately from saved model defaults.
//! Apply complete session model choices after server acceptance, separately from saved defaults.
//!
//! A successful config write can still be overridden. Report that distinction without
//! replacing the active task's explicit selection with launch-time configuration.
use super::App;
use crate::app_server_session::AppServerSession;
use crate::service_tier_resolution;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ConfigEdit;
use codex_app_server_protocol::WriteStatus;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ReasoningEffort;
use color_eyre::eyre::Result;
@@ -18,16 +21,50 @@ impl App {
app_server: &mut AppServerSession,
model: String,
effort: Option<ReasoningEffort>,
service_tier: Option<String>,
) {
let model_changed = self.chat_widget.current_model() != model
|| self.chat_widget.current_collaboration_mode().model() != model;
if model_changed
&& self
.active_thread_model_setting_update_params(model.clone())
.is_some_and(|params| params.permissions.is_some())
&& self.reject_pending_permission_change()
{
return;
let params = if model_changed {
self.active_thread_model_setting_update_params(model.clone())
} else {
self.active_thread_reasoning_setting_update_params(effort.clone())
};
if let Some(mut params) = params {
if params.permissions.is_some() && self.reject_pending_permission_change() {
return;
}
params.effort = effort.clone();
params.collaboration_mode = Some(
self.chat_widget
.effective_collaboration_mode()
.with_updates(
Some(model.clone()),
Some(effort.clone()),
/*developer_instructions*/ None,
),
);
params.service_tier = match service_tier.as_ref() {
Some(tier) => Some(Some(tier.clone())),
None => service_tier_resolution::service_tier_update_for_core(
self.chat_widget.config_ref(),
&self.local_settings.notices,
&model,
&self.model_catalog.models,
),
};
if !self.send_model_settings_update(app_server, params).await {
if app_server.thread_settings_update_supported {
return;
}
if service_tier.is_some() {
self.chat_widget.add_error_message(
"Model selection requires an app server with thread/settings/update support."
.into(),
);
return;
}
}
}
let in_plan_mode = self.chat_widget.effective_collaboration_mode().mode == ModeKind::Plan;
let ultra = effort == Some(ReasoningEffort::Ultra);
@@ -48,14 +85,9 @@ impl App {
self.chat_widget
.set_plan_mode_reasoning_effort(effort.clone());
}
if model_changed {
self.sync_active_thread_model_setting(app_server, model.clone(), effort.clone())
.await;
} else if let Some(mut params) =
self.active_thread_reasoning_setting_update_params(effort.clone())
{
params.collaboration_mode = Some(self.chat_widget.effective_collaboration_mode());
self.send_thread_settings_update(app_server, params).await;
if let Some(service_tier) = service_tier.as_ref() {
self.chat_widget
.set_service_tier(Some(service_tier.clone()));
}
self.sync_active_thread_service_tier_to_cached_session()
.await;
@@ -64,6 +96,16 @@ impl App {
message.push(' ');
message.push_str(&label);
}
if let Some(service_tier) = service_tier {
message.push(' ');
message.push_str(if service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE {
"standard"
} else if service_tier == ServiceTier::Fast.request_value() {
"fast"
} else {
&service_tier
});
}
message.push_str(" for this session only");
self.chat_widget.add_info_message(message, /*hint*/ None);
}
+10 -1
View File
@@ -4,6 +4,7 @@
use super::*;
use codex_config::ConfigLayerSource;
use codex_protocol::config_types::ServiceTier;
pub(crate) fn has_launch_setting(
config: &Config,
@@ -40,6 +41,15 @@ pub(super) fn overlay_new_session_defaults(
if !has_launch_setting(config, cli_kv_overrides, "model_reasoning_effort") {
config.model_reasoning_effort = defaults.model_reasoning_effort.clone();
}
if harness_overrides.service_tier.is_none()
&& !has_launch_setting(config, cli_kv_overrides, "service_tier")
{
config.service_tier = defaults.service_tier.as_deref().map(|service_tier| {
ServiceTier::from_request_value(service_tier)
.map(|tier| tier.request_value().to_string())
.unwrap_or_else(|| service_tier.to_string())
});
}
if !has_launch_setting(config, cli_kv_overrides, "daybreak") {
config.daybreak_enabled = defaults
.additional
@@ -87,7 +97,6 @@ impl App {
);
}
self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::ExplicitOnly)?;
config.service_tier = self.chat_widget.configured_service_tier();
if let Some(defaults) = defaults.as_ref() {
overlay_new_session_defaults(
&mut config,
+1
View File
@@ -9729,6 +9729,7 @@ async fn changing_cyber_model_reasoning_preserves_selected_permissions() {
AppEvent::SelectSessionModel {
model: model_name.clone(),
effort: Some(effort.clone()),
service_tier: None,
},
)
.await
@@ -1,5 +1,6 @@
use super::*;
use app_test_support::create_fake_rollout;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_utils_absolute_path::test_support::PathExt;
use pretty_assertions::assert_eq;
@@ -111,3 +112,50 @@ async fn switching_from_ultra_thread_restores_configured_plan_effort() {
Some(ReasoningEffortConfig::High)
);
}
#[tokio::test]
async fn switching_threads_restores_session_speed_for_the_next_turn() {
let (mut app, _events, mut ops) = make_test_app_with_channels().await;
app.config.service_tier = Some("flex".into());
app.chat_widget.set_service_tier(Some("flex".into()));
let standard_thread = ThreadId::new();
let flex_thread = ThreadId::new();
let session = |thread_id, service_tier: &str| ThreadSessionState {
model: "gpt-5.5".into(),
service_tier: Some(service_tier.into()),
..test_thread_session(thread_id, test_path_buf("/tmp/project"))
};
let snapshot = |session| ThreadEventSnapshot {
delegated_turns: Vec::new(),
session: Some(session),
turns: Vec::new(),
events: Vec::new(),
active_reasoning_item: None,
input_state: None,
};
app.replay_thread_snapshot(
snapshot(session(standard_thread, SERVICE_TIER_DEFAULT_REQUEST_VALUE)),
/*resume_restored_queue*/ false,
);
app.replay_thread_snapshot(
snapshot(session(flex_thread, "flex")),
/*resume_restored_queue*/ false,
);
assert_eq!(
app.chat_widget.configured_service_tier().as_deref(),
Some("flex")
);
app.replay_thread_snapshot(
snapshot(session(standard_thread, SERVICE_TIER_DEFAULT_REQUEST_VALUE)),
/*resume_restored_queue*/ false,
);
app.chat_widget
.apply_external_edit("Check this task".into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
assert_matches!(next_user_turn_op(&mut ops), Op::UserTurn { service_tier, .. }
if service_tier == Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.into())));
assert_eq!(app.config.service_tier.as_deref(), Some("flex"));
}
@@ -245,7 +245,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re
std::fs::write(
server_home.path().join("config.toml"),
format!(
"{}model_reasoning_effort = \"high\"\n{}",
"{}model_reasoning_effort = \"high\"\nservice_tier = \"fast\"\n{}",
if mode == "remote-null-fast" {
""
} else {
@@ -398,11 +398,7 @@ async fn command_center_new_reads_server_defaults_for_actual_destination() -> Re
serde_json::Value::Null
},
&serde_json::json!("high"),
&serde_json::json!(if mode == "local" && explicit_cwd {
"flex"
} else {
"priority"
}),
&serde_json::json!(if explicit_cwd { "flex" } else { "priority" }),
),
"{mode} {expected_cwd}"
);
@@ -233,6 +233,7 @@ async fn settings_confirmations_reveal_their_history_output() -> Result<()> {
AppEvent::SelectSessionModel {
model: "gpt-5.5".into(),
effort: Some(ReasoningEffortConfig::High),
service_tier: None,
},
"Failed to update thread settings",
),
@@ -2,6 +2,7 @@ use super::*;
use crate::collaboration_modes;
use codex_app_server_client::AppServerClient;
use codex_config::LoaderOverrides;
use crossterm::event::KeyCode;
use pretty_assertions::assert_eq;
#[tokio::test]
@@ -32,28 +33,59 @@ async fn session_model_selection_preserves_defaults_and_updates_active_thread()
ReasoningEffortConfig::Max,
] {
while events.try_recv().is_ok() {}
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::SelectSessionModel {
model: "gpt-5.6-terra".into(),
effort: Some(effort.clone()),
},
))
.await?;
let command = match effort {
ReasoningEffortConfig::High => {
"/model gpt-5.6-terra --reasoning high --speed=standard".into()
}
ReasoningEffortConfig::Ultra => "/model --reasoning=ultra".into(),
ReasoningEffortConfig::Max => "/model --reasoning max".into(),
_ => unreachable!(),
};
app.chat_widget.apply_external_edit(command);
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
let mut messages = Vec::new();
while let Ok(event) = events.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = &event {
messages.push(lines_to_single_string(&cell.transcript_lines(/*width*/ 80)));
}
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
}
let settings = next_thread_settings_updated(&mut server, thread_id).await;
assert_eq!(
(
settings.thread_settings.model.as_str(),
settings.thread_settings.effort.clone()
settings.thread_settings.effort.clone(),
settings.thread_settings.service_tier.as_deref(),
),
("gpt-5.6-terra", Some(effort.clone()))
("gpt-5.6-terra", Some(effort.clone()), Some("default"))
);
if mode == ModeKind::Default && effort == ReasoningEffortConfig::High {
insta::assert_snapshot!(
"session_only_model_confirmation",
next_history_message(&mut events)
);
for command in [
"/model gpt-5.6-terra fast",
"/model gpt-5.6-terra --speeed=fast",
"/model gpt-5.6-terra --reasoning=invalid",
"/model --speed",
"/model --reasoning=",
"/model --reasoning --speed=standard",
"/model --speed=fast --speed standard",
] {
app.chat_widget.apply_external_edit(command.into());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
app.chat_widget.handle_key_event(KeyCode::Enter.into());
}
while let Ok(event) = events.try_recv() {
assert!(!matches!(
event,
AppEvent::SelectSessionModel { .. }
| AppEvent::AstraSelectedFromModelPicker { .. }
));
if let AppEvent::InsertHistoryCell(cell) = &event {
messages.push(lines_to_single_string(&cell.transcript_lines(/*width*/ 80)));
}
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
}
insta::assert_snapshot!("session_only_model_confirmation", messages.join("\n"));
}
app.enqueue_thread_notification(
thread_id,
@@ -1,5 +1,12 @@
---
source: tui/src/app/tests/model_defaults_tests.rs
expression: next_history_message(&mut events)
expression: "messages.join(\"\\n\")"
---
• Model changed to gpt-5.6-terra high for this session only
• Model changed to gpt-5.6-terra high standard for this session only
■ Unexpected argument 'fast'. Use '--reasoning=<level>' or '--speed=<tier>'.
■ Unknown model setting '--speeed'. Expected '--reasoning=<level>' or '--speed=<tier>'.
■ Reasoning level 'invalid' is unavailable for gpt-5.6-terra. Available: low, medium, high, xhigh, max, ultra.
■ Model setting '--speed' requires a value.
■ Model setting '--reasoning' requires a value.
■ Model setting '--reasoning' requires a value.
■ Model setting '--speed' was provided more than once.
+9
View File
@@ -29,6 +29,14 @@ impl App {
return;
};
params.effort = effort;
self.send_model_settings_update(app_server, params).await;
}
pub(super) async fn send_model_settings_update(
&mut self,
app_server: &mut AppServerSession,
params: ThreadSettingsUpdateParams,
) -> bool {
let defaulted_to_auto_review = params.approvals_reviewer
== Some(AppServerApprovalsReviewer::AutoReview)
&& (self.chat_widget.config_ref().approvals_reviewer != ApprovalsReviewer::AutoReview
@@ -61,6 +69,7 @@ impl App {
if defaulted_to_auto_review && settings_updated {
self.app_event_tx.send(AppEvent::CyberModelAutoReviewNotice);
}
settings_updated
}
pub(super) fn active_thread_model_setting_update_params(
+3 -1
View File
@@ -395,7 +395,9 @@ impl App {
Ok(keymap) => keymap,
Err(error) => return self.chat_widget.add_error_message(error),
};
config.service_tier = self.chat_widget.configured_service_tier();
if !is_new_worktree {
config.service_tier = self.chat_widget.configured_service_tier();
}
if is_new_worktree {
apply_managed_new_thread_defaults(
&mut config,
+3 -2
View File
@@ -1231,7 +1231,7 @@ pub(crate) enum AppEvent {
/// Update the current model slug in the running app and widget.
UpdateModel(String),
/// Apply a final Astra picker action and offer the flourish only if it changed the model on
/// Apply an explicit Astra selection and offer the flourish only if it changed the model on
/// its original task. Automatic model updates do not use this event.
AstraSelectedFromModelPicker {
thread_id: ThreadId,
@@ -1269,10 +1269,11 @@ pub(crate) enum AppEvent {
effort: Option<ReasoningEffort>,
},
/// Apply a model and effort only to the active session, preserving saved defaults.
/// Apply a model, effort, and optional speed only to the active session, preserving saved defaults.
SelectSessionModel {
model: String,
effort: Option<ReasoningEffort>,
service_tier: Option<String>,
},
/// Show the cyber auto-review notice after the model selection confirmation.
+1 -1
View File
@@ -333,7 +333,7 @@ pub(crate) struct AppServerSession {
/// Explicit CLI provider selection, distinct from resolved configuration defaults.
pub(crate) model_provider_override: Option<String>,
history_support: ThreadHistorySupport,
thread_settings_update_supported: bool,
pub(crate) thread_settings_update_supported: bool,
default_model: Option<String>,
available_models: Vec<ModelPreset>,
daybreak_api_key_compat: Arc<tokio::sync::Semaphore>,
@@ -751,13 +751,13 @@ mod tests {
#[test]
fn slash_completion_does_not_preserve_existing_draft_tail_for_other_commands() {
let mut composer = composer_with_draft_tail(
"/mo",
"/sta",
"preserve this draft only for opted-in slash commands",
);
assert_eq!(press(&mut composer, KeyCode::Tab), InputResult::None);
assert_eq!(composer.draft.textarea.text(), "/model ");
assert_eq!(composer.draft.textarea.cursor(), "/model ".len());
assert_eq!(composer.draft.textarea.text(), "/status ");
assert_eq!(composer.draft.textarea.cursor(), "/status ".len());
}
#[test]
@@ -831,7 +831,7 @@ fn arbitrary_commands_preserve_a_never_used_fresh_opportunity() {
pane.select_sparkle_model("gpt-6-astra", &enabled());
assert!(!dots(&draw(&pane.composer, /*width*/ 80, now).0).is_empty());
}
for input in ["hello", "/model extra", "/other", "漢", "/tmp/file"] {
for input in ["hello", "/status extra", "/other", "漢", "/tmp/file"] {
let mut pane = pane();
pane.mark_fresh_task_for_sparkle("gpt-5.5", &enabled());
type_text(&mut pane, input);
+1
View File
@@ -322,6 +322,7 @@ use self::plugins::PluginListFetchState;
use self::plugins::PluginsCacheState;
mod plan_implementation;
use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE;
mod model_command;
mod model_popup_state;
mod model_popups;
mod notifications;
@@ -0,0 +1,210 @@
//! Validate inline model choices before applying them through session-only model selection.
use super::ChatWidget;
use super::session_model_selection::AstraModelPickerAction;
use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort;
#[derive(Default)]
struct ModelCommandArgs<'a> {
model: Option<&'a str>,
effort: Option<&'a str>,
speed: Option<&'a str>,
}
struct ModelCommand {
model: String,
effort: ReasoningEffort,
service_tier: Option<String>,
}
fn parse_model_command_args(args: &str) -> Result<ModelCommandArgs<'_>, String> {
let mut parsed = ModelCommandArgs::default();
let mut words = args.split_whitespace().peekable();
while let Some(word) = words.next() {
let (setting, inline_value) = word
.split_once('=')
.map_or((word, None), |(setting, value)| (setting, Some(value)));
match setting {
"--reasoning" => {
set_model_setting(&mut parsed.effort, setting, inline_value, &mut words)?;
}
"--speed" => {
set_model_setting(&mut parsed.speed, setting, inline_value, &mut words)?;
}
_ if word.starts_with("--") => {
return Err(format!(
"Unknown model setting '{setting}'. Expected '--reasoning=<level>' or '--speed=<tier>'."
));
}
_ if parsed.model.replace(word).is_some() => {
return Err(format!(
"Unexpected argument '{word}'. Use '--reasoning=<level>' or '--speed=<tier>'."
));
}
_ => {}
}
}
Ok(parsed)
}
fn set_model_setting<'a>(
target: &mut Option<&'a str>,
setting: &str,
inline_value: Option<&'a str>,
words: &mut std::iter::Peekable<impl Iterator<Item = &'a str>>,
) -> Result<(), String> {
let value = match inline_value {
Some("") => None,
Some(value) => Some(value),
None => words.next_if(|word| !word.starts_with("--")),
}
.ok_or_else(|| format!("Model setting '{setting}' requires a value."))?;
if target.replace(value).is_some() {
return Err(format!(
"Model setting '{setting}' was provided more than once."
));
}
Ok(())
}
fn resolve_reasoning_effort(
chat: &ChatWidget,
args: &ModelCommandArgs<'_>,
model: &ModelPreset,
) -> Result<ReasoningEffort, String> {
let Some(effort) = args.effort else {
return Ok(if args.model.is_none() {
chat.current_reasoning_effort()
.unwrap_or_else(|| model.default_reasoning_effort.clone())
} else {
model.default_reasoning_effort.clone()
});
};
let supported = if model.supported_reasoning_efforts.is_empty() {
(model.default_reasoning_effort.as_str() == effort)
.then_some(&model.default_reasoning_effort)
} else {
model
.supported_reasoning_efforts
.iter()
.find(|option| option.effort.as_str() == effort)
.map(|option| &option.effort)
};
supported.cloned().ok_or_else(|| {
let available = if model.supported_reasoning_efforts.is_empty() {
model.default_reasoning_effort.to_string()
} else {
model
.supported_reasoning_efforts
.iter()
.map(|option| option.effort.to_string())
.collect::<Vec<_>>()
.join(", ")
};
format!(
"Reasoning level '{effort}' is unavailable for {}. Available: {available}.",
model.model
)
})
}
fn resolve_service_tier(
chat: &ChatWidget,
speed: Option<&str>,
model: &ModelPreset,
) -> Result<Option<String>, String> {
let Some(speed) = speed else {
return Ok(None);
};
if speed == "standard" {
return Ok(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()));
}
if speed == "flex" {
return Ok(Some("flex".to_string()));
}
let tier = model
.service_tiers
.iter()
.find(|tier| tier.id == speed || tier.name.eq_ignore_ascii_case(speed))
.filter(|tier| chat.config.features.service_tier_enabled(&tier.id));
tier.map(|tier| Some(tier.id.clone())).ok_or_else(|| {
let available = std::iter::once("standard".to_string())
.chain(std::iter::once("flex".to_string()))
.chain(
model
.service_tiers
.iter()
.filter(|tier| chat.config.features.service_tier_enabled(&tier.id))
.map(|tier| tier.id.clone()),
)
.collect::<Vec<_>>()
.join(", ");
format!(
"Speed '{speed}' is unavailable for {}. Available: {available}.",
model.model
)
})
}
fn resolve_model_command(chat: &ChatWidget, args: &str) -> Result<ModelCommand, String> {
let args = parse_model_command_args(args)?;
if chat.thread_id().is_none() {
return Err("Session is still starting; try /model again in a moment.".into());
}
if chat.restrict_model_picker_to_luna_reserve() {
return Err(
"Model arguments are unavailable while using Reserve. Use /model to adjust effort."
.into(),
);
}
let model_name = args
.model
.map(str::to_owned)
.unwrap_or_else(|| chat.current_model().to_owned());
let Some(model) =
chat.model_catalog.models.iter().find(|model| {
model.model == model_name && (args.model.is_none() || model.show_in_picker)
})
else {
return Err(format!(
"Model '{model_name}' is unavailable. Use /model to see available models."
));
};
Ok(ModelCommand {
model: model.model.clone(),
effort: resolve_reasoning_effort(chat, &args, model)?,
service_tier: resolve_service_tier(chat, args.speed, model)?,
})
}
impl ChatWidget {
pub(super) fn handle_model_command_args(&mut self, args: &str) {
let command = match resolve_model_command(self, args) {
Ok(command) => command,
Err(error) => {
self.add_error_message(error);
return;
}
};
let ModelCommand {
model,
effort,
service_tier,
} = command;
let warning = self.ultra_reasoning_concurrency_warning(&effort);
let thread_id = self.sparkle_thread_for_picker_action(&model);
self.set_queue_autosend_suppressed(/*suppressed*/ true);
self.app_event_tx.send(
AstraModelPickerAction::SelectSessionModel {
effort: Some(effort),
service_tier,
}
.into_picker_event(thread_id, model),
);
if let Some(warning) = warning {
self.add_warning_message(warning);
}
}
}
@@ -125,6 +125,7 @@ impl ChatWidget {
self.config
.permissions
.set_workspace_roots(runtime_workspace_roots);
self.config.service_tier = session.service_tier.clone();
self.effective_service_tier = session.service_tier.clone();
if let Err(err) = self
.config
@@ -1,4 +1,4 @@
//! Session-only model picker actions leave durable defaults untouched. Astra picker actions retain
//! Session-only model selections leave durable defaults untouched. Explicit Astra selections retain
//! their originating task and are checked against its effective model when the app applies them.
use super::*;
@@ -15,6 +15,7 @@ pub(crate) enum AstraModelPickerAction {
},
SelectSessionModel {
effort: Option<ReasoningEffortConfig>,
service_tier: Option<String>,
},
}
@@ -36,7 +37,14 @@ impl AstraModelPickerAction {
Self::ApplyAdvancedReasoning { effort } => {
AppEvent::ApplyAdvancedReasoning { model, effort }
}
Self::SelectSessionModel { effort } => AppEvent::SelectSessionModel { model, effort },
Self::SelectSessionModel {
effort,
service_tier,
} => AppEvent::SelectSessionModel {
model,
effort,
service_tier,
},
}
}
}
@@ -77,6 +85,7 @@ impl ChatWidget {
tx.send(
AstraModelPickerAction::SelectSessionModel {
effort: effort.clone(),
service_tier: None,
}
.into_picker_event(sparkle_thread, model.clone()),
);
@@ -804,6 +804,10 @@ impl ChatWidget {
} = prepared;
let trimmed = args.trim();
match cmd {
SlashCommand::Model if !trimmed.is_empty() => {
self.handle_model_command_args(trimmed);
self.app_event_tx.send(AppEvent::SettingsSelectionClosed);
}
SlashCommand::Export if trimmed.is_empty() => self.show_transcript_export_popup(),
SlashCommand::Export => {
self.set_queue_autosend_suppressed(/*suppressed*/ true);
@@ -49,7 +49,7 @@ async fn session_model_selection_accepts_final_choices_without_saving() {
while rx.try_recv().is_ok() {}
chat.handle_key_event(KeyEvent::from(KeyCode::Char('s')));
let selected = rx.try_recv().expect("session-only selection");
assert_matches!(selected, AppEvent::SelectSessionModel { model, effort: selected_effort }
assert_matches!(selected, AppEvent::SelectSessionModel { model, effort: selected_effort, service_tier: None }
if model == expected_model && selected_effort.as_ref() == Some(&effort));
assert!(chat.bottom_pane.no_modal_or_popup_active(), "{picker}");
assert!(
@@ -100,7 +100,7 @@ async fn session_model_selection_notifies_the_original_task_after_each_final_ast
assert_matches!(events.try_recv(), Ok(AppEvent::AstraSelectedFromModelPicker {
thread_id: selected_thread,
model,
action: AstraModelPickerAction::SelectSessionModel { effort: selected_effort },
action: AstraModelPickerAction::SelectSessionModel { effort: selected_effort, service_tier: None },
}) if selected_thread == thread_id && model == "gpt-6-astra" && selected_effort == Some(effort));
assert!(
std::iter::from_fn(|| events.try_recv().ok()).all(|event| matches!(
@@ -112,6 +112,33 @@ async fn session_model_selection_notifies_the_original_task_after_each_final_ast
}
}
#[tokio::test]
async fn model_settings_only_command_accepts_the_current_hidden_model() {
let (mut chat, mut events, _ops) =
make_chatwidget_manual(/*model_override*/ Some("gpt-5.5")).await;
chat.thread_id = Some(ThreadId::new());
let mut preset = get_available_model(&chat, "gpt-5.5");
preset.show_in_picker = false;
chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset]));
chat.handle_model_command_args("--reasoning high --speed standard");
assert_matches!(events.try_recv(), Ok(AppEvent::SelectSessionModel {
model,
effort: Some(ReasoningEffortConfig::High),
service_tier: Some(service_tier),
}) if model == "gpt-5.5" && service_tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE);
while events.try_recv().is_ok() {}
chat.handle_model_command_args("gpt-5.5 --reasoning=high");
assert!(
std::iter::from_fn(|| events.try_recv().ok()).all(|event| !matches!(
event,
AppEvent::SelectSessionModel { .. } | AppEvent::AstraSelectedFromModelPicker { .. }
))
);
}
#[tokio::test]
async fn session_model_selection_hides_conflicting_shortcut() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.5")).await;
+1
View File
@@ -171,6 +171,7 @@ impl SlashCommand {
matches!(
self,
SlashCommand::Review
| SlashCommand::Model
| SlashCommand::Rename
| SlashCommand::New
| SlashCommand::Clear