diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 897a07cc00..56aeec9903 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -97,6 +97,7 @@ use codex_model_provider_info::GCP_PROVIDER_ID; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; +use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_model_provider_info::built_in_model_providers; use codex_model_provider_info::merge_configured_model_providers; use codex_models_manager::ModelsManagerConfig; @@ -1711,6 +1712,14 @@ impl Config { .unwrap_or(model_messages.auto_review().policy) } + /// Native Guardian agent history requires the reviewed thread's OpenAI provider. + pub fn guardian_native_multi_agent_history_enabled(&self) -> bool { + self.model_provider_id == OPENAI_PROVIDER_ID + && self + .features + .enabled(Feature::GuardianNativeMultiAgentHistory) + } + pub(crate) fn multi_agent_version_override(&self) -> Option { if self.features.enabled(Feature::MultiAgentV2) { Some(MultiAgentVersion::V2) diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index a4afaae26a..b05088d7ab 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -1,5 +1,4 @@ use codex_extension_api::ConversationHistorySnapshot; -use codex_features::Feature; use codex_guardian_context::CollectedContext; use codex_guardian_context::ContextProfile; use codex_guardian_context::GuardianRootMessage; @@ -145,9 +144,7 @@ pub(super) async fn collect_guardian_prompt_items_with_parent_turn( profile.transcript.options.native_multi_agent_namespace = parent_context .is_some_and(|context| { let turn = context.turn(); - turn.config - .features - .enabled(Feature::GuardianNativeMultiAgentHistory) + turn.config.guardian_native_multi_agent_history_enabled() && turn.multi_agent_version == codex_protocol::protocol::MultiAgentVersion::V2 }) .then(|| { diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 56a716bc04..6274bc6fb7 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -1500,8 +1500,7 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, registry: &mut Too let tool_namespace = turn_context.config.multi_agent_v2.tool_namespace.as_deref(); let native_review = if turn_context .config - .features - .enabled(Feature::GuardianNativeMultiAgentHistory) + .guardian_native_multi_agent_history_enabled() { NativeReview::Required } else { diff --git a/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs b/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs index 61f2ef6d24..514b914504 100644 --- a/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs +++ b/codex-rs/core/tests/suite/scenarios_guardian_agent_messages_tests.rs @@ -1,4 +1,4 @@ -//! Guardian retains native encrypted messages and historical calls across incremental reviews. +//! OpenAI-only native agent review preserves encrypted history across reviews and resumes. use codex_core::config::Constrained; use codex_features::Feature; @@ -21,6 +21,102 @@ use pretty_assertions::assert_eq; use serde_json::json; use test_case::test_case; +#[test_case("openai", true, true; "openai_treatment")] +#[test_case("openai", false, false; "openai_control")] +#[test_case("amazon-bedrock-runtime", true, false; "bedrock")] +#[test_case("litellm-hybrid", true, false; "litellm")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn native_agent_review_requires_openai_on_start_and_resume( + provider_id: &'static str, + requested: bool, + expected_review: bool, +) -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = responses::start_mock_server().await; + let mut previous = None; + for phase in ["fresh", "resumed"] { + let mut builder = test_codex() + .with_model("gpt-5.5") + .with_config(move |config| { + super::configure_scenario_catalog(config); + config.model_provider_id = provider_id.to_owned(); + // Keep the mock Responses transport and OpenAI display name for every provider ID. + config + .model_providers + .insert(provider_id.to_owned(), config.model_provider.clone()); + for feature in [Feature::Collab, Feature::MultiAgentV2] { + config + .features + .enable(feature) + .expect("enable multi-agent feature"); + } + config + .features + .set_enabled(Feature::GuardianNativeMultiAgentHistory, requested) + .expect("set native agent review flag"); + config.multi_agent_v2.tool_namespace = Some("collaboration".to_owned()); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + config.permissions.approval_policy = + Constrained::allow_any(AskForApproval::OnRequest); + config + .permissions + .set_permission_profile(PermissionProfile::read_only()) + .expect("set read-only permissions"); + }); + let test = if let Some(previous) = &previous { + builder.restart_with_auto_env(&server, previous).await? + } else { + builder.build_with_auto_env(&server).await? + }; + let call_id = format!("send-{phase}"); + let mut call = responses::ev_function_call_with_namespace( + &call_id, + "collaboration", + "send_message", + r#"{"target":"/root/missing","message":"opaque-message"}"#, + ); + call["item"]["encrypted_content"] = json!("opaque-replay"); + call["item"]["encrypted_function_args"] = json!(["message"]); + let mut stream = vec![responses::sse(vec![call, responses::ev_completed("send")])]; + if expected_review { + stream.push(responses::sse(vec![ + responses::ev_assistant_message("assessment", r#"{"outcome":"allow","rationale":"Mock approval.","risk_level":"low","user_authorization":"high"}"#), + responses::ev_completed("review"), + ])); + } + stream.push(responses::sse(vec![ + responses::ev_assistant_message("done", "The worker is unavailable."), + responses::ev_completed("done"), + ])); + let mock = responses::mount_sse_sequence(&server, stream).await; + test.submit_text_turn("Ask the missing worker to confirm receipt.") + .await?; + let requests = mock.requests(); + let reviews = requests + .iter() + .filter(|request| { + request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian" + }) + .collect::>(); + assert_eq!( + reviews.len(), + usize::from(expected_review), + "{phase}: {provider_id}" + ); + if let Some(review) = reviews.first() { + assert!( + review + .inputs_of_type("function_call") + .iter() + .any(|call| call["call_id"] == call_id), + "{phase}: native review evidence" + ); + } + previous = Some(test); + } + Ok(()) +} + #[cfg_attr(not(target_os = "windows"), test_case(None; "agent_reply"))] #[test_case(Some("functions"); "native_default_namespace")] #[test_case(Some("team"); "native_custom_namespace")] diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs index 6cc765638c..0d3454eade 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs @@ -80,9 +80,7 @@ impl GuardianV2Config { ..configured }); resolved.policy = GuardianPolicyLoader::new(Some(&legacy)); - resolved.review_multi_agent_calls = config - .features - .enabled(codex_features::Feature::GuardianNativeMultiAgentHistory); + resolved.review_multi_agent_calls = config.guardian_native_multi_agent_history_enabled(); resolved.multi_agent_namespace = config.multi_agent_v2.tool_namespace.clone(); Ok(resolved) }