diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 017b9d4d48a1..6d496070a338 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ 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 a1f69125ae2d..120bf66ff9ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -544,6 +544,15 @@ impl From for TurnsPage { pub struct ThreadForkParams { pub thread_id: String, + /// Start from the loaded parent's effective config, tools, environments, and + /// persisted history to maximize prompt-cache reuse. Requires a loaded, + /// persisted parent and `ephemeral: true`. Enables `features.reasoning_effort_override` + /// on the fork. Cannot be combined with configuration overrides, `path`, `lastTurnId`, + /// or `beforeTurnId`. + #[experimental("thread/fork.experimentalPredictionMode")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub experimental_prediction_mode: bool, + /// Optional last turn id to fork through, inclusive. /// /// When specified, turns after `last_turn_id` are omitted from the fork. diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 9bb4b7f6f521..123a52e9ca46 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -4890,8 +4890,34 @@ impl ThreadRequestProcessor { app_server_client_version: Option, client_mcp_extensions: ClientMcpExtensions, ) -> Result<(), JSONRPCErrorError> { + let has_config_overrides = params.model.is_some() + || params.model_provider.is_some() + || params.service_tier.is_some() + || params.cwd.is_some() + || params.runtime_workspace_roots.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox.is_some() + || params.permissions.is_some() + || params.base_instructions.is_some() + || params.developer_instructions.is_some() + || params + .config + .as_ref() + .is_some_and(|config| !config.is_empty()); + if params.experimental_prediction_mode && has_config_overrides { + return Err(invalid_request( + "`experimentalPredictionMode` cannot be combined with configuration overrides", + )); + } + if params.experimental_prediction_mode && !params.ephemeral { + return Err(invalid_request( + "`experimentalPredictionMode` requires `ephemeral: true`", + )); + } let ThreadForkParams { thread_id, + experimental_prediction_mode, last_turn_id, before_turn_id, path, @@ -4918,6 +4944,13 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } + if experimental_prediction_mode + && (path.is_some() || last_turn_id.is_some() || before_turn_id.is_some()) + { + return Err(invalid_request( + "`experimentalPredictionMode` requires a thread id without a path or turn cutoff", + )); + } let source_thread = self .read_stored_thread_for_resume( &thread_id, @@ -4925,6 +4958,31 @@ impl ThreadRequestProcessor { /*include_history*/ false, ) .await?; + let inherited_fork = if experimental_prediction_mode { + // Keep inherited state and its preparation off the fork handler's stack. + let (mut options, settings) = Box::pin(async { + self.thread_manager + .fork_options_from_parent(source_thread.thread_id) + .await + .map(|(options, settings)| (Box::new(options), Box::new(settings))) + }) + .await + .map_err(|err| match err.details() { + CodexErrorDetails::ThreadNotFound(_) => { + invalid_request("`experimentalPredictionMode` requires a loaded parent") + } + _ => internal_error(format!("failed to inherit parent state: {err}")), + })?; + options.config.ephemeral = ephemeral; + options + .config + .features + .enable(Feature::ReasoningEffortOverride) + .map_err(|err| invalid_request(err.to_string()))?; + Some((options, settings)) + } else { + None + }; let paginated_source = matches!(source_thread.history_mode, ThreadHistoryMode::Paginated); if last_turn_id.is_some() && before_turn_id.is_some() { return Err(invalid_request( @@ -5011,7 +5069,7 @@ impl ThreadRequestProcessor { // Persist Windows sandbox mode. let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) { + if cfg!(windows) && !experimental_prediction_mode { let mode = self.config.permissions.windows_sandbox_mode.or_else(|| { match WindowsSandboxLevel::from_config(&self.config) { WindowsSandboxLevel::Elevated => Some(WindowsSandboxModeToml::Elevated), @@ -5122,15 +5180,24 @@ impl ThreadRequestProcessor { .map(|profile| profile.id); } } - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = self - .config_manager - .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) - .await - .map_err(|err| config_load_error(&err))?; - let goals_enabled = config.features.enabled(Feature::Goals); + let (inherited_options, inherited_settings) = inherited_fork.unzip(); + let options = if let Some(options) = inherited_options { + *options + } else { + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = self + .config_manager + .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) + .await + .map_err(|err| config_load_error(&err))?; + StartThreadOptions { + client_mcp_extensions, + ..StartThreadOptions::new(config) + } + }; + let goals_enabled = options.config.features.enabled(Feature::Goals); - let fallback_model_provider = config.model_provider_id.clone(); + let fallback_model_provider = options.config.model_provider_id.clone(); let parent_trace = self.request_trace_context(&request_id).await; let thread_source = thread_source.map(Into::into); @@ -5186,7 +5253,7 @@ impl ThreadRequestProcessor { .then(|| restored_token_usage_turn_id(&history_items, ephemeral_turns.as_slice())); let token_usage_history_items = paginated_source.then(|| Arc::clone(&history_items)); let inherited_project_id = source_thread.project_id.clone(); - let reserved_thread_id = if config.ephemeral { + let reserved_thread_id = if options.config.ephemeral { None } else { stage_pending_thread_metadata( @@ -5205,9 +5272,8 @@ impl ThreadRequestProcessor { let fork_options = StartThreadOptions { thread_source, parent_trace, - client_mcp_extensions, reserved_thread_id, - ..StartThreadOptions::new(config) + ..options }; let new_thread = if let Some(prepared_fork) = prepared_fork { self.thread_manager @@ -5247,6 +5313,13 @@ impl ThreadRequestProcessor { } }; + if let Some(settings) = inherited_settings { + // Construct the restore future off the fork handler's stack + Box::pin(async { Box::pin(forked_thread.restore_thread_settings(*settings)).await }) + .await + .map_err(|err| invalid_request(err.to_string()))?; + } + Self::set_app_server_client_info( forked_thread.as_ref(), app_server_client_name, @@ -5395,9 +5468,9 @@ impl ThreadRequestProcessor { let response = ThreadForkResponse { thread: thread.clone(), disabled_plugin_ids: config_snapshot.disabled_plugin_ids, - model: session_configured.model, + model: config_snapshot.model.clone(), model_provider: session_configured.model_provider_id, - service_tier: session_configured.service_tier, + service_tier: config_snapshot.service_tier.clone(), cwd: session_configured.cwd, runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, @@ -5405,7 +5478,7 @@ impl ThreadRequestProcessor { approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, active_permission_profile, - reasoning_effort: session_configured.reasoning_effort, + reasoning_effort: config_snapshot.reasoning_effort.clone(), multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 40262b30c477..03835a08bd66 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -10,11 +10,14 @@ use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::rollout_path; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use codex_app_server_protocol::ActivePermissionProfile; use codex_app_server_protocol::ApprovalsReviewer; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::DynamicToolFunctionSpec; +use codex_app_server_protocol::DynamicToolSpec; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -55,13 +58,18 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; +use codex_core::test_support::all_model_presets; use codex_features::Feature; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; 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::items::TurnItem as CoreTurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::MultiAgentVersion; @@ -80,6 +88,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use tempfile::TempDir; +use test_case::test_case; use tokio::time::timeout; use wiremock::Mock; use wiremock::MockServer; @@ -2699,3 +2708,296 @@ async fn pathless_ephemeral_thread_rejects_codex_home_path_after_reload() -> Res Ok(()) } + +#[test_case(ThreadHistoryMode::Legacy; "legacy")] +#[test_case(ThreadHistoryMode::Paginated; "paginated")] +#[tokio::test] +async fn inheriting_fork_preserves_parent_config(history_mode: ThreadHistoryMode) -> Result<()> { + let server = responses::start_mock_server().await; + let mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_reasoning_item("reasoning", &["A summary"], &["Private reasoning"]), + responses::ev_assistant_message("answer", "Done"), + responses::ev_completed("initial"), + ]), + responses::sse(vec![responses::ev_completed("child")]), + responses::sse(vec![responses::ev_completed("parent")]), + ], + ) + .await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; + write_models_cache(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let model = all_model_presets() + .iter() + .find(|model| model.show_in_picker && !model.service_tiers.is_empty()) + .expect("a bundled model supports service tiers"); + let tier = &model.service_tiers[0].id; + let parent = mcp + .start_thread(ThreadStartParams { + history_mode: Some(history_mode), + base_instructions: Some("Retain these base instructions.".into()), + developer_instructions: Some("Retain this system policy context.".into()), + dynamic_tools: Some(vec![DynamicToolSpec::Function(DynamicToolFunctionSpec { + name: "parent_tool".into(), + description: "The parent's client-provided tool description.".into(), + input_schema: json!({"type": "object", "properties": {}}), + defer_loading: false, + })]), + ..Default::default() + }) + .await?; + mcp.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: parent.thread.id.clone(), + input: vec![UserInput::Text { + text: "Initial turn".into(), + text_elements: vec![], + }], + service_tier: Some(Some(tier.clone())), + collaboration_mode: Some(CollaborationMode { + mode: ModeKind::Plan, + settings: Settings { + model: model.id.clone(), + reasoning_effort: Some(ReasoningEffort::High), + developer_instructions: Some("Keep the parent's planning instructions.".into()), + }, + }), + disabled_plugin_ids: Some(vec!["unused@plugin".into()]), + ..Default::default() + }) + .await?; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: parent.thread.id.clone(), + experimental_prediction_mode: true, + ephemeral: true, + exclude_turns: true, + ..Default::default() + }) + .await?; + let child: ThreadForkResponse = + timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??; + assert_eq!( + ( + &child.model, + &child.reasoning_effort, + &child.service_tier, + &child.disabled_plugin_ids + ), + ( + &model.id, + &Some(ReasoningEffort::High), + &Some(tier.clone()), + &vec!["unused@plugin".to_string()] + ), + ); + assert!(child.thread.path.is_none()); + for id in [&child.thread.id, &parent.thread.id] { + mcp.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: id.clone(), + input: vec![UserInput::Text { + text: "Continue identically".into(), + text_elements: vec![], + }], + ..Default::default() + }) + .await?; + } + let requests = mock.requests(); + assert_eq!(requests.len(), 3); + let comparable = |index: usize| { + let body = requests[index].body_json(); + responses::strip_response_item_ids_from_json(json!({ + "input": body["input"], + "instructions": body["instructions"], + "tools": body["tools"], + "model": body["model"], + "reasoning": body["reasoning"], + "service_tier": body["service_tier"], + + })) + }; + assert_eq!(comparable(1), comparable(2)); + assert_eq!( + requests[1].body_json()["prompt_cache_key"], + requests[2].body_json()["prompt_cache_key"] + ); + assert!( + requests[1].body_json()["tools"] + .to_string() + .contains("parent_tool") + ); + Ok(()) +} + +#[test_case(ThreadHistoryMode::Legacy, false; "fresh persistent legacy")] +#[test_case(ThreadHistoryMode::Legacy, true; "ephemeral legacy")] +#[test_case(ThreadHistoryMode::Paginated, false; "fresh persistent paginated")] +#[test_case(ThreadHistoryMode::Paginated, true; "ephemeral paginated")] +#[tokio::test] +async fn inheriting_fork_requires_persisted_parent( + history_mode: ThreadHistoryMode, + ephemeral: bool, +) -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; + write_models_cache(codex_home.path()).await?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let parent = mcp + .start_thread(ThreadStartParams { + history_mode: Some(history_mode), + ephemeral: Some(ephemeral), + ..Default::default() + }) + .await?; + if ephemeral { + mcp.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: parent.thread.id.clone(), + input: vec![UserInput::Text { + text: "Complete an ephemeral turn".into(), + text_elements: vec![], + }], + ..Default::default() + }) + .await?; + } + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: parent.thread.id.clone(), + experimental_prediction_mode: true, + ephemeral: true, + exclude_turns: true, + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + assert!( + error + .error + .message + .contains("no rollout found for thread id"), + "unexpected fork error: {}", + error.error.message + ); + if let Some(path) = parent.thread.path { + assert!(!path.exists(), "forking should not materialize the parent"); + } + Ok(()) +} + +#[tokio::test] +async fn inheriting_fork_requires_loaded_parent() -> Result<()> { + let codex_home = TempDir::new()?; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id, + experimental_prediction_mode: true, + ephemeral: true, + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + assert_eq!( + error.error.message, + "`experimentalPredictionMode` requires a loaded parent", + ); + Ok(()) +} + +#[tokio::test] +async fn inheriting_fork_requires_ephemeral_child() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: "not-loaded".into(), + experimental_prediction_mode: true, + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), + ) + .await??; + assert_eq!( + error.error.message, + "`experimentalPredictionMode` requires `ephemeral: true`", + ); + Ok(()) +} + +#[tokio::test] +async fn inheriting_fork_rejects_configuration_overrides() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + for (key, value) in [ + ("model", json!("other-model")), + ("modelProvider", json!("other-provider")), + ("serviceTier", Value::Null), + ("cwd", json!(codex_home.path())), + ("runtimeWorkspaceRoots", json!([])), + ("approvalPolicy", json!("never")), + ("approvalsReviewer", json!("user")), + ("sandbox", json!("read-only")), + ("permissions", json!(":read-only")), + ("baseInstructions", json!("override")), + ("developerInstructions", json!("override")), + ("config", json!({"model": "other-model"})), + ("config", json!({"model_reasoning_effort": "low"})), + ] { + let mut params = json!({ + "threadId": "not-loaded", "experimentalPredictionMode": true, "ephemeral": true, + }); + params[key] = value; + let request = mcp.send_raw_request("thread/fork", Some(params)).await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request)), + ) + .await??; + assert_eq!( + error.error.message, + "`experimentalPredictionMode` cannot be combined with configuration overrides", + "override: {key}" + ); + } + Ok(()) +} diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3a1c0ae4f684..537fcf4ca4fe 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2077,6 +2077,53 @@ impl Session { state.take_session_startup_prewarm() } + pub(crate) async fn fork_config( + &self, + ) -> ( + Config, + ThreadConfigSnapshot, + WindowsSandboxLevel, + TurnEnvironmentSnapshot, + ReasoningEffortPin, + ) { + let (configuration, environments, mut reasoning_effort_pin) = { + let state = self.state.lock().await; + ( + state.session_configuration.clone(), + self.services.turn_environments.snapshot_now(), + state.reasoning_effort_pin.clone(), + ) + }; + let config = self.build_effective_session_config(&configuration); + if !config.features.enabled(Feature::ReasoningEffortOverride) { + let model = configuration + .step_settings + .resolve_model_info( + self.services.models_manager.as_ref(), + &configuration.model_info_overrides, + ) + .await; + if let Some(effort) = config + .model_reasoning_effort + .clone() + .or_else(|| model.default_reasoning_level.clone()) + { + // Without overrides, the parent's selected effort is its request baseline. + reasoning_effort_pin = ReasoningEffortPin::Active { + model: model.slug.clone(), + effort: model.resolve_reasoning_effort(effort), + }; + } + } + ( + config, + configuration.thread_config_snapshot(environments.all_selections()), + configuration.windows_sandbox_level, + environments, + reasoning_effort_pin, + ) + } + pub(crate) async fn get_config(&self) -> std::sync::Arc { let state = self.state.lock().await; state diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 263f08f2f1ea..6210785bf925 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1531,6 +1531,10 @@ impl Session { ), ); state.base_instructions_provenance = base_instructions_provenance.clone(); + // Restore a provided effort baseline before startup prewarm can establish a new one. + if let Some(pin) = thread_extension_data.remove::() { + state.reasoning_effort_pin = pin.as_ref().clone(); + } state.active_disabled_plugin_ids = session_configuration.disabled_plugin_ids.clone(); let managed_network_requirements_configured = config .config_layer_stack diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 53857e1cc2c7..0f63dad3ee79 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -30,8 +30,9 @@ use codex_protocol::protocol::TurnContextItem; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; -/// Runtime request effort, initially unset and established by prewarm or sampling. +/// Runtime request effort, inherited from a parent or established by prewarm or sampling. /// Successful compaction allows a fresh baseline without an override. +#[derive(Clone)] pub(crate) enum ReasoningEffortPin { Unset, Compacted, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index ea4dee313edd..61ba1ebd74cd 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -9,6 +9,7 @@ pub use shutdown::AgentTreeShutdownReport; pub(crate) use shutdown::thread_store_error_kind; use crate::CodexAppsToolsCache; +use crate::CodexThreadSettingsOverrides; use crate::agent::LocalAgentControl; use crate::agent::api::AgentConfigUpdate; use crate::agent::api::AgentControl; @@ -1436,6 +1437,36 @@ impl ThreadManager { report } + /// Captures the parent's current settings for a persisted-history fork. + /// Apply the returned settings to the new thread before starting its first turn. + pub async fn fork_options_from_parent( + &self, + source_thread_id: ThreadId, + ) -> CodexResult<(StartThreadOptions, CodexThreadSettingsOverrides)> { + let parent = self.get_thread(source_thread_id).await?; + let (config, settings, windows_sandbox_level, environments, reasoning_effort_pin) = + parent.session.fork_config().await; + let mut thread_extension_init = ExtensionDataInit::default(); + thread_extension_init.insert(reasoning_effort_pin); + Ok(( + StartThreadOptions { + history_mode: Some(settings.history_mode), + environments: Some(settings.environments.into_requests().environment_requests), + inherited_environments: Some(environments), + client_mcp_extensions: parent.client_mcp_extensions(), + disabled_plugin_ids: Some(settings.disabled_plugin_ids), + thread_extension_init, + turn_extension_init: settings.turn_extension_init, + ..StartThreadOptions::new(config) + }, + CodexThreadSettingsOverrides { + collaboration_mode: Some(settings.collaboration_mode), + windows_sandbox_level: Some(windows_sandbox_level), + ..Default::default() + }, + )) + } + /// Fork a legacy thread by snapshotting its full rollout history according to /// `snapshot` and starting a new thread with identical configuration /// (unless overridden by the caller's options). The new thread has a fresh id. diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index 542d9a73bc22..047b15d81cc3 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -691,6 +691,10 @@ fn render_item( .unwrap_or_else(|| "".to_string()); format!("{index:02}:local_shell_call:{command}") } + "configuration_update" => { + let reasoning = normalizer.json(&item["reasoning"]); + format!("{index:02}:configuration_update:reasoning={reasoning}") + } "reasoning" => { let summary = item .get("summary") diff --git a/codex-rs/core/tests/suite/reasoning_effort_override.rs b/codex-rs/core/tests/suite/reasoning_effort_override.rs index 6cce88e2cfc0..bdf9a0d68e2d 100644 --- a/codex-rs/core/tests/suite/reasoning_effort_override.rs +++ b/codex-rs/core/tests/suite/reasoning_effort_override.rs @@ -77,6 +77,209 @@ fn message(role: &str, text: &str) -> Value { }) } +pub(super) async fn prediction_fork_requests() -> anyhow::Result> { + let server = responses::start_mock_server().await; + let mut mocks = Vec::new(); + for id in ["parent-high", "parent-low", "child-medium", "parent-medium"] { + mocks.push( + responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_reasoning_item( + &format!("reasoning-{id}"), + &["Keep the existing context."], + &["Continue from the previous response."], + ), + responses::ev_assistant_message(&format!("answer-{id}"), "Ready to continue."), + responses::ev_completed(id), + ]), + ) + .await, + ); + } + let mut test = override_builder() + .with_config(|config| { + config.workspace_roots = vec![config.cwd.clone()]; + config.model_reasoning_effort = Some(ReasoningEffort::High); + // The child's first turn must preserve the pin even without startup prewarm. + config.model_provider.supports_websockets = false; + }) + .build_with_auto_env(&server) + .await?; + test.submit_text_turn("first parent turn").await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::Low)), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("second parent turn").await?; + let parent = Arc::clone(&test.codex); + parent.flush_rollout().await?; + let (mut options, settings) = test + .thread_manager + .fork_options_from_parent(test.session_configured.thread_id) + .await?; + options.config.ephemeral = true; + options.thread_source = Some(ThreadSource::Feature("composer_predictions".to_string())); + let child = test + .thread_manager + .fork_legacy_thread( + ForkSnapshot::Interrupted, + options, + parent.rollout_path().expect("parent rollout path"), + ) + .await?; + child.thread.restore_thread_settings(settings).await?; + assert_eq!( + child.thread.config_snapshot().await.collaboration_mode, + parent.config_snapshot().await.collaboration_mode, + ); + for thread in [child.thread, parent] { + test.codex = thread; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::Medium)), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("continue identically").await?; + } + let requests = mocks + .iter() + .map(responses::ResponseMock::single_request) + .collect::>(); + assert_eq!( + requests + .iter() + .map(|request| request.body_json()["reasoning"]["effort"].clone()) + .collect::>(), + vec![Value::from("high"); 4], + ); + assert_eq!( + effort_updates(&requests[2]), + vec![ + effort_update(ReasoningEffort::High), + effort_update(ReasoningEffort::Low), + effort_update(ReasoningEffort::Medium), + ], + ); + let comparable = |index: usize| { + let mut body = requests[index].body_json(); + body.as_object_mut() + .expect("request body") + .remove("client_metadata"); + responses::strip_response_item_ids_from_json(responses::strip_metadata_from_json(body)) + }; + assert_eq!(comparable(2), comparable(3)); + Ok(requests) +} + +#[test_case(Some(ReasoningEffort::Low); "selected effort")] +#[test_case(None; "model default")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn prediction_fork_pins_effort_from_parent_with_overrides_disabled( + parent_effort: Option, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mock = responses::mount_sse_sequence( + &server, + ["parent-high", "parent-low", "child-medium"] + .into_iter() + .map(|id| responses::sse(vec![responses::ev_completed(id)])) + .collect(), + ) + .await; + let mut test = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.use_responses_lite = true; + model.supports_reasoning_effort_updates = true; + model.default_reasoning_level = Some(ReasoningEffort::Low); + }) + .with_config(|config| { + config + .features + .disable(Feature::ReasoningEffortOverride) + .expect("disable reasoning effort overrides"); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_provider.supports_websockets = false; + }) + .build_with_auto_env(&server) + .await?; + test.submit_text_turn("first parent turn").await?; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(parent_effort), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("second parent turn").await?; + test.codex.flush_rollout().await?; + let (mut options, settings) = test + .thread_manager + .fork_options_from_parent(test.session_configured.thread_id) + .await?; + options.config.ephemeral = true; + options + .config + .features + .enable(Feature::ReasoningEffortOverride)?; + options.thread_source = Some(ThreadSource::Feature("composer_predictions".to_string())); + let child = test + .thread_manager + .fork_legacy_thread( + ForkSnapshot::Interrupted, + options, + test.codex.rollout_path().expect("parent rollout path"), + ) + .await?; + child.thread.restore_thread_settings(settings).await?; + test.codex = child.thread; + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + effort: Some(Some(ReasoningEffort::Medium)), + ..Default::default() + }, + ) + .await?; + test.submit_text_turn("child turn").await?; + + let requests = mock.requests(); + assert_eq!(requests.len(), 3); + assert_eq!(requests[0].body_json()["reasoning"]["effort"], "high"); + assert_eq!(requests[1].body_json()["reasoning"]["effort"], "low"); + assert!(effort_updates(&requests[1]).is_empty()); + assert_eq!( + effort_updates(&requests[2]), + vec![effort_update(ReasoningEffort::Medium)], + ); + let comparable = |index: usize| { + let mut body = requests[index].body_json(); + body.as_object_mut() + .expect("request body") + .remove("client_metadata"); + responses::strip_response_item_ids_from_json(responses::strip_metadata_from_json(body)) + }; + let parent = comparable(1); + let mut child = comparable(2); + let child_input = child["input"].as_array_mut().expect("child input"); + assert_eq!( + child_input.pop(), + Some(effort_update(ReasoningEffort::Medium)) + ); + assert_eq!(child_input.pop(), Some(message("user", "child turn"))); + assert_eq!(child, parent); + Ok(()) +} + #[derive(Clone, Copy, PartialEq, Eq)] enum WorkerOverrides { Enabled, diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 63843039bc24..42d13fe5d1f6 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -1842,6 +1842,18 @@ async fn subagent_waits_for_its_inherited_environment_configuration() -> Result< Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn prediction_fork_inherits_parent_context() -> Result<()> { + skip_if_no_network!(Ok(())); + let requests = super::reasoning_effort_override::prediction_fork_requests().await?; + insta::assert_snapshot!(context_snapshot::format_request_history_snapshot( + "A parent pins high request effort, then selects low. Its ephemeral prediction fork inherits the context and baseline, then selects medium on its first turn. Continuing the parent at medium produces the same request.", + &requests, + &ContextSnapshotOptions::default().include_request_settings(), + )); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_checkpoint_migration_request_history() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__prediction_fork_inherits_parent_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__prediction_fork_inherits_parent_context.snap new file mode 100644 index 000000000000..0dc61134a1fc --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__prediction_fork_inherits_parent_context.snap @@ -0,0 +1,158 @@ +--- +source: core/tests/suite/scenarios.rs +expression: "context_snapshot::format_request_history_snapshot(\"A parent pins high request effort, then selects low. Its ephemeral prediction fork inherits the context and baseline, then selects medium on its first turn. Continuing the parent at medium produces the same request.\",\n&requests, &ContextSnapshotOptions::default().include_request_settings(),)" +--- +Scenario: A parent pins high request effort, then selects low. Its ephemeral prediction fork inherits the context and baseline, then selects medium on its first turn. Continuing the parent at medium produces the same request. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.4" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"high"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (2; hash=44f4cad2ce4ab17d): + - namespace/functions; hash=101d32aa59ea258b + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + You are Codex, a coding agent based on GPT-5. You and the user share one workspace, and your job is to c...rate with them until their goal is genuinely handled. [hash=2a3efec66e7f8963] + + # Personality + + You have a vivid inner life as Codex: intelligent, playful, curious, and deeply present. One of your gif...re capable and imaginative inside their own thinking. [hash=0ff01c25471fbbef] + + You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good question...m casual moments that make serious work easier to do. [hash=131b64bb994b0d68] + + + - You provide user updates frequently, every 30s. + - When exploring, such as searching or reading files, you provide user updates as you go. You explain wh...in particular you do not start each one the same way. [hash=ad300cdda4074574] + - When working for a while, you keep updates informative and varied, but you stay concise. + - Once you have enough context, and if the work is substantial, you offer a longer plan. This is the onl...at may run past two sentences and include formatting. [hash=a4f9789e757eef2f] + - If you create a checklist or task list, you update item statuses incrementally as each item is completed rather than marking every item done only at the end. + - Before performing file edits of any kind, you provide updates explaining what edits you are making. + - Tone of your updates must match your personality. + +02:message/developer: + + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + +03:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +04:message/user: + first parent turn +05:configuration_update:reasoning={"effort":"high"} +-- request 2 (turn) -- +06:reasoning:summary=Keep the existing context.:encrypted=true +07:message/assistant: + Ready to continue. +08:message/user: + second parent turn +09:configuration_update:reasoning={"effort":"low"} +-- request 3 (turn) -- +10:reasoning:summary=Keep the existing context.:encrypted=true +11:message/assistant: + Ready to continue. +12:message/user: + continue identically +13:configuration_update:reasoning={"effort":"medium"} + +## Window 2 (after request 3: input repeated) +Settings: same as window 1 +-- request 4 (turn) -- +00:additional_tools/developer (2; hash=44f4cad2ce4ab17d): + - namespace/functions; hash=101d32aa59ea258b + - function/exec_command + - function/write_stdin + - function/request_user_input + - custom/apply_patch + - function/view_image + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 +01:message/developer: + You are Codex, a coding agent based on GPT-5. You and the user share one workspace, and your job is to c...rate with them until their goal is genuinely handled. [hash=2a3efec66e7f8963] + + # Personality + + You have a vivid inner life as Codex: intelligent, playful, curious, and deeply present. One of your gif...re capable and imaginative inside their own thinking. [hash=0ff01c25471fbbef] + + You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good question...m casual moments that make serious work easier to do. [hash=131b64bb994b0d68] + + + - You provide user updates frequently, every 30s. + - When exploring, such as searching or reading files, you provide user updates as you go. You explain wh...in particular you do not start each one the same way. [hash=ad300cdda4074574] + - When working for a while, you keep updates informative and varied, but you stay concise. + - Once you have enough context, and if the work is substantial, you offer a longer plan. This is the onl...at may run past two sentences and include formatting. [hash=a4f9789e757eef2f] + - If you create a checklist or task list, you update item statuses incrementally as each item is completed rather than marking every item done only at the end. + - Before performing file edits of any kind, you provide updates explaining what edits you are making. + - Tone of your updates must match your personality. + +02:message/developer: + + Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `read-only`: The san... permits reading files. Network access is restricted. [hash=be7da4425f89053c] + # Escalation Requests + + Commands are run outside the sandbox if they are approved by the user, or match an existing rule that al...hell control operators, including but not limited to: [hash=d24ebf5fa4e4b9bb] + + - Pipes: | + - Logical operators: &&, || + + NEVER provide a prefix_rule if your command uses a heredoc or herestring. + + ### Examples + Good examples of prefixes: + - ["npm", "run", "dev"] + - ["gh", "pr", "check"] + - ["cargo", "test"] + +03:message/user: + + + + + + [hash=e8c1de60307d0a3d] + +04:message/user: + first parent turn +05:configuration_update:reasoning={"effort":"high"} +06:reasoning:summary=Keep the existing context.:encrypted=true +07:message/assistant: + Ready to continue. +08:message/user: + second parent turn +09:configuration_update:reasoning={"effort":"low"} +10:reasoning:summary=Keep the existing context.:encrypted=true +11:message/assistant: + Ready to continue. +12:message/user: + continue identically +13:configuration_update:reasoning={"effort":"medium"} diff --git a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs index ed23a46982ab..525eed5ece8b 100644 --- a/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs +++ b/codex-rs/tui/src/app_server_session/workspace_roots_tests.rs @@ -7,29 +7,40 @@ use codex_app_server_protocol::ServerNotification; use core_test_support::responses; use pretty_assertions::assert_eq; -#[tokio::test] -async fn remote_workspace_roots_survive_start_turn_resume_and_fork() -> Result<()> { - let model_server = responses::start_mock_server().await; - let response = responses::mount_sse_once( - &model_server, - responses::sse(vec![ - responses::ev_response_created("response"), - responses::ev_completed("response"), - ]), - ) - .await; - let server_home = tempfile::tempdir()?; - let client_home = tempfile::tempdir()?; - let workspace = tempfile::tempdir()?; - let remote_cwd = AbsolutePathBuf::from_absolute_path(workspace.path().canonicalize()?)?; - let extra_root = remote_cwd.join("shared"); - std::fs::create_dir(extra_root.as_path())?; - let base_url = model_server.uri(); - let extra_root_toml = serde_json::to_string(&extra_root)?; - std::fs::write( - server_home.path().join("config.toml"), - format!( - r#" +#[test] +fn remote_workspace_roots_survive_start_turn_resume_and_fork() -> Result<()> { + const TEST_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; + + std::thread::Builder::new() + .name("remote-workspace-roots".to_string()) + .stack_size(TEST_STACK_SIZE_BYTES) + .spawn(|| { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + runtime.block_on(async { + let model_server = responses::start_mock_server().await; + let response = responses::mount_sse_once( + &model_server, + responses::sse(vec![ + responses::ev_response_created("response"), + responses::ev_completed("response"), + ]), + ) + .await; + let server_home = tempfile::tempdir()?; + let client_home = tempfile::tempdir()?; + let workspace = tempfile::tempdir()?; + let remote_cwd = + AbsolutePathBuf::from_absolute_path(workspace.path().canonicalize()?)?; + let extra_root = remote_cwd.join("shared"); + std::fs::create_dir(extra_root.as_path())?; + let base_url = model_server.uri(); + let extra_root_toml = serde_json::to_string(&extra_root)?; + std::fs::write( + server_home.path().join("config.toml"), + format!( + r#" model = "gpt-5.2" model_provider = "workspace-test" sandbox_mode = "workspace-write" @@ -42,130 +53,135 @@ wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 "# - ), - )?; - let server_config = ConfigBuilder::default() - .codex_home(server_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(remote_cwd.to_path_buf()), - ..Default::default() - }) - .build() - .await?; - let client_config = ConfigBuilder::default() - .codex_home(client_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(client_home.path().to_path_buf()), - ..Default::default() - }) - .build() - .await?; - let local_settings = LocalSettings::from(&client_config); - let expected_roots = vec![remote_cwd.clone(), extra_root.clone()]; - let mut app_server = crate::start_embedded_app_server_for_picker(&server_config).await?; - // Exercise remote request semantics with a real server and separate client config. - app_server.thread_params_mode = ThreadParamsMode::Remote; - app_server.remote_cwd_override = Some(remote_cwd.to_path_buf()); - let started = app_server.start_thread(&client_config).await?; - let thread_id = started.session.thread_id; - assert_eq!(started.session.cwd, remote_cwd); - assert_eq!(started.session.runtime_workspace_roots, expected_roots); + ), + )?; + let server_config = ConfigBuilder::default() + .codex_home(server_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(remote_cwd.to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let client_config = ConfigBuilder::default() + .codex_home(client_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(client_home.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + let local_settings = LocalSettings::from(&client_config); + let expected_roots = vec![remote_cwd.clone(), extra_root.clone()]; + let mut app_server = + crate::start_embedded_app_server_for_picker(&server_config).await?; + // Exercise remote request semantics with a real server and separate client config. + app_server.thread_params_mode = ThreadParamsMode::Remote; + app_server.remote_cwd_override = Some(remote_cwd.to_path_buf()); + let started = app_server.start_thread(&client_config).await?; + let thread_id = started.session.thread_id; + assert_eq!(started.session.cwd, remote_cwd); + assert_eq!(started.session.runtime_workspace_roots, expected_roots); - let (mut chat, _sender, _events, _commands) = - crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await; - chat.handle_thread_session(started.session); - let config = chat.config_ref(); - assert_eq!(config.workspace_roots, expected_roots); - app_server - .turn_start( - thread_id, - "user-message".to_string(), - vec![UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }], - config.cwd.to_path_buf(), - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - TurnPermissionsOverride::Preserve, - config.permissions.user_visible_workspace_roots(), - "gpt-5.2".to_string(), - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*output_schema*/ None, - /*cyber_access_program*/ None, - ) - .await?; - tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { - while let Some(event) = app_server.next_event().await { - if let AppServerEvent::ServerNotification(notification) = event - && let ServerNotification::TurnCompleted(completed) = *notification - { - assert_eq!( - completed.turn.status, - codex_app_server_protocol::TurnStatus::Completed - ); - return; - } - } - panic!("app-server disconnected before completing the turn"); - }) - .await?; - response.single_request(); + let (mut chat, _sender, _events, _commands) = + crate::chatwidget::tests::helpers::make_chatwidget_manual_with_sender().await; + chat.handle_thread_session(started.session); + let config = chat.config_ref(); + assert_eq!(config.workspace_roots, expected_roots); + app_server + .turn_start( + thread_id, + "user-message".to_string(), + vec![UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + config.cwd.to_path_buf(), + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + TurnPermissionsOverride::Preserve, + config.permissions.user_visible_workspace_roots(), + "gpt-5.2".to_string(), + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*output_schema*/ None, + /*cyber_access_program*/ None, + ) + .await?; + tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), async { + while let Some(event) = app_server.next_event().await { + if let AppServerEvent::ServerNotification(notification) = event + && let ServerNotification::TurnCompleted(completed) = *notification + { + assert_eq!( + completed.turn.status, + codex_app_server_protocol::TurnStatus::Completed + ); + return; + } + } + panic!("app-server disconnected before completing the turn"); + }) + .await?; + response.single_request(); - let resumed = app_server - .resume_thread( - &local_settings, - client_config.clone(), - thread_id, - ResumeModelSettings::RestoreFromThread, - ) - .await?; - assert_eq!(resumed.session.runtime_workspace_roots, expected_roots); - let forked = app_server - .fork_thread(&local_settings, client_config.clone(), thread_id) - .await?; - assert_eq!(forked.session.runtime_workspace_roots, expected_roots); + let resumed = app_server + .resume_thread( + &local_settings, + client_config.clone(), + thread_id, + ResumeModelSettings::RestoreFromThread, + ) + .await?; + assert_eq!(resumed.session.runtime_workspace_roots, expected_roots); + let forked = app_server + .fork_thread(&local_settings, client_config.clone(), thread_id) + .await?; + assert_eq!(forked.session.runtime_workspace_roots, expected_roots); - // Existing tasks may retain roots that are no longer in the server defaults. - let config_path = server_home.path().join("config.toml"); - let updated_config = std::fs::read_to_string(&config_path)?.replace( - &format!("writable_roots = [{extra_root_toml}]"), - "writable_roots = []", - ); - std::fs::write(config_path, updated_config)?; - let session_config = chat.config_ref().clone(); - let forked = app_server - .fork_thread_at( - &local_settings, - session_config.clone(), - thread_id, - /*last_turn_id*/ None, - /*before_turn_id*/ None, - ForkGoalContinuation::StartIfIdle, - /*selected_profile*/ None, - ) - .await?; - assert_eq!(forked.session.runtime_workspace_roots, expected_roots); - let side = app_server - .fork_side_thread( - &local_settings, - session_config, - thread_id, - /*selected_profile*/ None, - ) - .await?; - assert_eq!(side.session.runtime_workspace_roots, expected_roots); + // Existing tasks may retain roots that are no longer in the server defaults. + let config_path = server_home.path().join("config.toml"); + let updated_config = std::fs::read_to_string(&config_path)?.replace( + &format!("writable_roots = [{extra_root_toml}]"), + "writable_roots = []", + ); + std::fs::write(config_path, updated_config)?; + let session_config = chat.config_ref().clone(); + let forked = app_server + .fork_thread_at( + &local_settings, + session_config.clone(), + thread_id, + /*last_turn_id*/ None, + /*before_turn_id*/ None, + ForkGoalContinuation::StartIfIdle, + /*selected_profile*/ None, + ) + .await?; + assert_eq!(forked.session.runtime_workspace_roots, expected_roots); + let side = app_server + .fork_side_thread( + &local_settings, + session_config, + thread_id, + /*selected_profile*/ None, + ) + .await?; + assert_eq!(side.session.runtime_workspace_roots, expected_roots); - app_server.remote_cwd_override = None; - let started = app_server.start_thread(&client_config).await?; - let default_cwd = AbsolutePathBuf::current_dir()?; - assert_eq!(started.session.cwd, default_cwd); - assert_eq!(started.session.runtime_workspace_roots, vec![default_cwd]); - app_server.shutdown().await?; - Ok(()) + app_server.remote_cwd_override = None; + let started = app_server.start_thread(&client_config).await?; + let default_cwd = AbsolutePathBuf::current_dir()?; + assert_eq!(started.session.cwd, default_cwd); + assert_eq!(started.session.runtime_workspace_roots, vec![default_cwd]); + app_server.shutdown().await?; + Ok(()) + }) + })? + .join() + .expect("remote workspace roots test thread") } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index 491c96f2d109..6ef2db01fb53 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1033,7 +1033,7 @@ async fn plugin_mention_does_not_suppress_same_name_skill() { short_description: None, interface: None, dependencies: None, - path: skill_path.clone(), + path: skill_path.clone().into(), scope: crate::test_support::skill_scope_user(), enabled: true, plugin_id: None,