mirror of
https://github.com/openai/codex.git
synced 2026-11-05 16:29:31 +00:00
Sync TUI thread settings through app server (#23507)
Builds on #23502. ## Why #23502 adds the app-server `thread/settings/update` API and matching `thread/settings/updated` notification. The TUI already lets users change thread-scoped settings such as model, reasoning effort, service tier, approvals, permissions, personality, and collaboration mode, but those updates need to flow through the app server so embedded and connected clients observe the same thread state. This is a rework (simplification) of PR https://github.com/openai/codex/pull/22510. It has the same functionality, but the underlying `thread/settings/update` api is now simpler in that it no longer returns the effective settings as a response. Now, clients receive the effective settings only through the `thread/settings/updated` notification. ## What Changed This updates the TUI to send `thread/settings/update` whenever those thread-scoped settings change and to treat the RPC response as the authoritative acknowledgement. It also routes `thread/settings/updated` notifications back into cached session state and the visible chat widget so active and inactive threads stay in sync after app-server-originated changes. The implementation is kept to the TUI layer: settings conversion and merge logic live under `codex-rs/tui/src/app/thread_settings.rs`, with dispatch/routing hooks in the existing app and chat widget paths. ## Verification I manually tested using `codex app-server --listen unix://` and then launching two copies of the TUI that use the same local app server. I then resumed the same thread on both and verified that changes like plan mode, fast mode, model, reasoning effort, etc. are reflected "live" in the second client when modified in the first and vice versa.
This commit is contained in:
1 parent
771a4e74ac
commit
edc48e4612
25 files changed
+1086
-16
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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<ThreadSettingsUpdateParams> {
|
||||
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<codex_protocol::openai_models::ReasoningEffort>,
|
||||
) {
|
||||
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<codex_protocol::openai_models::ReasoningEffort>,
|
||||
) -> Option<ThreadSettingsUpdateParams> {
|
||||
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()
|
||||
}
|
||||
Reference in new issue
Block a user