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
This commit is contained in:
Benjamin Carlsson
2026-11-12 20:56:33 +00:00
committed by copyberry
parent 72532ecae5
commit b279cfb170
20 changed files with 583 additions and 73 deletions
+33
View File
@@ -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();
+28 -20
View File
@@ -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;
}
+2
View File
@@ -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,
@@ -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;
+2
View File
@@ -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(),
@@ -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::<Vec<_>>();
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());
+10 -2
View File
@@ -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);