mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Add optional MCP disablement for prediction forks and samples (#55463)
## What changed Add `experimentalDisablePredictionMcp` to `thread/fork` and `disableMcp` to `thread/sample`. Disable MCP servers when requested and the inherited history contains incremental tools on Responses Lite. The fork option requires `experimentalPredictionMode: true`. Apply disablement through a thread-local policy that excludes configured MCP servers and runtime contributions, including after MCP refresh. Preserve the inherited history prefix and append tool-removal instructions without changing the parent's tools. ## Testing Add coverage for prediction forks with legacy and paginated history, flag validation, conditional disablement, MCP refresh, and sample/fork request parity. Add a request-history snapshot covering preserved history, tool removal across child turns, and unchanged parent tools. GitOrigin-RevId: e31d602480e6550ed130d8152f538b8668280fa0
This commit is contained in:
BIN
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -20,6 +20,9 @@ pub struct ThreadSampleParams {
|
||||
pub effort: Option<ReasoningEffort>,
|
||||
#[ts(optional = nullable)]
|
||||
pub turn_trigger: Option<String>,
|
||||
/// 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)]
|
||||
|
||||
@@ -5135,6 +5135,11 @@ impl ThreadRequestProcessor {
|
||||
app_server_client_version: Option<String>,
|
||||
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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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::<Value>(&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<_>>(),
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::<McpServersDisabled>().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();
|
||||
|
||||
@@ -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::<crate::mcp::McpServersDisabled>()
|
||||
.is_some()
|
||||
{
|
||||
return projection;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
+90
@@ -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: "<PROMPT_CACHE_KEY 1>"
|
||||
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:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
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:
|
||||
<MODEL_INSTRUCTIONS>
|
||||
02:message/developer:
|
||||
<PERMISSIONS_INSTRUCTIONS>
|
||||
03:message/user:
|
||||
<ENVIRONMENT_CONTEXT>
|
||||
04:message/user:
|
||||
Start with the echo service.
|
||||
05:message/user:
|
||||
Continue using the echo service.
|
||||
Reference in New Issue
Block a user