From 654b2ec1f58b34af304404463549ab49c140a671 Mon Sep 17 00:00:00 2001 From: Benjamin Carlsson Date: Mon, 26 Oct 2026 17:59:09 +0000 Subject: [PATCH] Keep cloud conversation settings server-owned in the TUI (#57267) ## Why Cloud conversations own their execution settings and workspace. TUI requests must preserve those settings instead of overriding them with local configuration. ## What changed - Add a distinct cloud parameter mode that omits local execution settings from thread start, resume, and turn requests, including misalignment continuations. - Route cloud model and reasoning selections through `thread/settings/update`, requiring server acceptance rather than falling back to local changes or saved defaults. - Use server model defaults and tolerate an unavailable `configRequirements/read` method during cloud bootstrap. - Skip local tool setup and TUI-generated titles, and report that permission selection, forking, and conversation recaps are unavailable for cloud conversations. ## Testing Add regression coverage for cloud request parameters, bootstrap without configuration requirements, rejected model changes, and suppression of unsupported requests. GitOrigin-RevId: a2dafb6c8a6b0db8607575e2291fee739c2fb2b1 --- codex-rs/tui/src/app/agents_overview.rs | 3 +- codex-rs/tui/src/app/event_dispatch.rs | 69 +-- codex-rs/tui/src/app/misalignment_policy.rs | 87 ++-- codex-rs/tui/src/app/model_defaults.rs | 75 ++++ codex-rs/tui/src/app/new_session.rs | 3 +- codex-rs/tui/src/app/recap.rs | 10 + .../app/tests/fork_workspace_roots_tests.rs | 2 +- .../tui/src/app/tests/fresh_sparkle_tests.rs | 3 + .../app/tests/misalignment_policy_tests.rs | 58 ++- .../tui/src/app/tests/model_defaults_tests.rs | 80 ++++ .../src/app/tests/recap_generation_tests.rs | 40 ++ codex-rs/tui/src/app/thread_title.rs | 8 + codex-rs/tui/src/app/thread_title_tests.rs | 24 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/app_server_session.rs | 413 +++++++++++++++--- .../src/app_server_session/rollout_history.rs | 15 +- codex-rs/tui/src/chatwidget/model_popups.rs | 11 +- .../src/chatwidget/session_model_selection.rs | 4 + .../chatwidget/tests/composer_submission.rs | 1 + .../tests/model_display_name_tests.rs | 9 +- .../tui/src/chatwidget/tests/plan_mode.rs | 22 +- .../chatwidget/tests/popups_and_settings.rs | 16 +- .../tests/session_model_selection_tests.rs | 2 +- .../src/chatwidget/tests/slash_commands.rs | 4 + codex-rs/tui/src/permission_discovery.rs | 10 +- .../tui/src/permission_discovery_tests.rs | 32 ++ 26 files changed, 790 insertions(+), 217 deletions(-) diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index 13d06dc3e4..a6f8de6456 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -1010,7 +1010,8 @@ impl App { } let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => config.cwd.as_path(), - crate::app_server_session::ThreadParamsMode::Remote => remote_cwd + crate::app_server_session::ThreadParamsMode::Remote + | crate::app_server_session::ThreadParamsMode::Cloud => remote_cwd .as_deref() .or_else(|| app_server.remote_cwd_override()) .unwrap_or(Path::new(".")), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 08b748dd60..b9f8575356 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -11,6 +11,7 @@ use super::*; use crate::app_event::RecapTrigger; use crate::app_event::ThreadTitleDestination; use crate::app_server_session::ForkGoalContinuation; +use crate::app_server_session::ThreadSettingsOwner; use crate::app_server_session::UnsupportedLegacyPermissionProfile; use crate::app_server_session::turn_permissions_overrides; use crate::config_update::format_config_error; @@ -2134,6 +2135,22 @@ impl App { .await; } } + AppEvent::ApplyModelSelection { model, effort } => { + if app_server.thread_params_mode().settings_owner() + == ThreadSettingsOwner::Server + { + self.select_session_model( + app_server, + model, + effort, + /*service_tier*/ None, + ) + .await; + } else { + self.apply_default_model_selection(app_server, model, effort) + .await; + } + } AppEvent::AstraSelectedFromModelPicker { .. } => unreachable!("picker event unwrapped"), AppEvent::BackgroundVoiceError { thread_id, message } => { if self.chat_widget.thread_id() == Some(thread_id) { @@ -2307,6 +2324,22 @@ impl App { } AppEvent::ApplyAdvancedReasoning { model, effort } => { self.app_event_tx.send(AppEvent::FollowTranscript); + if app_server.thread_params_mode().settings_owner() + == ThreadSettingsOwner::Server + { + self.select_session_model( + app_server, + model, + Some(effort), + /*service_tier*/ None, + ) + .await; + if let Some(model) = sparkle_model.as_ref() { + self.chat_widget + .on_sparkle_model_selected_from_picker(model); + } + return Ok(AppRunControl::Continue); + } if self .active_thread_model_setting_update_params(model.clone()) .is_some_and(|params| params.permissions.is_some()) @@ -2561,40 +2594,8 @@ impl App { } } AppEvent::PersistModelSelection { model, effort } => { - self.app_event_tx.send(AppEvent::FollowTranscript); - match self.persist_model_defaults( - app_server.request_handle(), - crate::config_update::build_model_selection_edits( - model.as_str(), - effort.as_ref(), - ), - "default model and reasoning effort", - ) - .await - { - Ok(()) => { - let effort_label = effort - .as_ref() - .map(std::string::ToString::to_string) - .unwrap_or_else(|| "default".to_string()); - tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); - let mut message = format!("Model changed to {model}"); - if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) { - message.push(' '); - message.push_str(&label); - } - self.chat_widget.add_info_message(message, /*hint*/ None); - } - Err(err) => { - let error = format_config_error(&err); - tracing::error!( - error = %error, - "failed to persist model selection" - ); - self.chat_widget - .add_error_message(format!("Failed to save default model: {error}")); - } - } + self.persist_default_model_selection(app_server.request_handle(), model, effort) + .await; } AppEvent::PersistDaybreakSelection { thread_id, enabled } => { self.persist_daybreak_selection(app_server, thread_id, enabled).await; diff --git a/codex-rs/tui/src/app/misalignment_policy.rs b/codex-rs/tui/src/app/misalignment_policy.rs index fe10a086d4..eece358c3b 100644 --- a/codex-rs/tui/src/app/misalignment_policy.rs +++ b/codex-rs/tui/src/app/misalignment_policy.rs @@ -2,6 +2,7 @@ //! the ordinary input queue, which must remain blocked until the server accepts the new turn. use super::*; +use crate::app_server_session::ThreadSettingsOwner; use crate::app_server_session::turn_permissions_overrides; use crate::chatwidget::MisalignmentReview; use crate::chatwidget::MisalignmentTurnSource; @@ -130,53 +131,55 @@ impl App { return; } }; - let config = self.chat_widget.config_ref(); - let explicit_profile = - self.runtime_permission_profile_override - .as_ref() - .filter(|profile| { - profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox - }); - let permissions_override = Self::turn_permissions_override_from_config( - config, - explicit_profile.and_then(|profile| profile.active_permission_profile.as_ref()), - explicit_profile.and_then(RuntimePermissionProfileOverride::turn_permission_profile), - ); - let Ok((sandbox_policy, permissions)) = - turn_permissions_overrides(permissions_override, config.cwd.as_path()) - else { - self.chat_widget.add_error_message( - "Couldn’t continue this chat. Review its latest status before trying again." - .to_string(), - ); - return; + let mut params = TurnStartParams { + thread_id: review.thread_id.to_string(), + cyber_access_program: cyber_access_program.map(Into::into), + input: vec![UserInput::Text { + text: message.to_string(), + text_elements: Vec::new(), + }], + responsesapi_client_metadata: Some(HashMap::from([( + "misalignment_override".to_string(), + serde_json::json!({"timestamp": chrono::Utc::now().timestamp_millis()}).to_string(), + )])), + ..Default::default() }; + if app_server.thread_params_mode().settings_owner() == ThreadSettingsOwner::Client { + let config = self.chat_widget.config_ref(); + let explicit_profile = + self.runtime_permission_profile_override + .as_ref() + .filter(|profile| { + profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox + }); + let permissions_override = Self::turn_permissions_override_from_config( + config, + explicit_profile.and_then(|profile| profile.active_permission_profile.as_ref()), + explicit_profile + .and_then(RuntimePermissionProfileOverride::turn_permission_profile), + ); + let Ok((sandbox_policy, permissions)) = + turn_permissions_overrides(permissions_override, config.cwd.as_path()) + else { + self.chat_widget.add_error_message( + "Couldn’t continue this chat. Review its latest status before trying again." + .to_string(), + ); + return; + }; + params.cwd = Some(config.cwd.to_path_buf()); + params.runtime_workspace_roots = + Some(config.permissions.user_visible_workspace_roots().to_vec()); + params.approval_policy = Some(config.permissions.approval_policy.value().into()); + params.approvals_reviewer = Some(config.approvals_reviewer.into()); + params.sandbox_policy = sandbox_policy; + params.permissions = permissions; + } let result = app_server .request_handle() .request_typed::(ClientRequest::TurnStart { request_id: app_server.next_request_id(), - params: TurnStartParams { - thread_id: review.thread_id.to_string(), - cwd: Some(config.cwd.to_path_buf()), - runtime_workspace_roots: Some( - config.permissions.user_visible_workspace_roots().to_vec(), - ), - approval_policy: Some(config.permissions.approval_policy.value().into()), - approvals_reviewer: Some(config.approvals_reviewer.into()), - sandbox_policy, - permissions, - cyber_access_program: cyber_access_program.map(Into::into), - input: vec![UserInput::Text { - text: message.to_string(), - text_elements: Vec::new(), - }], - responsesapi_client_metadata: Some(HashMap::from([( - "misalignment_override".to_string(), - serde_json::json!({"timestamp": chrono::Utc::now().timestamp_millis()}) - .to_string(), - )])), - ..Default::default() - }, + params, }) .await; match result { diff --git a/codex-rs/tui/src/app/model_defaults.rs b/codex-rs/tui/src/app/model_defaults.rs index c384559a89..240700e2e9 100644 --- a/codex-rs/tui/src/app/model_defaults.rs +++ b/codex-rs/tui/src/app/model_defaults.rs @@ -5,6 +5,7 @@ use super::App; use crate::app_server_session::AppServerSession; +use crate::app_server_session::ThreadSettingsOwner; use crate::service_tier_resolution; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_protocol::ConfigEdit; @@ -16,6 +17,73 @@ use codex_protocol::openai_models::ReasoningEffort; use color_eyre::eyre::Result; impl App { + pub(super) async fn apply_default_model_selection( + &mut self, + app_server: &mut AppServerSession, + model: String, + effort: Option, + ) { + if self + .active_thread_model_setting_update_params(model.clone()) + .is_some_and(|params| params.permissions.is_some()) + && self.reject_pending_permission_change() + { + return; + } + let model_changed = self.chat_widget.current_model() != model + || self.chat_widget.current_collaboration_mode().model() != model; + if model_changed { + self.chat_widget.set_model(&model); + self.sync_active_thread_model_setting(app_server, model.clone(), /*effort*/ None) + .await; + self.sync_active_thread_service_tier_to_cached_session() + .await; + } + self.on_update_reasoning_effort(effort.clone()); + self.sync_active_thread_reasoning_setting(app_server, effort.clone()) + .await; + self.persist_default_model_selection(app_server.request_handle(), model, effort) + .await; + } + + pub(super) async fn persist_default_model_selection( + &mut self, + request_handle: AppServerRequestHandle, + model: String, + effort: Option, + ) { + self.app_event_tx + .send(crate::app_event::AppEvent::FollowTranscript); + match self + .persist_model_defaults( + request_handle, + crate::config_update::build_model_selection_edits(model.as_str(), effort.as_ref()), + "default model and reasoning effort", + ) + .await + { + Ok(()) => { + let effort_label = effort + .as_ref() + .map(std::string::ToString::to_string) + .unwrap_or_else(|| "default".to_string()); + tracing::info!("Selected model: {model}, Selected effort: {effort_label}"); + let mut message = format!("Model changed to {model}"); + if let Some(label) = Self::reasoning_label_for(&model, effort.as_ref()) { + message.push(' '); + message.push_str(&label); + } + self.chat_widget.add_info_message(message, /*hint*/ None); + } + Err(err) => { + let error = crate::config_update::format_config_error(&err); + tracing::error!(error = %error, "failed to persist model selection"); + self.chat_widget + .add_error_message(format!("Failed to save default model: {error}")); + } + } + } + pub(super) async fn select_session_model( &mut self, app_server: &mut AppServerSession, @@ -57,6 +125,13 @@ impl App { if app_server.thread_settings_update_supported { return; } + if app_server.thread_params_mode().settings_owner() == ThreadSettingsOwner::Server { + self.chat_widget.add_error_message( + "This cloud conversation does not support changing its model from the TUI." + .into(), + ); + return; + } if service_tier .as_deref() .is_some_and(|tier| tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE) diff --git a/codex-rs/tui/src/app/new_session.rs b/codex-rs/tui/src/app/new_session.rs index 73ce337a25..e6a1ade871 100644 --- a/codex-rs/tui/src/app/new_session.rs +++ b/codex-rs/tui/src/app/new_session.rs @@ -67,7 +67,8 @@ impl App { let cwd = self.chat_widget.config_ref().cwd.to_path_buf(); let defaults_cwd = match app_server.thread_params_mode() { crate::app_server_session::ThreadParamsMode::Embedded => cwd.as_path(), - crate::app_server_session::ThreadParamsMode::Remote => { + crate::app_server_session::ThreadParamsMode::Remote + | crate::app_server_session::ThreadParamsMode::Cloud => { app_server.remote_cwd_override().unwrap_or(Path::new(".")) } }; diff --git a/codex-rs/tui/src/app/recap.rs b/codex-rs/tui/src/app/recap.rs index 16c2662b77..36ba378693 100644 --- a/codex-rs/tui/src/app/recap.rs +++ b/codex-rs/tui/src/app/recap.rs @@ -10,6 +10,7 @@ use crate::app_event::AppEvent; use crate::app_event::RecapTrigger; use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; +use crate::app_server_session::ThreadSettingsOwner; use crate::history_cell::ThreadRecapHistoryCell; use crate::history_cell::ThreadRecapLoadingCell; use crate::pager_overlay::Overlay; @@ -39,6 +40,8 @@ const RECAP_RETRY_DELAY: Duration = Duration::from_secs(/*secs*/ 30); const MANUAL_RECAP_FAILURE_MESSAGE: &str = "Could not generate a recap. Please try again."; const MANUAL_RECAP_IN_PROGRESS_MESSAGE: &str = "A recap is already being generated."; const MANUAL_RECAP_EMPTY_HISTORY_MESSAGE: &str = "There is no conversation history to recap."; +const CLOUD_RECAP_UNAVAILABLE_MESSAGE: &str = + "Conversation recaps are not available for cloud conversations."; #[cfg(test)] pub(super) const RECAP_PROMPT_MAX_BYTES: usize = RecapPrompt::MAX_BYTES; @@ -184,6 +187,13 @@ impl App { thread_id: ThreadId, trigger: RecapTrigger, ) { + if app_server.thread_params_mode().settings_owner() == ThreadSettingsOwner::Server { + if matches!(trigger, RecapTrigger::Manual) { + self.chat_widget + .add_error_message(CLOUD_RECAP_UNAVAILABLE_MESSAGE.to_string()); + } + return; + } if !self.recap_trigger_enabled(trigger) { return; } diff --git a/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs b/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs index 762c26ac89..8b612c8e91 100644 --- a/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs +++ b/codex-rs/tui/src/app/tests/fork_workspace_roots_tests.rs @@ -46,7 +46,7 @@ async fn check_fork_dispatch(mode: ThreadParamsMode) -> Result<()> { .build() .await?; let (server, requests, proxy) = match mode { - ThreadParamsMode::Remote => { + ThreadParamsMode::Remote | ThreadParamsMode::Cloud => { Box::pin(start_recording_remote_app_server(&server_config)).await? } ThreadParamsMode::Embedded => { diff --git a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs index 714e9bd22b..0295121e9e 100644 --- a/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs +++ b/codex-rs/tui/src/app/tests/fresh_sparkle_tests.rs @@ -65,6 +65,7 @@ async fn choose_model( &event, AppEvent::OpenReasoningPopup { .. } | AppEvent::UpdateModel(_) + | AppEvent::ApplyModelSelection { .. } | AppEvent::SelectSessionModel { .. } | AppEvent::AstraSelectedFromModelPicker { .. } ) { @@ -544,6 +545,7 @@ async fn astra_picker_confirms_the_model_at_application_after_an_automatic_updat if matches!( &event, AppEvent::UpdateModel(_) + | AppEvent::ApplyModelSelection { .. } | AppEvent::UpdateReasoningEffort(_) | AppEvent::SelectSessionModel { .. } | AppEvent::ApplyAdvancedReasoning { .. } @@ -621,6 +623,7 @@ fn astra_picker_xhigh_applies_on_a_bounded_stack() -> Result<()> { if matches!( &event, AppEvent::AstraSelectedFromModelPicker { .. } + | AppEvent::ApplyModelSelection { .. } | AppEvent::UpdateReasoningEffort(_) | AppEvent::PersistModelSelection { .. } ) { diff --git a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs index b54c38edf4..04e554de99 100644 --- a/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs +++ b/codex-rs/tui/src/app/tests/misalignment_policy_tests.rs @@ -1,5 +1,7 @@ +use super::session_lifecycle_requests::HistoryCapabilities; use super::session_lifecycle_requests::recorded_params; use super::session_lifecycle_requests::start_recording_app_server; +use super::session_lifecycle_requests::start_recording_app_server_with_history; use super::*; use codex_app_server_protocol::MisalignmentErrorDetails; use codex_app_server_protocol::MisalignmentSteer; @@ -40,27 +42,41 @@ fn error_notification( #[tokio::test] async fn misalignment_continuation_requires_current_review_and_submits_once() -> Result<()> { - for (reject, preserve, daybreak, rollout_enabled, has_chatgpt_account) in [ - (false, false, false, true, true), - (false, true, true, true, true), - (true, false, false, true, true), - (false, false, false, false, true), - (false, true, true, false, true), + for (reject, preserve, daybreak, rollout_enabled, has_chatgpt_account, server_owned) in [ + (false, false, false, true, true, false), + (false, true, true, true, true, false), + (true, false, false, true, true, false), + (false, false, false, false, true, false), + (false, true, true, false, true, false), // Continuations submit directly rather than via the normal turn-start path. - (false, false, false, true, false), - (false, true, true, true, false), + (false, false, false, true, false, false), + (false, true, true, true, false, false), + // Server-owned continuations preserve the acknowledgement without local overrides. + (false, false, false, true, true, true), ] { let (mut app, mut rx, _) = make_test_app_with_channels().await; app.chat_widget .set_feature_enabled(Feature::CliDaybreak, rollout_enabled); app.chat_widget .set_feature_enabled(Feature::ApiKeyCyberAccessPrograms, !has_chatgpt_account); - let (mut server, requests, proxy) = start_recording_app_server( - &app.config, - /*blocked_thread_list*/ None, - /*failed_thread_name*/ None, - ) - .await?; + let (mut server, requests, proxy) = if server_owned { + start_recording_app_server_with_history( + &app.config, + HistoryCapabilities::Current, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + crate::app_server_session::ThreadParamsMode::Cloud, + codex_config::LoaderOverrides::without_managed_config_for_tests(), + ) + .await? + } else { + start_recording_app_server( + &app.config, + /*blocked_thread_list*/ None, + /*failed_thread_name*/ None, + ) + .await? + }; let mut session = if reject { test_thread_session(ThreadId::new(), app.config.cwd.to_path_buf()) } else { @@ -232,13 +248,13 @@ async fn misalignment_continuation_requires_current_review_and_submits_once() -> params, codex_app_server_protocol::TurnStartParams { thread_id: thread_id.to_string(), - cwd: Some(config.cwd.to_path_buf()), - runtime_workspace_roots: Some( - config.permissions.user_visible_workspace_roots().to_vec() - ), - approval_policy: Some(AskForApproval::OnRequest), - approvals_reviewer: Some(config.approvals_reviewer.into()), - sandbox_policy: (!preserve).then(|| config.legacy_sandbox_policy().into()), + cwd: (!server_owned).then(|| config.cwd.to_path_buf()), + runtime_workspace_roots: (!server_owned) + .then(|| config.permissions.user_visible_workspace_roots().to_vec()), + approval_policy: (!server_owned).then_some(AskForApproval::OnRequest), + approvals_reviewer: (!server_owned).then(|| config.approvals_reviewer.into()), + sandbox_policy: (!server_owned && !preserve) + .then(|| config.legacy_sandbox_policy().into()), cyber_access_program: rollout_enabled.then_some(if daybreak { CyberAccessProgram::DaybreakBlue.into() } else { 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 3e7be851bb..c1637e6d43 100644 --- a/codex-rs/tui/src/app/tests/model_defaults_tests.rs +++ b/codex-rs/tui/src/app/tests/model_defaults_tests.rs @@ -154,6 +154,86 @@ async fn standard_speed_selection_falls_back_for_older_app_servers() -> Result<( Ok(()) } +#[tokio::test] +async fn cloud_model_selection_requires_server_acceptance() -> Result<()> { + for event in [ + AppEvent::SelectSessionModel { + model: "gpt-5.6-terra".into(), + effort: Some(ReasoningEffortConfig::High), + service_tier: None, + }, + AppEvent::ApplyModelSelection { + model: "gpt-5.6-terra".into(), + effort: Some(ReasoningEffortConfig::High), + }, + AppEvent::ApplyAdvancedReasoning { + model: "gpt-5.6-terra".into(), + effort: ReasoningEffortConfig::Ultra, + }, + ] { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + 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 (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::app_server_session::ThreadParamsMode::Cloud, + ); + let mut tui = crate::tui::test_support::make_test_tui()?; + while events.try_recv().is_ok() {} + + let (result, operation) = + tokio::join!(app.handle_event(&mut tui, &mut server, event), async { + let command = commands.recv().await.expect("cloud settings update"); + let codex_app_server_client::CloudOperation::Request(request) = command.operation + else { + panic!("expected cloud request") + }; + let ClientRequest::ThreadSettingsUpdate { params, .. } = *request else { + panic!("expected thread/settings/update request") + }; + command + .response + .send(Ok(Err(JSONRPCErrorError { + code: -32601, + data: None, + message: "method not found".to_string(), + }))) + .expect("model selection should still await its response"); + params + }); + + result?; + assert_eq!(operation.thread_id, thread_id.to_string()); + assert_eq!( + ( + app.chat_widget.current_model(), + app.chat_widget.current_reasoning_effort(), + ), + (original_model.as_str(), original_effort) + ); + 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::>(); + insta::allow_duplicates! { + insta::assert_snapshot!( + messages.join("\n"), + @"■ This cloud conversation does not support changing its model from the TUI." + ); + } + } + Ok(()) +} + #[tokio::test] async fn model_default_saves_report_server_outcomes_and_target_server_profile() -> Result<()> { for outcome in ["saved", "overridden", "rejected"] { diff --git a/codex-rs/tui/src/app/tests/recap_generation_tests.rs b/codex-rs/tui/src/app/tests/recap_generation_tests.rs index 2aeaa2b39c..7d467c9389 100644 --- a/codex-rs/tui/src/app/tests/recap_generation_tests.rs +++ b/codex-rs/tui/src/app/tests/recap_generation_tests.rs @@ -3,6 +3,7 @@ use crate::app::session_lifecycle::ThreadAttachPresentation; use crate::app::tests::session_lifecycle_requests::recorded_params; use crate::app::tests::session_lifecycle_requests::start_recording_remote_app_server; use crate::app_event::RecapTrigger; +use codex_app_server_client::AppServerClient; use codex_model_provider_info::ModelProviderInfo; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -335,6 +336,45 @@ async fn manual_recap_works_when_auto_recap_disabled() -> Result<()> { Ok(()) } +#[tokio::test] +async fn cloud_recap_stays_idle_and_reports_manual_unavailability() -> Result<()> { + let (mut app, mut events, _ops) = make_test_app_with_channels().await; + let thread_id = ThreadId::new(); + let (client, mut commands, _server_events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + crate::app_server_session::ThreadParamsMode::Cloud, + ); + while events.try_recv().is_ok() {} + + app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); + app.request_recap(&app_server, thread_id, RecapTrigger::Manual); + + assert!(commands.try_recv().is_err()); + let observed = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + assert!( + !observed + .iter() + .any(|event| matches!(event, AppEvent::RecapStarted { .. })) + ); + let messages = observed + .into_iter() + .filter_map(|event| match event { + AppEvent::InsertHistoryCell(cell) => { + Some(lines_to_single_string(&cell.transcript_lines(/*width*/ 80))) + } + _ => None, + }) + .collect::>(); + insta::assert_snapshot!( + messages.join("\n"), + @"■ Conversation recaps are not available for cloud conversations." + ); + Ok(()) +} + #[tokio::test] async fn auto_recap_opt_out_blocks_requests_and_cleans_up_pending_start() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app/thread_title.rs b/codex-rs/tui/src/app/thread_title.rs index 3b09a741d0..648388132c 100644 --- a/codex-rs/tui/src/app/thread_title.rs +++ b/codex-rs/tui/src/app/thread_title.rs @@ -9,6 +9,7 @@ use super::thread_events::ThreadBufferedEvent; use crate::app_event::AppEvent; use crate::app_event::ThreadTitleDestination; use crate::app_server_session::AppServerSession; +use crate::app_server_session::ThreadSettingsOwner; use crate::temporary_structured_request::TemporaryStructuredThreadOptions; use crate::temporary_structured_request::run_temporary_structured_turn; use crate::temporary_structured_request::start_temporary_thread; @@ -67,6 +68,13 @@ impl App { destination: ThreadTitleDestination, prompt: String, ) { + if app_server.thread_params_mode().settings_owner() == ThreadSettingsOwner::Server { + if let ThreadTitleDestination::RenameSuggestion { request_id } = destination { + self.chat_widget + .apply_thread_name_suggestion(thread_id, request_id, /*suggestion*/ None); + } + return; + } let std::collections::hash_map::Entry::Vacant(entry) = self.pending_thread_titles.entry((thread_id, destination)) else { diff --git a/codex-rs/tui/src/app/thread_title_tests.rs b/codex-rs/tui/src/app/thread_title_tests.rs index 2f7eb1d06f..d09677b93c 100644 --- a/codex-rs/tui/src/app/thread_title_tests.rs +++ b/codex-rs/tui/src/app/thread_title_tests.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_model_provider_info::ModelProviderInfo; +use codex_protocol::ThreadId; use codex_protocol::models::MessagePhase; use core_test_support::responses; use crossterm::event::KeyCode; @@ -90,6 +91,29 @@ fn bounds_the_entire_title_prompt_for_dense_unicode() { } } +#[tokio::test] +async fn cloud_conversations_skip_tui_generated_titles() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + let (client, mut commands, _server_events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let app_server = crate::app_server_session::AppServerSession::new( + crate::backend_id::BackendId::new(), + codex_app_server_client::AppServerClient::Cloud(client), + crate::app_server_session::ThreadParamsMode::Cloud, + ); + + app.generate_thread_title( + &app_server, + thread_id, + ThreadTitleDestination::Automatic, + thread_title_prompt("Fix the login timeout"), + ); + + assert!(app.pending_thread_titles.is_empty()); + assert!(commands.try_recv().is_err()); +} + #[tokio::test] async fn automatic_thread_title_respects_origin_metadata_after_switching() -> color_eyre::Result<()> { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 514eca64f6..ba35770763 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1231,6 +1231,12 @@ pub(crate) enum AppEvent { /// Update the current model slug in the running app and widget. UpdateModel(String), + /// Apply a model-picker selection to the active conversation and saved local defaults. + ApplyModelSelection { + model: String, + effort: Option, + }, + /// 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 { diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9d0166a7e7..803719b675 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -351,6 +351,17 @@ pub(crate) struct AppServerSession { pub(crate) enum ThreadParamsMode { Embedded, Remote, + /// Native hosted sessions own their execution settings and workspace. + Cloud, +} + +/// Identifies which side supplies execution settings for a conversation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ThreadSettingsOwner { + /// The client may send its configured settings as request overrides. + Client, + /// The server retains its settings unless changed through a dedicated API. + Server, } /// Determines where model settings come from when resuming a thread. @@ -365,12 +376,19 @@ pub(crate) enum ResumeModelSettings { } impl ThreadParamsMode { + pub(crate) fn settings_owner(self) -> ThreadSettingsOwner { + match self { + Self::Embedded | Self::Remote => ThreadSettingsOwner::Client, + Self::Cloud => ThreadSettingsOwner::Server, + } + } + fn workspace_roots_from_config(self, config: &Config) -> Option> { match self { Self::Embedded => Some(config.workspace_roots.clone()), // Client config paths belong to the client host. Let the server resolve its // defaults or restore the saved roots, then adopt the roots in its response. - Self::Remote => None, + Self::Remote | Self::Cloud => None, } } } @@ -480,6 +498,9 @@ impl AppServerSession { app_event_tx: AppEventSender, status_updates: tokio::sync::broadcast::Sender, ) -> std::io::Result<()> { + if self.thread_params_mode == ThreadParamsMode::Cloud { + return Ok(()); + } let worktrees = if !self.uses_remote_workspace() && !config.active_project.is_untrusted() { // Listing a fresh, nonexistent thread is read-only and returns an empty page // on supported stores. Probe before advertising tools or allocating a checkout. @@ -608,7 +629,10 @@ impl AppServerSession { } pub(crate) fn uses_remote_workspace(&self) -> bool { - matches!(self.thread_params_mode, ThreadParamsMode::Remote) + matches!( + self.thread_params_mode, + ThreadParamsMode::Remote | ThreadParamsMode::Cloud + ) } pub(crate) fn uses_embedded_app_server(&self) -> bool { @@ -724,7 +748,8 @@ impl AppServerSession { }) }, async { - self.client + match self + .client .request_typed::( ClientRequest::ConfigRequirementsRead { request_id: requirements_request_id, @@ -732,12 +757,22 @@ impl AppServerSession { }, ) .await - .map_err(|err| { - bootstrap_request_error( - "configRequirements/read failed during TUI bootstrap", - err, - ) - }) + { + Ok(requirements) => Ok(Some(requirements)), + // A native transport may expose resolved thread permissions without + // exposing the conventional requirements catalog. Keep that absence + // distinct from an authoritative response with no requirements. + Err(TypedRequestError::Server { source, .. }) + if self.thread_params_mode == ThreadParamsMode::Cloud + && source.code == -32601 => + { + Ok(None) + } + Err(error) => Err(bootstrap_request_error( + "configRequirements/read failed during TUI bootstrap", + error, + )), + } }, async { Ok(crate::collaboration_modes::list(self.request_handle()).await) }, )?; @@ -746,31 +781,36 @@ impl AppServerSession { .into_iter() .map(model_preset_from_api_model) .collect::>(); - crate::service_tier_resolution::constrain_server_service_tiers( - &mut available_models, - &requirements, - ); + if let Some(requirements) = &requirements { + crate::service_tier_resolution::constrain_server_service_tiers( + &mut available_models, + requirements, + ); + } if matches!(account.account, Some(Account::ApiKey {})) { available_models = self .constrain_api_key_models( config, /*thread_id*/ None, - requirements.requirements.as_ref(), + requirements + .as_ref() + .and_then(|response| response.requirements.as_ref()), available_models, ) .await; } let allowed_approval_policies = requirements - .requirements .as_ref() + .and_then(|response| response.requirements.as_ref()) .and_then(|requirements| requirements.allowed_approval_policies.clone()); self.managed_new_thread_defaults = requirements - .requirements + .and_then(|response| response.requirements) .and_then(|requirements| requirements.models) .and_then(|models| models.new_thread); - let default_model = config - .model - .clone() + let default_model = (self.thread_params_mode.settings_owner() + == ThreadSettingsOwner::Client) + .then(|| config.model.clone()) + .flatten() .or_else(|| { available_models .iter() @@ -902,6 +942,13 @@ impl AppServerSession { remote_cwd_override: Option<&std::path::Path>, selected_profile: Option<&PermissionProfileSelection>, ) -> Result { + if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Server + && selected_profile.is_some() + { + color_eyre::eyre::bail!( + "Cloud conversations currently use server-selected permissions." + ); + } let request_id = self.next_request_id(); let session_config = self.session_config_with_effective_service_tier(config); let mut params = thread_start_params_from_config( @@ -910,11 +957,14 @@ impl AppServerSession { remote_cwd_override.or(self.remote_cwd_override.as_deref()), session_start_source, ); - params.model_provider = self - .model_provider_override - .clone() - .or(params.model_provider); - params.daybreak_enabled = (config.daybreak_enabled && !config.ephemeral).then_some(true); + if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Client { + params.model_provider = self + .model_provider_override + .clone() + .or(params.model_provider); + params.daybreak_enabled = + (config.daybreak_enabled && !config.ephemeral).then_some(true); + } if let Some(selected_profile) = selected_profile { params.runtime_workspace_roots = None; params.permissions = Some(selected_profile.profile_id.clone()); @@ -926,8 +976,10 @@ impl AppServerSession { if self.history_support == ThreadHistorySupport::LegacyOnly { params.history_mode = None; } - self.thread_tool_transport().validate_config(config)?; - self.thread_tool_transport().configure(&mut params); + if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Client { + self.thread_tool_transport().validate_config(config)?; + self.thread_tool_transport().configure(&mut params); + } let request_handle = self.request_handle(); let (response, history_support, task_tools_available) = request_thread_start_with_history_fallback(&request_handle, request_id, params) @@ -1068,6 +1120,9 @@ impl AppServerSession { permission_mode: ForkPermissionMode, config_source: ForkConfigSource, ) -> Result { + if self.thread_params_mode == ThreadParamsMode::Cloud { + color_eyre::eyre::bail!("Cloud conversations cannot be forked in the TUI yet."); + } self.thread_tool_transport().validate_config(&config)?; let fork_parent = match presentation { ForkPresentation::Regular => self @@ -1474,42 +1529,32 @@ impl AppServerSession { cyber_access_program: Option, ) -> Result { let request_id = self.next_request_id(); - let (sandbox_policy, permissions) = - turn_permissions_overrides(permissions_override, cwd.as_path())?; + let mut params = TurnStartParams { + thread_id: thread_id.to_string(), + turn_trigger: Some("user".to_string()), + client_user_message_id: Some(client_user_message_id), + input: items, + output_schema, + cyber_access_program, + ..TurnStartParams::default() + }; + if self.thread_params_mode.settings_owner() == ThreadSettingsOwner::Client { + let (sandbox_policy, permissions) = + turn_permissions_overrides(permissions_override, cwd.as_path())?; + params.cwd = Some(cwd); + params.runtime_workspace_roots = Some(workspace_roots.to_vec()); + params.approval_policy = approval_policy; + params.approvals_reviewer = approvals_reviewer; + params.sandbox_policy = sandbox_policy; + params.permissions = permissions; + params.model = Some(model); + params.service_tier = service_tier; + params.effort = effort; + params.summary = summary; + params.collaboration_mode = collaboration_mode; + } self.client - .request_typed(ClientRequest::TurnStart { - request_id, - params: TurnStartParams { - disabled_plugin_ids: None, - thread_id: thread_id.to_string(), - turn_trigger: Some("user".to_string()), - parent_turn_id: None, - root_turn_id: None, - client_user_message_id: Some(client_user_message_id), - input: items, - tool_output: None, - responsesapi_client_metadata: None, - responsesapi_client_metadata_patch: None, - additional_context: None, - environments: None, - cwd: Some(cwd), - runtime_workspace_roots: Some(workspace_roots.to_vec()), - approval_policy, - approvals_reviewer, - sandbox_policy, - permissions, - model: Some(model), - service_tier, - service_tier_for_turn: None, - effort, - summary, - personality: None, - output_schema, - collaboration_mode, - multi_agent_mode: None, - cyber_access_program, - }, - }) + .request_typed(ClientRequest::TurnStart { request_id, params }) .await .wrap_err("turn/start failed in TUI") } @@ -1865,10 +1910,12 @@ pub(crate) async fn start_thread_with_request_handle( launch_choices.approvals_reviewer, &launch_choices.approval_policy, ); - thread_tool_transport.validate_config(&config)?; - launch_choices.configure(&mut params); - params.daybreak_enabled = (config.daybreak_enabled && !config.ephemeral).then_some(true); - thread_tool_transport.configure(&mut params); + if thread_params_mode.settings_owner() == ThreadSettingsOwner::Client { + thread_tool_transport.validate_config(&config)?; + launch_choices.configure(&mut params); + params.daybreak_enabled = (config.daybreak_enabled && !config.ephemeral).then_some(true); + thread_tool_transport.configure(&mut params); + } let (response, _history_support, task_tools_available) = request_thread_start_with_history_fallback(&request_handle, request_id, params) .await @@ -2203,6 +2250,9 @@ fn thread_start_params_with_reviewer( reviewer_source: startup_launch::StartupApprovalsReviewer, policy_source: &startup_launch::StartupApprovalPolicy, ) -> ThreadStartParams { + if thread_params_mode.settings_owner() == ThreadSettingsOwner::Server { + return ThreadStartParams::default(); + } let permissions = permissions_selection_from_config(config, thread_params_mode); let sandbox = permissions .is_none() @@ -2254,7 +2304,9 @@ fn thread_resume_params_from_config( model_settings: ResumeModelSettings, permission_overrides: crate::resume_permissions::ResumePermissions, ) -> ThreadResumeParams { - if model_settings == ResumeModelSettings::PreserveExistingThread { + if thread_params_mode.settings_owner() == ThreadSettingsOwner::Server + || model_settings == ResumeModelSettings::PreserveExistingThread + { return ThreadResumeParams { thread_id: thread_id.to_string(), ..ThreadResumeParams::default() @@ -2373,7 +2425,7 @@ fn thread_cwd_from_config( ) -> Option { match thread_params_mode { ThreadParamsMode::Embedded => Some(config.cwd.to_string_lossy().to_string()), - ThreadParamsMode::Remote => { + ThreadParamsMode::Remote | ThreadParamsMode::Cloud => { remote_cwd_override.map(|cwd| cwd.to_string_lossy().to_string()) } } @@ -2576,7 +2628,7 @@ fn display_permission_profile_from_thread_response( PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) } ThreadParamsMode::Embedded => config.permissions.effective_permission_profile(), - ThreadParamsMode::Remote => { + ThreadParamsMode::Remote | ThreadParamsMode::Cloud => { PermissionProfile::from_legacy_sandbox_policy_for_cwd(&sandbox.to_core(), cwd) } } @@ -2839,6 +2891,65 @@ mod tests { Ok(()) } + #[tokio::test] + async fn cloud_bootstrap_uses_server_models_without_config_requirements() -> Result<()> { + let codex_home = tempfile::tempdir()?; + let config = build_config(&codex_home).await; + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 3); + let mut app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + ThreadParamsMode::Cloud, + ); + let account = GetAccountResponse { + account: Some(Account::Chatgpt { + email: Some("teammate@openai.com".to_string()), + plan_type: codex_protocol::account::PlanType::Plus, + }), + requires_openai_auth: true, + workspace_routing: None, + }; + + let (bootstrap, ()) = + tokio::join!(app_server.bootstrap_with_account(&config, account), async { + for _ in 0..3 { + let command = commands.recv().await.expect("bootstrap request"); + let codex_app_server_client::CloudOperation::Request(request) = + command.operation + else { + panic!("expected bootstrap request") + }; + let result = match *request { + ClientRequest::ModelList { .. } => { + Ok(serde_json::to_value(ModelListResponse { + data: vec![api_model_with_upgrade_retirement_at( + /*retirement_at*/ None, + )], + next_cursor: None, + }) + .expect("model response should serialize")) + } + ClientRequest::ConfigRequirementsRead { .. } => Err(JSONRPCErrorError { + code: JSONRPC_METHOD_NOT_FOUND, + message: "method not found".to_string(), + data: None, + }), + ClientRequest::CollaborationModeList { .. } => { + Ok(serde_json::json!({"data": []})) + } + request => panic!("unexpected bootstrap request: {request:?}"), + }; + command.response.send(Ok(result)).unwrap(); + } + }); + let bootstrap = bootstrap?; + + assert_ne!(config.model.as_deref(), Some("current-model")); + assert_eq!(bootstrap.default_model, "current-model"); + Ok(()) + } + #[tokio::test] async fn bootstrap_empty_catalog_reports_configuration_error() -> Result<()> { let codex_home = tempfile::tempdir()?; @@ -3069,6 +3180,148 @@ mod tests { assert_eq!(params.history_mode, None); } + #[tokio::test] + async fn cloud_start_resume_and_turn_leave_settings_server_owned() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&temp_dir).await; + config.base_instructions = Some("local base instructions".to_string()); + config.developer_instructions = Some("local developer instructions".to_string()); + config.daybreak_enabled = true; + config.ephemeral = true; + let thread_id = ThreadId::new(); + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let request_handle = AppServerClient::Cloud(client).request_handle(); + let mut launch_choices = StartupLaunchChoices::default(); + launch_choices.model = Some("local-model".to_string()); + launch_choices.effort = Some(serde_json::json!("high")); + let local_settings = LocalSettings::from(&config); + + let (start, sent_params) = tokio::join!( + start_thread_with_request_handle( + request_handle, + &local_settings, + config.clone(), + ThreadParamsMode::Cloud, + Some(std::path::PathBuf::from("/local/workspace")), + ThreadToolTransport::Dynamic, + launch_choices, + ), + async { + let command = commands.recv().await.expect("cloud thread start"); + let codex_app_server_client::CloudOperation::Request(request) = command.operation + else { + panic!("expected cloud request") + }; + let ClientRequest::ThreadStart { params, .. } = *request else { + panic!("expected thread/start request") + }; + command + .response + .send(Ok(Err(JSONRPCErrorError { + code: JSONRPC_INVALID_PARAMS, + data: None, + message: "stop after recording request".to_string(), + }))) + .expect("startup should still be waiting for its response"); + params + }, + ); + let _ = start.expect_err("recording response should stop startup"); + let resume = thread_resume_params_from_config( + config, + thread_id, + ThreadParamsMode::Cloud, + Some(std::path::Path::new("/local/workspace")), + ResumeModelSettings::OverrideFromCurrentConfig, + crate::resume_permissions::ResumePermissions::CURRENT_CONFIG, + ); + + assert_eq!(sent_params, ThreadStartParams::default()); + assert_eq!( + resume, + ThreadResumeParams { + thread_id: thread_id.to_string(), + ..ThreadResumeParams::default() + } + ); + + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + ThreadParamsMode::Cloud, + ); + let client_user_message_id = "local-message-id".to_string(); + let input = vec![UserInput::Text { + text: "hello from the TUI".to_string(), + text_elements: Vec::new(), + }]; + let output_schema = serde_json::json!({"type": "string"}); + let workspace_roots = vec![test_path_buf("/local/workspace").abs()]; + let (turn, sent_params) = tokio::join!( + app_server.turn_start( + thread_id, + client_user_message_id.clone(), + input.clone(), + test_path_buf("/local/workspace"), + Some(codex_app_server_protocol::AskForApproval::Never), + Some(codex_app_server_protocol::ApprovalsReviewer::User), + TurnPermissionsOverride::ActiveProfile(ActivePermissionProfile::new( + "local-permission-profile".to_string(), + )), + &workspace_roots, + "local-model".to_string(), + Some(ReasoningEffort::High), + Some(ReasoningSummary::Detailed), + Some(Some("local-service-tier".to_string())), + Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: "local-plan-model".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: Some("local plan instructions".to_string()), + }, + }), + Some(output_schema.clone()), + Some(CyberAccessProgram::DaybreakBlue.into()), + ), + async { + let command = commands.recv().await.expect("cloud turn start"); + let codex_app_server_client::CloudOperation::Request(request) = command.operation + else { + panic!("expected cloud request") + }; + let ClientRequest::TurnStart { params, .. } = *request else { + panic!("expected turn/start request") + }; + command + .response + .send(Ok(Err(JSONRPCErrorError { + code: JSONRPC_INVALID_PARAMS, + data: None, + message: "stop after recording request".to_string(), + }))) + .expect("turn start should still be waiting for its response"); + params + }, + ); + let _ = turn.expect_err("recording response should stop turn start"); + assert_eq!( + sent_params, + TurnStartParams { + thread_id: thread_id.to_string(), + client_user_message_id: Some(client_user_message_id), + input, + turn_trigger: Some("user".to_string()), + output_schema: Some(output_schema), + cyber_access_program: Some(CyberAccessProgram::DaybreakBlue.into()), + ..TurnStartParams::default() + } + ); + } + #[tokio::test] async fn shared_thread_start_preserves_explicit_session_overrides() -> Result<()> { let codex_home = tempfile::tempdir()?; @@ -4095,6 +4348,30 @@ mod tests { assert_eq!(params.base_instructions, None); } + #[tokio::test] + async fn cloud_fork_is_rejected_before_sending_local_settings() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let config = build_config(&temp_dir).await; + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let mut app_server = AppServerSession::new( + crate::backend_id::BackendId::new(), + AppServerClient::Cloud(client), + ThreadParamsMode::Cloud, + ); + + let error = app_server + .fork_thread(&LocalSettings::from(&config), config, ThreadId::new()) + .await + .expect_err("cloud fork should be unavailable"); + + assert_eq!( + error.to_string(), + "Cloud conversations cannot be forked in the TUI yet." + ); + assert!(commands.try_recv().is_err()); + } + #[tokio::test] async fn side_fork_excludes_turns_without_clearing_regular_ephemeral_fork() -> Result<()> { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index cfe0499592..98d979a5ff 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -5,6 +5,7 @@ use super::AppServerStartedThread; use super::HistoryHydrationScope; use super::ResumeModelSettings; use super::ThreadHistorySupport; +use super::ThreadSettingsOwner; use super::bootstrap_request_error; use super::is_history_pagination_unsupported; use super::started_thread_from_resume_response; @@ -155,7 +156,9 @@ impl AppServerSession { model_settings: ResumeModelSettings, permission_overrides: crate::resume_permissions::ResumePermissions, ) -> Result { - self.thread_tool_transport().validate_config(&config)?; + if self.thread_params_mode().settings_owner() == ThreadSettingsOwner::Client { + self.thread_tool_transport().validate_config(&config)?; + } let session_config = if matches!( model_settings, ResumeModelSettings::RestoreFromThread | ResumeModelSettings::PreserveExistingThread @@ -172,11 +175,15 @@ impl AppServerSession { model_settings, permission_overrides, ); - if model_settings == ResumeModelSettings::OverrideFromCurrentConfig { + if self.thread_params_mode().settings_owner() == ThreadSettingsOwner::Client + && model_settings == ResumeModelSettings::OverrideFromCurrentConfig + { params.model_provider = self.explicit_model_provider(&config); } - self.thread_tool_transport() - .configure_mcp(&mut params.config); + if self.thread_params_mode().settings_owner() == ThreadSettingsOwner::Client { + self.thread_tool_transport() + .configure_mcp(&mut params.config); + } let mut rollout_maintenance_guard = None; params.exclude_turns = if self.history_support == ThreadHistorySupport::Paginated { let known_legacy_history = self diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs index aa5de8be28..62af5d124d 100644 --- a/codex-rs/tui/src/chatwidget/model_popups.rs +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -322,14 +322,11 @@ impl ChatWidget { }); } else { tx.send( - AstraModelPickerAction::UpdateModel - .into_picker_event(sparkle_thread, model_for_action.clone()), + AstraModelPickerAction::ApplyModelSelection { + effort: effort_for_action.clone(), + } + .into_picker_event(sparkle_thread, 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( diff --git a/codex-rs/tui/src/chatwidget/session_model_selection.rs b/codex-rs/tui/src/chatwidget/session_model_selection.rs index 3e9e3db760..0e896a8b0e 100644 --- a/codex-rs/tui/src/chatwidget/session_model_selection.rs +++ b/codex-rs/tui/src/chatwidget/session_model_selection.rs @@ -10,6 +10,9 @@ use crate::model_catalog::LUNA_RESERVE_MODEL; #[derive(Debug)] pub(crate) enum AstraModelPickerAction { UpdateModel, + ApplyModelSelection { + effort: Option, + }, ApplyAdvancedReasoning { effort: ReasoningEffortConfig, }, @@ -34,6 +37,7 @@ impl AstraModelPickerAction { pub(crate) fn into_app_event(self, model: String) -> AppEvent { match self { Self::UpdateModel => AppEvent::UpdateModel(model), + Self::ApplyModelSelection { effort } => AppEvent::ApplyModelSelection { model, effort }, Self::ApplyAdvancedReasoning { effort } => { AppEvent::ApplyAdvancedReasoning { model, effort } } diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 0434900691..f1e0b40973 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -325,6 +325,7 @@ async fn parent_owned_thread_blocks_settings_shortcuts() { event, AppEvent::SubmitThreadOp { .. } | AppEvent::UpdateModel(_) + | AppEvent::ApplyModelSelection { .. } | AppEvent::UpdateReasoningEffort(_) | AppEvent::UpdatePlanModeReasoningEffort(_) ))); diff --git a/codex-rs/tui/src/chatwidget/tests/model_display_name_tests.rs b/codex-rs/tui/src/chatwidget/tests/model_display_name_tests.rs index 596893d3da..88a8288ed4 100644 --- a/codex-rs/tui/src/chatwidget/tests/model_display_name_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/model_display_name_tests.rs @@ -51,14 +51,7 @@ async fn custom_model_display_name_in_pickers_preserves_selection_slug() { render_bottom_popup(&chat, /*width*/ 80) ); chat.handle_key_event(KeyCode::Enter.into()); - assert_matches!(events.try_recv(), Ok(AppEvent::UpdateModel(model)) if model == slug); - assert_matches!( - events.try_recv(), - Ok(AppEvent::UpdateReasoningEffort(Some( - ReasoningEffortConfig::High - ))) - ); - let persisted = assert_matches!(events.try_recv(), Ok(AppEvent::PersistModelSelection { model, effort }) => (model, effort)); + let persisted = assert_matches!(events.try_recv(), Ok(AppEvent::ApplyModelSelection { model, effort }) => (model, effort)); assert_eq!( persisted, (slug.to_string(), Some(ReasoningEffortConfig::High)) diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 2b325ba2db..5b9ded0f4b 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -283,15 +283,10 @@ async fn reasoning_selection_in_plan_mode_without_effort_change_does_not_open_sc assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5.5" + AppEvent::ApplyModelSelection { model, effort: Some(_) } + if model == "gpt-5.5" )), - "expected model update event; events: {events:?}" - ); - assert!( - events - .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + "expected model and reasoning update event; events: {events:?}" ); } @@ -461,15 +456,10 @@ async fn reasoning_selection_in_plan_mode_model_switch_does_not_open_scope_promp assert!( events.iter().any(|event| matches!( event, - AppEvent::UpdateModel(model) if model == "gpt-5.6-terra" + AppEvent::ApplyModelSelection { model, effort: Some(_) } + if model == "gpt-5.6-terra" )), - "expected model update event; events: {events:?}" - ); - assert!( - events - .iter() - .any(|event| matches!(event, AppEvent::UpdateReasoningEffort(Some(_)))), - "expected reasoning update event; events: {events:?}" + "expected model and reasoning update event; events: {events:?}" ); } diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index ec22db363f..54e1f1fc67 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -3807,17 +3807,13 @@ async fn model_reasoning_selection_popup_applies_custom_effort() { let selected_effort_events = std::iter::from_fn(|| rx.try_recv().ok()) .filter_map(|event| match event { - AppEvent::UpdateReasoningEffort(effort) => Some((None, effort)), - AppEvent::PersistModelSelection { model, effort } => Some((Some(model), effort)), + AppEvent::ApplyModelSelection { model, effort } => Some((model, effort)), _ => None, }) .collect::>(); assert_eq!( selected_effort_events, - vec![ - (None, Some(custom_effort.clone())), - (Some("gpt-5.5".to_string()), Some(custom_effort)), - ] + vec![("gpt-5.5".to_string(), Some(custom_effort))] ); } @@ -3909,11 +3905,7 @@ async fn max_reasoning_selection_persists_model_selection() { let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!(events.iter().any(|event| matches!( event, - AppEvent::UpdateReasoningEffort(Some(ReasoningEffortConfig::Max)) - ))); - assert!(events.iter().any(|event| matches!( - event, - AppEvent::PersistModelSelection { + AppEvent::ApplyModelSelection { model, effort: Some(ReasoningEffortConfig::Max), } if model == "gpt-5.5" @@ -4260,7 +4252,7 @@ async fn single_reasoning_option_skips_selection() { assert!( events .iter() - .any(|ev| matches!(ev, AppEvent::UpdateReasoningEffort(Some(effort)) if *effort == ReasoningEffortConfig::High)), + .any(|ev| matches!(ev, AppEvent::ApplyModelSelection { effort: Some(effort), .. } if *effort == ReasoningEffortConfig::High)), "expected reasoning effort to be applied automatically; events: {events:?}" ); } diff --git a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs index 9db819d0ff..3a0774ac56 100644 --- a/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/session_model_selection_tests.rs @@ -156,6 +156,6 @@ async fn session_model_selection_hides_conflicting_shortcut() { chat.handle_key_event(KeyEvent::from(KeyCode::Char('s'))); assert!( std::iter::from_fn(|| rx.try_recv().ok()) - .any(|event| matches!(event, AppEvent::PersistModelSelection { .. })) + .any(|event| matches!(event, AppEvent::ApplyModelSelection { .. })) ); } diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 41ff4c3fbd..166eec2ebb 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -500,6 +500,10 @@ async fn queued_settings_selection_applies_before_next_input() { AppEvent::OpenReasoningPopup { model } => chat.open_reasoning_popup(model), AppEvent::UpdateModel(model) => chat.set_model(&model), AppEvent::UpdateReasoningEffort(effort) => chat.set_reasoning_effort(effort), + AppEvent::ApplyModelSelection { model, effort } => { + chat.set_model(&model); + chat.set_reasoning_effort(effort); + } AppEvent::SettingsSelectionClosed => { chat.app_event_tx.send(AppEvent::SettingsSelectionSettled); } diff --git a/codex-rs/tui/src/permission_discovery.rs b/codex-rs/tui/src/permission_discovery.rs index effb98e7a4..91153c6e3f 100644 --- a/codex-rs/tui/src/permission_discovery.rs +++ b/codex-rs/tui/src/permission_discovery.rs @@ -5,6 +5,7 @@ use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::app_server_session::AppServerSession; use crate::app_server_session::ThreadParamsMode; +use crate::app_server_session::ThreadSettingsOwner; use crate::legacy_core::config::Config; use codex_app_server_client::TypedRequestError; use codex_app_server_protocol::ApprovalsReviewer; @@ -88,10 +89,17 @@ pub(crate) fn fetch( ) { let handle = app_server.request_handle(); let mode = app_server.thread_params_mode(); + if mode.settings_owner() == ThreadSettingsOwner::Server { + tx.send(AppEvent::PermissionProfilesLoaded { + request_id, + result: Err("Cloud conversations use server-selected permissions.".to_string()), + }); + return; + } let cwd = thread_cwd .or_else(|| match mode { ThreadParamsMode::Embedded => Some(config.cwd.as_path()), - ThreadParamsMode::Remote => app_server.remote_cwd_override(), + ThreadParamsMode::Remote | ThreadParamsMode::Cloud => app_server.remote_cwd_override(), }) .map(|cwd| cwd.to_string_lossy().into_owned()); tokio::spawn(async move { diff --git a/codex-rs/tui/src/permission_discovery_tests.rs b/codex-rs/tui/src/permission_discovery_tests.rs index d7d454eb28..525a3839f7 100644 --- a/codex-rs/tui/src/permission_discovery_tests.rs +++ b/codex-rs/tui/src/permission_discovery_tests.rs @@ -182,3 +182,35 @@ async fn discovery_preserves_config_scope_and_bounds_server_requests() { } } } + +#[tokio::test] +async fn cloud_discovery_reports_server_selected_permissions_without_sending_requests() { + let home = tempfile::tempdir().unwrap(); + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await + .unwrap(); + let (client, mut commands, _events) = + codex_app_server_client::CloudClient::channel(/*capacity*/ 1); + let session = AppServerSession::new( + crate::backend_id::BackendId::new(), + codex_app_server_client::AppServerClient::Cloud(client), + ThreadParamsMode::Cloud, + ); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + fetch( + &session, + Uuid::new_v4(), + &config, + /*thread_cwd*/ None, + AppEventSender::new(tx), + ); + + let AppEvent::PermissionProfilesLoaded { result, .. } = rx.recv().await.unwrap() else { + panic!("expected discovery result") + }; + insta::assert_snapshot!(result.unwrap_err(), @"Cloud conversations use server-selected permissions."); + assert!(commands.try_recv().is_err()); +}