From 7a152fbcf9ecd32d9fb4b45edf80127ee4c8499e Mon Sep 17 00:00:00 2001 From: Henry Levy Date: Tue, 20 Oct 2026 17:08:42 +0000 Subject: [PATCH] Treat code-mode collaboration messages as plaintext (#55633) ## What changed Classify messages from code-mode collaboration calls as `AgentMessage::Plaintext` so they receive the context wrapper for plaintext messages. Apply this to `spawn_agent`, `send_message`, and `followup_task`. ## Testing Add a regression test that spawns an agent through `exec` and verifies that the child receives the task as plaintext with sender and recipient attribution. GitOrigin-RevId: 495c70ea13fd2bb06989e5aacab60beed0c19a56 --- .../src/tools/handlers/multi_agents_v2.rs | 1 + .../suite/code_mode_agent_message_tests.rs | 114 ++++++++++++++++++ .../tests/suite/subagent_notifications.rs | 3 + 3 files changed, 118 insertions(+) create mode 100644 codex-rs/core/tests/suite/code_mode_agent_message_tests.rs diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index ea44c0a427..8333a10d6f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -58,6 +58,7 @@ fn agent_message_from_tool( if matches!( source, crate::tools::context::ToolCallSource::DirectPlaintextMessage + | crate::tools::context::ToolCallSource::CodeMode { .. } ) { AgentMessage::Plaintext(message) } else { diff --git a/codex-rs/core/tests/suite/code_mode_agent_message_tests.rs b/codex-rs/core/tests/suite/code_mode_agent_message_tests.rs new file mode 100644 index 0000000000..178d9ff096 --- /dev/null +++ b/codex-rs/core/tests/suite/code_mode_agent_message_tests.rs @@ -0,0 +1,114 @@ +//! Exec-spawned tasks reach the child model as attributed plaintext. + +use anyhow::Result; +use codex_features::Feature; +use codex_protocol::openai_models::ToolMode; +use codex_protocol::protocol::EventMsg; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_custom_tool_call; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once_match; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; +use core_test_support::responses::strip_response_item_ids_from_json; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use wiremock::matchers::header; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_spawn_delivers_plaintext() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let test = test_codex() + .with_model("gpt-5.6-sol") + .with_model_info_override("gpt-5.6-sol", |model| { + model.tool_mode = Some(ToolMode::CodeMode); + model.use_responses_lite = true; + }) + .with_config(|config| { + for feature in [ + Feature::Collab, + Feature::MultiAgentV2, + Feature::CodeMode, + Feature::CodeModeHostGrpc, + ] { + config + .features + .enable(feature) + .expect("enable test feature"); + } + config.multi_agent_v2.non_code_mode_only = false; + }) + .build_with_auto_env(&server) + .await?; + let root_header = test.session_configured.thread_id.to_string(); + let mut created = test.thread_manager.subscribe_thread_created(); + + mount_sse_once_match( + &server, + header("thread-id", root_header.as_str()), + sse(vec![ + ev_response_created("spawn"), + ev_custom_tool_call( + "spawn-worker", + "exec", + r#"text(await tools.collaboration__spawn_agent({task_name: "worker", message: "Summarize the three stories.", fork_turns: "all"}));"#, + ), + ev_completed("spawn"), + ]), + ) + .await; + mount_sse_once_match( + &server, + header("thread-id", root_header.as_str()), + sse(vec![ + ev_assistant_message("delegated", "The worker started."), + ev_completed("delegated"), + ]), + ) + .await; + let child_requests = mount_sse_once_match( + &server, + move |request: &wiremock::Request| { + request + .headers + .get("thread-id") + .is_some_and(|id| id != root_header.as_str()) + }, + sse(vec![ev_response_created("child"), ev_completed("child")]), + ) + .await; + test.submit_turn("Delegate the summary task.").await?; + let child_id = created.try_recv()?; + let child = test.thread_manager.get_thread(child_id).await?; + wait_for_event(&child, |event| matches!(event, EventMsg::TurnComplete(_))).await; + + let child_request = child_requests + .requests() + .into_iter() + .find(|request| request.header("thread-id") == Some(child_id.to_string())) + .expect("child receives its task"); + assert_eq!( + strip_response_item_ids_from_json(strip_metadata_from_json(Value::Array( + child_request.inputs_of_type("agent_message"), + ))), + json!([{ + "type": "agent_message", + "author": "/root", + "recipient": "/root/worker", + "content": [{ + "type": "input_text", + "text": "Message Type: NEW_TASK\nTask name: /root/worker\nSender: /root\nPayload:\nSummarize the three stories.", + }], + }]), + ); + assert_eq!(test.thread_manager.list_thread_ids().await.len(), 2); + Ok(()) +} diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c689e5bb9f..c37557affa 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -83,6 +83,9 @@ mod spawn_settings_tests; #[path = "guardian_subagent_notification_tests.rs"] mod guardian_subagent_notification_tests; +#[path = "code_mode_agent_message_tests.rs"] +mod code_mode_agent_message_tests; + const SPAWN_CALL_ID: &str = "spawn-call-1"; const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration";