From 74004b5397b24662a87a5264a6ae80664168c7f3 Mon Sep 17 00:00:00 2001 From: Ankush Gupta Date: Wed, 12 Aug 2026 19:04:39 +0000 Subject: [PATCH] Include Node REPL policy in turn metadata (#38241) ## What changed - Parse `node_repl_auto_review_required` and `node_repl_disabled` from model catalog entries and include their values in each turn's Responses API metadata. - Treat both fields as reserved metadata so client-provided values cannot override the selected model's policy. - Use the selected review model's catalog entry when building metadata for review turns. ## Testing - Cover metadata propagation across model switches, review turns, Responses API requests, and MCP tool calls. GitOrigin-RevId: c1f63426eeb8e559abff439fbc618716a4908aff --- .../app-server/tests/common/models_cache.rs | 2 + .../codex-api/tests/models_integration.rs | 4 ++ codex-rs/core/src/mcp_tool_call_tests.rs | 42 +++++++++----- codex-rs/core/src/responses_metadata.rs | 14 +++++ codex-rs/core/src/session/review.rs | 17 +++--- codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/turn_metadata.rs | 8 +++ codex-rs/core/src/turn_metadata_tests.rs | 28 ++++++++++ codex-rs/core/tests/suite/agent_websocket.rs | 16 ++++++ codex-rs/core/tests/suite/auto_review.rs | 2 + codex-rs/core/tests/suite/model_switching.rs | 4 ++ codex-rs/core/tests/suite/models_cache_ttl.rs | 2 + codex-rs/core/tests/suite/personality.rs | 4 ++ codex-rs/core/tests/suite/remote_models.rs | 6 ++ codex-rs/core/tests/suite/review.rs | 24 ++++++++ codex-rs/core/tests/suite/rmcp_client.rs | 56 ++++++++++++++++++- ...pi_auth_prompt_cache_key_request_diff.snap | 2 +- ...ce_tier_prompt_cache_key_request_diff.snap | 2 +- .../tests/suite/spawn_agent_description.rs | 2 + codex-rs/core/tests/suite/view_image.rs | 2 + codex-rs/models-manager/src/model_info.rs | 2 + codex-rs/protocol/src/openai_models.rs | 8 +++ codex-rs/tools/src/tool_config_tests.rs | 2 + 23 files changed, 223 insertions(+), 27 deletions(-) diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 86ce5e6801..220d4615ce 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -65,6 +65,8 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index f26a44d6cf..6d59fd4976 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -103,6 +103,8 @@ async fn models_client_hits_models_endpoint() { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: true, + node_repl_disabled: true, auto_review_model_override: None, model_specialty: None, tool_mode: None, @@ -136,6 +138,8 @@ async fn models_client_hits_models_endpoint() { assert_eq!(models.len(), 1); assert_eq!(models[0].slug, "gpt-test"); + assert!(models[0].node_repl_auto_review_required); + assert!(models[0].node_repl_disabled); let received = server .received_requests() diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index cad340d7c0..71a6653d1f 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -97,6 +97,13 @@ fn mcp_turn_metadata_context(turn_context: &TurnContext) -> McpTurnMetadataConte } } +fn expected_mcp_turn_metadata(turn_context: &TurnContext) -> serde_json::Value { + turn_context + .turn_metadata_state + .current_meta_value_for_mcp_request(mcp_turn_metadata_context(turn_context)) + .expect("turn metadata") +} + fn write_sample_plugin_mcp(codex_home: &std::path::Path) { let plugin_root = codex_home.join("plugins/cache/test/sample/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create plugin manifest dir"); @@ -1004,10 +1011,16 @@ fn truncate_mcp_tool_result_for_event_bounds_large_error() { #[tokio::test] async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = turn_context + turn_context .turn_metadata_state - .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) - .expect("turn metadata"); + .set_responsesapi_client_metadata(HashMap::from([ + ( + "node_repl_auto_review_required".to_string(), + "true".to_string(), + ), + ("node_repl_disabled".to_string(), "true".to_string()), + ])); + let expected_turn_metadata = expected_mcp_turn_metadata(&turn_context); let meta = build_mcp_tool_call_request_meta( &turn_context, @@ -1026,6 +1039,14 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { .and_then(serde_json::Value::as_str), Some(turn_context.model_info.slug.as_str()) ); + assert_eq!( + turn_metadata["node_repl_auto_review_required"], + serde_json::Value::Bool(turn_context.model_info.node_repl_auto_review_required), + ); + assert_eq!( + turn_metadata["node_repl_disabled"], + serde_json::Value::Bool(turn_context.model_info.node_repl_disabled), + ); assert_eq!( turn_metadata .get("reasoning_effort") @@ -1122,10 +1143,7 @@ async fn mcp_sandbox_cwd_is_none_for_unselected_server_environment() -> anyhow:: #[tokio::test] async fn plugin_mcp_tool_call_request_meta_includes_plugin_id() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = turn_context - .turn_metadata_state - .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) - .expect("turn metadata"); + let expected_turn_metadata = expected_mcp_turn_metadata(&turn_context); let mut metadata = approval_metadata( /*connector_id*/ None, /*connector_name*/ None, /*connector_description*/ None, /*tool_title*/ None, @@ -1246,10 +1264,7 @@ async fn mcp_tool_call_item_includes_app_identity() { #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = turn_context - .turn_metadata_state - .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) - .expect("turn metadata"); + let expected_turn_metadata = expected_mcp_turn_metadata(&turn_context); let metadata = McpToolApprovalMetadata { annotations: None, connector_id: Some("calendar".to_string()), @@ -1297,10 +1312,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps #[tokio::test] async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_codex_apps_meta() { let (_, turn_context) = make_session_and_context().await; - let expected_turn_metadata = turn_context - .turn_metadata_state - .current_meta_value_for_mcp_request(mcp_turn_metadata_context(&turn_context)) - .expect("turn metadata"); + let expected_turn_metadata = expected_mcp_turn_metadata(&turn_context); assert_eq!( build_mcp_tool_call_request_meta( diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index e785af35ab..af2326968a 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -44,6 +44,8 @@ pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source"; pub(crate) const SANDBOX_KEY: &str = "sandbox"; pub(crate) const SANDBOX_MODE_KEY: &str = "sandbox_mode"; pub(crate) const AUTO_REVIEW_ENABLED_KEY: &str = "auto_review_enabled"; +pub(crate) const NODE_REPL_AUTO_REVIEW_REQUIRED_KEY: &str = "node_repl_auto_review_required"; +pub(crate) const NODE_REPL_DISABLED_KEY: &str = "node_repl_disabled"; pub(crate) const WORKSPACES_KEY: &str = "workspaces"; // App-server clients can specify additional metadata in the `responsesapi_client_metadata` param @@ -73,6 +75,8 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ SANDBOX_KEY, SANDBOX_MODE_KEY, AUTO_REVIEW_ENABLED_KEY, + NODE_REPL_AUTO_REVIEW_REQUIRED_KEY, + NODE_REPL_DISABLED_KEY, WORKSPACES_KEY, ]; const MAX_EXTRA_METADATA_ENTRIES: usize = 16; @@ -213,6 +217,8 @@ pub struct CodexResponsesMetadata { pub(crate) sandbox: Option, pub(crate) sandbox_mode: Option, pub(crate) auto_review_enabled: Option, + pub(crate) node_repl_auto_review_required: Option, + pub(crate) node_repl_disabled: Option, pub(crate) workspaces: BTreeMap, pub(crate) tool_namespaces_info: Option, pub(crate) turn_started_at_unix_ms: Option, @@ -244,6 +250,8 @@ impl CodexResponsesMetadata { sandbox: None, sandbox_mode: None, auto_review_enabled: None, + node_repl_auto_review_required: None, + node_repl_disabled: None, workspaces: BTreeMap::new(), tool_namespaces_info: None, turn_started_at_unix_ms: None, @@ -360,6 +368,8 @@ impl CodexResponsesMetadata { sandbox: self.sandbox.as_deref(), sandbox_mode: self.sandbox_mode.as_deref(), auto_review_enabled: self.auto_review_enabled, + node_repl_auto_review_required: self.node_repl_auto_review_required, + node_repl_disabled: self.node_repl_disabled, workspaces: non_empty_workspaces(&self.workspaces), tool_namespaces_info: self.tool_namespaces_info.as_ref(), turn_started_at_unix_ms: self.turn_started_at_unix_ms, @@ -488,6 +498,10 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] auto_review_enabled: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + node_repl_auto_review_required: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + node_repl_disabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] workspaces: Option<&'a BTreeMap>, #[serde(default, skip_serializing_if = "Option::is_none")] tool_namespaces_info: Option<&'a TurnToolNamespacesInfo>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 9bb5c33428..c115fbd479 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -13,6 +13,14 @@ pub(super) async fn spawn_review_thread( .review_model .clone() .unwrap_or_else(|| parent_turn_context.model_info.slug.clone()); + let available_models = sess + .services + .models_manager + .list_models( + RefreshStrategy::OnlineIfUncached, + config.http_client_factory(), + ) + .await; let review_model_info = sess .services .models_manager @@ -24,14 +32,6 @@ pub(super) async fn spawn_review_thread( let _ = review_features.disable(Feature::WebSearchCached); let _ = review_features.disable(Feature::Goals); let review_web_search_mode = WebSearchMode::Disabled; - let available_models = sess - .services - .models_manager - .list_models( - RefreshStrategy::OnlineIfUncached, - config.http_client_factory(), - ) - .await; let unified_exec_shell_mode = UnifiedExecShellMode::for_session( codex_tools::unified_exec_feature_mode_for_features(review_features.get()), crate::tools::tool_user_shell_type(sess.services.user_shell.as_ref()), @@ -120,6 +120,7 @@ pub(super) async fn spawn_review_thread( parent_turn_context.windows_sandbox_level, parent_turn_context.network.is_some(), auto_review_enabled, + &model_info, )); if turn_metadata_state.can_start_root_turn(&session_source) { turn_metadata_state.set_root_turn_id(review_turn_id.clone()); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 60f1d14cc3..aca4785b2d 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -605,6 +605,7 @@ impl Session { session_configuration.windows_sandbox_level, network.is_some(), auto_review_enabled, + &model_info, )); turn_metadata_state .set_responses_api_metadata(per_turn_config.responses_api_metadata.clone()); diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index 27ead5c714..5b5d928c75 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -30,6 +30,7 @@ use codex_git_utils::get_head_commit_hash; use codex_protocol::ThreadId; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; @@ -110,6 +111,8 @@ pub(crate) struct TurnMetadataState { sandbox: Option, sandbox_mode: Option, auto_review_enabled: bool, + node_repl_auto_review_required: bool, + node_repl_disabled: bool, enriched_workspaces: RwLock>>, tool_namespaces_info: RwLock>, turn_started_at_unix_ms: RwLock>, @@ -135,6 +138,7 @@ impl TurnMetadataState { windows_sandbox_level: WindowsSandboxLevel, enforce_managed_network: bool, auto_review_enabled: bool, + model_info: &ModelInfo, ) -> Self { let repo_root = get_git_repo_root(&cwd); let sandbox = Some( @@ -163,6 +167,8 @@ impl TurnMetadataState { sandbox, sandbox_mode, auto_review_enabled, + node_repl_auto_review_required: model_info.node_repl_auto_review_required, + node_repl_disabled: model_info.node_repl_disabled, enriched_workspaces: RwLock::new(None), tool_namespaces_info: RwLock::new(None), turn_started_at_unix_ms: RwLock::new(None), @@ -347,6 +353,8 @@ impl TurnMetadataState { sandbox: self.sandbox.clone(), sandbox_mode: self.sandbox_mode.clone(), auto_review_enabled: Some(self.auto_review_enabled), + node_repl_auto_review_required: Some(self.node_repl_auto_review_required), + node_repl_disabled: Some(self.node_repl_disabled), workspaces: self.current_workspaces(), tool_namespaces_info: self .tool_namespaces_info diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index aa0c9375de..ef416c1bda 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -5,6 +5,8 @@ use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_metadata::INSTALLATION_ID_KEY; use crate::responses_metadata::LEGACY_CODE_MODE_TOOL_NAMES_KEY; +use crate::responses_metadata::NODE_REPL_AUTO_REVIEW_REQUIRED_KEY; +use crate::responses_metadata::NODE_REPL_DISABLED_KEY; use crate::responses_metadata::PARENT_TURN_ID_KEY; use crate::responses_metadata::ROOT_TURN_ID_KEY; use crate::responses_metadata::SANDBOX_MODE_KEY; @@ -19,6 +21,7 @@ use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; +use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; @@ -235,6 +238,7 @@ fn turn_metadata_state_includes_sandbox_metadata() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ true, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -283,6 +287,7 @@ fn turn_metadata_state_includes_root_fork_lineage() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -323,6 +328,7 @@ fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -363,6 +369,7 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -405,6 +412,7 @@ fn turn_metadata_state_includes_known_parent_for_non_thread_spawn_subagents_with WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -438,6 +446,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); state.set_turn_started_at_unix_ms(/*turn_started_at_unix_ms*/ 1_700_000_000_123); @@ -469,6 +478,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -519,6 +529,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); let header = test_turn_metadata_header(&state); @@ -573,6 +584,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); state.set_responsesapi_client_metadata(HashMap::from([ ( @@ -603,6 +615,11 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { "danger-full-access".to_string(), ), (AUTO_REVIEW_ENABLED_KEY.to_string(), "true".to_string()), + ( + NODE_REPL_AUTO_REVIEW_REQUIRED_KEY.to_string(), + "true".to_string(), + ), + (NODE_REPL_DISABLED_KEY.to_string(), "true".to_string()), ])); let header = test_turn_metadata_header(&state); @@ -618,6 +635,11 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { assert!(json.get("subagent_kind").is_none()); assert_eq!(json[SANDBOX_MODE_KEY].as_str(), Some("read-only")); assert_eq!(json[AUTO_REVIEW_ENABLED_KEY].as_bool(), Some(false)); + assert_eq!( + json[NODE_REPL_AUTO_REVIEW_REQUIRED_KEY].as_bool(), + Some(false) + ); + assert_eq!(json[NODE_REPL_DISABLED_KEY].as_bool(), Some(false)); } #[test] @@ -649,6 +671,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); state.set_responses_api_metadata(BTreeMap::from([( "codex_security_surface".to_string(), @@ -873,6 +896,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), ); state.set_responses_api_metadata(BTreeMap::from([( "codex_security_surface".to_string(), @@ -953,6 +977,7 @@ async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), )); state.spawn_git_enrichment_task(); @@ -996,6 +1021,7 @@ async fn turn_metadata_state_coalesces_concurrent_git_enrichment() { WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), )); let barrier = Arc::new(tokio::sync::Barrier::new(8)); let tasks = (0..8) @@ -1050,6 +1076,7 @@ async fn turn_metadata_state_git_enrichment_cancellation_is_retryable_and_errors WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), )); state.spawn_git_enrichment_task(); state.cancel_git_enrichment_task(); @@ -1084,6 +1111,7 @@ async fn turn_metadata_state_git_enrichment_cancellation_is_retryable_and_errors WindowsSandboxLevel::Disabled, /*enforce_managed_network*/ false, /*auto_review_enabled*/ false, + &model_info_from_slug("gpt-5.4"), )); invalid_state.spawn_git_enrichment_task(); tokio::time::timeout(Duration::from_secs(2), async { diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index 5677b03c9c..ddb719cd22 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -36,10 +36,12 @@ async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Res let mut builder = test_codex() .with_model_info_override("gpt-5.2", |model_info| { model_info.tool_mode = Some(ToolMode::CodeMode); + model_info.node_repl_auto_review_required = true; }) .with_model_info_override("gpt-5.4", |model_info| { model_info.use_responses_lite = true; model_info.tool_mode = Some(ToolMode::CodeMode); + model_info.node_repl_disabled = true; }) .with_model("gpt-5.2"); let test = builder.build_with_websocket_server(&server).await?; @@ -79,6 +81,20 @@ async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Res assert_eq!(non_lite_turn["model"].as_str(), Some("gpt-5.2")); assert_eq!(lite_turn["model"].as_str(), Some("gpt-5.4")); + for (request, auto_review_required, disabled) in + [(&non_lite_turn, true, false), (&lite_turn, false, true)] + { + let metadata: Value = serde_json::from_str( + request["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("websocket request should include turn metadata"), + )?; + assert_eq!( + metadata["node_repl_auto_review_required"], + auto_review_required + ); + assert_eq!(metadata["node_repl_disabled"], disabled); + } assert!( non_lite_turn .get("tools") diff --git a/codex-rs/core/tests/suite/auto_review.rs b/codex-rs/core/tests/suite/auto_review.rs index a0d4df6483..5823659398 100644 --- a/codex-rs/core/tests/suite/auto_review.rs +++ b/codex-rs/core/tests/suite/auto_review.rs @@ -260,6 +260,8 @@ fn remote_model_with_auto_review_override(slug: &str, review_model: &str) -> Mod used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: Some(review_model.to_string()), model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index bd4df3f6d3..bb2ea22a39 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -117,6 +117,8 @@ fn test_model_info( used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, @@ -1187,6 +1189,8 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result< used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index 60a82bd80c..5c6a9137e6 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -534,6 +534,8 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index fa61fc1303..f70866d439 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -669,6 +669,8 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, @@ -792,6 +794,8 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 5e8950a43f..1a8bd6cf4d 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -527,6 +527,8 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, @@ -783,6 +785,8 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, @@ -1368,6 +1372,8 @@ fn test_remote_model_with_policy( used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 86d55e4c87..e122f27fb4 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -7,6 +7,7 @@ use codex_exec_server::CreateDirectoryOptions; use codex_features::Feature; use codex_history::RolloutItem; use codex_history::RolloutLine; +use codex_login::CodexAuth; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ServiceTier; use codex_protocol::items::TurnItem; @@ -774,6 +775,7 @@ async fn review_uses_custom_review_model_from_config() { let codex_home = Arc::new(TempDir::new().unwrap()); let test = test_codex() .with_home(Arc::clone(&codex_home)) + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_config(|config| { config.model = Some("gpt-4.1".to_string()); config.review_model = Some("custom-review-model".to_string()); @@ -783,6 +785,19 @@ async fn review_uses_custom_review_model_from_config() { .await .expect("custom review conversation should be created"); let codex = Arc::clone(&test.codex); + std::fs::remove_file(codex_home.path().join("models_cache.json")) + .expect("initial empty model catalog should be cached"); + let mut models = codex_models_manager::bundled_models_response() + .expect("bundled model catalog should parse"); + let model = models + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.6-sol") + .expect("bundled model should exist"); + model.slug = "custom-review-model".to_string(); + model.node_repl_auto_review_required = true; + model.node_repl_disabled = true; + let models_mock = responses::mount_models_once(&server, models).await; codex .submit(Op::Review { @@ -816,6 +831,15 @@ async fn review_uses_custom_review_model_from_config() { let body = request.body_json(); assert_eq!(body["model"].as_str().unwrap(), "custom-review-model"); assert_eq!(body["reasoning"]["effort"].as_str(), Some("max")); + let turn_metadata: serde_json::Value = serde_json::from_str( + &request + .header("x-codex-turn-metadata") + .expect("review request turn metadata"), + ) + .expect("review request turn metadata json"); + assert_eq!(turn_metadata["node_repl_auto_review_required"], true); + assert_eq!(turn_metadata["node_repl_disabled"], true); + assert_eq!(models_mock.requests().len(), 1); let _codex_home_guard = codex_home; server.verify().await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index b1f1f18808..9f7a29b20e 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -1344,8 +1344,15 @@ async fn local_stdio_server_uses_runtime_fallback_cwd_when_config_omits_cwd() -> Ok(()) } +#[test_case(false, false; "both disabled")] +#[test_case(true, false; "auto review required")] +#[test_case(false, true; "disabled")] +#[test_case(true, true; "both enabled")] #[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> { +async fn stdio_mcp_tool_call_includes_sandbox_state_meta( + node_repl_auto_review_required: bool, + node_repl_disabled: bool, +) -> anyhow::Result<()> { // TODO(anp): Remove after packaging a Windows stdio test server for Wine exec. skip_if_wine_exec!( Ok(()), @@ -1358,8 +1365,17 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let call_id = "sandbox-meta-call"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); + let mut models = codex_models_manager::bundled_models_response()?; + let model = models + .models + .iter_mut() + .find(|model| model.slug == "gpt-5.4") + .expect("bundled model should exist"); + model.node_repl_auto_review_required = node_repl_auto_review_required; + model.node_repl_disabled = node_repl_disabled; + let models_mock = mount_models_once(&server, models).await; - mount_sse_once( + let initial_mock = mount_sse_once( &server, responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -1379,6 +1395,8 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model("gpt-5.4") .with_config(move |config| { insert_mcp_server( config, @@ -1394,6 +1412,15 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> .await?; wait_for_mcp_server(&fixture.codex, server_name).await?; + fixture + .thread_manager + .get_models_manager() + .list_models( + RefreshStrategy::Online, + codex_core::test_support::default_http_client_factory(), + ) + .await; + assert_eq!(models_mock.requests().len(), 1); fixture .submit_turn_with_permission_profile( @@ -1402,6 +1429,21 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> ) .await?; + let initial_request = initial_mock.single_request().body_json(); + let response_metadata: Value = serde_json::from_str( + initial_request["client_metadata"]["x-codex-turn-metadata"] + .as_str() + .expect("responses request should include turn metadata"), + )?; + assert_eq!( + response_metadata["node_repl_auto_review_required"], + json!(node_repl_auto_review_required), + ); + assert_eq!( + response_metadata["node_repl_disabled"], + json!(node_repl_disabled) + ); + let output_item = final_mock.single_request().function_call_output(call_id); let output_text = output_item .get("output") @@ -1413,6 +1455,14 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let meta = output_json .as_object() .expect("sandbox_meta should return metadata object"); + assert_eq!( + output_json.pointer("/x-codex-turn-metadata/node_repl_auto_review_required"), + Some(&json!(node_repl_auto_review_required)) + ); + assert_eq!( + output_json.pointer("/x-codex-turn-metadata/node_repl_disabled"), + Some(&json!(node_repl_disabled)) + ); let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) @@ -2240,6 +2290,8 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap index 9518f2912e..c693e4ca42 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_api_auth_prompt_cache_key_request_diff.snap @@ -12,7 +12,7 @@ Scenario: After five varied API-key-auth turns, remote manual compaction omits s - "thread_id": "", - "turn_id": "", - "x-codex-installation-id": "", -- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"read-only\",\"auto_review_enabled\":false,\"turn_started_at_unix_ms\":}", +- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"read-only\",\"auto_review_enabled\":false,\"node_repl_auto_review_required\":false,\"node_repl_disabled\":false,\"turn_started_at_unix_ms\":}", - "x-codex-window-id": ":0" - }, - "include": [ diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap index 97dd35e113..d40c967e2c 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_manual_compact_chatgpt_auth_service_tier_prompt_cache_key_request_diff.snap @@ -12,7 +12,7 @@ Scenario: After five varied ChatGPT-auth turns, remote manual compaction reuses - "thread_id": "", - "turn_id": "", - "x-codex-installation-id": "", -- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"read-only\",\"auto_review_enabled\":false,\"turn_started_at_unix_ms\":}", +- "x-codex-turn-metadata": "{\"installation_id\":\"\",\"session_id\":\"\",\"thread_id\":\"\",\"turn_id\":\"\",\"window_id\":\":0\",\"request_kind\":\"turn\",\"root_turn_id\":\"\",\"sandbox\":\"\",\"sandbox_mode\":\"read-only\",\"auto_review_enabled\":false,\"node_repl_auto_review_required\":false,\"node_repl_disabled\":false,\"turn_started_at_unix_ms\":}", - "x-codex-window-id": ":0" - }, - "include": [ diff --git a/codex-rs/core/tests/suite/spawn_agent_description.rs b/codex-rs/core/tests/suite/spawn_agent_description.rs index 4d4dacf7ef..d78511193a 100644 --- a/codex-rs/core/tests/suite/spawn_agent_description.rs +++ b/codex-rs/core/tests/suite/spawn_agent_description.rs @@ -74,6 +74,8 @@ fn test_model_info( used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index fe9928e198..ace0eba4cc 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -1620,6 +1620,8 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 5c1356fbf7..e9dc94ff4a 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -175,6 +175,8 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { used_fallback_model_metadata: true, // this is the fallback model metadata supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index e6c5db8d93..a0ebcc7c7a 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -442,6 +442,10 @@ pub struct ModelInfo { pub supports_search_tool: bool, #[serde(default)] pub use_responses_lite: bool, + #[serde(default)] + pub node_repl_auto_review_required: bool, + #[serde(default)] + pub node_repl_disabled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub auto_review_model_override: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -855,6 +859,8 @@ mod tests { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None, @@ -1435,6 +1441,8 @@ mod tests { assert_eq!(model.web_search_tool_type, WebSearchToolType::Text); assert!(!model.supports_search_tool); assert!(!model.use_responses_lite); + assert!(!model.node_repl_auto_review_required); + assert!(!model.node_repl_disabled); assert_eq!(model.comp_hash, None); assert_eq!(model.auto_review_model_override, None); assert_eq!(model.tool_mode, None); diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 89d21f06f2..49b8bee710 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -48,6 +48,8 @@ fn model_with_shell_type(shell_type: ConfigShellToolType) -> ModelInfo { used_fallback_model_metadata: false, supports_search_tool: false, use_responses_lite: false, + node_repl_auto_review_required: false, + node_repl_disabled: false, auto_review_model_override: None, model_specialty: None, tool_mode: None,