diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 0288c9c59..cbdbee040 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -211,6 +211,7 @@ mod thread_events; mod thread_goal_actions; mod thread_routing; mod thread_session_state; +mod thread_settings; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 63791e0d9..0b2143b42 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -178,12 +178,46 @@ pub(super) fn server_notification_thread_target( mod tests { use super::ServerNotificationThreadTarget; use super::server_notification_thread_target; + use crate::test_support::PathBufExt; + use crate::test_support::test_path_buf; use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadSettings; + use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::WarningNotification; use codex_protocol::ThreadId; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; + use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; + fn test_thread_settings() -> ThreadSettings { + ThreadSettings { + cwd: test_path_buf("/tmp/thread-settings").abs(), + approval_policy: codex_app_server_protocol::AskForApproval::Never, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::User, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: None, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: Some(ReasoningEffort::High), + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: None, + }, + }, + personality: None, + } + } + #[test] fn warning_notifications_without_threads_are_global() { let notification = ServerNotification::Warning(WarningNotification { @@ -221,4 +255,18 @@ mod tests { assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); } + + #[test] + fn thread_settings_updated_notifications_route_to_threads() { + let thread_id = ThreadId::new(); + let notification = + ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification { + thread_id: thread_id.to_string(), + thread_settings: test_thread_settings(), + }); + + let target = server_notification_thread_target(¬ification); + + assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id)); + } } diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 5b2b5ab23..1f594d64c 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -692,6 +692,8 @@ mod tests { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index c1cd816b0..ce0eb0ff9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -760,12 +760,18 @@ impl App { } AppEvent::UpdateReasoningEffort(effort) => { self.on_update_reasoning_effort(effort); + self.sync_active_thread_reasoning_setting(app_server, effort) + .await; } AppEvent::UpdateModel(model) => { self.chat_widget.set_model(&model); + self.sync_active_thread_model_setting(app_server, model) + .await; } AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); + self.sync_active_thread_personality_setting(app_server, personality) + .await; } AppEvent::OpenRealtimeAudioDeviceSelection { kind } => { self.chat_widget.open_realtime_audio_device_selection(kind); @@ -1548,6 +1554,8 @@ impl App { AppEvent::UpdatePlanModeReasoningEffort(effort) => { self.config.plan_mode_reasoning_effort = effort; self.chat_widget.set_plan_mode_reasoning_effort(effort); + self.sync_active_thread_plan_mode_reasoning_setting(app_server) + .await; } AppEvent::PersistFullAccessWarningAcknowledged => { if let Err(err) = ConfigEditsBuilder::for_config(&self.config) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 8d52500af..0a2f382a8 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -59,6 +59,8 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadClosedNotification; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -77,8 +79,10 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; +use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::NetworkPermissions; use codex_protocol::models::PermissionProfile; @@ -108,6 +112,29 @@ fn test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from(PathBuf::from(path)).expect("absolute test path") } +async fn next_thread_settings_updated( + app_server: &mut AppServerSession, + thread_id: ThreadId, +) -> ThreadSettingsUpdatedNotification { + for _ in 0..20 { + let event = time::timeout( + std::time::Duration::from_secs(/*secs*/ 2), + app_server.next_event(), + ) + .await + .expect("app-server should emit an event") + .expect("app-server event stream should remain open"); + if let codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadSettingsUpdated(notification), + ) = event + && notification.thread_id == thread_id.to_string() + { + return notification; + } + } + panic!("expected ThreadSettingsUpdated for thread {thread_id}"); +} + #[tokio::test] async fn handle_mcp_inventory_result_clears_committed_loading_cell() { let mut app = make_test_app().await; @@ -3721,6 +3748,8 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::High), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -3972,6 +4001,8 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4548,6 +4579,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4612,6 +4645,8 @@ async fn backtrack_selection_with_duplicate_history_targets_unique_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -4705,6 +4740,8 @@ async fn backtrack_resubmit_preserves_data_image_urls_in_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -5105,6 +5142,8 @@ async fn new_session_requests_shutdown_for_previous_conversation() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -5206,6 +5245,294 @@ async fn interrupt_without_active_turn_is_treated_as_handled() { .await; } +#[tokio::test] +async fn override_turn_context_sends_thread_settings_update() { + Box::pin(async { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()) + .await + .expect("embedded app server"); + let started = app_server + .start_thread(app.chat_widget.config_ref()) + .await + .expect("thread/start should succeed"); + let thread_id = started.session.thread_id; + let initial_model = started.session.model.clone(); + let initial_effort = started.session.reasoning_effort; + app.enqueue_primary_thread_session(started.session, started.turns) + .await + .expect("primary thread should be registered"); + let service_tier = ServiceTier::Fast.request_value().to_string(); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }; + let op = AppCommand::override_turn_context( + /*cwd*/ None, + Some(AskForApproval::OnRequest), + Some(ApprovalsReviewer::AutoReview), + Some(ActivePermissionProfile::new( + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + )), + /*windows_sandbox_level*/ None, + Some("gpt-5.4".to_string()), + Some(Some(ReasoningEffortConfig::High)), + /*summary*/ None, + Some(Some(service_tier.clone())), + Some(collaboration_mode.clone()), + Some(Personality::Pragmatic), + ); + + let handled = app + .try_submit_active_thread_op_via_app_server(&mut app_server, thread_id, &op) + .await + .expect("settings update submission should not fail"); + + assert_eq!(handled, true); + assert_eq!( + app.primary_session_configured + .as_ref() + .expect("primary session") + .model, + initial_model, + "thread/settings/update response is only an ack; cached state changes on notification" + ); + + let notification = next_thread_settings_updated(&mut app_server, thread_id).await; + assert_eq!(notification.thread_settings.model, "gpt-5.4"); + assert_eq!( + notification.thread_settings.effort, + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + notification.thread_settings.service_tier, + Some(service_tier.clone()) + ); + assert_eq!( + notification.thread_settings.approval_policy, + AskForApproval::OnRequest + ); + assert_eq!( + notification.thread_settings.approvals_reviewer.to_core(), + ApprovalsReviewer::AutoReview + ); + let notified_mode = ¬ification.thread_settings.collaboration_mode; + assert_eq!(notified_mode.mode, collaboration_mode.mode); + assert_eq!( + notified_mode.settings.model, + collaboration_mode.settings.model + ); + assert_eq!( + notified_mode.settings.reasoning_effort, + collaboration_mode.settings.reasoning_effort + ); + assert_eq!( + notification.thread_settings.personality, + Some(Personality::Pragmatic) + ); + + app.handle_app_server_event( + &app_server, + codex_app_server_client::AppServerEvent::ServerNotification( + ServerNotification::ThreadSettingsUpdated(notification), + ), + ) + .await; + let updated_session = app + .primary_session_configured + .as_ref() + .expect("primary session should be updated from notification"); + assert_eq!(updated_session.model, initial_model); + assert_eq!(updated_session.reasoning_effort, initial_effort); + let updated_mode = updated_session + .collaboration_mode + .as_deref() + .expect("collaboration mode should be cached"); + assert_eq!(updated_mode.mode, collaboration_mode.mode); + assert_eq!( + updated_mode.settings.model, + collaboration_mode.settings.model + ); + assert_eq!( + updated_mode.settings.reasoning_effort, + collaboration_mode.settings.reasoning_effort + ); + assert_eq!(updated_session.personality, Some(Personality::Pragmatic)); + assert_eq!(updated_session.service_tier, Some(service_tier)); + assert_eq!(updated_session.approval_policy, AskForApproval::OnRequest); + assert_eq!( + updated_session.approvals_reviewer, + ApprovalsReviewer::AutoReview + ); + assert_eq!( + updated_session + .active_permission_profile + .as_ref() + .expect("active profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE + ); + }) + .await; +} + +#[tokio::test] +async fn thread_setting_update_params_sync_model_and_default_reasoning() { + let mut app = make_test_app().await; + let thread_id = ThreadId::new(); + app.active_thread_id = Some(thread_id); + + app.chat_widget.set_model("gpt-5.4"); + let params = app + .active_thread_model_setting_update_params("gpt-5.4".to_string()) + .expect("active thread should produce update params"); + + assert_eq!(params.thread_id, thread_id.to_string()); + assert_eq!(params.model, Some("gpt-5.4".to_string())); + assert_eq!( + params + .collaboration_mode + .as_ref() + .expect("collaboration mode should sync with model") + .settings + .model, + "gpt-5.4" + ); + + app.chat_widget + .set_reasoning_effort(Some(ReasoningEffortConfig::Low)); + app.chat_widget + .set_collaboration_mask(CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(ModeKind::Plan), + model: Some("gpt-plan".to_string()), + reasoning_effort: Some(Some(ReasoningEffortConfig::Medium)), + developer_instructions: None, + }); + app.on_update_reasoning_effort(Some(ReasoningEffortConfig::High)); + + let params = app + .active_thread_reasoning_setting_update_params(Some(ReasoningEffortConfig::High)) + .expect("active thread should produce update params"); + + assert_eq!(params.thread_id, thread_id.to_string()); + assert_eq!(params.effort, Some(ReasoningEffortConfig::High)); + let collaboration_mode = params + .collaboration_mode + .expect("collaboration mode should sync with reasoning"); + assert_eq!(collaboration_mode.mode, ModeKind::Default); + assert_eq!( + collaboration_mode.settings.reasoning_effort, + Some(ReasoningEffortConfig::High) + ); +} + +#[tokio::test] +async fn inactive_thread_settings_notification_updates_cached_collaboration_mode() { + let mut app = make_test_app().await; + let primary_thread_id = ThreadId::new(); + let inactive_thread_id = ThreadId::new(); + let primary_session = test_thread_session(primary_thread_id, test_path_buf("/tmp/main")); + let inactive_session = test_thread_session(inactive_thread_id, test_path_buf("/tmp/inactive")); + let collaboration_mode = CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: "gpt-plan".to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: Some("draft a plan first".to_string()), + }, + }; + + app.primary_thread_id = Some(primary_thread_id); + app.active_thread_id = Some(primary_thread_id); + app.primary_session_configured = Some(primary_session.clone()); + app.thread_event_channels.insert( + primary_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + primary_session, + Vec::new(), + ), + ); + app.thread_event_channels.insert( + inactive_thread_id, + ThreadEventChannel::new_with_session( + THREAD_EVENT_CHANNEL_CAPACITY, + inactive_session, + Vec::new(), + ), + ); + + let notification = ThreadSettingsUpdatedNotification { + thread_id: inactive_thread_id.to_string(), + thread_settings: ThreadSettings { + cwd: test_absolute_path("/tmp/thread-settings"), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: "gpt-plan".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: collaboration_mode.settings.reasoning_effort, + summary: None, + collaboration_mode: collaboration_mode.clone(), + personality: Some(Personality::Pragmatic), + }, + }; + app.enqueue_thread_notification( + inactive_thread_id, + ServerNotification::ThreadSettingsUpdated(notification), + ) + .await + .expect("settings notification should be cached"); + + let cached_session = app + .thread_event_channels + .get(&inactive_thread_id) + .expect("inactive thread channel") + .store + .lock() + .await + .session + .clone() + .expect("inactive session should remain cached"); + assert_eq!(cached_session.model, "gpt-test"); + assert_eq!(cached_session.personality, Some(Personality::Pragmatic)); + assert_eq!( + cached_session.collaboration_mode.as_deref(), + Some(&collaboration_mode) + ); + + app.chat_widget.handle_thread_session(cached_session); + assert_eq!( + app.chat_widget.active_collaboration_mode_kind(), + ModeKind::Plan + ); + assert_eq!(app.chat_widget.current_model(), "gpt-plan"); + assert_eq!( + app.chat_widget.current_collaboration_mode().model(), + "gpt-test" + ); + assert_eq!( + app.chat_widget.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + app.chat_widget.config_ref().personality, + Some(Personality::Pragmatic) + ); +} + #[tokio::test] async fn clear_only_ui_reset_preserves_chat_session_state() { let mut app = make_test_app().await; @@ -5227,6 +5554,8 @@ async fn clear_only_ui_reset_preserves_chat_session_state() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_events.rs b/codex-rs/tui/src/app/thread_events.rs index 30f68dc64..b0c9e7e1b 100644 --- a/codex-rs/tui/src/app/thread_events.rs +++ b/codex-rs/tui/src/app/thread_events.rs @@ -355,6 +355,8 @@ mod tests { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index d270bb53c..2c2baf512 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -685,7 +685,11 @@ impl App { self.refresh_in_memory_config_from_disk().await?; Ok(true) } - AppCommand::OverrideTurnContext { .. } => Ok(true), + AppCommand::OverrideTurnContext { .. } => { + self.sync_override_turn_context_settings(app_server, thread_id, op) + .await; + Ok(true) + } AppCommand::ApproveGuardianDeniedAction { event } => { app_server .thread_approve_guardian_denied_action(thread_id, event) @@ -825,6 +829,17 @@ impl App { thread_id: ThreadId, notification: ServerNotification, ) -> Result<()> { + if matches!(notification, ServerNotification::ThreadSettingsUpdated(_)) + && self.primary_thread_id.is_some() + && self.primary_thread_id != Some(thread_id) + && !self.thread_event_channels.contains_key(&thread_id) + { + return Ok(()); + } + if let ServerNotification::ThreadSettingsUpdated(notification) = ¬ification { + self.apply_thread_settings_to_cached_session(thread_id, ¬ification.thread_settings) + .await; + } let inferred_session = self .infer_session_for_thread_notification(thread_id, ¬ification) .await; diff --git a/codex-rs/tui/src/app/thread_session_state.rs b/codex-rs/tui/src/app/thread_session_state.rs index 4e7f0c674..80aa54efb 100644 --- a/codex-rs/tui/src/app/thread_session_state.rs +++ b/codex-rs/tui/src/app/thread_session_state.rs @@ -78,10 +78,16 @@ impl App { ) -> ThreadSessionState { let permission_profile = self.current_permission_profile(); let active_permission_profile = self.current_active_permission_profile(); - let mut session = self - .primary_session_configured - .clone() - .unwrap_or(ThreadSessionState { + let mut session = if let Some(mut session) = self.primary_session_configured.clone() { + if session.thread_id != thread_id { + // `thread/read` does not include thread settings, so do not carry + // thread-scoped state from the currently active session. + session.collaboration_mode = None; + session.personality = None; + } + session + } else { + ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, @@ -99,10 +105,13 @@ impl App { runtime_workspace_roots: self.config.workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: self.chat_widget.current_reasoning_effort(), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: thread.path.clone(), - }); + } + }; session.thread_id = thread_id; session.thread_name = thread.name.clone(); session.model_provider_id = thread.model_provider.clone(); @@ -178,6 +187,8 @@ mod tests { runtime_workspace_roots: vec![cwd.abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs new file mode 100644 index 000000000..bf9282787 --- /dev/null +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -0,0 +1,208 @@ +//! Thread settings sync between TUI-local state and app-server thread state. + +use super::App; +use crate::app_command::AppCommand; +use crate::app_server_session::AppServerSession; +use crate::session_state::ThreadSessionState; +use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_protocol::ThreadId; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::PermissionProfile; + +impl App { + pub(super) async fn sync_active_thread_model_setting( + &mut self, + app_server: &mut AppServerSession, + model: String, + ) { + let Some(params) = self.active_thread_model_setting_update_params(model) else { + return; + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) fn active_thread_model_setting_update_params( + &self, + model: String, + ) -> Option { + let thread_id = self.active_thread_id?; + Some(ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + model: Some(model), + collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), + ..ThreadSettingsUpdateParams::default() + }) + } + + pub(super) async fn sync_active_thread_reasoning_setting( + &mut self, + app_server: &mut AppServerSession, + effort: Option, + ) { + let Some(params) = self.active_thread_reasoning_setting_update_params(effort) else { + return; + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) fn active_thread_reasoning_setting_update_params( + &self, + effort: Option, + ) -> Option { + let thread_id = self.active_thread_id?; + Some(ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + effort, + collaboration_mode: Some(self.chat_widget.current_collaboration_mode().clone()), + ..ThreadSettingsUpdateParams::default() + }) + } + + pub(super) async fn sync_active_thread_plan_mode_reasoning_setting( + &mut self, + app_server: &mut AppServerSession, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + collaboration_mode: Some(self.chat_widget.effective_collaboration_mode()), + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn sync_active_thread_personality_setting( + &mut self, + app_server: &mut AppServerSession, + personality: codex_protocol::config_types::Personality, + ) { + let Some(thread_id) = self.active_thread_id else { + return; + }; + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + personality: Some(personality), + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn sync_override_turn_context_settings( + &mut self, + app_server: &mut AppServerSession, + thread_id: ThreadId, + op: &AppCommand, + ) { + let AppCommand::OverrideTurnContext { + cwd, + approval_policy, + approvals_reviewer, + active_permission_profile, + windows_sandbox_level: _, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = op + else { + return; + }; + + let params = ThreadSettingsUpdateParams { + thread_id: thread_id.to_string(), + cwd: cwd.clone(), + approval_policy: *approval_policy, + approvals_reviewer: approvals_reviewer.map(AppServerApprovalsReviewer::from), + permissions: active_permission_profile + .as_ref() + .map(|profile| profile.id.clone()), + model: model.clone(), + effort: effort.unwrap_or_default(), + summary: *summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality: *personality, + ..ThreadSettingsUpdateParams::default() + }; + self.send_thread_settings_update(app_server, params).await; + } + + pub(super) async fn apply_thread_settings_to_cached_session( + &mut self, + thread_id: ThreadId, + settings: &ThreadSettings, + ) { + if self.primary_thread_id == Some(thread_id) + && let Some(session) = self.primary_session_configured.as_mut() + { + apply_thread_settings_to_session(session, settings); + } + + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + if let Some(session) = store.session.as_mut() { + apply_thread_settings_to_session(session, settings); + } + } + } + + async fn send_thread_settings_update( + &mut self, + app_server: &mut AppServerSession, + params: ThreadSettingsUpdateParams, + ) { + if !thread_settings_update_has_changes(¶ms) { + return; + } + if let Err(err) = app_server.thread_settings_update(params).await { + tracing::warn!("failed to update app-server thread settings from TUI: {err}"); + self.chat_widget + .add_error_message(format!("Failed to update thread settings: {err}")); + } + } +} + +fn apply_thread_settings_to_session(session: &mut ThreadSessionState, settings: &ThreadSettings) { + if settings.collaboration_mode.mode == ModeKind::Default { + session.model = settings.model.clone(); + session.reasoning_effort = settings.effort; + } + session.model_provider_id = settings.model_provider.clone(); + session.service_tier = settings.service_tier.clone(); + session.approval_policy = settings.approval_policy; + session.approvals_reviewer = settings.approvals_reviewer.to_core(); + session.permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.sandbox_policy.to_core(), + settings.cwd.as_path(), + ); + session.active_permission_profile = settings.active_permission_profile.clone().map(Into::into); + session.set_cwd_retargeting_implicit_runtime_workspace_root(settings.cwd.clone()); + session.personality = settings.personality; + let mut collaboration_mode = settings.collaboration_mode.clone(); + collaboration_mode + .settings + .model + .clone_from(&settings.model); + collaboration_mode.settings.reasoning_effort = settings.effort; + session.collaboration_mode = Some(Box::new(collaboration_mode)); +} + +fn thread_settings_update_has_changes(params: &ThreadSettingsUpdateParams) -> bool { + params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permissions.is_some() + || params.model.is_some() + || params.service_tier.is_some() + || params.effort.is_some() + || params.summary.is_some() + || params.collaboration_mode.is_some() + || params.personality.is_some() +} diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 51029a6de..9eca24e05 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -86,6 +86,8 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSource; @@ -121,10 +123,20 @@ use std::collections::HashMap; use std::path::PathBuf; use uuid::Uuid; +const JSONRPC_INVALID_REQUEST: i64 = -32600; +const JSONRPC_METHOD_NOT_FOUND: i64 = -32601; +const THREAD_SETTINGS_UPDATE_METHOD: &str = "thread/settings/update"; + fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } +fn is_thread_settings_update_unsupported(source: &JSONRPCErrorError) -> bool { + source.code == JSONRPC_METHOD_NOT_FOUND + || (source.code == JSONRPC_INVALID_REQUEST + && source.message.contains(THREAD_SETTINGS_UPDATE_METHOD)) +} + /// Data collected during the TUI bootstrap phase that the main event loop /// needs to configure the UI, telemetry, and initial rate-limit prefetch. /// @@ -151,6 +163,7 @@ pub(crate) struct AppServerSession { next_request_id: i64, remote_cwd_override: Option, thread_params_mode: ThreadParamsMode, + thread_settings_update_supported: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -191,6 +204,7 @@ impl AppServerSession { next_request_id: 1, remote_cwd_override: None, thread_params_mode, + thread_settings_update_supported: true, } } @@ -528,6 +542,39 @@ impl AppServerSession { .wrap_err("thread/metadata/update failed while syncing git branch") } + pub(crate) async fn thread_settings_update( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> Result<()> { + if !self.thread_settings_update_supported { + return Ok(()); + } + let request_id = self.next_request_id(); + match self + .client + .request_typed::(ClientRequest::ThreadSettingsUpdate { + request_id, + params, + }) + .await + { + Ok(_) => Ok(()), + Err(TypedRequestError::Server { source, .. }) + if is_thread_settings_update_unsupported(&source) => + { + // Older remote app servers can reject this experimental method as + // method-not-found, experimental-capability-gated, or an unknown + // request variant. Treat those as a session-level capability + // downgrade so local TUI setting changes stay best-effort instead + // of showing an error every time the user changes model, effort, + // personality, or mode. + self.thread_settings_update_supported = false; + Ok(()) + } + Err(err) => Err(err).wrap_err("thread/settings/update failed in TUI"), + } + } + pub(crate) async fn thread_inject_items( &mut self, thread_id: ThreadId, @@ -1599,6 +1646,8 @@ async fn thread_session_state_from_thread_response( runtime_workspace_roots, instruction_source_paths, reasoning_effort, + collaboration_mode: None, + personality: config.personality, message_history: Some(MessageHistoryMetadata { log_id, entry_count, @@ -1700,6 +1749,37 @@ mod tests { ); } + #[test] + fn thread_settings_update_compat_detects_unsupported_errors() { + let cases = [ + (JSONRPC_METHOD_NOT_FOUND, "method not found", true), + ( + JSONRPC_INVALID_REQUEST, + "thread/settings/update requires experimentalApi capability", + true, + ), + ( + JSONRPC_INVALID_REQUEST, + "Invalid request: unknown variant `thread/settings/update`", + true, + ), + (JSONRPC_INVALID_REQUEST, "invalid thread id", false), + ]; + + for (code, message, expected) in cases { + let source = JSONRPCErrorError { + code, + data: None, + message: message.to_string(), + }; + assert_eq!( + is_thread_settings_update_unsupported(&source), + expected, + "{message}" + ); + } + } + #[tokio::test] async fn thread_start_params_include_cwd_for_embedded_sessions() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 240b72ab9..0ba974d88 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -115,6 +115,8 @@ use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoal as AppThreadGoal; use codex_app_server_protocol::ThreadGoalStatus as AppThreadGoalStatus; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::Turn; diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index dbc52269a..3930224e0 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -178,7 +178,7 @@ impl ChatWidget { ); return; } - self.set_collaboration_mask(collaboration_mode); + self.set_collaboration_mask_from_user_action(collaboration_mode); let should_queue = self.is_plan_streaming_in_tui(); let user_message = UserMessage { text, diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index 171b03915..6a980e347 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -51,6 +51,9 @@ impl ChatWidget { ServerNotification::ThreadGoalCleared(notification) => { self.on_thread_goal_cleared(notification.thread_id.as_str()); } + ServerNotification::ThreadSettingsUpdated(notification) => { + self.on_thread_settings_updated(notification); + } ServerNotification::TurnStarted(notification) => { self.turn_lifecycle.last_turn_id = Some(notification.turn.id); self.last_non_retry_error = None; @@ -247,6 +250,10 @@ impl ChatWidget { notification: TurnCompletedNotification, replay_kind: Option, ) { + // User-message dedupe only suppresses the app-server echo of a prompt + // this TUI already rendered locally. Once that turn ends, another + // client can submit the same text and it still needs its own user cell. + self.last_rendered_user_message_display = None; match notification.turn.status { TurnStatus::Completed => { self.last_non_retry_error = None; diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index 9837eaa6e..01a6b7d57 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -73,18 +73,31 @@ impl ChatWidget { } } self.config.approvals_reviewer = session.approvals_reviewer; + self.config.personality = session.personality; self.status_line_project_root_name_cache = None; let forked_from_id = session.forked_from_id; - let model_for_header = session.model.clone(); - self.session_header.set_model(&model_for_header); + let default_model = session.model.clone(); self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - Some(model_for_header.clone()), + Some(default_model.clone()), Some(session.reasoning_effort), /*developer_instructions*/ None, ); - if let Some(mask) = self.active_collaboration_mask.as_mut() { - mask.model = Some(model_for_header.clone()); - mask.reasoning_effort = Some(session.reasoning_effort); + match session.collaboration_mode.as_deref() { + Some(collaboration_mode) => { + self.set_effective_collaboration_mode(collaboration_mode.clone()); + } + None => { + self.active_collaboration_mask = Self::initial_collaboration_mask( + &self.config, + self.model_catalog.as_ref(), + Some(&default_model), + ); + if let Some(mask) = self.active_collaboration_mask.as_mut() { + mask.reasoning_effort = Some(session.reasoning_effort); + } + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + } } self.refresh_model_display(); self.refresh_status_surfaces(); @@ -93,6 +106,7 @@ impl ChatWidget { self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); self.refresh_plugin_mentions(); + let model_for_header = self.current_model().to_string(); if display == SessionConfiguredDisplay::Normal { let startup_tooltip_override = self.startup_tooltip_override.take(); let show_fast_status = self diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 91abc728d..0434f7e38 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -1,6 +1,7 @@ //! Runtime settings state and model/collaboration coordination for `ChatWidget`. use super::*; +use crate::app_event::AppEvent; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -345,6 +346,24 @@ impl ChatWidget { self.effective_reasoning_effort() } + pub(crate) fn on_thread_settings_updated( + &mut self, + notification: ThreadSettingsUpdatedNotification, + ) { + let Ok(thread_id) = ThreadId::from_string(¬ification.thread_id) else { + tracing::warn!( + thread_id = notification.thread_id, + "ignoring app-server ThreadSettingsUpdated with invalid thread_id" + ); + return; + }; + if self.thread_id != Some(thread_id) { + return; + } + + self.apply_thread_settings(notification.thread_settings); + } + #[cfg(test)] pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { self.active_mode_kind() @@ -430,7 +449,7 @@ impl ChatWidget { .unwrap_or(current_effort) } - pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode { + pub(crate) fn effective_collaboration_mode(&self) -> CollaborationMode { if !self.collaboration_modes_enabled() { return self.current_collaboration_mode.clone(); } @@ -462,6 +481,96 @@ impl ChatWidget { self.refresh_status_line(); } + fn apply_thread_settings(&mut self, mut settings: ThreadSettings) { + let cwd_changed = self.config.cwd != settings.cwd; + self.apply_thread_settings_cwd(settings.cwd.clone()); + self.config.model_provider_id = settings.model_provider.clone(); + self.set_service_tier(settings.service_tier.clone()); + self.set_approval_policy(settings.approval_policy); + self.set_approvals_reviewer(settings.approvals_reviewer.to_core()); + self.config.personality = settings.personality; + + let permission_profile = PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &settings.sandbox_policy.to_core(), + settings.cwd.as_path(), + ); + let permission_snapshot = PermissionProfileSnapshot::from_session_snapshot( + permission_profile, + settings.active_permission_profile.take().map(Into::into), + ); + if let Err(err) = self + .config + .permissions + .set_permission_profile_from_session_snapshot(permission_snapshot.clone()) + { + tracing::warn!(%err, "failed to sync permissions from ThreadSettingsUpdated"); + if let Err(replace_err) = self + .config + .permissions + .replace_permission_profile_from_session_snapshot(permission_snapshot) + { + tracing::error!( + %replace_err, + "failed to replace permissions from ThreadSettingsUpdated after constraint fallback" + ); + } + } + + settings.collaboration_mode.settings.model = settings.model; + settings.collaboration_mode.settings.reasoning_effort = settings.effort; + self.set_effective_collaboration_mode(settings.collaboration_mode); + self.refresh_status_surfaces(); + self.sync_service_tier_commands(); + self.sync_personality_command_enabled(); + if cwd_changed { + self.refresh_skills_for_current_cwd(/*force_reload*/ true); + } + self.refresh_plugin_mentions(); + self.request_redraw(); + } + + fn apply_thread_settings_cwd(&mut self, cwd: AbsolutePathBuf) { + let previous_cwd = std::mem::replace(&mut self.config.cwd, cwd.clone()); + self.current_cwd = Some(cwd.to_path_buf()); + self.status_line_project_root_name_cache = None; + + if !self.config.workspace_roots.contains(&previous_cwd) { + return; + } + + let previous_roots = std::mem::take(&mut self.config.workspace_roots); + self.config.workspace_roots.push(cwd); + for root in previous_roots { + if root != previous_cwd && !self.config.workspace_roots.contains(&root) { + self.config.workspace_roots.push(root); + } + } + self.config + .permissions + .set_workspace_roots(self.config.workspace_roots.clone()); + } + + pub(super) fn set_effective_collaboration_mode(&mut self, mode: CollaborationMode) { + let mode_kind = mode.mode; + let settings = mode.settings; + if mode_kind == ModeKind::Default { + self.current_collaboration_mode = CollaborationMode { + mode: ModeKind::Default, + settings: settings.clone(), + }; + } + self.active_collaboration_mask = Some(CollaborationModeMask { + name: mode_kind.display_name().to_string(), + mode: Some(mode_kind), + model: Some(settings.model.clone()), + reasoning_effort: Some(settings.reasoning_effort), + developer_instructions: Some(settings.developer_instructions), + }); + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + self.refresh_model_dependent_surfaces(); + } + pub(super) fn model_display_name(&self) -> &str { let model = self.current_model(); if model.is_empty() { @@ -555,10 +664,15 @@ impl ChatWidget { self.model_catalog.as_ref(), self.active_collaboration_mask.as_ref(), ) { - self.set_collaboration_mask(next_mask); + self.set_collaboration_mask_from_user_action(next_mask); } } + pub(crate) fn set_collaboration_mask_from_user_action(&mut self, mask: CollaborationModeMask) { + self.set_collaboration_mask(mask); + self.submit_collaboration_mode_settings_update(); + } + /// Update the active collaboration mask. /// /// When collaboration modes are enabled and a preset is selected, @@ -609,4 +723,26 @@ impl ChatWidget { } self.request_redraw(); } + + fn submit_collaboration_mode_settings_update(&self) { + let Some(thread_id) = self.thread_id else { + return; + }; + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*active_permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + Some(self.effective_collaboration_mode()), + /*personality*/ None, + ), + }); + } } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index dcf048682..aa9e5112f 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -88,7 +88,7 @@ impl ChatWidget { return false; } if let Some(mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) { - self.set_collaboration_mask(mask); + self.set_collaboration_mask_from_user_action(mask); true } else { self.add_info_message( diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 0b5f98914..c8d45d2a5 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,6 +1,65 @@ use super::*; use pretty_assertions::assert_eq; +fn thread_settings_for_test( + model: &str, + thread_id: ThreadId, +) -> codex_app_server_protocol::ThreadSettingsUpdatedNotification { + codex_app_server_protocol::ThreadSettingsUpdatedNotification { + thread_id: thread_id.to_string(), + thread_settings: codex_app_server_protocol::ThreadSettings { + cwd: test_path_buf("/tmp/thread-settings").abs(), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, + sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: Some( + codex_app_server_protocol::ActivePermissionProfile::read_only(), + ), + model: model.to_string(), + model_provider: "openai".to_string(), + service_tier: Some(ServiceTier::Fast.request_value().to_string()), + effort: Some(ReasoningEffortConfig::High), + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: model.to_string(), + reasoning_effort: Some(ReasoningEffortConfig::High), + developer_instructions: None, + }, + }, + personality: Some(Personality::Pragmatic), + }, + } +} + +fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { + crate::session_state::ThreadSessionState { + thread_id, + forked_from_id: None, + fork_parent_title: None, + thread_name: None, + model: "gpt-5.3-codex".to_string(), + model_provider_id: "openai".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: ApprovalsReviewer::User, + permission_profile: PermissionProfile::read_only(), + active_permission_profile: None, + cwd: test_path_buf("/tmp/thread-settings").abs(), + runtime_workspace_roots: vec![test_path_buf("/tmp/thread-settings").abs()], + instruction_source_paths: Vec::new(), + reasoning_effort: None, + collaboration_mode: None, + personality: None, + message_history: None, + network_proxy: None, + rollout_path: None, + } +} + #[tokio::test] async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; @@ -38,6 +97,98 @@ async fn invalid_url_elicitation_is_declined() { ); } +#[tokio::test] +async fn thread_settings_updated_updates_visible_state_without_transcript() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + set_fast_mode_test_catalog(&mut chat); + let thread_id = ThreadId::new(); + chat.handle_thread_session(configured_thread_session(thread_id)); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_model(), "gpt-5.4"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!( + chat.current_service_tier(), + Some(ServiceTier::Fast.request_value()) + ); + assert_eq!( + chat.config_ref().permissions.approval_policy.value(), + AskForApproval::OnRequest.to_core() + ); + assert_eq!( + chat.config_ref().approvals_reviewer, + ApprovalsReviewer::AutoReview + ); + assert_eq!( + chat.config_ref() + .permissions + .active_permission_profile() + .expect("active profile") + .id, + codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY + ); + assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic)); + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert!( + drain_insert_history(&mut rx).is_empty(), + "ThreadSettingsUpdated should not render transcript history" + ); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test( + "gpt-5.2", + ThreadId::new(), + )), + /*replay_kind*/ None, + ); + + assert_eq!(chat.current_model(), "gpt-5.4"); +} + +#[tokio::test] +async fn thread_settings_updated_preserves_default_settings_for_plan_mode() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; + let thread_id = ThreadId::new(); + let mut session = configured_thread_session(thread_id); + session.model = "gpt-default".to_string(); + session.reasoning_effort = Some(ReasoningEffortConfig::Low); + chat.handle_thread_session(session); + let _ = drain_insert_history(&mut rx); + let default_mode = chat.current_collaboration_mode().clone(); + + chat.handle_server_notification( + ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-plan", thread_id)), + /*replay_kind*/ None, + ); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); + assert_eq!(chat.current_model(), "gpt-plan"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::High) + ); + assert_eq!(chat.current_collaboration_mode(), &default_mode); + + let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref()) + .expect("expected default collaboration mode"); + chat.set_collaboration_mask(default_mask); + + assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Default); + assert_eq!(chat.current_model(), "gpt-default"); + assert_eq!( + chat.current_reasoning_effort(), + Some(ReasoningEffortConfig::Low) + ); +} + #[tokio::test] async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 4cd4cf450..70b5c49b0 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -30,6 +30,8 @@ async fn submission_preserves_text_elements_and_local_images() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -135,6 +137,8 @@ async fn submission_includes_configured_active_permission_profile() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -188,6 +192,8 @@ async fn submission_omits_active_permission_profile_for_legacy_snapshot() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -231,6 +237,8 @@ async fn submission_with_remote_and_local_images_keeps_local_placeholder_numberi runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -327,6 +335,8 @@ async fn enter_with_only_remote_images_submits_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -392,6 +402,8 @@ async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -431,6 +443,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -470,6 +484,8 @@ async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -512,6 +528,8 @@ async fn submission_prefers_selected_duplicate_skill_path() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index ffc883377..8dbace437 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -976,6 +976,8 @@ async fn bang_shell_enter_while_task_running_submits_run_user_shell_command() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 5ba32ead5..a7ec121de 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -31,6 +31,8 @@ async fn resumed_initial_messages_render_history() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -102,6 +104,8 @@ async fn replayed_user_message_preserves_text_elements_and_local_images() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -172,6 +176,8 @@ async fn replayed_user_message_preserves_remote_image_urls() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -272,6 +278,8 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -343,6 +351,8 @@ async fn session_configured_preserves_profile_workspace_roots() { runtime_workspace_roots: session_runtime_workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -388,6 +398,8 @@ async fn session_configured_external_sandbox_keeps_external_runtime_policy() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -427,6 +439,8 @@ async fn replayed_user_message_with_only_remote_images_renders_history_cell() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -483,6 +497,8 @@ async fn replayed_user_message_with_only_local_images_renders_history_cell() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -755,6 +771,8 @@ async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, @@ -801,6 +819,8 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index d682d24d6..71483bd39 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -584,6 +584,8 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), @@ -632,6 +634,8 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 091f92c3f..5fc2fa2ce 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1220,6 +1220,8 @@ async fn submit_user_message_emits_structured_plugin_mentions_from_bindings() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), @@ -1407,6 +1409,8 @@ async fn plan_slash_command_with_args_submits_prompt_in_plan_mode() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 306a90d78..1d5793e82 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2493,6 +2493,8 @@ async fn session_configured_clears_goal_status_footer() { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(rollout_file.path().to_path_buf()), diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index b0b87cdd0..1307f9a82 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -451,6 +451,8 @@ fn session_configured_event(model: &str) -> ThreadSessionState { runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, + collaboration_mode: None, + personality: None, message_history: None, network_proxy: None, rollout_path: Some(PathBuf::new()), diff --git a/codex-rs/tui/src/session_state.rs b/codex-rs/tui/src/session_state.rs index c9d964d62..7988ba226 100644 --- a/codex-rs/tui/src/session_state.rs +++ b/codex-rs/tui/src/session_state.rs @@ -7,6 +7,8 @@ use std::path::PathBuf; use codex_app_server_protocol::AskForApproval; use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -47,6 +49,8 @@ pub(crate) struct ThreadSessionState { pub(crate) runtime_workspace_roots: Vec, pub(crate) instruction_source_paths: Vec, pub(crate) reasoning_effort: Option, + pub(crate) collaboration_mode: Option>, + pub(crate) personality: Option, pub(crate) message_history: Option, pub(crate) network_proxy: Option, pub(crate) rollout_path: Option,