From b279cfb170df7bd2f6dc891cd08d032116533efb Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Thu, 12 Nov 2026 19:11:25 +0000 Subject: [PATCH] Enable conversation-scoped speed selection for Cloud turns (#61178) ## What changed - Stage Cloud speed choices for the next new turn in the conversation, alongside pending model and effort selections. Update the displayed conversation speed after admission without changing the local default. - Offer speed commands supported by the pending model. Clear an incompatible pending speed change with a warning when switching models. - Carry `service_tier` through Cloud message requests, preserving omission to inherit the thread tier and explicit null to clear it. Translate `default` to null and reject tier overrides on queued requests. ## Testing Add tests for Cloud speed toggling, model-dependent speed choices, admission-time updates, and preserving the conversation tier when selecting a model. Extend request and wire tests to cover service-tier values, explicit clearing, and queue rejection. GitOrigin-RevId: b4daa701f1fe29593d90a938e60cc20cb980298a --- .../src/cloud_backend/request_tests.rs | 11 + .../src/cloud_backend/requests.rs | 19 +- codex-rs/cloud-client/examples/attach.rs | 1 + codex-rs/cloud-client/src/turns.rs | 15 +- codex-rs/cloud-client/src/turns_tests.rs | 16 + codex-rs/cloud-client/src/turns_wire.rs | 2 + codex-rs/tui/src/app/event_dispatch.rs | 33 ++ codex-rs/tui/src/app/model_defaults.rs | 48 +-- codex-rs/tui/src/app/reconnect.rs | 2 + codex-rs/tui/src/app/session_lifecycle.rs | 3 + codex-rs/tui/src/app/startup.rs | 2 + .../tui/src/app/tests/model_defaults_tests.rs | 289 +++++++++++++++++- codex-rs/tui/src/app/user_turn_submission.rs | 12 +- codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/app_server_session.rs | 9 +- .../user_message_submission.rs | 135 ++++++-- codex-rs/tui/src/chatwidget.rs | 3 + codex-rs/tui/src/chatwidget/constructor.rs | 2 + codex-rs/tui/src/chatwidget/service_tiers.rs | 46 ++- .../tui/src/chatwidget/status_surfaces.rs | 2 +- 20 files changed, 583 insertions(+), 73 deletions(-) diff --git a/codex-rs/app-server-client/src/cloud_backend/request_tests.rs b/codex-rs/app-server-client/src/cloud_backend/request_tests.rs index 55d1a03739..6a990f3463 100644 --- a/codex-rs/app-server-client/src/cloud_backend/request_tests.rs +++ b/codex-rs/app-server-client/src/cloud_backend/request_tests.rs @@ -76,6 +76,17 @@ fn user_input_uses_stable_id_and_the_existing_cloud_configuration() { ); assert!(matches!(request.mode, cloud::SubmissionMode::StartIfIdle)); assert!(!request.shared_access); + assert_eq!(request.service_tier, None); + + for (tier, expected) in [("ultrafast", Some("ultrafast")), ("default", None)] { + let request = cloud_message(protocol::TurnStartParams { + turn_trigger: Some("user".into()), + service_tier: Some(Some(tier.into())), + ..message() + }) + .unwrap(); + assert_eq!(request.service_tier, Some(expected.map(str::to_owned))); + } for tier in ["default", "priority"] { assert!( diff --git a/codex-rs/app-server-client/src/cloud_backend/requests.rs b/codex-rs/app-server-client/src/cloud_backend/requests.rs index ed5ef2e1bd..a34673a056 100644 --- a/codex-rs/app-server-client/src/cloud_backend/requests.rs +++ b/codex-rs/app-server-client/src/cloud_backend/requests.rs @@ -6,6 +6,7 @@ use super::send_notification; use crate::CloudUserMessageReceipt; use codex_app_server_protocol as protocol; use codex_cloud_client as cloud; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::models::ImageDetail; use color_eyre::eyre::Result; use color_eyre::eyre::bail; @@ -314,10 +315,21 @@ fn cloud_message( ))?; let input = std::mem::take(&mut params.input); let turn_trigger = params.turn_trigger.take(); - let (model, effort) = if turn_trigger.as_deref() == Some("user") { - (params.model.take(), params.effort.take()) + let (model, effort, service_tier) = if turn_trigger.as_deref() == Some("user") { + let tier = match params.service_tier.take() { + Some(Some(tier)) if tier == SERVICE_TIER_DEFAULT_REQUEST_VALUE => Some(None), + Some(Some(tier)) + if !matches!(tier.as_str(), "fast" | "priority" | "flex" | "ultrafast") => + { + return Err(PreflightRejection( + "This service tier is unavailable in Cloud.", + )); + } + tier => tier, + }; + (params.model.take(), params.effort.take(), tier) } else { - (None, None) + (None, None, None) }; if params != protocol::TurnStartParams::default() { return Err(PreflightRejection( @@ -392,6 +404,7 @@ fn cloud_message( turn_trigger, model, effort: effort.map(|effort| effort.to_string()), + service_tier, mode: cloud::SubmissionMode::StartIfIdle, shared_access: false, }) diff --git a/codex-rs/cloud-client/examples/attach.rs b/codex-rs/cloud-client/examples/attach.rs index 012b8fc48e..e259026df8 100644 --- a/codex-rs/cloud-client/examples/attach.rs +++ b/codex-rs/cloud-client/examples/attach.rs @@ -32,6 +32,7 @@ async fn main() -> anyhow::Result<()> { turn_trigger: None, model: None, effort: None, + service_tier: None, mode: SubmissionMode::StartIfIdle, shared_access: false, }), diff --git a/codex-rs/cloud-client/src/turns.rs b/codex-rs/cloud-client/src/turns.rs index eb9393e66c..3ecfc097c4 100644 --- a/codex-rs/cloud-client/src/turns.rs +++ b/codex-rs/cloud-client/src/turns.rs @@ -30,6 +30,8 @@ pub struct AddUserMessageRequest { /// Omit both to inherit the thread settings. A model alone uses its default effort. pub model: Option, pub effort: Option, + /// Omit to inherit the thread tier, or send an explicit null to clear it. + pub service_tier: Option>, pub mode: SubmissionMode, pub shared_access: bool, } @@ -128,7 +130,9 @@ impl Client { ) -> Result { if request.thread_id.trim().is_empty() || request.client_user_message_id.trim().is_empty() - || ((request.model.is_some() || request.effort.is_some()) + || ((request.model.is_some() + || request.effort.is_some() + || request.service_tier.is_some()) && matches!(request.mode, SubmissionMode::Queue)) || !request.input.iter().any(|input| match input { UserMessageInput::Text { text, .. } => !text.trim().is_empty(), @@ -213,6 +217,15 @@ impl Client { }), model: request.model.clone(), effort: request.effort.clone(), + service_tier: request.service_tier.as_ref().map(|tier| { + use crate::activation_wire::nullable_string::Value; + crate::activation_wire::NullableString { + value: Some(match tier { + Some(tier) => Value::String(tier.clone()), + None => Value::Null(crate::activation_wire::Empty {}), + }), + } + }), thread_id: request.thread_id.clone(), turn_trigger: request.turn_trigger.clone(), access: request.shared_access.then_some(1), diff --git a/codex-rs/cloud-client/src/turns_tests.rs b/codex-rs/cloud-client/src/turns_tests.rs index 34a6463e72..94a34ef8ec 100644 --- a/codex-rs/cloud-client/src/turns_tests.rs +++ b/codex-rs/cloud-client/src/turns_tests.rs @@ -13,6 +13,7 @@ fn request() -> AddUserMessageRequest { turn_trigger: None, model: None, effort: None, + service_tier: None, mode: SubmissionMode::StartIfIdle, shared_access: true, } @@ -45,12 +46,21 @@ async fn submission_preserves_admission_variants_and_wire_identity() { let mut fixture = fixture(response(vec![frame(&payload)], tonic::Status::ok(""))).await; let has_selection = !matches!(mode, SubmissionMode::Queue); let turn_trigger = (mode_number == 3).then(|| "user".to_owned()); + let (service_tier, wire_tier) = match (mode_number, outcome_number) { + (3, 1) => ( + Some(Some("ultrafast".into())), + message(/*tag*/ 20, &message(/*tag*/ 1, b"ultrafast")), + ), + (1, 2) => (Some(None), message(/*tag*/ 20, &message(/*tag*/ 2, b""))), + _ => (None, Vec::new()), + }; let result = fixture .client .add_user_message(&AddUserMessageRequest { turn_trigger, model: has_selection.then(|| "selected-model".into()), effort: has_selection.then(|| "high".into()), + service_tier, mode, ..request() }) @@ -106,6 +116,7 @@ async fn submission_preserves_admission_variants_and_wire_identity() { ), ), if has_selection { model } else { vec![] }, + wire_tier, message(/*tag*/ 23, b"t"), if mode_number == 3 { message(/*tag*/ 25, b"user") @@ -295,6 +306,11 @@ async fn invalid_input_never_sends_and_diagnostics_redact() { model: Some("selected-model".into()), ..request() }, + AddUserMessageRequest { + mode: SubmissionMode::Queue, + service_tier: Some(Some("ultrafast".into())), + ..request() + }, AddUserMessageRequest { input: vec![], ..request() diff --git a/codex-rs/cloud-client/src/turns_wire.rs b/codex-rs/cloud-client/src/turns_wire.rs index 34d1b2c1ac..245584f373 100644 --- a/codex-rs/cloud-client/src/turns_wire.rs +++ b/codex-rs/cloud-client/src/turns_wire.rs @@ -11,6 +11,8 @@ pub(super) struct AddRequest { pub input: Option, #[prost(string, optional, tag = "12")] pub model: Option, + #[prost(message, optional, tag = "20")] + pub service_tier: Option, #[prost(string, tag = "23")] pub thread_id: String, #[prost(string, optional, tag = "25")] diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index eeebcbac23..06dad6766d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3016,6 +3016,39 @@ impl App { AppEvent::PersistRealtimeVoiceSelection { voice } => { self.persist_realtime_voice(app_server, voice).await; } + AppEvent::ToggleCloudServiceTier { thread_id, tier } => { + if self.active_thread_id == Some(thread_id) + && self.chat_widget.thread_id() == Some(thread_id) + && app_server.thread_params_mode() == crate::ThreadParamsMode::Cloud + { + if !self.chat_widget.current_model_service_tier_commands().iter() + .any(|command| command.id == tier) + { + self.chat_widget.add_error_message( + "The selected Cloud model does not support that speed.".into(), + ); + return Ok(AppRunControl::Continue); + } + let current = self.chat_widget.config_ref().service_tier.as_deref(); + match app_server.toggle_cloud_service_tier(thread_id, current, tier) { + Some(tier) => { + let label = match tier.as_str() { + codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE => "standard", + "priority" => "fast", + tier => tier, + }; + self.chat_widget.add_info_message( + format!("Selected {label} speed for the next new turn in this conversation"), + /*hint*/ None, + ); + } + None => self.chat_widget.add_error_message( + "The Cloud account changed. Reopen the conversation before choosing a speed." + .into(), + ), + } + } + } AppEvent::PersistServiceTierSelection { service_tier } => { self.refresh_status_line(); self.config.service_tier = service_tier.clone(); diff --git a/codex-rs/tui/src/app/model_defaults.rs b/codex-rs/tui/src/app/model_defaults.rs index 39bfaff64b..1e204d922c 100644 --- a/codex-rs/tui/src/app/model_defaults.rs +++ b/codex-rs/tui/src/app/model_defaults.rs @@ -103,36 +103,44 @@ impl App { ); return; }; - if service_tier.is_some() - || model == crate::model_catalog::LUNA_RESERVE_MODEL - || !self.model_catalog.models.iter().any(|preset| { - preset.model == model - && effort.as_ref().is_none_or(|effort| { - *effort == preset.default_reasoning_effort - || preset - .supported_reasoning_efforts - .iter() - .any(|preset| &preset.effort == effort) - }) - && preset.model_specialty.as_deref() != Some(MODEL_SPECIALTY_CYBER) - }) - { + let preset = self.model_catalog.models.iter().find(|preset| { + preset.model == model + && effort.as_ref().is_none_or(|effort| { + *effort == preset.default_reasoning_effort + || preset + .supported_reasoning_efforts + .iter() + .any(|preset| &preset.effort == effort) + }) + && preset.model_specialty.as_deref() != Some(MODEL_SPECIALTY_CYBER) + }); + let Some(preset) = preset.filter(|_| { + service_tier.is_none() && model != crate::model_catalog::LUNA_RESERVE_MODEL + }) else { self.chat_widget.add_error_message( "Choose a supported Cloud model and effort at the current speed.".into(), ); return; - } + }; let label = Self::reasoning_label_for(&model, effort.as_ref()) .map_or_else(String::new, |label| format!(" {label}")); let message = format!("Selected {model}{label} for the next new turn in this conversation"); - if app_server.stage_cloud_model_selection(thread_id, model, effort) { - self.chat_widget.add_info_message(message, /*hint*/ None); - } else { - self.chat_widget.add_error_message( + match app_server.stage_cloud_model_selection(thread_id, preset, effort) { + Ok(dropped_tier) => { + self.chat_widget.sync_service_tier_commands(); + self.chat_widget.add_info_message(message, /*hint*/ None); + if let Some(tier) = dropped_tier { + let tier = if tier == "priority" { "fast" } else { &tier }; + self.chat_widget.add_warning_message(format!( + "{model} does not support {tier} speed. Cleared the pending speed change." + )); + } + } + Err(()) => self.chat_widget.add_error_message( "The Cloud account changed. Reopen the conversation before choosing a model." .into(), - ); + ), } return; } diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index d9f1f1f5c2..57d1286f36 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -327,6 +327,8 @@ impl App { search_dir: PathBuf, ) { self.chat_widget.snapshot_local_images = self.app_server_target.uses_remote_workspace(); + self.chat_widget.thread_settings_owner = app_server.thread_params_mode().settings_owner(); + self.chat_widget.pending_cloud_turn_settings = app_server.pending_cloud_turn_settings(); self.chat_widget.set_local_worktree_operations( !crate::uses_remote_workspace_or_environment( &self.app_server_target, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index d5503ad496..f853f3c6e0 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -564,6 +564,9 @@ impl App { chat_widget.config_panel_tui = self.chat_widget.config_panel_tui.clone(); chat_widget.remote_connection = self.chat_widget.remote_connection.clone(); chat_widget.snapshot_local_images = self.app_server_target.uses_remote_workspace(); + chat_widget.thread_settings_owner = self.chat_widget.thread_settings_owner; + chat_widget.pending_cloud_turn_settings = + self.chat_widget.pending_cloud_turn_settings.clone(); chat_widget.set_local_worktree_operations(self.chat_widget.local_worktree_operations); chat_widget.windows_sandbox_local_server = self.chat_widget.windows_sandbox_local_server; chat_widget.windows_sandbox_host = WindowsSandboxHost::Unknown; diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 3bc5b3c9c8..2a13db47bb 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -936,6 +936,8 @@ impl App { } chat_widget.remote_connection = remote_connection; chat_widget.snapshot_local_images = app_server_target.uses_remote_workspace(); + chat_widget.thread_settings_owner = app_server.thread_params_mode().settings_owner(); + chat_widget.pending_cloud_turn_settings = app_server.pending_cloud_turn_settings(); chat_widget.set_local_worktree_operations(!crate::uses_remote_workspace_or_environment( &app_server_target, environment_manager.as_ref(), diff --git a/codex-rs/tui/src/app/tests/model_defaults_tests.rs b/codex-rs/tui/src/app/tests/model_defaults_tests.rs index 33a99c33da..5a83087498 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -156,6 +156,253 @@ async fn standard_speed_selection_falls_back_for_older_app_servers() -> Result<( Ok(()) } +#[tokio::test] +async fn cloud_ultrafast_toggle_updates_the_conversation_on_admission() -> Result<()> { + for (current, expected) in [ + (None, "ultrafast"), + (Some("default"), "ultrafast"), + (Some("ultrafast"), "default"), + ] { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let mut session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); + session.service_tier = current.map(str::to_owned); + app.chat_widget.handle_thread_session(session); + app.active_thread_id = Some(thread_id); + app.config.service_tier = Some("flex".into()); + let (client, mut commands, _server_events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + crate::ThreadParamsMode::Cloud, + ); + app.app_server_target = crate::AppServerTarget::Cloud; + app.bind_backend_workspace(&server, app.config.cwd.to_path_buf()); + app.chat_widget + .set_feature_enabled(Feature::FastMode, /*enabled*/ false); + app.chat_widget + .set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true); + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + app.chat_widget.open_model_popup(); + let request_id = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::FetchModels { request_id } => Some(request_id), + _ => None, + }) + .expect("Cloud catalog request"); + let mut model = app.model_catalog.models[0].clone(); + model.model = app.chat_widget.current_model().to_owned(); + app.chat_widget.local_settings.tui.terminal_title = Some(vec!["fast-mode".into()]); + model.default_service_tier = Some("ultrafast".into()); + model.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier { + id: "ultrafast".into(), + name: "Ultrafast".into(), + description: String::new(), + }]; + assert!( + app.chat_widget + .on_models_loaded(request_id, Ok(vec![model])) + ); + if current.is_none() { + insta::assert_snapshot!(app.chat_widget.last_terminal_title.as_deref().unwrap(), @"Standard"); + } + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + while events.try_recv().is_ok() {} + + app.chat_widget.apply_external_edit("/ultrafast".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + if !app.chat_widget.composer_text_with_pending().is_empty() { + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + } + assert_eq!(app.chat_widget.composer_text_with_pending(), ""); + let event = std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::ToggleCloudServiceTier { .. })) + .expect("Cloud service tier toggle"); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + assert!(commands.try_recv().is_err()); + assert_eq!(app.chat_widget.current_service_tier(), current); + + let messages = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>(); + if expected == "ultrafast" { + insta::allow_duplicates! { + insta::assert_snapshot!(messages.join("\n"), @"• Selected ultrafast speed for the next new turn in this conversation"); + } + } else { + insta::assert_snapshot!(messages.join("\n"), @"• Selected standard speed for the next new turn in this conversation"); + } + + app.chat_widget.apply_external_edit("continue".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let op = next_user_turn_op(&mut ops); + let admit = async { + let command = commands.recv().await.expect("native admission request"); + let CloudOperation::SubmitUserMessage(params) = command.operation else { + panic!("expected native admission"); + }; + assert_eq!( + (params.model, params.effort, params.service_tier), + (None, None, Some(Some(expected.into()))), + ); + let turn = test_turn("cloud-turn", TurnStatus::InProgress, Vec::new()); + let receipt = serde_json::to_value(CloudUserMessageReceipt::Started(turn)).unwrap(); + command.response.send(Ok(Ok(receipt))).unwrap(); + }; + let (result, ()) = tokio::join!( + Box::pin(super::super::user_turn_submission::try_submit_user_turn( + &mut app, + &mut server, + thread_id, + &op, + )), + admit, + ); + assert!(result?); + assert_eq!( + ( + app.chat_widget.current_service_tier(), + app.config.service_tier.as_deref() + ), + (Some(expected), Some("flex")), + ); + } + Ok(()) +} + +#[tokio::test] +async fn cloud_speed_tracks_the_model_staged_for_the_next_turn() -> Result<()> { + for (initial, selected, expected_tier) in [ + ( + "standard-model", + "fast-model", + Some(Some("ultrafast".into())), + ), + ("fast-model", "standard-model", None), + ] { + let (mut app, mut events, mut ops) = make_test_app_with_channels().await; + let (client, mut commands, _server_events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + crate::ThreadParamsMode::Cloud, + ); + app.app_server_target = crate::AppServerTarget::Cloud; + app.bind_backend_workspace(&server, app.config.cwd.to_path_buf()); + let thread_id = ThreadId::new(); + let mut session = test_thread_session(thread_id, app.config.cwd.to_path_buf()); + session.model = initial.into(); + app.chat_widget.handle_thread_session(session); + app.active_thread_id = Some(thread_id); + app.chat_widget + .set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true); + let mut tui = crate::tui::test_support::make_test_tui()?; + app.chat_widget.open_model_popup(); + let request_id = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::FetchModels { request_id } => Some(request_id), + _ => None, + }) + .expect("Cloud catalog request"); + let mut standard = app.model_catalog.models[0].clone(); + standard.id = "standard-model".into(); + standard.model = standard.id.clone(); + standard.model_specialty = None; + standard.default_service_tier = None; + standard.service_tiers.clear(); + let mut fast = standard.clone(); + fast.id = "fast-model".into(); + fast.model = fast.id.clone(); + fast.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier { + id: "ultrafast".into(), + name: "Ultrafast".into(), + description: String::new(), + }]; + let event = AppEvent::ModelsLoaded { + request_id, + result: Ok(vec![standard, fast]), + }; + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + while events.try_recv().is_ok() {} + + let select_model = || AppEvent::ApplyModelSelection { + model: selected.into(), + effort: None, + }; + if selected == "fast-model" { + Box::pin(app.handle_event(&mut tui, &mut server, select_model())).await?; + } + app.chat_widget.apply_external_edit("/ultrafast".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + if !app.chat_widget.composer_text_with_pending().is_empty() { + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + } + let event = std::iter::from_fn(|| events.try_recv().ok()) + .find(|event| matches!(event, AppEvent::ToggleCloudServiceTier { .. })) + .expect("Cloud service tier toggle"); + Box::pin(app.handle_event(&mut tui, &mut server, event)).await?; + if selected == "standard-model" { + Box::pin(app.handle_event(&mut tui, &mut server, select_model())).await?; + let warning = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string( + &cell.transcript_lines(/*width*/ 120), + )) + } + _ => None, + }) + .find(|line| line.contains("Cleared the pending speed change.")) + .expect("speed warning before submission"); + insta::assert_snapshot!(warning, @"⚠ standard-model does not support ultrafast speed. Cleared the pending speed change."); + } + + assert!(commands.try_recv().is_err()); + assert!(app.chat_widget.cloud_picker_selection.is_none()); + app.chat_widget.apply_external_edit("continue".into()); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + app.chat_widget.handle_key_event(KeyCode::Enter.into()); + let op = next_user_turn_op(&mut ops); + let admit = async { + let command = commands.recv().await.expect("native admission request"); + let CloudOperation::SubmitUserMessage(params) = command.operation else { + panic!("expected native admission"); + }; + assert_eq!( + (params.model.as_deref(), params.service_tier), + (Some(selected), expected_tier) + ); + let turn = test_turn("cloud-turn", TurnStatus::InProgress, Vec::new()); + let receipt = serde_json::to_value(CloudUserMessageReceipt::Started(turn)).unwrap(); + command.response.send(Ok(Ok(receipt))).unwrap(); + }; + let submit = Box::pin(super::super::user_turn_submission::try_submit_user_turn( + &mut app, + &mut server, + thread_id, + &op, + )); + let (result, ()) = tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!(submit, admit) + }) + .await?; + assert!(result?); + } + Ok(()) +} + #[tokio::test] async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { let turn = test_turn("cloud-turn", TurnStatus::InProgress, Vec::new()); @@ -182,6 +429,7 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { app.active_thread_id = Some(thread_id); let original_model = app.chat_widget.current_model().to_string(); let original_effort = app.chat_widget.current_reasoning_effort(); + let original_tier = app.chat_widget.current_service_tier().map(str::to_owned); let (client, mut commands, _server_events) = codex_app_server_client::CloudClient::channel(/*capacity*/ 1); let mut server = AppServerSession::new( @@ -192,6 +440,32 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { let mut tui = crate::tui::test_support::make_test_tui()?; while events.try_recv().is_ok() {} + app.app_server_target = crate::AppServerTarget::Cloud; + app.bind_backend_workspace(&server, app.config.cwd.to_path_buf()); + app.chat_widget + .set_feature_enabled(Feature::UltrafastMode, /*enabled*/ true); + app.chat_widget.open_model_popup(); + let request_id = std::iter::from_fn(|| events.try_recv().ok()) + .find_map(|event| match event { + AppEvent::FetchModels { request_id } => Some(request_id), + _ => None, + }) + .expect("Cloud catalog request"); + let mut models = app.model_catalog.models.clone(); + let selected = models + .iter_mut() + .find(|model| model.model == "gpt-5.6-terra") + .expect("Cloud model"); + selected.default_service_tier = Some("ultrafast".into()); + selected.service_tiers = vec![codex_protocol::openai_models::ModelServiceTier { + id: "ultrafast".into(), + name: "Ultrafast".into(), + description: String::new(), + }]; + assert!(app.chat_widget.on_models_loaded(request_id, Ok(models))); + app.chat_widget.handle_key_event(KeyCode::Esc.into()); + while events.try_recv().is_ok() {} + let event = AppEvent::ApplyModelSelection { model: "gpt-5.6-terra".into(), effort: Some(ReasoningEffortConfig::High), @@ -223,8 +497,12 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { panic!("expected StartOrSteer"); }; assert_eq!( - (params.model.as_deref(), params.effort), - (Some("gpt-5.6-terra"), Some(ReasoningEffortConfig::High)) + (params.model.as_deref(), params.effort, params.service_tier), + ( + Some("gpt-5.6-terra"), + Some(ReasoningEffortConfig::High), + None + ) ); let response = receipt.map(|receipt| serde_json::to_value(receipt).unwrap()); command.response.send(Ok(response)).unwrap(); @@ -244,8 +522,13 @@ async fn cloud_model_selection_waits_for_native_admission() -> Result<()> { ( app.chat_widget.current_model(), app.chat_widget.current_reasoning_effort(), + app.chat_widget.current_service_tier(), + ), + ( + original_model.as_str(), + original_effort, + original_tier.as_deref() ), - (original_model.as_str(), original_effort), ); let input = app.chat_widget.capture_thread_input_state(); let held = input.is_some_and(|input| input.has_unconfirmed_messages()); diff --git a/codex-rs/tui/src/app/user_turn_submission.rs b/codex-rs/tui/src/app/user_turn_submission.rs index 07e5f78b22..174c4ec1c0 100644 --- a/codex-rs/tui/src/app/user_turn_submission.rs +++ b/codex-rs/tui/src/app/user_turn_submission.rs @@ -94,8 +94,16 @@ pub(super) async fn try_submit_user_turn( match result { Ok((admission, selection)) => { - if selection.is_some() { - app.chat_widget.cloud_picker_selection = selection; + if let Some(selection) = selection { + if selection.model.is_some() { + app.chat_widget.cloud_picker_selection = selection.model; + } + let service_tier = selection + .service_tier + .or_else(|| app.chat_widget.configured_service_tier()); + app.chat_widget.set_service_tier(service_tier); + app.sync_active_thread_service_tier_to_cached_session() + .await; } if !matches!(&admission, UserMessageAdmission::Queued(_)) { crate::startup_recovery::acknowledged(client_user_message_id); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index f900fa57fb..a8de774000 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1521,6 +1521,12 @@ pub(crate) enum AppEvent { voice: codex_protocol::protocol::RealtimeVoice, }, + /// Toggle the service tier staged for the next Cloud submission. + ToggleCloudServiceTier { + thread_id: ThreadId, + tier: String, + }, + /// Persist the selected service tier to the appropriate config. PersistServiceTierSelection { service_tier: Option, diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 1a79ff504e..ec48e09b41 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -413,7 +413,7 @@ pub(crate) struct AppServerSession { pub(crate) model_provider_override: Option, history_support: ThreadHistorySupport, pub(crate) thread_settings_update_supported: bool, - cloud_model_selections: HashMap, + cloud_turn_settings: user_message_submission::PendingCloudTurnSettings, default_model: Option, available_models: Vec, prefetched_model_list: Option>>>, @@ -633,7 +633,7 @@ impl AppServerSession { model_provider_override: None, history_support: ThreadHistorySupport::Paginated, thread_settings_update_supported: true, - cloud_model_selections: HashMap::new(), + cloud_turn_settings: Default::default(), default_model: None, available_models: Vec::new(), prefetched_model_list: None, @@ -1865,7 +1865,7 @@ impl AppServerSession { collaboration_mode: Option, output_schema: Option, cyber_access_program: Option, - cloud_selection: Option<&user_message_submission::CloudModelSelection>, + cloud_selection: Option<&user_message_submission::CloudTurnSettings>, ) -> Result { let mut params = TurnStartParams { thread_id: thread_id.to_string(), @@ -1891,8 +1891,7 @@ impl AppServerSession { params.summary = summary; params.collaboration_mode = collaboration_mode; } else if let Some(selection) = cloud_selection { - params.model = Some(selection.model.clone()); - params.effort.clone_from(&selection.effort); + selection.apply(&mut params); } let request_id = self.next_request_id(); self.request_handle diff --git a/codex-rs/tui/src/app_server_session/user_message_submission.rs b/codex-rs/tui/src/app_server_session/user_message_submission.rs index 8bcfcd5775..0f60909a5e 100644 --- a/codex-rs/tui/src/app_server_session/user_message_submission.rs +++ b/codex-rs/tui/src/app_server_session/user_message_submission.rs @@ -1,6 +1,12 @@ //! Route composer input and distinguish admission, rejection, and uncertainty. //! A duplicate or an unconfirmed response must never trigger an automatic resend. +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::MutexGuard; +use std::sync::PoisonError; + use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::CloudUserMessageReceipt; use codex_app_server_client::TypedRequestError; @@ -10,6 +16,8 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_protocol::ThreadId; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::turn_input::CyberAccessProgram; use codex_utils_absolute_path::AbsolutePathBuf; @@ -83,7 +91,50 @@ pub(crate) struct CloudModelSelection { pub(crate) effort: Option, } +#[derive(Clone, Default)] +pub(crate) struct CloudTurnSettings { + pub(crate) model: Option, + pub(crate) service_tier: Option, +} + +impl CloudTurnSettings { + pub(super) fn apply(&self, params: &mut TurnStartParams) { + if let Some(model) = &self.model { + params.model = Some(model.model.clone()); + params.effort.clone_from(&model.effort); + } + params.service_tier = self.service_tier.clone().map(Some); + } +} + +/// Pending choices are scoped to one Cloud backend and keyed by conversation. +#[derive(Clone, Default)] +pub(crate) struct PendingCloudTurnSettings(Arc>>); + +impl PendingCloudTurnSettings { + fn lock(&self) -> MutexGuard<'_, HashMap> { + self.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) fn model(&self, thread_id: ThreadId) -> Option { + self.lock() + .get(&thread_id)? + .model + .as_ref() + .map(|selection| selection.model.clone()) + } +} + +fn same_cloud_speed(first: &str, second: &str) -> bool { + first == second || matches!((first, second), ("fast", "priority") | ("priority", "fast")) +} + impl AppServerSession { + pub(crate) fn pending_cloud_turn_settings(&self) -> Option { + (self.thread_params_mode() == ThreadParamsMode::Cloud) + .then(|| self.cloud_turn_settings.clone()) + } + fn cloud_model_catalog_is_current(&self) -> bool { matches!(&self.request_handle, AppServerRequestHandle::Cloud(handle) if handle.model_catalog_is_current() @@ -93,15 +144,47 @@ impl AppServerSession { pub(crate) fn stage_cloud_model_selection( &mut self, thread_id: ThreadId, - model: String, + preset: &ModelPreset, effort: Option, - ) -> bool { + ) -> Result, ()> { if !self.cloud_model_catalog_is_current() { - return false; + return Err(()); } - self.cloud_model_selections - .insert(thread_id, CloudModelSelection { model, effort }); - true + let mut pending = self.cloud_turn_settings.lock(); + let settings = pending.entry(thread_id).or_default(); + settings.model = Some(CloudModelSelection { + model: preset.model.clone(), + effort, + }); + let dropped_tier = settings.service_tier.take_if(|tier| { + tier.as_str() != SERVICE_TIER_DEFAULT_REQUEST_VALUE + && !preset + .service_tiers + .iter() + .any(|available| same_cloud_speed(&available.id, tier)) + }); + Ok(dropped_tier) + } + + pub(crate) fn toggle_cloud_service_tier( + &mut self, + thread_id: ThreadId, + current: Option<&str>, + tier: String, + ) -> Option { + if !self.cloud_model_catalog_is_current() { + return None; + } + let mut pending = self.cloud_turn_settings.lock(); + let settings = pending.entry(thread_id).or_default(); + let current = settings.service_tier.as_deref().or(current); + let selected = if current.is_some_and(|current| same_cloud_speed(current, &tier)) { + SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_owned() + } else { + tier + }; + settings.service_tier = Some(selected.clone()); + Some(selected) } pub(crate) async fn submit_user_message<'a>( @@ -112,7 +195,7 @@ impl AppServerSession { active_turn: &mut Option, preflight: impl FnOnce(&str) -> Result, String>, start_settings: impl FnOnce() -> TurnStartSettings<'a>, - ) -> Result<(UserMessageAdmission, Option), UserMessageSubmissionFailure> + ) -> Result<(UserMessageAdmission, Option), UserMessageSubmissionFailure> { let AppCommand::UserTurn { turn_trigger, @@ -180,46 +263,42 @@ impl AppServerSession { let selection = if cloud && *turn_trigger == "user" && intent != CloudSubmissionIntent::UseKnownTurn { - self.cloud_model_selections.get(&thread_id).cloned() + self.cloud_turn_settings.lock().get(&thread_id).cloned() } else { None }; if selection.is_some() && !self.cloud_model_catalog_is_current() { - self.cloud_model_selections.remove(&thread_id); + self.cloud_turn_settings.lock().remove(&thread_id); return Err(UserMessageSubmissionFailure::Preflight( "The Cloud account changed. Reopen the conversation before sending.".into(), )); } let selected_model = selection .as_ref() + .and_then(|selection| selection.model.as_ref()) .map_or(model.as_str(), |selection| selection.model.as_str()); let program = preflight(selected_model).map_err(UserMessageSubmissionFailure::Preflight)?; let take_selection = |session: &mut Self| { selection.as_ref()?; - session - .cloud_model_selections - .remove(&thread_id) - .filter(|_| session.cloud_model_catalog_is_current()) + let settings = session.cloud_turn_settings.lock().remove(&thread_id); + settings.filter(|_| session.cloud_model_catalog_is_current()) }; if cloud && intent == CloudSubmissionIntent::StartOrSteer && let AppServerRequestHandle::Cloud(handle) = &self.request_handle { - let (model, effort) = selection.as_ref().map_or((None, None), |selection| { - (Some(selection.model.clone()), selection.effort.clone()) - }); - let result = handle - .submit_user_message(TurnStartParams { - thread_id: thread_id.to_string(), - turn_trigger: model.as_ref().map(|_| turn_trigger.to_string()), - client_user_message_id: Some(client_user_message_id.clone()), - input: items.clone(), - output_schema: final_output_json_schema.clone(), - model, - effort, - ..Default::default() - }) - .await; + let mut params = TurnStartParams { + thread_id: thread_id.to_string(), + client_user_message_id: Some(client_user_message_id.clone()), + input: items.clone(), + output_schema: final_output_json_schema.clone(), + ..Default::default() + }; + if let Some(selection) = &selection { + params.turn_trigger = Some(turn_trigger.to_string()); + selection.apply(&mut params); + } + let result = handle.submit_user_message(params).await; let admission = match result { Ok(CloudUserMessageReceipt::Started(turn)) => { Ok(UserMessageAdmission::Started(turn.id)) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 9b2da49216..eace65fcd9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -554,6 +554,8 @@ pub(crate) struct ChatWidget { model_popup_request_id: Option, pub(crate) cloud_picker_selection: Option, + pub(crate) pending_cloud_turn_settings: + Option, /// An app event has been queued to open the permissions picker, but no view exists yet. pub(crate) permission_popup_pending: bool, permission_popup_request_id: Option, @@ -570,6 +572,7 @@ pub(crate) struct ChatWidget { pub(crate) remote_connection: Option, /// Remote app servers cannot read image paths on the TUI host. pub(crate) snapshot_local_images: bool, + pub(crate) thread_settings_owner: crate::app_server_session::ThreadSettingsOwner, pending_image_submission: Option, pub(crate) local_worktree_operations: bool, pub(crate) windows_sandbox_local_server: bool, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index aa61bafc05..7f23d54cde 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -135,6 +135,7 @@ impl ChatWidget { model_catalog, model_popup_request_id: None, cloud_picker_selection: None, + pending_cloud_turn_settings: None, permission_popup_pending: false, permission_popup_request_id: None, permission_discovery: None, @@ -148,6 +149,7 @@ impl ChatWidget { status_account_display, remote_connection: None, snapshot_local_images: false, + thread_settings_owner: crate::app_server_session::ThreadSettingsOwner::Client, pending_image_submission: None, local_worktree_operations: true, windows_sandbox_local_server: false, diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index 6ff2786510..7670804c77 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -3,6 +3,7 @@ use super::ChatWidget; use crate::app_command::AppCommand; use crate::app_event::AppEvent; +use crate::app_server_session::ThreadSettingsOwner; use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::service_tier_resolution; use codex_features::Feature; @@ -48,15 +49,23 @@ impl ChatWidget { let Some(fast_tier) = self.current_model_fast_service_tier() else { return; }; - let next_tier = if self.current_service_tier() == Some(fast_tier.id.as_str()) { - Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) - } else { - Some(fast_tier.id) - }; - self.set_service_tier_selection(next_tier); + self.toggle_service_tier_from_ui(fast_tier); } pub(crate) fn toggle_service_tier_from_ui(&mut self, command: ServiceTierCommand) { + if self.thread_settings_owner == ThreadSettingsOwner::Server { + if let Some(thread_id) = self.thread_id { + self.app_event_tx.send(AppEvent::ToggleCloudServiceTier { + thread_id, + tier: command.id, + }); + } else { + self.add_error_message( + "Open the Cloud conversation before choosing a speed.".into(), + ); + } + return; + } let next_tier = if self.current_service_tier() == Some(command.id.as_str()) { Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) } else { @@ -65,15 +74,20 @@ impl ChatWidget { self.set_service_tier_selection(next_tier); } - pub(super) fn sync_service_tier_commands(&mut self) { + pub(crate) fn sync_service_tier_commands(&mut self) { let commands = self.current_model_service_tier_commands(); self.bottom_pane .set_service_tier_commands_enabled(!commands.is_empty()); self.bottom_pane.set_service_tier_commands(commands); } - pub(super) fn current_model_service_tier_commands(&self) -> Vec { - let model = self.current_model(); + pub(crate) fn current_model_service_tier_commands(&self) -> Vec { + let pending_model = self + .thread_id + .and_then(|id| self.pending_cloud_turn_settings.as_ref()?.model(id)); + let model = pending_model + .as_deref() + .unwrap_or_else(|| self.service_tier_model()); self.model_catalog .try_list_models() .ok() @@ -122,11 +136,23 @@ impl ChatWidget { .find(|tier| tier.name.eq_ignore_ascii_case(SPEED_TIER_FAST)) } + pub(super) fn service_tier_model(&self) -> &str { + self.cloud_picker_selection.as_ref().map_or_else( + || self.current_model(), + |selection| selection.model.as_str(), + ) + } + pub(super) fn refresh_effective_service_tier(&mut self) { + if self.thread_settings_owner == ThreadSettingsOwner::Server { + self.effective_service_tier + .clone_from(&self.config.service_tier); + return; + } self.effective_service_tier = service_tier_resolution::effective_service_tier( &self.config, &self.local_settings.notices, - self.current_model(), + self.service_tier_model(), &self.model_catalog.try_list_models().unwrap_or_default(), ); } diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index df1c67bf3b..551cc6d238 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -1002,7 +1002,7 @@ impl ChatWidget { self.model_catalog .models .iter() - .find(|preset| preset.model == self.current_model()) + .find(|preset| preset.model == self.service_tier_model()) .and_then(|preset| preset.service_tiers.iter().find(|tier| tier.id == id)) .map(|tier| tier.name.as_str()) .or_else(|| {