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
This commit is contained in:
Benjamin Carlsson
2026-10-26 21:28:54 +00:00
committed by copyberry
parent c93a54e164
commit 654b2ec1f5
26 changed files with 790 additions and 217 deletions
+2 -1
View File
@@ -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(".")),
+35 -34
View File
@@ -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;
+45 -42
View File
@@ -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::<TurnStartResponse>(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 {
+75
View File
@@ -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<ReasoningEffort>,
) {
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<ReasoningEffort>,
) {
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)
+2 -1
View File
@@ -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("."))
}
};
+10
View File
@@ -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;
}
@@ -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 => {
@@ -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 { .. }
) {
@@ -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 {
@@ -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::<Vec<_>>();
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"] {
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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;
+8
View File
@@ -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 {
@@ -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<()>
{
+6
View File
@@ -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<ReasoningEffort>,
},
/// 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 {
+345 -68
View File
@@ -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<Vec<AbsolutePathBuf>> {
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<ThreadStatusChangedNotification>,
) -> 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::<ConfigRequirementsReadResponse>(
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::<Vec<_>>();
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<AppServerStartedThread> {
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<AppServerStartedThread> {
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<codex_app_server_protocol::CyberAccessProgram>,
) -> Result<TurnStartResponse> {
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<String> {
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");
@@ -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<AppServerStartedThread> {
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
+4 -7
View File
@@ -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(
@@ -10,6 +10,9 @@ use crate::model_catalog::LUNA_RESERVE_MODEL;
#[derive(Debug)]
pub(crate) enum AstraModelPickerAction {
UpdateModel,
ApplyModelSelection {
effort: Option<ReasoningEffortConfig>,
},
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 }
}
@@ -325,6 +325,7 @@ async fn parent_owned_thread_blocks_settings_shortcuts() {
event,
AppEvent::SubmitThreadOp { .. }
| AppEvent::UpdateModel(_)
| AppEvent::ApplyModelSelection { .. }
| AppEvent::UpdateReasoningEffort(_)
| AppEvent::UpdatePlanModeReasoningEffort(_)
)));
@@ -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))
+6 -16
View File
@@ -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:?}"
);
}
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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:?}"
);
}
@@ -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 { .. }))
);
}
@@ -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);
}
+9 -1
View File
@@ -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 {
@@ -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());
}