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 f19d6ffdcc..83c3f9612c 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 4905a31d14..a7a76234f3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -553,6 +553,12 @@ pub struct ThreadForkParams { #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub experimental_prediction_mode: bool, + /// Disable MCP servers in prediction mode when the inherited window uses incremental tools on Responses Lite. + /// Requires `experimentalPredictionMode: true`. + #[experimental("thread/fork.experimentalDisablePredictionMcp")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub experimental_disable_prediction_mcp: 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-protocol/src/protocol/v2/thread_sample.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_sample.rs index bbdaac3ab5..27c26437b6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_sample.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_sample.rs @@ -20,6 +20,9 @@ pub struct ThreadSampleParams { pub effort: Option, #[ts(optional = nullable)] pub turn_trigger: Option, + /// Disable MCP servers when the inherited window uses incremental tools on Responses Lite. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_mcp: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] 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 1b73520af4..de9b90925f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -5135,6 +5135,11 @@ impl ThreadRequestProcessor { app_server_client_version: Option, client_mcp_extensions: ClientMcpExtensions, ) -> Result<(), JSONRPCErrorError> { + if params.experimental_disable_prediction_mcp && !params.experimental_prediction_mode { + return Err(invalid_request( + "`experimentalDisablePredictionMcp` requires `experimentalPredictionMode: true`", + )); + } let has_config_overrides = params.model.is_some() || params.model_provider.is_some() || params.service_tier.is_some() @@ -5163,6 +5168,7 @@ impl ThreadRequestProcessor { let ThreadForkParams { thread_id, experimental_prediction_mode, + experimental_disable_prediction_mcp, last_turn_id, before_turn_id, path, @@ -5203,6 +5209,11 @@ impl ThreadRequestProcessor { /*include_history*/ false, ) .await?; + let loaded_parent = self + .thread_manager + .get_thread(source_thread.thread_id) + .await + .ok(); 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 { @@ -5236,6 +5247,25 @@ impl ThreadRequestProcessor { options .thread_extension_init .insert(codex_core::compact::AutoCompactionDisabled); + if experimental_disable_prediction_mcp + && let Some(parent) = loaded_parent.as_ref() + && parent + .conversation_history_snapshot() + .await + .items() + .any(|item| matches!(item, ResponseItem::AdditionalTools { .. })) + && let Some(model) = options.config.model.as_deref() + && self + .thread_manager + .get_models_manager() + .get_model_info(model, &options.config.to_models_manager_config()) + .await + .use_responses_lite + { + options + .thread_extension_init + .insert(codex_core::McpServersDisabled); + } options .config .features @@ -5375,7 +5405,6 @@ impl ThreadRequestProcessor { !has_permission_override(request_overrides.as_ref(), &typesafe_overrides); let needs_latest_settings = restore_approval_policy || restore_approvals_reviewer || restore_permission_profile; - let loaded_parent = self.thread_manager.get_thread(source_thread_id).await.ok(); let loaded_parent_settings = if paginated_source && needs_latest_settings { if let Some(parent) = loaded_parent.as_ref() { let snapshot = parent.thread_settings_snapshot().await; diff --git a/codex-rs/app-server/src/thread_sample.rs b/codex-rs/app-server/src/thread_sample.rs index edf7b0d1c9..9afaa8eea1 100644 --- a/codex-rs/app-server/src/thread_sample.rs +++ b/codex-rs/app-server/src/thread_sample.rs @@ -123,10 +123,8 @@ impl SampleTask { "sample source must be a persistent root thread", )); } - let revision = parent - .conversation_history_snapshot() - .await - .history_version(); + let parent_history = parent.conversation_history_snapshot().await; + let revision = parent_history.history_version(); parent .flush_rollout() .await @@ -191,6 +189,16 @@ impl SampleTask { options .thread_extension_init .insert(codex_core::compact::AutoCompactionDisabled); + if params.disable_mcp + && model.use_responses_lite + && parent_history + .items() + .any(|item| matches!(item, ResponseItem::AdditionalTools { .. })) + { + options + .thread_extension_init + .insert(codex_core::McpServersDisabled); + } options.thread_extension_init.insert(SampleThread); let effort = params.effort.filter(|_| { model.supports_reasoning_effort_updates && options.config.model_provider.is_openai() 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 b3f473496c..046111e8c7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -21,6 +21,7 @@ use codex_app_server_protocol::DynamicToolSpec; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::McpServerRefreshResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::SandboxPolicy; @@ -61,7 +62,9 @@ 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::config::ConfigBuilder; use codex_core::test_support::all_model_presets; +use codex_core::test_support::construct_model_info_offline; use codex_features::Feature; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; @@ -2922,6 +2925,175 @@ async fn inheriting_fork_preserves_parent_config(history_mode: ThreadHistoryMode Ok(()) } +#[test_case(ThreadHistoryMode::Legacy, false, true, true; "prediction fork retains MCPs")] +#[test_case(ThreadHistoryMode::Legacy, true, true, true; "disable MCPs in prediction fork")] +#[test_case(ThreadHistoryMode::Paginated, true, true, true; "disable MCPs in paginated fork")] +#[test_case(ThreadHistoryMode::Legacy, true, false, true; "retain MCPs without incremental tools")] +#[test_case(ThreadHistoryMode::Legacy, true, true, false; "retain MCPs without Responses Lite")] +#[tokio::test] +async fn fork_option_disables_mcp_servers( + history_mode: ThreadHistoryMode, + disable_mcp: bool, + incremental_tools: bool, + responses_lite: bool, +) -> Result<()> { + let server = responses::start_mock_server().await; + let mock = responses::mount_sse_sequence( + &server, + ["parent", "child", "refreshed-child", "continued-parent"] + .into_iter() + .map(|id| responses::sse(vec![responses::ev_completed(id)])) + .collect(), + ) + .await; + let mcp_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/mcp")) + .respond_with(|request: &wiremock::Request| { + let request: Value = + serde_json::from_slice(&request.body).expect("MCP request must be valid JSON"); + let result = match request["method"].as_str() { + Some("initialize") => json!({ + "protocolVersion": "2025-11-25", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "fixture", "version": "1.0.0" } + }), + Some("tools/list") => json!({ "tools": [{ + "name": "prediction_mcp_lookup", + "description": "Look up a test value.", + "inputSchema": { "type": "object", "properties": {} } + }] }), + Some("resources/list") => json!({ "resources": [] }), + Some("resources/templates/list") => json!({ "resourceTemplates": [] }), + _ => return ResponseTemplate::new(202), + }; + ResponseTemplate::new(200).set_body_json(json!({ + "jsonrpc": "2.0", "id": request["id"], "result": result + })) + }) + .mount(&mcp_server) + .await; + let codex_home = TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .loader_overrides(codex_config::LoaderOverrides::without_managed_config_for_tests()) + .build() + .await?; + let mut model = construct_model_info_offline("gpt-5.4", &config); + model.use_responses_lite = responses_lite; + model.supports_search_tool = false; + model.tool_mode = Some(codex_protocol::openai_models::ToolMode::Direct); + let catalog = codex_home.path().join("catalog.json"); + std::fs::write( + &catalog, + serde_json::to_string(&json!({"models": [model]}))?, + )?; + MockResponsesConfig::new(&server.uri()) + .with_model("gpt-5.4") + .with_root_config(&format!( + "model_catalog_json = {}\nfeatures.incremental_tools = {incremental_tools}", + serde_json::to_string(&catalog)? + )) + .with_extra_config(&format!( + "[mcp_servers.fixture]\nurl = \"{}/mcp\"\nrequired = true\n", + mcp_server.uri() + )) + .write(codex_home.path())?; + let mut app = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let parent = app + .start_thread(ThreadStartParams { + history_mode: Some(history_mode), + ..Default::default() + }) + .await?; + app.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: parent.thread.id.clone(), + input: vec![UserInput::Text { + text: "Initial turn".into(), + text_elements: vec![], + }], + ..Default::default() + }) + .await?; + let child: ThreadForkResponse = app + .request(|request_id| ClientRequest::ThreadFork { + request_id, + params: ThreadForkParams { + thread_id: parent.thread.id.clone(), + experimental_prediction_mode: true, + experimental_disable_prediction_mcp: disable_mcp, + ephemeral: true, + exclude_turns: true, + ..Default::default() + }, + }) + .await?; + let removes_mcp = disable_mcp && incremental_tools && responses_lite; + for (index, thread_id) in [&child.thread.id, &child.thread.id, &parent.thread.id] + .into_iter() + .enumerate() + { + if index == 1 { + let initializations = mcp_server + .received_requests() + .await + .expect("MCP request recording must be enabled") + .iter() + .filter(|request| request.method.as_str() == "POST") + .filter(|request| { + serde_json::from_slice::(&request.body) + .expect("MCP request must be valid JSON")["method"] + == "initialize" + }) + .count(); + if removes_mcp { + assert_eq!(initializations, 1, "disabled fork must not connect MCPs"); + } + let _: McpServerRefreshResponse = app + .request(|request_id| ClientRequest::McpServerRefresh { + request_id, + params: None, + }) + .await?; + } + app.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread_id.clone(), + input: vec![UserInput::Text { + text: "Continue".into(), + text_elements: vec![], + }], + ..Default::default() + }) + .await?; + } + let requests = mock.requests(); + assert!( + requests[0] + .body_json() + .to_string() + .contains("prediction_mcp_lookup") + ); + assert_eq!( + requests + .iter() + .map(|request| request.body_json()["input"] + .as_array() + .expect("input") + .iter() + .any(|item| { + let text = item.to_string(); + text.contains("namespaces are no longer available") + && text.contains("mcp__fixture") + })) + .collect::>(), + vec![false, removes_mcp, removes_mcp, false], + ); + Ok(()) +} + #[test_case(ThreadHistoryMode::Legacy, false; "fresh persistent legacy")] #[test_case(ThreadHistoryMode::Legacy, true; "ephemeral legacy")] #[test_case(ThreadHistoryMode::Paginated, false; "fresh persistent paginated")] @@ -3020,8 +3192,14 @@ async fn inheriting_fork_requires_loaded_parent() -> Result<()> { Ok(()) } +#[test_case(true, false, "`experimentalPredictionMode` requires `ephemeral: true`"; "ephemeral child")] +#[test_case(false, true, "`experimentalDisablePredictionMcp` requires `experimentalPredictionMode: true`"; "MCP disablement requires prediction mode")] #[tokio::test] -async fn inheriting_fork_requires_ephemeral_child() -> Result<()> { +async fn inheriting_fork_rejects_invalid_prediction_flags( + prediction_mode: bool, + disable_mcp: bool, + expected_error: &str, +) -> Result<()> { let codex_home = TempDir::new()?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) @@ -3030,7 +3208,8 @@ async fn inheriting_fork_requires_ephemeral_child() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: "not-loaded".into(), - experimental_prediction_mode: true, + experimental_prediction_mode: prediction_mode, + experimental_disable_prediction_mcp: disable_mcp, ..Default::default() }) .await?; @@ -3039,10 +3218,7 @@ async fn inheriting_fork_requires_ephemeral_child() -> Result<()> { mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), ) .await??; - assert_eq!( - error.error.message, - "`experimentalPredictionMode` requires `ephemeral: true`", - ); + assert_eq!(error.error.message, expected_error); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_sample.rs b/codex-rs/app-server/tests/suite/v2/thread_sample.rs index e90988c7e9..57a898fce4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_sample.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_sample.rs @@ -264,6 +264,7 @@ async fn sample_preserves_context_and_keeps_source_unchanged( prompt: "Continue identically".to_string(), effort: Some(ReasoningEffort::Medium), turn_trigger: Some("composer_predictions".to_string()), + ..Default::default() }, ) .await?; @@ -589,8 +590,14 @@ async fn sample_returns_null_for_custom_tool_calls() -> Result<()> { Ok(()) } +#[test_case(false, true; "inherit MCPs by default")] +#[test_case(true, true; "disable MCPs with incremental tools")] +#[test_case(true, false; "retain MCPs without incremental tools")] #[tokio::test] -async fn sample_blocks_mcp_execution_without_changing_parent_tools() -> Result<()> { +async fn sample_blocks_mcp_execution_without_changing_parent_tools( + disable_mcp: bool, + incremental_tools: bool, +) -> Result<()> { let server = responses::start_mock_server().await; let mcp = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("POST")) @@ -654,13 +661,24 @@ async fn sample_blocks_mcp_execution_without_changing_parent_tools() -> Result<( &extra_config, ) .await?; - let (parent_id, turn_id) = ready_parent(&mut client, ThreadStartParams::default()).await?; + let (parent_id, turn_id) = ready_parent( + &mut client, + ThreadStartParams { + config: Some(std::collections::HashMap::from([( + "features.incremental_tools".to_string(), + json!(incremental_tools), + )])), + ..Default::default() + }, + ) + .await?; let response = sample( &mut client, ThreadSampleParams { thread_id: parent_id.clone(), expected_turn_id: turn_id, prompt: "Call the tool again".to_string(), + disable_mcp, ..Default::default() }, ) @@ -669,20 +687,40 @@ async fn sample_blocks_mcp_execution_without_changing_parent_tools() -> Result<( (response.text, response.response_id), (None, "sample-tool".to_string()) ); - let fork = prediction_fork(&mut client, &parent_id).await?; + let fork_id = client + .send_thread_fork_request(ThreadForkParams { + thread_id: parent_id, + experimental_prediction_mode: true, + experimental_disable_prediction_mcp: disable_mcp, + ephemeral: true, + exclude_turns: true, + ..Default::default() + }) + .await?; + let fork: ThreadForkResponse = timeout(WAIT, client.read_response(fork_id)).await??; client .start_turn_and_wait_for_completion(TurnStartParams { - thread_id: fork, + thread_id: fork.thread.id, input: input("Call the tool again"), ..Default::default() }) .await?; let requests = mock.requests(); assert_eq!(requests.len(), 4); + let parent = comparable_request(requests[1].body_json()); + let sample = comparable_request(requests[2].body_json()); + let prefix = parent["input"].as_array().expect("parent input"); + let sample_input = sample["input"].as_array().expect("sample input"); + assert_eq!(&sample_input[..prefix.len()], prefix); + let removes_mcp = disable_mcp && incremental_tools; assert_eq!( - comparable_request(requests[2].body_json()), - comparable_request(requests[3].body_json()) + sample_input[prefix.len()..].iter().any(|item| { + let text = item.to_string(); + text.contains("namespaces are no longer available") && text.contains("mcp__sample_test") + }), + removes_mcp ); + assert_eq!(sample, comparable_request(requests[3].body_json())); assert_eq!( requests[2].body_json()["tools"], requests[1].body_json()["tools"] diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e722464838..477b780f12 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -108,6 +108,7 @@ mod mcp_tool_approval_templates; mod mcp_tool_exposure; mod network_policy_decision; pub use mcp::McpManager; +pub use mcp::McpServersDisabled; mod original_image_detail; pub use codex_mcp::CodexAppsToolsCache; pub use codex_mcp::SandboxState; diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index eaf301eb4e..2cdc8a2659 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -42,6 +42,10 @@ use codex_protocol::protocol::TurnEnvironmentSelection; const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps"; +/// Thread-local policy that excludes all MCP servers, including runtime contributions. +/// Insert into `StartThreadOptions::thread_extension_init` before starting the thread. +pub struct McpServersDisabled; + /// MCP configuration and capability availability derived from the same inputs. #[derive(Clone)] pub(crate) struct McpRuntimeProjection { @@ -263,6 +267,19 @@ impl McpManager { disabled_plugin_ids: &[String], ) -> McpRuntimeProjection { let config = context.config(); + if context + .thread_init() + .is_some_and(|init| init.get::().is_some()) + { + let mut mcp_config = config.to_mcp_config_with_loaded_plugins(&Default::default(), []); + mcp_config.mcp_server_catalog = Default::default(); + mcp_config.apps_enabled = false; + return McpRuntimeProjection { + config: mcp_config, + plugins_available: false, + selected_plugins: Default::default(), + }; + } let plugins_config = &config.plugins; let mut selected_plugin_connector_sources = Vec::new(); let mut selected_plugin_registrations = Vec::new(); diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index a249e36da2..a2b28fadd7 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -141,7 +141,13 @@ impl Session { mut projection: McpRuntimeProjection, ) -> BoxFuture<'a, McpRuntimeProjection> { Box::pin(async move { - if self.isolation == codex_extension_api::SessionIsolation::Isolated { + if self.isolation == codex_extension_api::SessionIsolation::Isolated + || self + .services + .mcp_thread_init + .get::() + .is_some() + { return projection; } diff --git a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs index 47b5eb2da6..a07fe7129e 100644 --- a/codex-rs/core/tests/suite/scenarios_incremental_tools.rs +++ b/codex-rs/core/tests/suite/scenarios_incremental_tools.rs @@ -1,6 +1,9 @@ //! Request-history coverage for incremental tools on Responses Lite. use anyhow::Result; +use codex_core::ForkSnapshot; +use codex_core::McpServersDisabled; +use codex_core::compact::AutoCompactionDisabled; use codex_features::Feature; use codex_otel::MetricsClient; use codex_otel::MetricsConfig; @@ -33,6 +36,106 @@ use test_case::test_case; use super::super::rmcp_client::remote_aware_environment_id; use super::super::rmcp_client::remote_aware_stdio_server_bin; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn prediction_fork_appends_mcp_removal_without_rewriting_history() -> Result<()> { + skip_if_wine_exec!( + Ok(()), + "requires a Windows test_stdio_server in the Wine-exec environment" + ); + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mcp = serde_json::from_value(json!({ + "command": remote_aware_stdio_server_bin()?, + "environment_id": remote_aware_environment_id(), + "enabled_tools": ["echo"], + "required": true, + }))?; + let mut test = test_codex() + .with_model_info_override("gpt-5.4", |model| { + model.use_responses_lite = true; + model.supports_search_tool = false; + model.tool_mode = Some(ToolMode::Direct); + }) + .with_config(move |config| { + config.workspace_roots = vec![config.cwd.clone()]; + config.base_instructions = Some("Use the available tools to help the user.".into()); + config + .features + .enable(Feature::IncrementalTools) + .expect("enable incremental tools"); + config + .mcp_servers + .set(std::collections::HashMap::from([( + "echo_service".into(), + mcp, + )])) + .expect("set MCP server"); + }) + .build_with_auto_env(&server) + .await?; + wait_for_mcp_server(&test.codex, "echo_service").await?; + let mock = responses::mount_sse_sequence( + &server, + ["parent", "child", "child-again", "parent-again"] + .into_iter() + .map(|id| responses::sse(vec![responses::ev_completed(id)])) + .collect(), + ) + .await; + test.submit_text_turn("Start with the echo service.") + .await?; + + let parent = std::sync::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?; + // Match experimentalPredictionMode with experimentalDisablePredictionMcp enabled. + options.config.ephemeral = true; + options + .config + .features + .enable(Feature::ReasoningEffortOverride)?; + options.thread_extension_init.insert(AutoCompactionDisabled); + options.thread_extension_init.insert(McpServersDisabled); + let child = test + .thread_manager + .fork_legacy_thread( + ForkSnapshot::Interrupted, + options, + parent.rollout_path().expect("parent rollout"), + ) + .await?; + child.thread.restore_thread_settings(settings).await?; + test.codex = child.thread; + test.submit_text_turn("Predict the next message.").await?; + test.submit_text_turn("Predict again.").await?; + test.codex = parent; + test.submit_text_turn("Continue using the echo service.") + .await?; + + let requests = mock.requests(); + let parent = requests[0].body_json(); + let child = requests[1].body_json(); + let prefix = parent["input"].as_array().expect("parent input"); + assert_eq!( + &child["input"].as_array().expect("child input")[..prefix.len()], + prefix + ); + insta::assert_snapshot!( + "prediction_fork_mcp_removal", + context_snapshot::format_request_history_snapshot( + "The prediction fork preserves the incremental parent's complete prefix, then appends MCP removal. Its second turn retains that update; continuing the parent keeps its original tools.", + &requests, + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ) + ); + Ok(()) +} + #[test_case(true; "responses_lite")] #[test_case(false; "responses_api")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__prediction_fork_mcp_removal.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__prediction_fork_mcp_removal.snap new file mode 100644 index 0000000000..655d2bcbdf --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__incremental_tools__prediction_fork_mcp_removal.snap @@ -0,0 +1,90 @@ +--- +source: core/tests/suite/scenarios_incremental_tools.rs +expression: "context_snapshot::format_request_history_snapshot(\"The prediction fork preserves the incremental parent's complete prefix, then appends MCP removal. Its second turn retains that update; continuing the parent keeps its original tools.\",\n&requests,\n&ContextSnapshotOptions::default().rewrite_known_segments().include_request_settings(),)" +--- +Scenario: The prediction fork preserves the incremental parent's complete prefix, then appends MCP removal. Its second turn retains that update; continuing the parent keeps its original tools. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.4" + parallel_tool_calls: false + prompt_cache_key: "" + reasoning: {"context":"all_turns","effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=94348ab8527021ff): + - namespace/functions; hash=124fee69a69ed6b2 + - function/exec_command + - function/write_stdin + - function/list_mcp_resources + - function/list_mcp_resource_templates + - function/read_mcp_resource + - function/request_user_input + - custom/apply_patch + - function/view_image + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__echo_service: Use these tools to exercise the rmcp test server.; hash=6650953285437e87 + - function/echo +01:message/developer: + +02:message/developer: + +03:message/user: + +04:message/user: + Start with the echo service. +-- request 2 (turn) -- +05:message/developer: + The following namespaces are no longer available. Do not call tools in them unless those tools are declared in a later update: + - mcp__echo_service + + The following tools are no longer available. Do not call them: + - functions.list_mcp_resource_templates + - functions.list_mcp_resources + - functions.read_mcp_resource +06:message/user: + Predict the next message. +-- request 3 (turn) -- +07:message/user: + Predict again. + +## Window 2 (after request 3: input diverged at item 05) +Settings: same as window 1 +-- request 4 (turn) -- +00:additional_tools/developer (3; hash=94348ab8527021ff): + - namespace/functions; hash=124fee69a69ed6b2 + - function/exec_command + - function/write_stdin + - function/list_mcp_resources + - function/list_mcp_resource_templates + - function/read_mcp_resource + - function/request_user_input + - custom/apply_patch + - function/view_image + - namespace/multi_agent_v1: Tools for spawning and managing sub-agents.; hash=2327142bda177c50 + - function/close_agent + - function/resume_agent + - function/send_input + - function/spawn_agent + - function/wait_agent + - namespace/mcp__echo_service: Use these tools to exercise the rmcp test server.; hash=6650953285437e87 + - function/echo +01:message/developer: + +02:message/developer: + +03:message/user: + +04:message/user: + Start with the echo service. +05:message/user: + Continue using the echo service.