From 9e9a62dc2899ce4fc3321617f3d26a8bebd8dad8 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 18 May 2026 20:33:27 -0700 Subject: [PATCH 1/5] [codex] Extract turn skill and plugin injections (#23396) ## Why `run_turn` had accumulated the turn-scoped skill, plugin, app, MCP, connector-selection, and analytics setup inline. That made the orchestration path harder to scan even though the actual turn item injection still needs to stay in `run_turn` so ordering is explicit. ## What changed This extracts that setup into `build_skills_and_plugins`, which returns the combined injection `ResponseItem`s and the explicitly enabled connector IDs. `run_turn` now keeps the required orchestration pieces: context update recording, user input handling, connector selection merge, and the explicit per-item `record_conversation_items` calls for injection items. The refactor keeps the change LOC-neutral in `core/src/session/turn.rs` and preserves the existing response-item based injection path. ## Validation - `cargo test -p codex-core collect_explicit_app_ids_from_skill_items` - `just fix -p codex-core` --- codex-rs/core/src/session/turn.rs | 280 +++++++++++++++--------------- 1 file changed, 140 insertions(+), 140 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 8edf2e4785..9fe7f93dc3 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -134,10 +134,6 @@ use tracing::warn; /// - If the model sends only an assistant message, we record it in the /// conversation history and consider the turn complete. /// -#[expect( - clippy::await_holding_invalid_type, - reason = "turn execution must keep active-turn state transitions atomic" -)] pub(crate) async fn run_turn( sess: Arc, turn_context: Arc, @@ -172,131 +168,11 @@ pub(crate) async fn run_turn( return None; } - let skills_outcome = Some(turn_context.turn_skills.outcome.as_ref()); - sess.record_context_updates_and_set_reference_context_item(turn_context.as_ref()) .await; - let loaded_plugins = sess - .services - .plugins_manager - .plugins_for_config(&turn_context.config.plugins_config_input()) - .await; - // Structured plugin:// mentions are resolved from the current session's - // enabled plugins, then converted into turn-scoped guidance below. - let mentioned_plugins = - collect_explicit_plugin_mentions(&input, loaded_plugins.capability_summaries()); - let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { - // Plugin mentions need raw MCP/app inventory even when app tools - // are normally hidden so we can describe the plugin's currently - // usable capabilities for this turn. - match sess - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .or_cancel(&cancellation_token) - .await - { - Ok(mcp_tools) => mcp_tools, - Err(_) if turn_context.apps_enabled() => return None, - Err(_) => Vec::new(), - } - } else { - Vec::new() - }; - let available_connectors = if turn_context.apps_enabled() { - let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( - loaded_plugins - .effective_apps() - .into_iter() - .map(|connector_id| connector_id.0), - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - ); - connectors::with_app_enabled_state(connectors, &turn_context.config) - } else { - Vec::new() - }; - let connector_slug_counts = build_connector_slug_counts(&available_connectors); - let skill_name_counts_lower = skills_outcome - .as_ref() - .map_or_else(HashMap::new, |outcome| { - build_skill_name_counts(&outcome.skills, &outcome.disabled_paths).1 - }); - let mentioned_skills = skills_outcome.as_ref().map_or_else(Vec::new, |outcome| { - collect_explicit_skill_mentions( - &input, - &outcome.skills, - &outcome.disabled_paths, - &connector_slug_counts, - ) - }); - maybe_prompt_and_install_mcp_dependencies( - sess.as_ref(), - turn_context.as_ref(), - &cancellation_token, - &mentioned_skills, - Some(sess.mcp_elicitation_reviewer()), - ) - .await; - - let session_telemetry = turn_context.session_telemetry.clone(); - let thread_id = sess.conversation_id.to_string(); - let tracking = build_track_events_context( - turn_context.model_info.slug.clone(), - thread_id, - turn_context.sub_id.clone(), - ); - let SkillInjections { - items: skill_injections, - warnings: skill_warnings, - } = build_skill_injections( - &mentioned_skills, - skills_outcome, - Some(&session_telemetry), - &sess.services.analytics_events_client, - tracking.clone(), - ) - .await; - - for message in skill_warnings { - sess.send_event(&turn_context, EventMsg::Warning(WarningEvent { message })) - .await; - } - - let skill_items: Vec = skill_injections - .iter() - .map(|skill| ContextualUserFragment::into(crate::context::SkillInstructions::from(skill))) - .collect(); - - let plugin_items = - build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); - let mentioned_plugin_metadata = mentioned_plugins - .iter() - .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) - .collect::>(); - - let mut explicitly_enabled_connectors = collect_explicit_app_ids(&input); - explicitly_enabled_connectors.extend(collect_explicit_app_ids_from_skill_items( - &skill_items, - &available_connectors, - &skill_name_counts_lower, - )); - let connector_names_by_id = available_connectors - .iter() - .map(|connector| (connector.id.as_str(), connector.name.as_str())) - .collect::>(); - let mentioned_app_invocations = explicitly_enabled_connectors - .iter() - .map(|connector_id| AppInvocation { - connector_id: Some(connector_id.clone()), - app_name: connector_names_by_id - .get(connector_id.as_str()) - .map(|name| (*name).to_string()), - invocation_type: Some(InvocationType::Explicit), - }) - .collect::>(); + let (injection_items, explicitly_enabled_connectors) = + build_skills_and_plugins(&sess, turn_context.as_ref(), &input, &cancellation_token).await?; if run_pending_session_start_hooks(&sess, &turn_context).await { return None; @@ -325,14 +201,6 @@ pub(crate) async fn run_turn( .await; user_prompt_submit_outcome.additional_contexts }; - sess.services - .analytics_events_client - .track_app_mentioned(tracking.clone(), mentioned_app_invocations); - for plugin in mentioned_plugin_metadata { - sess.services - .analytics_events_client - .track_plugin_used(tracking.clone(), plugin); - } sess.merge_connector_selection(explicitly_enabled_connectors.clone()) .await; record_additional_contexts(&sess, &turn_context, additional_contexts).await; @@ -346,12 +214,8 @@ pub(crate) async fn run_turn( })) .await; } - if !skill_items.is_empty() { - sess.record_conversation_items(&turn_context, &skill_items) - .await; - } - if !plugin_items.is_empty() { - sess.record_conversation_items(&turn_context, &plugin_items) + for response_item in injection_items { + sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item)) .await; } @@ -698,6 +562,142 @@ pub(crate) async fn run_turn( last_agent_message } +#[expect( + clippy::await_holding_invalid_type, + reason = "MCP tool listing borrows the read guard across cancellation-aware await" +)] +async fn build_skills_and_plugins( + sess: &Arc, + turn_context: &TurnContext, + input: &[UserInput], + cancellation_token: &CancellationToken, +) -> Option<(Vec, HashSet)> { + let tracking = build_track_events_context( + turn_context.model_info.slug.clone(), + sess.conversation_id.to_string(), + turn_context.sub_id.clone(), + ); + let loaded_plugins = sess + .services + .plugins_manager + .plugins_for_config(&turn_context.config.plugins_config_input()) + .await; + // Structured plugin:// mentions are resolved from the current session's + // enabled plugins, then converted into turn-scoped guidance below. + let mentioned_plugins = + collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries()); + let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() { + // Plugin mentions need raw MCP/app inventory even when app tools + // are normally hidden so we can describe the plugin's currently + // usable capabilities for this turn. + match sess + .services + .mcp_connection_manager + .read() + .await + .list_all_tools() + .or_cancel(cancellation_token) + .await + { + Ok(mcp_tools) => mcp_tools, + Err(_) if turn_context.apps_enabled() => return None, + Err(_) => Vec::new(), + } + } else { + Vec::new() + }; + let available_connectors = if turn_context.apps_enabled() { + let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible( + loaded_plugins + .effective_apps() + .into_iter() + .map(|connector_id| connector_id.0), + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + ); + connectors::with_app_enabled_state(connectors, &turn_context.config) + } else { + Vec::new() + }; + let skills_outcome = turn_context.turn_skills.outcome.as_ref(); + let connector_slug_counts = build_connector_slug_counts(&available_connectors); + let skill_name_counts_lower = + build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1; + let mentioned_skills = collect_explicit_skill_mentions( + input, + &skills_outcome.skills, + &skills_outcome.disabled_paths, + &connector_slug_counts, + ); + maybe_prompt_and_install_mcp_dependencies( + sess, + turn_context, + cancellation_token, + &mentioned_skills, + Some(sess.mcp_elicitation_reviewer()), + ) + .await; + + let SkillInjections { + items: skill_injections, + warnings: skill_warnings, + } = build_skill_injections( + &mentioned_skills, + Some(skills_outcome), + Some(&turn_context.session_telemetry), + &sess.services.analytics_events_client, + tracking.clone(), + ) + .await; + + for message in skill_warnings { + sess.send_event(turn_context, EventMsg::Warning(WarningEvent { message })) + .await; + } + + let skill_items: Vec = skill_injections + .iter() + .map(|skill| ContextualUserFragment::into(crate::context::SkillInstructions::from(skill))) + .collect(); + let skill_connector_ids = collect_explicit_app_ids_from_skill_items( + &skill_items, + &available_connectors, + &skill_name_counts_lower, + ); + let plugin_items = + build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors); + let mut explicitly_enabled_connectors = collect_explicit_app_ids(input); + explicitly_enabled_connectors.extend(skill_connector_ids); + let connector_names_by_id = available_connectors + .iter() + .map(|connector| (connector.id.as_str(), connector.name.as_str())) + .collect::>(); + let mentioned_app_invocations = explicitly_enabled_connectors + .iter() + .map(|connector_id| AppInvocation { + connector_id: Some(connector_id.clone()), + app_name: connector_names_by_id + .get(connector_id.as_str()) + .map(|name| (*name).to_string()), + invocation_type: Some(InvocationType::Explicit), + }) + .collect::>(); + sess.services + .analytics_events_client + .track_app_mentioned(tracking.clone(), mentioned_app_invocations); + for plugin in mentioned_plugins + .iter() + .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) + { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } + + let mut injection_items = skill_items; + injection_items.extend(plugin_items); + Some((injection_items, explicitly_enabled_connectors)) +} + async fn track_turn_resolved_config_analytics( sess: &Session, turn_context: &TurnContext, From d3d38159eddc00f6dec46f375e8a5e94d246dbeb Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Mon, 18 May 2026 21:02:30 -0700 Subject: [PATCH 2/5] fix(plugins): keep version upgrades additive (#23356) ## Why Windows can reject plugin cache upgrades when a running MCP server still has its working directory inside the currently active plugin version. The existing cache refresh path replaces `plugins/cache//` as a whole, so a live handle under the old version can make an otherwise ordinary version bump fail. This PR keeps the existing plugin-selection model intact while making version bumps less disruptive. ## What changed - When installing a new version beside an existing plugin cache root, move only the staged version directory into place instead of replacing the whole plugin root. - Best-effort prune older sibling version directories after the new version is activated. - Preserve the existing whole-root replacement path for first installs and same-version refreshes. - Add regression coverage for upgrading from `1.0.0` to `2.0.0` without replacing the plugin root. ## Verification - `cargo test -p codex-core-plugins install_with_new_version` - `cargo fmt --package codex-core-plugins --check` --- codex-rs/Cargo.lock | 1 + codex-rs/core-plugins/Cargo.toml | 1 + codex-rs/core-plugins/src/store.rs | 59 ++++++++++++++++++- codex-rs/core-plugins/src/store_tests.rs | 72 +++++++++++++++++++++++- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5ced1803fa..2cbc80c513 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2636,6 +2636,7 @@ dependencies = [ "libc", "pretty_assertions", "reqwest", + "semver", "serde", "serde_json", "tar", diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 352d6e5714..83df070dd3 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -32,6 +32,7 @@ chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } reqwest = { workspace = true } +semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tar = { workspace = true } diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index fe662a142e..19c3b0116f 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -4,8 +4,10 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; +use semver::Version; use serde::Deserialize; use serde_json::Value as JsonValue; +use std::cmp::Ordering; use std::fs; use std::io; use std::path::Path; @@ -75,7 +77,7 @@ impl PluginStore { }) .filter(|version| validate_plugin_version_segment(version).is_ok()) .collect::>(); - discovered_versions.sort_unstable(); + discovered_versions.sort_unstable_by(|left, right| compare_plugin_versions(left, right)); if discovered_versions.is_empty() { None } else if discovered_versions @@ -286,6 +288,15 @@ fn replace_plugin_root_atomically( let staged_version_root = staged_root.join(plugin_version); copy_dir_recursive(source, &staged_version_root)?; + let target_version_root = target_root.join(plugin_version); + if target_root.exists() && !target_version_root.exists() { + fs::rename(&staged_version_root, &target_version_root).map_err(|err| { + PluginStoreError::io("failed to activate updated plugin cache version", err) + })?; + remove_old_plugin_versions(target_root, plugin_version)?; + return Ok(()); + } + if target_root.exists() { let backup_dir = tempfile::Builder::new() .prefix("plugin-backup-") @@ -322,6 +333,52 @@ fn replace_plugin_root_atomically( Ok(()) } +fn remove_old_plugin_versions( + target_root: &Path, + plugin_version: &str, +) -> Result<(), PluginStoreError> { + let Ok(entries) = fs::read_dir(target_root) else { + return Ok(()); + }; + + for entry in entries.filter_map(Result::ok) { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let Ok(version) = entry.file_name().into_string() else { + continue; + }; + if version == plugin_version || validate_plugin_version_segment(&version).is_err() { + continue; + } + + if fs::remove_dir_all(entry.path()).is_err() + && old_plugin_version_would_stay_active(&version, plugin_version) + { + return Err(PluginStoreError::Invalid(format!( + "failed to activate updated plugin cache version `{plugin_version}` while `{version}` remains active" + ))); + } + } + + Ok(()) +} + +fn old_plugin_version_would_stay_active(old_version: &str, new_version: &str) -> bool { + old_version == DEFAULT_PLUGIN_VERSION + || compare_plugin_versions(old_version, new_version).is_gt() +} + +fn compare_plugin_versions(left: &str, right: &str) -> Ordering { + match (Version::parse(left), Version::parse(right)) { + (Ok(left), Ok(right)) => left.cmp(&right), + _ => left.cmp(right), + } +} + fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> { fs::create_dir_all(target) .map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?; diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 0ba6b0d2c6..200055fe6c 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -247,7 +247,7 @@ fn active_plugin_version_prefers_default_local_version_when_multiple_versions_ex } #[test] -fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { +fn active_plugin_version_returns_latest_version_when_default_is_missing() { let tmp = tempdir().unwrap(); write_plugin( &tmp.path().join("plugins/cache/debug"), @@ -268,6 +268,76 @@ fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { ); } +#[test] +fn active_plugin_version_compares_semver_versions_semantically() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/9.0.0", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/10.0.0", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("10.0.0".to_string()) + ); +} + +#[test] +fn install_with_new_version_keeps_existing_plugin_root_and_prunes_old_versions() { + let tmp = tempdir().unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + write_plugin_with_version(tmp.path(), "v1", "sample-plugin", Some("1.0.0")); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("v1")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + write_plugin_with_version(tmp.path(), "v2", "sample-plugin", Some("2.0.0")); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("v2")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("2.0.0".to_string()) + ); + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/2.0.0") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin/1.0.0") + .exists() + ); +} + +#[test] +fn old_plugin_version_would_stay_active_for_local_or_later_versions() { + assert!(old_plugin_version_would_stay_active( + DEFAULT_PLUGIN_VERSION, + "1.0.0" + )); + assert!(old_plugin_version_would_stay_active("10.0.0", "9.0.0")); + assert!(!old_plugin_version_would_stay_active("1.0.0", "2.0.0")); +} + #[test] fn plugin_root_rejects_path_separators_in_key_segments() { let err = PluginId::parse("../../etc@debug").unwrap_err(); From a668379abf0f67d81a61dc971ea463c483846fd2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 21:03:51 -0700 Subject: [PATCH 3/5] [5 of 7] Replace OverrideTurnContext with ThreadSettings (#22508) **Stack position:** [5 of 7] ## Summary This PR adds `Op::ThreadSettings`, a queued settings-only update mechanism for changing stored thread settings without starting a new turn. It also removes the legacy `Op::OverrideTurnContext` in the same layer, so reviewers can see the replacement and deletion together. ## Changes - Add `Op::ThreadSettings` for settings-only queued updates. - Emit `ThreadSettingsApplied` with the effective thread settings snapshot after core applies an update. - Route settings-only updates through the same submission queue as user input. - Migrate remaining `OverrideTurnContext` tests and callers to the queued `Op::ThreadSettings` path. - Delete `Op::OverrideTurnContext` from the core protocol and submission loop. This stack addresses #20656 and #22090. ## Stack 1. [1 of 7] [Add thread settings to UserInput](https://github.com/openai/codex/pull/23080) 2. [2 of 7] [Remove UserInputWithTurnContext](https://github.com/openai/codex/pull/23081) 3. [3 of 7] [Remove UserTurn](https://github.com/openai/codex/pull/23075) 4. [4 of 7] [Placeholder for OverrideTurnContext cleanup](https://github.com/openai/codex/pull/23087) 5. [5 of 7] [Replace OverrideTurnContext with ThreadSettings](https://github.com/openai/codex/pull/22508) (this PR) 6. [6 of 7] [Add app-server thread settings API](https://github.com/openai/codex/pull/22509) 7. [7 of 7] [Sync TUI thread settings](https://github.com/openai/codex/pull/22510) --- .../thread_processor_tests.rs | 12 + .../src/request_processors/turn_processor.rs | 2 +- codex-rs/core/src/codex_thread.rs | 21 +- codex-rs/core/src/session/handlers.rs | 245 ++++++++------- codex-rs/core/src/session/mod.rs | 9 +- codex-rs/core/src/session/session.rs | 2 + codex-rs/core/src/session/tests.rs | 70 ++--- codex-rs/core/src/session/turn.rs | 1 + codex-rs/core/tests/common/lib.rs | 25 ++ .../tests/suite/collaboration_instructions.rs | 287 ++++++------------ codex-rs/core/tests/suite/compact.rs | 24 +- codex-rs/core/tests/suite/compact_remote.rs | 44 +-- .../core/tests/suite/compact_resume_fork.rs | 23 +- codex-rs/core/tests/suite/model_overrides.rs | 50 ++- codex-rs/core/tests/suite/model_switching.rs | 65 ++-- .../core/tests/suite/model_visible_layout.rs | 22 +- codex-rs/core/tests/suite/override_updates.rs | 74 ++--- .../core/tests/suite/permissions_messages.rs | 90 ++---- codex-rs/core/tests/suite/personality.rs | 88 ++---- codex-rs/core/tests/suite/prompt_caching.rs | 39 +-- codex-rs/core/tests/suite/remote_models.rs | 44 +-- codex-rs/core/tests/suite/resume.rs | 23 +- codex-rs/core/tests/suite/review.rs | 24 +- codex-rs/docs/protocol_v1.md | 2 +- codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/memories/write/src/startup_tests.rs | 22 +- codex-rs/protocol/src/protocol.rs | 110 +++---- codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + 29 files changed, 553 insertions(+), 869 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index f9841421ca..aa33fc623f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -62,6 +62,9 @@ mod thread_processor_behavior_tests { use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; 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::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; @@ -662,7 +665,16 @@ mod thread_processor_behavior_tests { profile_workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, + reasoning_summary: None, personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, session_source: SessionSource::Cli, thread_source: None, }; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d383f64d4e..71715e5079 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -479,7 +479,7 @@ impl TurnRequestProcessor { // still queued together with the input below to preserve submission order. if has_any_overrides { thread - .validate_thread_settings_overrides(CodexThreadSettingsOverrides { + .preview_thread_settings_overrides(CodexThreadSettingsOverrides { cwd: cwd.clone(), workspace_roots: runtime_workspace_roots.clone(), approval_policy, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index aff76b8c02..1b40387c3f 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -63,7 +63,9 @@ pub struct ThreadConfigSnapshot { pub profile_workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, + pub reasoning_summary: Option, pub personality: Option, + pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, pub thread_source: Option, } @@ -257,11 +259,19 @@ impl CodexThread { .await } - /// Validate persistent thread settings overrides without committing them. - pub async fn validate_thread_settings_overrides( + /// Preview persistent thread settings overrides without committing them. + pub async fn preview_thread_settings_overrides( &self, overrides: CodexThreadSettingsOverrides, - ) -> ConstraintResult<()> { + ) -> ConstraintResult { + let updates = self.thread_settings_update(overrides).await; + self.codex.session.preview_settings(&updates).await + } + + async fn thread_settings_update( + &self, + overrides: CodexThreadSettingsOverrides, + ) -> SessionSettingsUpdate { let CodexThreadSettingsOverrides { cwd, workspace_roots, @@ -289,7 +299,7 @@ impl CodexThread { .with_updates(model, effort, /*developer_instructions*/ None) }; - let updates = SessionSettingsUpdate { + SessionSettingsUpdate { cwd, workspace_roots, profile_workspace_roots, @@ -304,8 +314,7 @@ impl CodexThread { service_tier, personality, ..Default::default() - }; - self.codex.session.validate_settings(&updates).await + } } /// Use sparingly: this is intended to be removed soon. diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index e01ee28f99..3d57bd7074 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -42,7 +42,9 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::ThreadSettingsAppliedEvent; use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::protocol::ThreadSettingsSnapshot; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::WarningEvent; use codex_protocol::request_permissions::RequestPermissionsResponse; @@ -81,19 +83,6 @@ pub async fn realtime_conversation_list_voices(sess: &Session, sub_id: String) { .await; } -pub async fn override_turn_context(sess: &Session, sub_id: String, updates: SessionSettingsUpdate) { - if let Err(err) = sess.update_settings(updates).await { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: err.to_string(), - codex_error_info: Some(CodexErrorInfo::BadRequest), - }), - }) - .await; - } -} - pub async fn user_input_or_turn(sess: &Arc, sub_id: String, op: Op) { user_input_or_turn_inner( sess, @@ -104,36 +93,132 @@ pub async fn user_input_or_turn(sess: &Arc, sub_id: String, op: Op) { .await; } +pub async fn update_thread_settings( + sess: &Arc, + sub_id: String, + thread_settings: ThreadSettingsOverrides, +) { + let updates = thread_settings_update(sess, thread_settings).await; + let msg = match sess.update_settings(updates).await { + Ok(()) => thread_settings_applied_event(sess).await, + Err(err) => EventMsg::Error(ErrorEvent { + message: format!("invalid thread settings override: {err}"), + codex_error_info: Some(CodexErrorInfo::BadRequest), + }), + }; + sess.send_event_raw(Event { id: sub_id, msg }).await; +} + +async fn thread_settings_update( + sess: &Session, + thread_settings: ThreadSettingsOverrides, +) -> SessionSettingsUpdate { + let ThreadSettingsOverrides { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } = thread_settings; + let collaboration_mode = match collaboration_mode { + Some(collaboration_mode) => collaboration_mode, + None => { + let state = sess.state.lock().await; + // Model and reasoning effort live in CollaborationMode settings today, so + // partial thread-settings updates refresh those fields on the active mode. + state + .session_configuration + .collaboration_mode + .with_updates(model, effort, /*developer_instructions*/ None) + } + }; + SessionSettingsUpdate { + cwd, + workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level, + collaboration_mode: Some(collaboration_mode), + reasoning_summary: summary, + service_tier, + personality, + ..Default::default() + } +} + +async fn thread_settings_applied_event(sess: &Session) -> EventMsg { + let snapshot = { + let state = sess.state.lock().await; + state.session_configuration.thread_config_snapshot() + }; + EventMsg::ThreadSettingsApplied(ThreadSettingsAppliedEvent { + thread_settings: ThreadSettingsSnapshot { + model: snapshot.model, + model_provider_id: snapshot.model_provider_id, + service_tier: snapshot.service_tier, + approval_policy: snapshot.approval_policy, + approvals_reviewer: snapshot.approvals_reviewer, + permission_profile: snapshot.permission_profile, + active_permission_profile: snapshot.active_permission_profile, + cwd: snapshot.cwd, + reasoning_effort: snapshot.reasoning_effort, + reasoning_summary: snapshot.reasoning_summary, + personality: snapshot.personality, + collaboration_mode: snapshot.collaboration_mode, + }, + }) +} + pub(super) async fn user_input_or_turn_inner( sess: &Arc, sub_id: String, op: Op, mirror_user_text_to_realtime: Option<()>, ) { - let (items, updates, responsesapi_client_metadata) = match op { - Op::UserInput { - items, - environments, - final_output_json_schema, - responsesapi_client_metadata, - thread_settings, - } => { - let mut updates = if thread_settings == ThreadSettingsOverrides::default() { - SessionSettingsUpdate::default() - } else { - thread_settings_update(sess, thread_settings).await - }; - updates.final_output_json_schema = Some(final_output_json_schema); - updates.environments = environments; - (items, updates, responsesapi_client_metadata) - } - _ => unreachable!(), + let Op::UserInput { + items, + environments, + final_output_json_schema, + responsesapi_client_metadata, + thread_settings, + } = op + else { + unreachable!(); }; + let emit_thread_settings_applied = thread_settings != ThreadSettingsOverrides::default(); + let mut updates = if emit_thread_settings_applied { + thread_settings_update(sess, thread_settings).await + } else { + SessionSettingsUpdate::default() + }; + updates.final_output_json_schema = Some(final_output_json_schema); + updates.environments = environments; let Ok(current_context) = sess.new_turn_with_sub_id(sub_id.clone(), updates).await else { // new_turn_with_sub_id already emits the error event. return; }; + if emit_thread_settings_applied { + sess.send_event_raw(Event { + id: sub_id.clone(), + msg: thread_settings_applied_event(sess).await, + }) + .await; + } sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) .await; let accepted_items = match sess @@ -183,56 +268,6 @@ pub(super) async fn user_input_or_turn_inner( } } -async fn thread_settings_update( - sess: &Session, - thread_settings: ThreadSettingsOverrides, -) -> SessionSettingsUpdate { - let ThreadSettingsOverrides { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } = thread_settings; - let collaboration_mode = if let Some(collaboration_mode) = collaboration_mode { - collaboration_mode - } else { - let state = sess.state.lock().await; - // Model and reasoning effort live in CollaborationMode settings today, so - // partial thread-settings updates refresh those fields on the active mode. - state - .session_configuration - .collaboration_mode - .with_updates(model, effort, /*developer_instructions*/ None) - }; - SessionSettingsUpdate { - cwd, - workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level, - collaboration_mode: Some(collaboration_mode), - reasoning_summary: summary, - service_tier, - personality, - ..Default::default() - } -} - async fn mirror_user_text_to_realtime(sess: &Arc, items: &[UserInput]) { let text = UserMessageItem::new(items).message(); if text.is_empty() { @@ -729,54 +764,14 @@ pub(super) async fn submission_loop( realtime_conversation_list_voices(&sess, sub.id.clone()).await; false } - Op::OverrideTurnContext { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - windows_sandbox_level, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } => { - let collaboration_mode = if let Some(collab_mode) = collaboration_mode { - collab_mode - } else { - let state = sess.state.lock().await; - state.session_configuration.collaboration_mode.with_updates( - model.clone(), - effort, - /*developer_instructions*/ None, - ) - }; - override_turn_context( - &sess, - sub.id.clone(), - SessionSettingsUpdate { - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - windows_sandbox_level, - collaboration_mode: Some(collaboration_mode), - reasoning_summary: summary, - service_tier, - personality, - ..Default::default() - }, - ) - .await; - false - } Op::UserInput { .. } => { user_input_or_turn(&sess, sub.id.clone(), sub.op).await; false } + Op::ThreadSettings { thread_settings } => { + update_thread_settings(&sess, sub.id.clone(), thread_settings).await; + false + } Op::InterAgentCommunication { communication } => { inter_agent_communication(&sess, sub.id.clone(), communication).await; false diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 7c3b4803dd..cb848286c9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1384,12 +1384,15 @@ impl Session { Ok(()) } - pub(crate) async fn validate_settings( + pub(crate) async fn preview_settings( &self, updates: &SessionSettingsUpdate, - ) -> ConstraintResult<()> { + ) -> ConstraintResult { let state = self.state.lock().await; - state.session_configuration.apply(updates).map(|_| ()) + state + .session_configuration + .apply(updates) + .map(|configuration| configuration.thread_config_snapshot()) } pub(crate) async fn set_session_startup_prewarm( diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 082308f20d..2d96cf9165 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -178,7 +178,9 @@ impl SessionConfiguration { profile_workspace_roots: self.profile_workspace_roots().to_vec(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), + reasoning_summary: self.model_reasoning_summary, personality: self.personality, + collaboration_mode: self.collaboration_mode.clone(), session_source: self.session_source.clone(), thread_source: self.thread_source, } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 4f090a8a3a..2363f02cc6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -118,6 +118,7 @@ use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadRolledBackEvent; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -2257,24 +2258,6 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< developer_instructions: Some("Fork turn collaboration instructions.".to_string()), }, }; - forked - .thread - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; - forked .thread .submit(Op::UserInput { @@ -2285,7 +2268,11 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< }], final_output_json_schema: None, responsesapi_client_metadata: None, - thread_settings: Default::default(), + thread_settings: ThreadSettingsOverrides { + approval_policy: Some(AskForApproval::Never), + collaboration_mode: Some(collaboration_mode), + ..Default::default() + }, }) .await?; wait_for_event(&forked.thread, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; @@ -2338,7 +2325,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let turn_id = previous_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let rollout_items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted( codex_protocol::protocol::TurnStartedEvent { @@ -2521,14 +2508,14 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context let first_turn_id = first_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let mut rolled_back_context_item = first_context_item.clone(); rolled_back_context_item.turn_id = Some("rolled-back-turn".to_string()); rolled_back_context_item.model = "rolled-back-model".to_string(); let rolled_back_turn_id = rolled_back_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let turn_one_user = user_message("turn 1 user"); let turn_one_assistant = assistant_message("turn 1 assistant"); let turn_two_user = user_message("turn 2 user"); @@ -2637,7 +2624,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio let first_turn_id = first_context_item .turn_id .clone() - .expect("turn context should have turn_id"); + .expect("thread settings should have turn_id"); let compact_turn_id = "compact-turn".to_string(); let rolled_back_turn_id = "rolled-back-turn".to_string(); let compacted_history = vec![ @@ -4833,7 +4820,7 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::get_mut(&mut turn_context) - .expect("single turn context ref") + .expect("single thread settings ref") .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, @@ -4911,7 +4898,7 @@ async fn request_permissions_response_materializes_session_cwd_grants_before_rec let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::get_mut(&mut turn_context) - .expect("single turn context ref") + .expect("single thread settings ref") .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, @@ -5008,7 +4995,7 @@ async fn request_permissions_is_auto_denied_when_granular_policy_blocks_tool_req let (session, mut turn_context, rx) = make_session_and_context_with_rx().await; *session.active_turn.lock().await = Some(ActiveTurn::default()); Arc::get_mut(&mut turn_context) - .expect("single turn context ref") + .expect("single thread settings ref") .approval_policy .set(AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, @@ -5198,24 +5185,6 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() { #[test] fn op_kind_for_input_and_context_ops() { - assert_eq!( - Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - } - .kind(), - "override_turn_context" - ); assert_eq!( Op::UserInput { environments: None, @@ -5227,6 +5196,13 @@ fn op_kind_for_input_and_context_ops() { .kind(), "user_input" ); + assert_eq!( + Op::ThreadSettings { + thread_settings: ThreadSettingsOverrides::default(), + } + .kind(), + "thread_settings" + ); } #[tokio::test] @@ -6798,7 +6774,7 @@ async fn build_initial_context_adds_multi_agent_v2_subagent_usage_hint_as_develo .session_configuration .session_source = session_source.clone(); Arc::get_mut(&mut turn_context) - .expect("turn context should not be shared") + .expect("thread settings should not be shared") .session_source = session_source; let initial_context = session.build_initial_context(turn_context.as_ref()).await; @@ -7064,7 +7040,7 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o #[tokio::test] async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_builds() { let (session, turn_context, rx) = make_session_and_context_with_rx().await; - let mut turn_context = Arc::into_inner(turn_context).expect("sole turn context owner"); + let mut turn_context = Arc::into_inner(turn_context).expect("sole thread settings owner"); let mut outcome = SkillLoadOutcome::default(); outcome.skills = vec![ SkillMetadata { @@ -9890,7 +9866,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { // The rejection should not poison the non-escalated path for the same // command. Force DangerFullAccess so this check stays focused on approval // policy rather than platform-specific sandbox behavior. - let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique turn context Arc"); + let turn_context_mut = Arc::get_mut(&mut turn_context).expect("unique thread settings Arc"); turn_context_mut.permission_profile = PermissionProfile::Disabled; let file_system_sandbox_policy = turn_context.file_system_sandbox_policy(); diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9fe7f93dc3..24788d4d91 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1476,6 +1476,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::TurnComplete(_) | EventMsg::TokenCount(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index ad7858f804..194fb2ae0a 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -248,6 +248,31 @@ where wait_for_event_with_timeout(codex, predicate, Duration::from_secs(1)).await } +pub async fn submit_thread_settings( + codex: &CodexThread, + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides, +) -> anyhow::Result<()> { + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::Op; + use tokio::time::Duration; + use tokio::time::timeout; + + let submission_id = codex.submit(Op::ThreadSettings { thread_settings }).await?; + loop { + let ev = timeout(Duration::from_secs(10), codex.next_event()) + .await + .expect("timeout waiting for thread settings update") + .expect("stream ended unexpectedly"); + if ev.id == submission_id { + match ev.msg { + EventMsg::ThreadSettingsApplied(_) => return Ok(()), + EventMsg::Error(err) => panic!("thread settings update failed: {}", err.message), + other => panic!("unexpected thread settings update event: {other:?}"), + } + } + } +} + pub async fn wait_for_event_match(codex: &CodexThread, matcher: F) -> T where F: Fn(&codex_protocol::protocol::EventMsg) -> Option, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index bd01dc4e7d..bf8de53dd1 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -123,22 +123,14 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu let collab_text = "collab instructions"; let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -277,22 +269,14 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re let collab_text = "override instructions"; let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -333,22 +317,14 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu let turn_text = "turn override"; let turn_mode = collab_mode_with_instructions(Some(turn_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(base_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -405,22 +381,14 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> let first_text = "first instructions"; let second_text = "second instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(first_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -436,22 +404,14 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(second_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -496,22 +456,14 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { let test = test_codex().build(&server).await?; let collab_text = "same instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -527,22 +479,14 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -586,25 +530,17 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang let default_text = "default mode instructions"; let plan_text = "plan mode instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(default_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -620,25 +556,17 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Plan, Some(plan_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -683,25 +611,17 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() let test = test_codex().build(&server).await?; let collab_text = "mode-stable instructions"; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -717,25 +637,17 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_mode_and_instructions( ModeKind::Default, Some(collab_text), )), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -785,23 +697,14 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { let home = initial.home.clone(); let collab_text = "resume instructions"; - initial - .codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &initial.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collab_mode_with_instructions(Some(collab_text))), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; initial .codex @@ -856,18 +759,9 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { let test = test_codex().build(&server).await?; let current_model = test.session_configured.model.clone(); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -876,9 +770,10 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { developer_instructions: Some("".to_string()), }, }), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 2c789a271d..b07adfc61c 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -3279,23 +3279,15 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess .expect("submit user input"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; } - codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .expect("override turn context"); + ..Default::default() + }, + ) + .await + .expect("override thread settings"); let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" .to_string(); codex diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 9db94016e6..9aac846ffe 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -2773,22 +2773,14 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us for user in ["USER_ONE", "USER_TWO", "USER_THREE"] { if user == "USER_THREE" { - codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(PathBuf::from(PRETURN_CONTEXT_DIFF_CWD)), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; } codex .submit(Op::UserInput { @@ -2891,22 +2883,14 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(next_model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; codex .submit(Op::UserInput { environments: None, diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 20ae213b26..b2b02f5e66 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -508,7 +508,7 @@ async fn snapshot_rollback_past_compaction_replays_append_only_history() -> Resu } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -/// Scenario: rolling back a turn that introduced persistent thread settings +/// Scenario: rolling back a turn that introduced persistent pre-thread settings /// diffs should trim those context updates so the next request includes them /// only once. async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { @@ -548,18 +548,10 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD); std::fs::create_dir_all(&override_cwd)?; - conversation - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &conversation, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(override_cwd.to_path_buf()), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, collaboration_mode: Some(CollaborationMode { mode: ModeKind::Default, settings: Settings { @@ -568,9 +560,10 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { developer_instructions: Some(ROLLED_BACK_DEV_INSTRUCTIONS.to_string()), }, }), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; user_turn(&conversation, TURN_TWO_USER).await; diff --git a/codex-rs/core/tests/suite/model_overrides.rs b/codex-rs/core/tests/suite/model_overrides.rs index 9f8835f19f..f70c0ae1c2 100644 --- a/codex-rs/core/tests/suite/model_overrides.rs +++ b/codex-rs/core/tests/suite/model_overrides.rs @@ -9,7 +9,7 @@ use pretty_assertions::assert_eq; const CONFIG_TOML: &str = "config.toml"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_does_not_persist_when_config_exists() { +async fn thread_settings_update_does_not_persist_when_config_exists() { let server = start_mock_server().await; let initial_contents = "model = \"gpt-4o\"\n"; let mut builder = test_codex() @@ -24,23 +24,16 @@ async fn override_turn_context_does_not_persist_when_config_exists() { let codex = test.codex.clone(); let config_path = test.home.path().join(CONFIG_TOML); - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::High)), - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .expect("submit override"); + ..Default::default() + }, + ) + .await + .expect("submit override"); codex.submit(Op::Shutdown).await.expect("request shutdown"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -52,7 +45,7 @@ async fn override_turn_context_does_not_persist_when_config_exists() { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_does_not_create_config_file() { +async fn thread_settings_update_does_not_create_config_file() { let server = start_mock_server().await; let mut builder = test_codex(); let test = builder.build(&server).await.expect("create conversation"); @@ -63,23 +56,16 @@ async fn override_turn_context_does_not_create_config_file() { "test setup should start without config" ); - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some("o3".to_string()), effort: Some(Some(ReasoningEffort::Medium)), - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .expect("submit override"); + ..Default::default() + }, + ) + .await + .expect("submit override"); codex.submit(Op::Shutdown).await.expect("request shutdown"); wait_for_event(&codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 1c9358a8a3..009dfda60f 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -161,22 +161,14 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<( .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(next_model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_user_turn( @@ -241,22 +233,15 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(next_model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, personality: Some(Personality::Pragmatic), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_user_turn( @@ -988,22 +973,14 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< ); wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(smaller_model_slug.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_user_turn( diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index 9cf83424c9..17bc0f6703 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -501,23 +501,15 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - let resumed = resume_builder.resume(&server, home, rollout_path).await?; let resume_override_cwd = resumed.cwd_path().join(PRETURN_CONTEXT_DIFF_CWD); fs::create_dir_all(&resume_override_cwd)?; - resumed - .codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &resumed.codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(resume_override_cwd), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, model: Some("gpt-5.2".to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; resumed .codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index d0e949947a..ce7c87a93b 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -24,7 +24,7 @@ fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMod } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_permissions_update() -> Result<()> +async fn thread_settings_update_without_user_turn_does_not_record_permissions_update() -> Result<()> { skip_if_no_network!(Ok(())); @@ -34,22 +34,14 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd }); let test = builder.build(&server).await?; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -64,7 +56,7 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_environment_update() -> Result<()> +async fn thread_settings_update_without_user_turn_does_not_record_environment_update() -> Result<()> { skip_if_no_network!(Ok(())); @@ -72,22 +64,14 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd let test = test_codex().build(&server).await?; let new_cwd = TempDir::new()?; - test.codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(new_cwd.path().to_path_buf()), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; @@ -102,8 +86,8 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_collaboration_update() -> Result<()> -{ +async fn thread_settings_update_without_user_turn_does_not_record_collaboration_update() +-> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -111,22 +95,14 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u let collab_text = "override collaboration instructions"; let collaboration_mode = collab_mode_with_instructions(Some(collab_text)); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex.submit(Op::Shutdown).await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 33d494ae45..b77e65c0f5 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -103,22 +103,14 @@ async fn permissions_message_added_on_override_change() -> Result<()> { .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -240,22 +232,14 @@ async fn permissions_message_omitted_when_disabled() -> Result<()> { .await?; wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(Op::UserInput { @@ -330,23 +314,14 @@ async fn resume_replays_permissions_messages() -> Result<()> { .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - initial - .codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &initial.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; initial .codex @@ -439,23 +414,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { .await?; wait_for_event(&initial.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - initial - .codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &initial.codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; initial .codex diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index 8924ca2a25..bbe8178f8d 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -333,22 +333,14 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()> wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Friendly), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( @@ -417,22 +409,14 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Pragmatic), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( @@ -514,22 +498,14 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()> wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Pragmatic), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( @@ -763,22 +739,14 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { personality: Some(Personality::Friendly), - }) - .await?; + ..Default::default() + }, + ) + .await?; test.codex .submit(read_only_text_turn( diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index accf3fb789..68b3816ae6 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -442,22 +442,18 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let sandbox_policy = permission_profile .to_legacy_sandbox_policy(config.cwd.as_path()) .expect("workspace profile should have legacy projection"); - codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, sandbox_policy: Some(sandbox_policy), permission_profile: Some(permission_profile), - windows_sandbox_level: None, - model: None, effort: Some(Some(ReasoningEffort::High)), summary: Some(ReasoningSummary::Detailed), - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; // Second turn after overrides codex @@ -493,7 +489,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an }); let expected_permissions_msg = body1["input"][0].clone(); let body1_input = body1["input"].as_array().expect("input array"); - // After overriding thread settings, emit one updated permissions message. + // After overriding the thread settings, emit one updated permissions message. let expected_permissions_msg_2 = body2["input"][body1_input.len()].clone(); assert_ne!( expected_permissions_msg_2, expected_permissions_msg, @@ -529,22 +525,17 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul }, }; - codex - .submit(Op::OverrideTurnContext { - cwd: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { approval_policy: Some(AskForApproval::Never), - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, model: Some("gpt-5.4".to_string()), effort: Some(Some(ReasoningEffort::Low)), - summary: None, - service_tier: None, collaboration_mode: Some(collaboration_mode), - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index adc73d8a79..e143cb8e3e 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -534,22 +534,14 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { .await; assert_eq!(model_info.shell_type, ConfigShellToolType::UnifiedExec); - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(REMOTE_MODEL_SLUG.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; let call_id = "call"; let args = json!({ @@ -783,22 +775,14 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> { let models_manager = thread_manager.get_models_manager(); wait_for_model_available(&models_manager, model).await; - codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some(model.to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; let cwd_path = cwd.path().to_path_buf(); let (sandbox_policy, permission_profile) = diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index db64289354..fc7a23215f 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -423,23 +423,14 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu config.model = Some("gpt-5.3-codex".to_string()); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; - resumed - .codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, + core_test_support::submit_thread_settings( + &resumed.codex, + codex_protocol::protocol::ThreadSettingsOverrides { model: Some("gpt-5.4".to_string()), - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; resumed .codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index e128a189b5..05ec967d09 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -789,23 +789,15 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { }) .await; - codex - .submit(Op::OverrideTurnContext { + core_test_support::submit_thread_settings( + &codex, + codex_protocol::protocol::ThreadSettingsOverrides { cwd: Some(repo_path.to_path_buf()), - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await - .unwrap(); + ..Default::default() + }, + ) + .await + .unwrap(); codex .submit(Op::Review { diff --git a/codex-rs/docs/protocol_v1.md b/codex-rs/docs/protocol_v1.md index d18aa669ca..464e8187b4 100644 --- a/codex-rs/docs/protocol_v1.md +++ b/codex-rs/docs/protocol_v1.md @@ -70,7 +70,7 @@ For complete documentation of the `Op` and `EventMsg` variants, refer to [protoc - `Op::Interrupt` – Interrupts a running turn - `Op::ExecApproval` – Approve or deny code execution - `Op::UserInputAnswer` – Provide answers for a `request_user_input` tool call - - `Op::UserTurn` and `Op::OverrideTurnContext` accept an optional `personality` override that updates the model’s communication style + - `Op::UserInput` accepts an optional `personality` turn-context override that updates the model’s communication style Valid `personality` values are `friendly`, `pragmatic`, and `none`. When `none` is selected, the personality placeholder is replaced with an empty string. diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 1898bebeeb..167d56da5b 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -333,6 +333,7 @@ async fn run_codex_tool_session_inner( } EventMsg::AgentReasoningRawContent(_) | EventMsg::TurnStarted(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) | EventMsg::AgentReasoning(_) | EventMsg::AgentReasoningSectionBreak(_) diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 4fcb1d409b..30240f04ca 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -242,22 +242,14 @@ async fn memories_startup_phase1_uses_live_thread_service_tier() -> anyhow::Resu let test = build_test_codex(&server, home).await?; assert_eq!(test.config.service_tier, None); - test.codex - .submit(Op::OverrideTurnContext { - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permission_profile: None, - windows_sandbox_level: None, - model: None, - effort: None, - summary: None, + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { service_tier: Some(Some(ServiceTier::Fast.request_value().to_string())), - collaboration_mode: None, - personality: None, - }) - .await?; + ..Default::default() + }, + ) + .await?; let config_snapshot = wait_for_service_tier(&test, Some(ServiceTier::Fast.request_value().to_string())).await?; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index cec929b07f..d5bf200386 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -396,7 +396,8 @@ pub struct ConversationTextParams { pub text: String, } -/// Persistent thread-settings overrides that can be applied before user input. +/// Persistent thread-settings overrides that can be applied before user input or +/// on their own. #[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, JsonSchema)] pub struct ThreadSettingsOverrides { /// Updated `cwd` for sandbox/tool calls. @@ -518,76 +519,22 @@ pub enum Op { thread_settings: ThreadSettingsOverrides, }, + /// Apply persistent thread-settings overrides without starting a turn. + /// + /// This uses the same submission queue as turn starts so app-server can + /// preserve caller order between both kinds of mutation. + ThreadSettings { + /// Persistent thread-settings overrides to apply. + #[serde(flatten)] + thread_settings: ThreadSettingsOverrides, + }, + /// Inter-agent communication that should be recorded as assistant history /// while still using the normal thread submission lifecycle. InterAgentCommunication { communication: InterAgentCommunication, }, - /// Override parts of the persistent thread settings for subsequent turns. - /// - /// All fields are optional; when omitted, the existing value is preserved. - /// This does not enqueue any input – it only updates defaults used for - /// turns that rely on persistent session-level settings (for example, - /// [`Op::UserInput`]). - OverrideTurnContext { - /// Updated `cwd` for sandbox/tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - cwd: Option, - - /// Updated command approval policy. - #[serde(skip_serializing_if = "Option::is_none")] - approval_policy: Option, - - /// Updated approval reviewer for future approval prompts. - #[serde(skip_serializing_if = "Option::is_none")] - approvals_reviewer: Option, - - /// Updated sandbox policy for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - sandbox_policy: Option, - - /// Updated permissions profile for tool calls. - #[serde(skip_serializing_if = "Option::is_none")] - permission_profile: Option, - - /// Updated Windows sandbox mode for tool execution. - #[serde(skip_serializing_if = "Option::is_none")] - windows_sandbox_level: Option, - - /// Updated model slug. When set, the model info is derived - /// automatically. - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - - /// Updated reasoning effort (honored only for reasoning-capable models). - /// - /// Use `Some(Some(_))` to set a specific effort, `Some(None)` to clear - /// the effort, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - effort: Option>, - - /// Updated reasoning summary preference (honored only for reasoning-capable models). - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - - /// Updated service tier preference for future turns. - /// - /// Use `Some(Some(_))` to set a specific tier, `Some(None)` to clear the - /// preference, or `None` to leave the existing value unchanged. - #[serde(skip_serializing_if = "Option::is_none")] - service_tier: Option>, - - /// EXPERIMENTAL - set a pre-set collaboration mode. - /// Takes precedence over model, effort, and developer instructions if set. - #[serde(skip_serializing_if = "Option::is_none")] - collaboration_mode: Option, - - /// Updated personality preference. - #[serde(skip_serializing_if = "Option::is_none")] - personality: Option, - }, - /// Approve a command execution ExecApproval { /// The id of the submission we are approving @@ -775,8 +722,8 @@ impl Op { Self::RealtimeConversationClose => "realtime_conversation_close", Self::RealtimeConversationListVoices => "realtime_conversation_list_voices", Self::UserInput { .. } => "user_input", + Self::ThreadSettings { .. } => "thread_settings", Self::InterAgentCommunication { .. } => "inter_agent_communication", - Self::OverrideTurnContext { .. } => "override_turn_context", Self::ExecApproval { .. } => "exec_approval", Self::PatchApproval { .. } => "patch_approval", Self::ResolveElicitation { .. } => "resolve_elicitation", @@ -1227,6 +1174,10 @@ pub enum EventMsg { #[serde(rename = "task_started", alias = "turn_started")] TurnStarted(TurnStartedEvent), + /// Persistent thread-settings overrides from the correlated submission have + /// been applied to the session configuration. + ThreadSettingsApplied(ThreadSettingsAppliedEvent), + /// Agent has completed all actions. /// v1 wire format uses `task_complete`; accept `turn_complete` for v2 interop. #[serde(rename = "task_complete", alias = "turn_complete")] @@ -1907,6 +1858,33 @@ pub struct TurnStartedEvent { pub collaboration_mode_kind: ModeKind, } +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ThreadSettingsAppliedEvent { + pub thread_settings: ThreadSettingsSnapshot, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +pub struct ThreadSettingsSnapshot { + pub model: String, + pub model_provider_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub permission_profile: PermissionProfile, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub active_permission_profile: Option, + pub cwd: AbsolutePathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub personality: Option, + pub collaboration_mode: CollaborationMode, +} + #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] pub struct TokenUsage { #[ts(type = "number")] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 1e49c82be2..a862af116c 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -228,6 +228,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::ContextCompacted(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::TokenCount(_) | EventMsg::AgentMessage(_) | EventMsg::UserMessage(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index ceb617763f..b6dbba98f8 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -183,6 +183,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) + | EventMsg::ThreadSettingsApplied(_) | EventMsg::McpToolCallBegin(_) | EventMsg::ExecCommandBegin(_) | EventMsg::TerminalInteraction(_) From b6b22ad1b3138bb26c6e415ceefc56f40a412175 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 21:11:15 -0700 Subject: [PATCH 4/5] [6 of 7] Add app-server thread settings API --- codex-rs/app-server-client/src/lib.rs | 1 + .../schema/json/ServerNotification.json | 507 +++++++++++++ .../codex_app_server_protocol.schemas.json | 244 ++++++ .../codex_app_server_protocol.v2.schemas.json | 244 ++++++ .../v2/ThreadSettingsUpdatedNotification.json | 703 ++++++++++++++++++ .../schema/typescript/ServerNotification.ts | 3 +- .../v2/ManagedFileSystemPermissions.ts | 6 + .../typescript/v2/NetworkSandboxPolicy.ts | 5 + .../schema/typescript/v2/PermissionProfile.ts | 7 + .../schema/typescript/v2/ThreadSettings.ts | 15 + .../v2/ThreadSettingsUpdatedNotification.ts | 6 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 125 ++++ .../src/protocol/v2/permissions.rs | 117 +++ .../src/protocol/v2/tests.rs | 33 + .../src/protocol/v2/thread.rs | 97 +++ codex-rs/app-server/README.md | 2 + codex-rs/app-server/src/in_process.rs | 5 +- codex-rs/app-server/src/message_processor.rs | 5 + codex-rs/app-server/src/request_processors.rs | 6 + .../src/request_processors/turn_processor.rs | 660 ++++++++++++---- codex-rs/app-server/src/thread_state.rs | 47 ++ .../app-server/tests/common/mcp_process.rs | 10 + .../suite/v2/connection_handling_websocket.rs | 125 +++- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/thread_settings.rs | 505 +++++++++++++ .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 28 files changed, 3332 insertions(+), 156 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts create mode 100644 codex-rs/app-server/tests/suite/v2/thread_settings.rs diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 49a7a3b800..d4553c639f 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -176,6 +176,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific matches!( notification, ServerNotification::TurnCompleted(_) + | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ItemCompleted(_) | ServerNotification::AgentMessageDelta(_) | ServerNotification::PlanDelta(_) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 5d4af3d936..de65efab78 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -64,6 +64,26 @@ }, "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -415,6 +435,65 @@ ], "type": "object" }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -658,6 +737,22 @@ ], "type": "string" }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, "CommandAction": { "oneOf": [ { @@ -2083,6 +2178,57 @@ ], "type": "object" }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, "McpServerOauthLoginCompletedNotification": { "properties": { "error": { @@ -2257,6 +2403,14 @@ } ] }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, "ModelRerouteReason": { "enum": [ "highRiskCyberActivity" @@ -2318,6 +2472,13 @@ ], "type": "object" }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", @@ -2327,6 +2488,13 @@ ], "type": "string" }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, "NonSteerableTurnKind": { "enum": [ "review", @@ -2401,6 +2569,78 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, "PlanDeltaNotification": { "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", "properties": { @@ -2654,6 +2894,26 @@ ], "type": "string" }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, "ReasoningSummaryPartAddedNotification": { "properties": { "itemId": { @@ -2806,6 +3066,105 @@ }, "type": "object" }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, "ServerRequestResolvedNotification": { "properties": { "requestId": { @@ -2861,6 +3220,34 @@ } ] }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, "SkillsChangedNotification": { "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", "type": "object" @@ -4147,6 +4534,106 @@ ], "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "permissionProfile": { + "$ref": "#/definitions/PermissionProfile" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "permissionProfile", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "type": "object" + }, "ThreadSource": { "enum": [ "user", @@ -4948,6 +5435,26 @@ "title": "Thread/status/changedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index fdaaf3e857..bf4f974238 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -3891,6 +3891,26 @@ "title": "Thread/status/changedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -10441,6 +10461,57 @@ "title": "LogoutAccountResponse", "type": "object" }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/v2/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { @@ -11552,6 +11623,13 @@ }, "type": "object" }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, "NetworkUnixSocketPermission": { "enum": [ "allow", @@ -11650,6 +11728,70 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/v2/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/v2/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/v2/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -17059,6 +17201,108 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/v2/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/v2/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "permissionProfile": { + "$ref": "#/definitions/v2/PermissionProfile" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/v2/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/v2/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "permissionProfile", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/v2/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e413d79234..9f518a38a0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -6990,6 +6990,57 @@ "title": "LogoutAccountResponse", "type": "object" }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, "ManagedHooksRequirements": { "properties": { "PermissionRequest": { @@ -8101,6 +8152,13 @@ }, "type": "object" }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, "NetworkUnixSocketPermission": { "enum": [ "allow", @@ -8199,6 +8257,70 @@ } ] }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, "Personality": { "enum": [ "none", @@ -11092,6 +11214,26 @@ "title": "Thread/status/changedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -14883,6 +15025,108 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "permissionProfile": { + "$ref": "#/definitions/PermissionProfile" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "permissionProfile", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json new file mode 100644 index 0000000000..3dd3e2612d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -0,0 +1,703 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "FileSystemAccessMode": { + "enum": [ + "read", + "write", + "none" + ], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "path" + ], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": [ + "glob_pattern" + ], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": [ + "pattern", + "type" + ], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "special" + ], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": [ + "type", + "value" + ], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": [ + "access", + "path" + ], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": [ + "root" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "minimal" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "project_roots" + ], + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind" + ], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "tmpdir" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "slash_tmp" + ], + "type": "string" + } + }, + "required": [ + "kind" + ], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": [ + "unknown" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "kind", + "path" + ], + "type": "object" + } + ] + }, + "ManagedFileSystemPermissions": { + "oneOf": [ + { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": "array" + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": [ + "integer", + "null" + ] + }, + "type": { + "enum": [ + "restricted" + ], + "title": "RestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "entries", + "type" + ], + "title": "RestrictedManagedFileSystemPermissions", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "unrestricted" + ], + "title": "UnrestrictedManagedFileSystemPermissionsType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "UnrestrictedManagedFileSystemPermissions", + "type": "object" + } + ] + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "NetworkSandboxPolicy": { + "enum": [ + "enabled", + "restricted" + ], + "type": "string" + }, + "PermissionProfile": { + "oneOf": [ + { + "properties": { + "fileSystem": { + "$ref": "#/definitions/ManagedFileSystemPermissions" + }, + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "managed" + ], + "title": "ManagedPermissionProfileType", + "type": "string" + } + }, + "required": [ + "fileSystem", + "network", + "type" + ], + "title": "ManagedPermissionProfile", + "type": "object" + }, + { + "properties": { + "type": { + "enum": [ + "disabled" + ], + "title": "DisabledPermissionProfileType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DisabledPermissionProfile", + "type": "object" + }, + { + "properties": { + "network": { + "$ref": "#/definitions/NetworkSandboxPolicy" + }, + "type": { + "enum": [ + "external" + ], + "title": "ExternalPermissionProfileType", + "type": "string" + } + }, + "required": [ + "network", + "type" + ], + "title": "ExternalPermissionProfile", + "type": "object" + } + ] + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "permissionProfile": { + "$ref": "#/definitions/PermissionProfile" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "permissionProfile", + "sandboxPolicy" + ], + "type": "object" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index f4dd0e1864..89d5aebe5c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -54,6 +54,7 @@ import type { ThreadRealtimeSdpNotification } from "./v2/ThreadRealtimeSdpNotifi import type { ThreadRealtimeStartedNotification } from "./v2/ThreadRealtimeStartedNotification"; import type { ThreadRealtimeTranscriptDeltaNotification } from "./v2/ThreadRealtimeTranscriptDeltaNotification"; import type { ThreadRealtimeTranscriptDoneNotification } from "./v2/ThreadRealtimeTranscriptDoneNotification"; +import type { ThreadSettingsUpdatedNotification } from "./v2/ThreadSettingsUpdatedNotification"; import type { ThreadStartedNotification } from "./v2/ThreadStartedNotification"; import type { ThreadStatusChangedNotification } from "./v2/ThreadStatusChangedNotification"; import type { ThreadTokenUsageUpdatedNotification } from "./v2/ThreadTokenUsageUpdatedNotification"; @@ -69,4 +70,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts new file mode 100644 index 0000000000..72a1153902 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedFileSystemPermissions.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; + +export type ManagedFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts new file mode 100644 index 0000000000..9549972b35 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkSandboxPolicy.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkSandboxPolicy = "enabled" | "restricted"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts new file mode 100644 index 0000000000..f9ffff3285 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ManagedFileSystemPermissions } from "./ManagedFileSystemPermissions"; +import type { NetworkSandboxPolicy } from "./NetworkSandboxPolicy"; + +export type PermissionProfile = { "type": "managed", fileSystem: ManagedFileSystemPermissions, network: NetworkSandboxPolicy, } | { "type": "disabled" } | { "type": "external", network: NetworkSandboxPolicy, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts new file mode 100644 index 0000000000..1f106f192f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -0,0 +1,15 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { CollaborationMode } from "../CollaborationMode"; +import type { Personality } from "../Personality"; +import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ActivePermissionProfile } from "./ActivePermissionProfile"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { PermissionProfile } from "./PermissionProfile"; +import type { SandboxPolicy } from "./SandboxPolicy"; + +export type ThreadSettings = { model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, permissionProfile: PermissionProfile, activePermissionProfile: ActivePermissionProfile | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, personality: Personality | null, collaborationMode: CollaborationMode, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts new file mode 100644 index 0000000000..964811ca69 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadSettings } from "./ThreadSettings"; + +export type ThreadSettingsUpdatedNotification = { threadId: string, threadSettings: ThreadSettings, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index dc400a6f8e..055d4330c2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -173,6 +173,7 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse" export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; +export type { ManagedFileSystemPermissions } from "./ManagedFileSystemPermissions"; export type { ManagedHooksRequirements } from "./ManagedHooksRequirements"; export type { MarketplaceAddParams } from "./MarketplaceAddParams"; export type { MarketplaceAddResponse } from "./MarketplaceAddResponse"; @@ -249,12 +250,14 @@ export type { NetworkDomainPermission } from "./NetworkDomainPermission"; export type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction"; export type { NetworkRequirements } from "./NetworkRequirements"; +export type { NetworkSandboxPolicy } from "./NetworkSandboxPolicy"; export type { NetworkUnixSocketPermission } from "./NetworkUnixSocketPermission"; export type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; @@ -393,6 +396,8 @@ export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; +export type { ThreadSettings } from "./ThreadSettings"; +export type { ThreadSettingsUpdatedNotification } from "./ThreadSettingsUpdatedNotification"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7a7fe5642c..d7ed1f2c9a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -738,6 +738,13 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::TurnStartResponse, }, + #[experimental("thread/settings/update")] + ThreadSettingsUpdate => "thread/settings/update" { + params: v2::ThreadSettingsUpdateParams, + inspect_params: true, + serialization: thread_id(params.thread_id), + response: v2::ThreadSettingsUpdateResponse, + }, TurnSteer => "turn/steer" { params: v2::TurnSteerParams, inspect_params: true, @@ -1456,6 +1463,8 @@ server_notification_definitions! { Error => "error" (v2::ErrorNotification), ThreadStarted => "thread/started" (v2::ThreadStartedNotification), ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification), + #[experimental("thread/settings/updated")] + ThreadSettingsUpdated => "thread/settings/updated" (v2::ThreadSettingsUpdatedNotification), ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification), ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification), ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), @@ -1555,6 +1564,9 @@ mod tests { use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; @@ -1577,6 +1589,31 @@ mod tests { test_path_buf(&path).abs() } + fn sample_thread_settings(cwd: AbsolutePathBuf) -> v2::ThreadSettings { + v2::ThreadSettings { + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd, + approval_policy: v2::AskForApproval::OnFailure, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox_policy: v2::SandboxPolicy::DangerFullAccess, + permission_profile: v2::PermissionProfile::Disabled, + active_permission_profile: None, + effort: None, + summary: None, + personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + } + } + fn request_id() -> RequestId { const REQUEST_ID: i64 = 1; RequestId::Integer(REQUEST_ID) @@ -1614,6 +1651,20 @@ mod tests { }) ); + let thread_settings_update = ClientRequest::ThreadSettingsUpdate { + request_id: request_id(), + params: v2::ThreadSettingsUpdateParams { + thread_id: thread_id.clone(), + ..Default::default() + }, + }; + assert_eq!( + thread_settings_update.serialization_scope(), + Some(ClientRequestSerializationScope::Thread { + thread_id: thread_id.clone() + }) + ); + let thread_fork = ClientRequest::ThreadFork { request_id: request_id(), params: v2::ThreadForkParams { @@ -2287,6 +2338,40 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_settings_update_request() -> Result<()> { + let request = ClientRequest::ThreadSettingsUpdate { + request_id: RequestId::Integer(5), + params: v2::ThreadSettingsUpdateParams { + thread_id: "thread-1".to_string(), + model: Some("gpt-5.2".to_string()), + service_tier: Some(None), + ..Default::default() + }, + }; + assert_eq!( + json!({ + "method": "thread/settings/update", + "id": 5, + "params": { + "threadId": "thread-1", + "cwd": null, + "approvalPolicy": null, + "approvalsReviewer": null, + "sandboxPolicy": null, + "permissions": null, + "model": "gpt-5.2", + "serviceTier": null, + "summary": null, + "personality": null, + "collaborationMode": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); @@ -2378,6 +2463,46 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_settings_response_and_notification() -> Result<()> { + let cwd = absolute_path("/tmp"); + let thread_settings = sample_thread_settings(cwd); + let thread_settings_json = serde_json::to_value(&thread_settings)?; + let response = ClientResponse::ThreadSettingsUpdate { + request_id: RequestId::Integer(11), + response: v2::ThreadSettingsUpdateResponse { + thread_settings: thread_settings.clone(), + }, + }; + let notification = + ServerNotification::ThreadSettingsUpdated(v2::ThreadSettingsUpdatedNotification { + thread_id: "thread-1".to_string(), + thread_settings, + }); + + assert_eq!( + json!({ + "method": "thread/settings/update", + "id": 11, + "response": { + "threadSettings": thread_settings_json + } + }), + serde_json::to_value(&response)?, + ); + assert_eq!( + json!({ + "method": "thread/settings/updated", + "params": { + "threadId": "thread-1", + "threadSettings": thread_settings_json + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + #[test] fn serialize_config_requirements_read() -> Result<()> { let request = ClientRequest::ConfigRequirementsRead { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index f00bcfaefb..aebd1051c7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -7,11 +7,14 @@ use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleA use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -182,6 +185,13 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum NetworkSandboxPolicy from CoreNetworkSandboxPolicy { + Enabled, + Restricted + } +); + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "kind", rename_all = "snake_case")] #[ts(tag = "kind")] @@ -287,6 +297,113 @@ impl From for CoreFileSystemSandboxEntry { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ManagedFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for ManagedFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: ManagedFileSystemPermissions) -> Self { + match value { + ManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + file_system: ManagedFileSystemPermissions, + network: NetworkSandboxPolicy, + }, + Disabled, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: NetworkSandboxPolicy, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.to_core(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.to_core(), + }, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index b445fc4d01..ede9adf013 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3546,6 +3546,39 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn thread_settings_update_params_support_partial_updates_and_explicit_nulls() { + let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "model": "gpt-5.2", + "serviceTier": null, + "effort": null + })) + .expect("params should deserialize"); + assert_eq!(params.thread_id, "thread_123"); + assert_eq!(params.model.as_deref(), Some("gpt-5.2")); + assert_eq!(params.service_tier, Some(None)); + assert_eq!(params.effort, Some(None)); + assert_eq!(params.cwd, None); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + assert_eq!(serialized.get("effort"), Some(&serde_json::Value::Null)); + assert_eq!(serialized.get("cwd"), Some(&serde_json::Value::Null)); + + let without_overrides = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + ..Default::default() + }; + let serialized_without_overrides = + serde_json::to_value(&without_overrides).expect("params should serialize"); + assert_eq!(serialized_without_overrides.get("serviceTier"), None); + assert_eq!(serialized_without_overrides.get("effort"), None); +} + #[test] fn turn_start_params_round_trip_environments() { let cwd = test_absolute_path(); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 41994e59b6..571663253f 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,6 +1,7 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; +use super::PermissionProfile; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; @@ -11,7 +12,9 @@ use super::TurnEnvironmentParams; use super::TurnItemsView; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -170,6 +173,92 @@ pub struct ThreadStartParams { pub persist_extended_history: bool, } +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateParams { + pub thread_id: String, + /// Override the working directory for subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile for subsequent turns. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("thread/settings/update.permissions")] + #[schemars(with = "Option")] + #[ts(type = "string | null")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for subsequent turns. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for subsequent turns. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub effort: Option>, + /// Override the reasoning summary for subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// Override the personality for subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, + /// Set a pre-set collaboration mode for subsequent turns. + #[experimental("thread/settings/update.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettings { + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + pub permission_profile: PermissionProfile, + pub active_permission_profile: Option, + pub effort: Option, + pub summary: Option, + pub personality: Option, + pub collaboration_mode: CollaborationMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateResponse { + pub thread_settings: ThreadSettings, +} + #[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -1139,6 +1228,14 @@ pub struct ThreadStatusChangedNotification { pub status: ThreadStatus, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdatedNotification { + pub thread_id: String, + pub thread_settings: ThreadSettings, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 9073282b4e..4cc1b4fa87 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -157,6 +157,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `thread/settings/update` — update the stored defaults used by subsequent turns without starting a turn (experimental; requires `capabilities.experimentalApi`). Omitted fields leave the current value unchanged; fields with explicit clearing support, such as `serviceTier`, accept `null` to clear the value. The response is `{ "threadSettings": ... }` with the full effective state, and `thread/settings/updated` is emitted only when that state changes. `turn/start` emits the same notification when its thread-settings overrides change the stored defaults. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -1211,6 +1212,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. +- `thread/settings/updated` — `{ threadId, threadSettings }` whenever the effective thread settings change. `threadSettings` includes the full effective state: model/provider, service tier, cwd, approval policy, approvals reviewer, sandbox compatibility projection, permission profile, active permission profile, reasoning effort/summary, personality, and collaboration mode. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index c75c2d5ad1..f7072d0fa1 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -102,7 +102,10 @@ pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY; type PendingClientRequestResponse = std::result::Result; fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { - matches!(notification, ServerNotification::TurnCompleted(_)) + matches!( + notification, + ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_) + ) } /// Input needed to start an in-process app-server runtime. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4e2c6f38cc..24176e7328 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1166,6 +1166,11 @@ impl MessageProcessor { ) .await } + ClientRequest::ThreadSettingsUpdate { params, .. } => { + self.turn_processor + .thread_settings_update(&request_id, params) + .await + } ClientRequest::ThreadInjectItems { params, .. } => { self.turn_processor.thread_inject_items(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 305e864ba0..a09a88bbc6 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -213,6 +213,10 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSortKey; @@ -380,6 +384,8 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; #[cfg(test)] use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadSettingsOverrides; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::USER_MESSAGE_BEGIN; use codex_protocol::protocol::W3cTraceContext; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 71715e5079..50cefaf4f0 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::openai_models::ReasoningEffort; #[derive(Clone)] pub(crate) struct TurnRequestProcessor { @@ -16,6 +17,62 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } +struct ThreadSettingsOverrideRequest { + cwd: Option, + runtime_workspace_roots: Option>, + approval_policy: Option, + approvals_reviewer: Option, + sandbox_policy: Option, + permissions: Option, + model: Option, + service_tier: Option>, + effort: Option>, + summary: Option, + collaboration_mode: Option, + personality: Option, +} + +impl ThreadSettingsOverrideRequest { + fn has_any_overrides(&self) -> bool { + self.cwd.is_some() + || self.runtime_workspace_roots.is_some() + || self.approval_policy.is_some() + || self.approvals_reviewer.is_some() + || self.sandbox_policy.is_some() + || self.permissions.is_some() + || self.model.is_some() + || self.service_tier.is_some() + || self.effort.is_some() + || self.summary.is_some() + || self.collaboration_mode.is_some() + || self.personality.is_some() + } +} + +fn op_thread_settings_overrides( + overrides: CodexThreadSettingsOverrides, +) -> ThreadSettingsOverrides { + ThreadSettingsOverrides { + cwd: overrides.cwd, + workspace_roots: overrides.workspace_roots, + profile_workspace_roots: overrides.profile_workspace_roots, + approval_policy: overrides.approval_policy, + approvals_reviewer: overrides.approvals_reviewer, + sandbox_policy: overrides.sandbox_policy, + permission_profile: overrides.permission_profile, + active_permission_profile: overrides.active_permission_profile, + windows_sandbox_level: overrides.windows_sandbox_level, + model: overrides.model, + effort: overrides.effort, + summary: overrides.summary, + service_tier: overrides.service_tier, + collaboration_mode: overrides.collaboration_mode, + personality: overrides.personality, + } +} + +const THREAD_SETTINGS_ACK_TIMEOUT: Duration = Duration::from_secs(5); + fn resolve_runtime_workspace_roots( workspace_roots: Vec, base_cwd: &AbsolutePathBuf, @@ -30,6 +87,35 @@ fn resolve_runtime_workspace_roots( resolved_roots } +fn effective_workspace_roots( + base_snapshot: &ThreadConfigSnapshot, + effective_cwd: &AbsolutePathBuf, + runtime_workspace_roots: Option<&[AbsolutePathBuf]>, +) -> Vec { + if let Some(workspace_roots) = runtime_workspace_roots { + return workspace_roots.to_vec(); + } + + if effective_cwd != &base_snapshot.cwd + && base_snapshot.workspace_roots.contains(&base_snapshot.cwd) + { + let mut retargeted_roots = Vec::with_capacity(base_snapshot.workspace_roots.len()); + for root in &base_snapshot.workspace_roots { + let root = if root == &base_snapshot.cwd { + effective_cwd.clone() + } else { + root.clone() + }; + if !retargeted_roots.contains(&root) { + retargeted_roots.push(root); + } + } + retargeted_roots + } else { + base_snapshot.workspace_roots.clone() + } +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -88,6 +174,16 @@ impl TurnRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_settings_update( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_settings_update_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn turn_steer( &self, request_id: &ConnectionRequestId, @@ -326,6 +422,175 @@ impl TurnRequestProcessor { Ok(()) } + async fn resolve_thread_settings_overrides( + &self, + base_snapshot: &ThreadConfigSnapshot, + request: ThreadSettingsOverrideRequest, + ) -> Result, JSONRPCErrorError> { + // Both turn/start and thread/settings/update accept the same + // persistent thread-settings fields. Resolve them once into the core + // override shape so validation and permission-profile expansion stay + // consistent between the two entry points. + if request.sandbox_policy.is_some() && request.permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandboxPolicy`", + )); + } + + if !request.has_any_overrides() { + return Ok(None); + } + + let requested_cwd = request.cwd; + let effective_cwd = requested_cwd + .as_ref() + .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, base_snapshot.cwd.as_path())) + .unwrap_or_else(|| base_snapshot.cwd.clone()); + let cwd = requested_cwd.map(|_| effective_cwd.to_path_buf()); + let runtime_workspace_roots = request.runtime_workspace_roots.map(|workspace_roots| { + resolve_runtime_workspace_roots(workspace_roots, &effective_cwd) + }); + let effective_workspace_roots = effective_workspace_roots( + base_snapshot, + &effective_cwd, + runtime_workspace_roots.as_deref(), + ); + let approval_policy = request.approval_policy.map(AskForApproval::to_core); + let approvals_reviewer = request + .approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = request.sandbox_policy.map(|p| p.to_core()); + let (permission_profile, active_permission_profile, profile_workspace_roots) = + if let Some(permissions) = request.permissions { + let overrides = ConfigOverrides { + cwd: cwd.clone(), + workspace_roots: Some( + effective_workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect(), + ), + default_permissions: Some(permissions), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(base_snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit thread settings + // update is different: reject it before accepting the request. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid thread settings override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile().clone()), + config.permissions.active_permission_profile(), + Some(config.permissions.profile_workspace_roots().to_vec()), + ) + } else { + (None, None, None) + }; + + // None means the caller sent no settings fields at all. Some means at + // least one explicit override was present, even if the effective value + // matches the current thread settings. + Ok(Some(CodexThreadSettingsOverrides { + cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model: request.model, + effort: request.effort, + summary: request.summary, + service_tier: request.service_tier, + collaboration_mode: request + .collaboration_mode + .map(|mode| self.normalize_turn_start_collaboration_mode(mode)), + personality: request.personality, + })) + } + + async fn maybe_emit_thread_settings_updated( + &self, + thread_id: ThreadId, + api_thread_id: &str, + before: &ThreadSettings, + after: ThreadSettings, + ) { + if before == &after { + return; + } + + let connection_ids = self + .thread_state_manager + .subscribed_connection_ids(thread_id) + .await; + if connection_ids.is_empty() { + return; + } + + self.outgoing + .send_server_notification_to_connections( + &connection_ids, + ServerNotification::ThreadSettingsUpdated(ThreadSettingsUpdatedNotification { + thread_id: api_thread_id.to_string(), + thread_settings: after, + }), + ) + .await; + } + + async fn wait_for_pending_thread_settings( + &self, + thread_id: ThreadId, + ) -> Result<(), JSONRPCErrorError> { + let pending = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let mut thread_state = thread_state.lock().await; + thread_state.track_current_pending_thread_settings() + }; + + for thread_settings_applied in pending { + match tokio::time::timeout(THREAD_SETTINGS_ACK_TIMEOUT, thread_settings_applied).await { + Ok(Ok(Ok(_))) => {} + Ok(Ok(Err(err))) => { + return Err(internal_error(format!( + "failed to apply pending thread settings override: {err}" + ))); + } + Ok(Err(_)) => { + return Err(internal_error( + "pending thread settings override waiter was cancelled".to_string(), + )); + } + Err(_) => { + return Err(internal_error( + "timed out waiting for pending thread settings overrides to apply" + .to_string(), + )); + } + } + } + Ok(()) + } + async fn turn_start_inner( &self, request_id: ConnectionRequestId, @@ -357,9 +622,26 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let collaboration_mode = params - .collaboration_mode - .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); + let thread_settings_request = ThreadSettingsOverrideRequest { + cwd: params.cwd, + runtime_workspace_roots: params.runtime_workspace_roots, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort.map(Some), + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }; + if thread_settings_request.has_any_overrides() { + self.wait_for_pending_thread_settings(thread_id).await?; + } + + let before_snapshot = thread.config_snapshot().await; + let before_thread_settings = thread_settings_from_snapshot(&before_snapshot); let environment_selections = self.parse_environment_selections(params.environments)?; // Map v2 input items to core input items. @@ -369,156 +651,24 @@ impl TurnRequestProcessor { .map(V2UserInput::into_core) .collect(); let turn_has_input = !mapped_items.is_empty(); - let runtime_workspace_roots_request = params.runtime_workspace_roots.clone(); - let snapshot = if params.permissions.is_some() || runtime_workspace_roots_request.is_some() - { - Some(thread.config_snapshot().await) - } else { - None - }; - - let has_any_overrides = params.cwd.is_some() - || runtime_workspace_roots_request.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() - || collaboration_mode.is_some() - || params.personality.is_some(); - - if params.sandbox_policy.is_some() && params.permissions.is_some() { - return Err(invalid_request( - "`permissions` cannot be combined with `sandboxPolicy`", - )); - } - - let cwd = params.cwd; - let runtime_workspace_roots = if let Some(workspace_roots) = - runtime_workspace_roots_request.clone() - { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error( - "turn/start runtime workspace roots missing thread snapshot", - )); - }; - let base_cwd = cwd - .as_ref() - .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) - .unwrap_or_else(|| snapshot.cwd.clone()); - Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) - } else { - None - }; - let approval_policy = params.approval_policy.map(AskForApproval::to_core); - let approvals_reviewer = params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core); - let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile, profile_workspace_roots) = - if let Some(permissions) = params.permissions { - let Some(snapshot) = snapshot.as_ref() else { - return Err(internal_error( - "turn/start permission selection missing thread snapshot", - )); - }; - let overrides = ConfigOverrides { - cwd: cwd.clone(), - workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( - || { - snapshot - .workspace_roots - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect() - }, - )), - default_permissions: Some(permissions), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }; - let config = self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - overrides, - Some(snapshot.cwd.to_path_buf()), - ) - .await - .map_err(|err| config_load_error(&err))?; - // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. - if let Some(warning) = config.startup_warnings.iter().find(|warning| { - warning.contains("Configured value for `permission_profile` is disallowed") - }) { - return Err(invalid_request(format!( - "invalid thread settings override: {warning}" - ))); - } - ( - Some(config.permissions.permission_profile().clone()), - config.permissions.active_permission_profile(), - Some(config.permissions.profile_workspace_roots().to_vec()), - ) - } else { - (None, None, None) - }; - let model = params.model; - let effort = params.effort.map(Some); - let summary = params.summary; - let service_tier = params.service_tier; - let personality = params.personality; - - // If any overrides are provided, validate them synchronously so the - // request can fail before accepting user input. The actual update is - // still queued together with the input below to preserve submission order. - if has_any_overrides { + let resolved_overrides = self + .resolve_thread_settings_overrides(&before_snapshot, thread_settings_request) + .await?; + let has_thread_settings_overrides = resolved_overrides.is_some(); + if let Some(overrides) = resolved_overrides.as_ref() { + // Validate before accepting input so the request can fail without + // queuing a user turn. thread - .preview_thread_settings_overrides(CodexThreadSettingsOverrides { - cwd: cwd.clone(), - workspace_roots: runtime_workspace_roots.clone(), - approval_policy, - approvals_reviewer, - sandbox_policy: sandbox_policy.clone(), - permission_profile: permission_profile.clone(), - active_permission_profile: active_permission_profile.clone(), - profile_workspace_roots: profile_workspace_roots.clone(), - windows_sandbox_level: None, - model: model.clone(), - effort, - summary, - service_tier: service_tier.clone(), - collaboration_mode: collaboration_mode.clone(), - personality, - }) + .preview_thread_settings_overrides(overrides.clone()) .await .map_err(|err| { invalid_request(format!("invalid thread settings override: {err}")) })?; } - let thread_settings = codex_protocol::protocol::ThreadSettingsOverrides { - cwd, - workspace_roots: runtime_workspace_roots, - profile_workspace_roots, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level: None, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - }; + let thread_settings = resolved_overrides + .map(op_thread_settings_overrides) + .unwrap_or_default(); // Start the turn by submitting the user input. Return its submission id as turn_id. let turn_op = Op::UserInput { @@ -528,14 +678,71 @@ impl TurnRequestProcessor { responsesapi_client_metadata: params.responsesapi_client_metadata, thread_settings, }; - let turn_id = self - .submit_core_op(&request_id, thread.as_ref(), turn_op) + let turn_id = Uuid::now_v7().to_string(); + let pending_thread_settings = if has_thread_settings_overrides { + let (thread_settings_applied, notification_lock) = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let mut thread_state = thread_state.lock().await; + thread_state.track_pending_thread_settings(turn_id.clone()) + }; + Some(( + thread_settings_applied, + notification_lock.lock_owned().await, + )) + } else { + None + }; + if let Err(err) = thread + .submit_with_id(Submission { + id: turn_id.clone(), + op: turn_op, + trace: self.request_trace_context(&request_id).await, + }) .await - .map_err(|err| { - let error = internal_error(format!("failed to start turn: {err}")); - self.track_error_response(&request_id, &error, /*error_type*/ None); - error - })?; + { + if has_thread_settings_overrides { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let mut thread_state = thread_state.lock().await; + thread_state.cancel_pending_thread_settings(&turn_id); + } + let error = internal_error(format!("failed to start turn: {err}")); + self.track_error_response(&request_id, &error, /*error_type*/ None); + return Err(error); + } + + if let Some((thread_settings_applied, thread_settings_notification_guard)) = + pending_thread_settings + { + let processor = self.clone(); + let api_thread_id = params.thread_id.clone(); + let tracked_turn_id = turn_id.clone(); + tokio::spawn(async move { + let _thread_settings_notification_guard = thread_settings_notification_guard; + match thread_settings_applied.await { + Ok(Ok(payload)) => { + let after_thread_settings = thread_settings_from_applied_event(&payload); + processor + .maybe_emit_thread_settings_updated( + thread_id, + &api_thread_id, + &before_thread_settings, + after_thread_settings, + ) + .await; + } + Ok(Err(err)) => { + tracing::warn!( + "failed to apply thread settings overrides for turn {tracked_turn_id}: {err}" + ); + } + Err(_) => { + tracing::warn!( + "thread settings override acknowledgement was cancelled for turn {tracked_turn_id}" + ); + } + } + }); + } if turn_has_input { let config_snapshot = thread.config_snapshot().await; @@ -566,6 +773,104 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn thread_settings_update_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result { + let (thread_id, thread) = + self.load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(request_id, error, /*error_type*/ None); + })?; + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + super::thread_lifecycle::ensure_listener_task_running( + self.listener_task_context(), + thread_id, + thread.clone(), + thread_state.clone(), + ) + .await?; + self.wait_for_pending_thread_settings(thread_id).await?; + let before_snapshot = thread.config_snapshot().await; + let before_thread_settings = thread_settings_from_snapshot(&before_snapshot); + let resolved_overrides = self + .resolve_thread_settings_overrides( + &before_snapshot, + ThreadSettingsOverrideRequest { + cwd: params.cwd, + runtime_workspace_roots: None, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; + + let after_thread_settings = if let Some(overrides) = resolved_overrides { + thread + .preview_thread_settings_overrides(overrides.clone()) + .await + .map_err(|err| { + invalid_request(format!("invalid thread settings override: {err}")) + })?; + let update_id = Uuid::now_v7().to_string(); + let (thread_settings_applied, notification_lock) = { + let mut thread_state = thread_state.lock().await; + thread_state.track_pending_thread_settings(update_id.clone()) + }; + let thread_settings_notification_guard = notification_lock.lock_owned().await; + if let Err(err) = thread + .submit_with_id(Submission { + id: update_id.clone(), + op: Op::ThreadSettings { + thread_settings: op_thread_settings_overrides(overrides), + }, + trace: self.request_trace_context(request_id).await, + }) + .await + { + let mut thread_state = thread_state.lock().await; + thread_state.cancel_pending_thread_settings(&update_id); + return Err(internal_error(format!( + "failed to update thread settings: {err}" + ))); + } + let after_thread_settings = match thread_settings_applied.await { + Ok(Ok(payload)) => thread_settings_from_applied_event(&payload), + Ok(Err(err)) => return Err(invalid_request(err)), + Err(_) => { + return Err(internal_error( + "thread settings override waiter was cancelled".to_string(), + )); + } + }; + self.maybe_emit_thread_settings_updated( + thread_id, + ¶ms.thread_id, + &before_thread_settings, + after_thread_settings.clone(), + ) + .await; + drop(thread_settings_notification_guard); + after_thread_settings + } else { + before_thread_settings.clone() + }; + + Ok(ThreadSettingsUpdateResponse { + thread_settings: after_thread_settings, + }) + } + async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, @@ -1160,6 +1465,55 @@ impl TurnRequestProcessor { } } +fn thread_settings_from_snapshot(config_snapshot: &ThreadConfigSnapshot) -> ThreadSettings { + ThreadSettings { + model: config_snapshot.model.clone(), + model_provider: config_snapshot.model_provider_id.clone(), + service_tier: config_snapshot.service_tier.clone(), + cwd: config_snapshot.cwd.clone(), + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox_policy: thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ), + permission_profile: config_snapshot.permission_profile.clone().into(), + active_permission_profile: thread_response_active_permission_profile( + config_snapshot.active_permission_profile.clone(), + ), + effort: config_snapshot.reasoning_effort, + summary: config_snapshot.reasoning_summary, + personality: config_snapshot.personality, + collaboration_mode: config_snapshot.collaboration_mode.clone(), + } +} + +fn thread_settings_from_applied_event( + event: &codex_protocol::protocol::ThreadSettingsAppliedEvent, +) -> ThreadSettings { + let thread_settings = &event.thread_settings; + ThreadSettings { + model: thread_settings.model.clone(), + model_provider: thread_settings.model_provider_id.clone(), + service_tier: thread_settings.service_tier.clone(), + cwd: thread_settings.cwd.clone(), + approval_policy: thread_settings.approval_policy.into(), + approvals_reviewer: thread_settings.approvals_reviewer.into(), + sandbox_policy: thread_response_sandbox_policy( + &thread_settings.permission_profile, + thread_settings.cwd.as_path(), + ), + permission_profile: thread_settings.permission_profile.clone().into(), + active_permission_profile: thread_response_active_permission_profile( + thread_settings.active_permission_profile.clone(), + ), + effort: thread_settings.reasoning_effort, + summary: thread_settings.reasoning_summary, + personality: thread_settings.personality, + collaboration_mode: thread_settings.collaboration_mode.clone(), + } +} + fn xcode_26_4_mcp_elicitations_auto_deny( client_name: Option<&str>, client_version: Option<&str>, diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 32dfcc325d..f4fb7a0bb4 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -11,6 +11,7 @@ use codex_file_watcher::WatchRegistration; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::ThreadSettingsAppliedEvent; use codex_rollout::state_db::StateDbHandle; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -24,6 +25,7 @@ use tokio::sync::watch; use tracing::error; type PendingInterruptQueue = Vec; +type ThreadSettingsAck = Result; pub(crate) struct PendingThreadResumeRequest { pub(crate) request_id: ConnectionRequestId, @@ -78,6 +80,8 @@ pub(crate) struct ThreadState { pub(crate) listener_generation: u64, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, + pending_thread_settings_waiters: HashMap>>, + thread_settings_notification_lock: Arc>, listener_thread: Option>, watch_registration: WatchRegistration, } @@ -112,6 +116,7 @@ impl ThreadState { let _ = cancel_tx.send(()); } self.listener_command_tx = None; + self.pending_thread_settings_waiters.clear(); self.current_turn_history.reset(); self.listener_thread = None; self.watch_registration = WatchRegistration::default(); @@ -131,11 +136,45 @@ impl ThreadState { self.current_turn_history.active_turn_snapshot() } + pub(crate) fn track_pending_thread_settings( + &mut self, + submission_id: String, + ) -> (oneshot::Receiver, Arc>) { + let (tx, rx) = oneshot::channel(); + self.pending_thread_settings_waiters + .entry(submission_id) + .or_default() + .push(tx); + (rx, Arc::clone(&self.thread_settings_notification_lock)) + } + + pub(crate) fn track_current_pending_thread_settings( + &mut self, + ) -> Vec> { + let mut receivers = Vec::with_capacity(self.pending_thread_settings_waiters.len()); + for waiters in self.pending_thread_settings_waiters.values_mut() { + let (tx, rx) = oneshot::channel(); + waiters.push(tx); + receivers.push(rx); + } + receivers + } + + pub(crate) fn cancel_pending_thread_settings(&mut self, submission_id: &str) { + self.pending_thread_settings_waiters.remove(submission_id); + } + pub(crate) fn track_current_turn_event(&mut self, event_turn_id: &str, event: &EventMsg) { if let EventMsg::TurnStarted(payload) = event { self.turn_summary.started_at = payload.started_at; } self.current_turn_history.handle_event(event); + if let EventMsg::ThreadSettingsApplied(payload) = event { + self.notify_thread_settings_applied(event_turn_id, Ok(payload.clone())); + } + if let EventMsg::Error(error) = event { + self.notify_thread_settings_applied(event_turn_id, Err(error.message.clone())); + } if matches!(event, EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)) && !self.current_turn_history.has_active_turn() { @@ -143,6 +182,14 @@ impl ThreadState { self.current_turn_history.reset(); } } + + fn notify_thread_settings_applied(&mut self, submission_id: &str, result: ThreadSettingsAck) { + if let Some(waiters) = self.pending_thread_settings_waiters.remove(submission_id) { + for waiter in waiters { + let _ = waiter.send(result.clone()); + } + } + } } pub(crate) async fn resolve_server_request_on_thread_listener( diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3445010a7f..b8b31a7d0b 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -88,6 +88,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadTurnsItemsListParams; @@ -488,6 +489,15 @@ impl McpProcess { self.send_request("thread/rollback", params).await } + /// Send a `thread/settings/update` JSON-RPC request. + pub async fn send_thread_settings_update_request( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/settings/update", params).await + } + /// Send a `thread/list` JSON-RPC request. pub async fn send_thread_list_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index cf76b9d573..67e67e30a3 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -7,6 +7,7 @@ use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -14,8 +15,10 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use futures::SinkExt; @@ -104,6 +107,62 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Ok(()) } +#[tokio::test] +async fn websocket_thread_settings_updates_stay_on_subscribed_connections() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + + let mut ws1 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(bind_addr).await?; + + send_initialize_experimental_request(&mut ws1, /*id*/ 1, "ws_context_owner").await?; + read_response_for_id(&mut ws1, /*id*/ 1).await?; + send_initialize_experimental_request(&mut ws2, /*id*/ 2, "ws_context_peer").await?; + read_response_for_id(&mut ws2, /*id*/ 2).await?; + + let thread_id = start_thread(&mut ws1, /*id*/ 3).await?; + send_request( + &mut ws1, + "thread/settings/update", + /*id*/ 4, + Some(serde_json::to_value(ThreadSettingsUpdateParams { + thread_id: thread_id.clone(), + model: Some("mock-model-updated".to_string()), + ..Default::default() + })?), + ) + .await?; + + let (_response, caller_notification) = read_response_and_notification_for_method( + &mut ws1, + /*id*/ 4, + "thread/settings/updated", + ) + .await?; + + let ServerNotification::ThreadSettingsUpdated(caller) = + ServerNotification::try_from(caller_notification)? + else { + bail!("expected caller thread/settings/updated notification"); + }; + assert_eq!(caller.thread_id, thread_id); + assert_no_notification_for_method( + &mut ws2, + "thread/settings/updated", + Duration::from_millis(250), + ) + .await?; + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + #[tokio::test] async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; @@ -591,6 +650,32 @@ pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, client_name: &str, +) -> Result<()> { + send_initialize_request_with_capabilities(stream, id, client_name, /*capabilities*/ None).await +} + +async fn send_initialize_experimental_request( + stream: &mut WsClient, + id: i64, + client_name: &str, +) -> Result<()> { + send_initialize_request_with_capabilities( + stream, + id, + client_name, + Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + ) + .await +} + +async fn send_initialize_request_with_capabilities( + stream: &mut WsClient, + id: i64, + client_name: &str, + capabilities: Option, ) -> Result<()> { let params = InitializeParams { client_info: ClientInfo { @@ -598,7 +683,7 @@ pub(super) async fn send_initialize_request( title: Some("WebSocket Test Client".to_string()), version: "0.1.0".to_string(), }, - capabilities: None, + capabilities, }; send_request( stream, @@ -823,6 +908,44 @@ pub(super) async fn assert_no_message(stream: &mut WsClient, wait_for: Duration) } } +async fn assert_no_notification_for_method( + stream: &mut WsClient, + method: &str, + wait_for: Duration, +) -> Result<()> { + let deadline = Instant::now() + wait_for; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Ok(()); + } + + let frame = match timeout(remaining, stream.next()).await { + Ok(Some(Ok(frame))) => frame, + Ok(Some(Err(err))) => bail!("unexpected websocket read error: {err}"), + Ok(None) => bail!("websocket closed unexpectedly while waiting for notification"), + Err(_) => return Ok(()), + }; + + match frame { + WebSocketMessage::Text(text) => { + let message: JSONRPCMessage = serde_json::from_str(text.as_ref())?; + if let JSONRPCMessage::Notification(notification) = message + && notification.method == method + { + bail!("unexpected notification for method `{method}`"); + } + } + WebSocketMessage::Ping(payload) => { + stream.send(WebSocketMessage::Pong(payload)).await?; + } + WebSocketMessage::Pong(_) | WebSocketMessage::Frame(_) => {} + WebSocketMessage::Close(frame) => bail!("websocket closed unexpectedly: {frame:?}"), + WebSocketMessage::Binary(_) => bail!("unexpected binary websocket frame"), + } + } +} + pub(super) fn create_config_toml( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdbe7b7ddd..1a8441c240 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -57,6 +57,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_settings; mod thread_shell_command; mod thread_start; mod thread_status; diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings.rs b/codex-rs/app-server/tests/suite/v2/thread_settings.rs new file mode 100644 index 0000000000..f76a13d451 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_settings.rs @@ -0,0 +1,505 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionProfile; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; +use codex_app_server_protocol::ThreadSource; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_features::Feature; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +fn write_config(codex_home: &TempDir, server_uri: &str) -> Result<()> { + write_mock_responses_config_toml( + codex_home.path(), + server_uri, + &BTreeMap::::new(), + /*auto_compact_limit*/ 1_000_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + Ok(()) +} + +async fn start_thread(mcp: &mut McpProcess) -> Result { + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + service_tier: Some(Some("flex".to_string())), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + read_response(mcp, request_id).await +} + +async fn send_thread_settings_update( + mcp: &mut McpProcess, + params: ThreadSettingsUpdateParams, +) -> Result { + let request_id = mcp.send_thread_settings_update_request(params).await?; + read_response(mcp, request_id).await +} + +async fn read_response( + mcp: &mut McpProcess, + request_id: i64, +) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +fn text_input(text: &str) -> V2UserInput { + V2UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + } +} + +async fn wait_for_turn_completed(mcp: &mut McpProcess) -> Result<()> { + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +async fn read_thread_settings_updated( + mcp: &mut McpProcess, +) -> Result { + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadSettingsUpdated(notification) = notification else { + anyhow::bail!("expected thread/settings/updated notification"); + }; + Ok(notification) +} + +fn assert_permission_profile_write_root( + permission_profile: &PermissionProfile, + expected_root: &AbsolutePathBuf, + unexpected_root: &AbsolutePathBuf, +) { + let permission_profile: CorePermissionProfile = permission_profile.clone().into(); + let sandbox_policy = permission_profile.file_system_sandbox_policy(); + assert!( + sandbox_policy.entries.iter().any(|entry| { + entry.access == FileSystemAccessMode::Write + && matches!(&entry.path, FileSystemPath::Path { path } if path == expected_root) + }), + "expected permission profile write entries to contain {expected_root:?}; got {:?}", + sandbox_policy.entries + ); + assert!( + !sandbox_policy.entries.iter().any(|entry| { + entry.access == FileSystemAccessMode::Write + && matches!(&entry.path, FileSystemPath::Path { path } if path == unexpected_root) + }), + "did not expect permission profile write entries to contain {unexpected_root:?}; got {:?}", + sandbox_policy.entries + ); +} + +#[tokio::test] +async fn thread_settings_update_applies_partial_patch_and_emits_full_state() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let response = send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some("gpt-5.2".to_string()), + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }, + ) + .await?; + + assert_eq!(response.thread_settings.model, "gpt-5.2"); + assert_eq!( + response.thread_settings.service_tier.as_deref(), + Some("flex") + ); + assert_eq!(response.thread_settings.effort, Some(ReasoningEffort::High)); + assert_eq!(response.thread_settings.cwd, thread.cwd); + + let notification = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(notification.thread_id, thread.id); + assert_eq!(notification.thread_settings, response.thread_settings); + + mcp.clear_message_buffer(); + let no_op_response = send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id, + model: Some("gpt-5.2".to_string()), + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }, + ) + .await?; + assert_eq!(no_op_response.thread_settings, response.thread_settings); + assert!( + !mcp.pending_notification_methods() + .iter() + .any(|method| method == "thread/settings/updated") + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_absolutizes_relative_cwd_before_permissions() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + let next_cwd = std::path::PathBuf::from("next-cwd"); + let next_cwd_abs = thread.cwd.join(&next_cwd); + std::fs::create_dir_all(next_cwd_abs.as_path())?; + + let response = send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id, + cwd: Some(next_cwd), + permissions: Some(":workspace".to_string()), + ..Default::default() + }, + ) + .await?; + + assert_eq!(response.thread_settings.cwd, next_cwd_abs); + assert_permission_profile_write_root( + &response.thread_settings.permission_profile, + &next_cwd_abs, + &thread.cwd, + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_clears_service_tier_with_explicit_null() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let response = send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id, + service_tier: Some(None), + ..Default::default() + }, + ) + .await?; + + assert_eq!(response.thread_settings.service_tier, None); + let notification = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(notification.thread_settings.service_tier, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permissions: Some(":read-only".to_string()), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert!( + err.error + .message + .contains("`permissions` cannot be combined with `sandboxPolicy`"), + "unexpected error message: {}", + err.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_waits_for_pending_cwd_before_permissions() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("Done")?, + ]) + .await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + let next_cwd = TempDir::new()?; + let next_cwd_abs = AbsolutePathBuf::try_from(next_cwd.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![text_input("Hello")], + cwd: Some(next_cwd.path().to_path_buf()), + ..Default::default() + }) + .await?; + let update_request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + permissions: Some(":workspace".to_string()), + ..Default::default() + }) + .await?; + + let _: TurnStartResponse = read_response(&mut mcp, turn_request_id).await?; + let update_response = + read_response::(&mut mcp, update_request_id).await?; + + assert_eq!(update_response.thread_settings.cwd, next_cwd_abs); + assert_permission_profile_write_root( + &update_response.thread_settings.permission_profile, + &next_cwd_abs, + &thread.cwd, + ); + + wait_for_turn_completed(&mut mcp).await?; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_emits_thread_settings_updated_when_overrides_change_defaults() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("Done")?, + ]) + .await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![text_input("Hello")], + model: Some("gpt-5.2".to_string()), + effort: Some(ReasoningEffort::Low), + ..Default::default() + }) + .await?; + let _: TurnStartResponse = read_response(&mut mcp, request_id).await?; + + let notification = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(notification.thread_id, thread.id); + assert_eq!(notification.thread_settings.model, "gpt-5.2"); + assert_eq!( + notification.thread_settings.effort, + Some(ReasoningEffort::Low) + ); + assert_eq!( + notification.thread_settings.service_tier.as_deref(), + Some("flex") + ); + + wait_for_turn_completed(&mut mcp).await?; + + Ok(()) +} + +async fn assert_newer_update_survives_turn_start( + turn_start_overrides: TurnStartParams, +) -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("Done")?, + ]) + .await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![text_input("Hello")], + ..turn_start_overrides + }) + .await?; + let update_request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some("gpt-5.4".to_string()), + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }) + .await?; + + let _: TurnStartResponse = read_response(&mut mcp, turn_request_id).await?; + let update_response = + read_response::(&mut mcp, update_request_id).await?; + assert_eq!(update_response.thread_settings.model, "gpt-5.4"); + assert_eq!( + update_response.thread_settings.effort, + Some(ReasoningEffort::High) + ); + + wait_for_turn_completed(&mut mcp).await?; + + mcp.clear_message_buffer(); + let read_current_response = send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id, + ..Default::default() + }, + ) + .await?; + assert_eq!( + read_current_response.thread_settings, + update_response.thread_settings + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_after_turn_start_preserves_newer_update() -> Result<()> { + assert_newer_update_survives_turn_start(TurnStartParams { + model: Some("gpt-5.2".to_string()), + effort: Some(ReasoningEffort::Low), + ..Default::default() + }) + .await +} + +#[tokio::test] +async fn queued_updates_keep_each_thread_settings_notification_snapshot() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("Done")?, + ]) + .await; + let codex_home = TempDir::new()?; + write_config(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let ThreadStartResponse { thread, .. } = start_thread(&mut mcp).await?; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![text_input("Hello")], + model: Some("gpt-5.2".to_string()), + effort: Some(ReasoningEffort::Low), + ..Default::default() + }) + .await?; + let update_request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + model: Some("gpt-5.4".to_string()), + effort: Some(Some(ReasoningEffort::High)), + ..Default::default() + }) + .await?; + + let _: TurnStartResponse = read_response(&mut mcp, turn_request_id).await?; + let _: ThreadSettingsUpdateResponse = read_response(&mut mcp, update_request_id).await?; + + let notifications = [ + read_thread_settings_updated(&mut mcp).await?, + read_thread_settings_updated(&mut mcp).await?, + ]; + assert!(notifications.iter().any(|notification| { + notification.thread_settings.model == "gpt-5.2" + && notification.thread_settings.effort == Some(ReasoningEffort::Low) + })); + assert!(notifications.iter().any(|notification| { + notification.thread_settings.model == "gpt-5.4" + && notification.thread_settings.effort == Some(ReasoningEffort::High) + })); + + wait_for_turn_completed(&mut mcp).await?; + + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_after_no_op_turn_start_override_preserves_newer_update() +-> Result<()> { + assert_newer_update_survives_turn_start(TurnStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await +} 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 d535bf8e3d..b99354b0ea 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -47,6 +47,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ThreadStatusChanged(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ThreadSettingsUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()), ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()), ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()), diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index f0e3efea0e..171b039150 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -217,6 +217,7 @@ impl ChatWidget { | ServerNotification::AccountRateLimitsUpdated(_) | ServerNotification::ThreadStarted(_) | ServerNotification::ThreadStatusChanged(_) + | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ThreadArchived(_) | ServerNotification::ThreadUnarchived(_) | ServerNotification::RawResponseItemCompleted(_) From 62df4d9f9e2bb56f19082d58773bb5d2adcbb70f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 18 May 2026 21:46:46 -0700 Subject: [PATCH 5/5] Wait for pending settings before turn start --- codex-rs/app-server/src/request_processors/turn_processor.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 50cefaf4f0..4ad481f994 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -636,9 +636,7 @@ impl TurnRequestProcessor { collaboration_mode: params.collaboration_mode, personality: params.personality, }; - if thread_settings_request.has_any_overrides() { - self.wait_for_pending_thread_settings(thread_id).await?; - } + self.wait_for_pending_thread_settings(thread_id).await?; let before_snapshot = thread.config_snapshot().await; let before_thread_settings = thread_settings_from_snapshot(&before_snapshot);