mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
committed by
copyberry
parent
c93a54e164
commit
654b2ec1f5
@@ -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(".")),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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<()>
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user