From 9f82ffed1b2fb63370055dad3f2ad63564eeebbf Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 27 Jul 2026 18:07:16 +0000 Subject: [PATCH] Test developer instruction inheritance for multi-agent workers (#35653) ## Testing - Verify full, compacted, and bounded history forks preserve developer messages while removing stale parent usage hints. - Add an app-server regression test that cold-resumes a root thread and confirms a roleless worker retains inherited developer instructions when lazily reloaded for follow-up work. GitOrigin-RevId: 40e454a2a2b721ea645cbc607b17a3ae6b617bd9 --- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../multi_agent_v2_developer_instructions.rs | 255 ++++++++++++++++++ codex-rs/core/src/agent/control_tests.rs | 91 +++++++ 3 files changed, 347 insertions(+) create mode 100644 codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 83d5c37671..5f766bacce 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -43,6 +43,7 @@ mod mcp_tool; mod memory_reset; mod model_list; mod model_provider_capabilities_read; +mod multi_agent_v2_developer_instructions; mod output_schema; mod permission_profile_list; mod plan_item; diff --git a/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs b/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs new file mode 100644 index 0000000000..958176062a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/multi_agent_v2_developer_instructions.rs @@ -0,0 +1,255 @@ +use anyhow::Result; +use app_test_support::MockResponsesConfig; +use app_test_support::TestAppServer; +use app_test_support::write_models_cache; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput; +use core_test_support::responses; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +#[cfg(windows)] +const READ_TIMEOUT: Duration = Duration::from_secs(25); +#[cfg(not(windows))] +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// A roleless worker keeps inherited developer instructions across cold root resume and lazy reload. +#[tokio::test] +async fn cold_resume_preserves_inherited_developer_instructions_for_roleless_worker() -> Result<()> +{ + const PARENT_INSTRUCTIONS: &str = "parent-only developer instructions"; + const INITIAL_PROMPT: &str = "spawn a durable instruction worker"; + const INITIAL_TASK: &str = "perform the initial durable instruction task"; + const FOLLOWUP_PROMPT: &str = "continue the durable instruction worker"; + const FOLLOWUP_TASK: &str = "perform the resumed durable instruction task"; + const SPAWN_CALL_ID: &str = "spawn-durable-instruction-worker"; + const WAIT_CALL_ID: &str = "wait-for-durable-instruction-worker"; + const FOLLOWUP_CALL_ID: &str = "followup-durable-instruction-worker"; + const NAMESPACE: &str = "collaboration"; + + let server = responses::start_mock_server().await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + String::from_utf8_lossy(&request.body).contains(INITIAL_PROMPT) + }, + responses::sse(vec![ + responses::ev_response_created("initial-parent-spawn"), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + NAMESPACE, + "spawn_agent", + &serde_json::to_string(&json!({ + "message": INITIAL_TASK, + "task_name": "worker", + "fork_turns": "none", + }))?, + ), + responses::ev_completed("initial-parent-spawn"), + ]), + ) + .await; + let initial_child_request = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + let body = String::from_utf8_lossy(&request.body); + body.contains(INITIAL_TASK) && !body.contains(SPAWN_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("initial-child-work"), + responses::ev_assistant_message("initial-child-message", "initial child complete"), + responses::ev_completed("initial-child-work"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + let body = String::from_utf8_lossy(&request.body); + body.contains(SPAWN_CALL_ID) && !body.contains(WAIT_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("initial-parent-wait"), + responses::ev_function_call_with_namespace(WAIT_CALL_ID, NAMESPACE, "wait_agent", "{}"), + responses::ev_completed("initial-parent-wait"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| String::from_utf8_lossy(&request.body).contains(WAIT_CALL_ID), + responses::sse(vec![ + responses::ev_response_created("initial-parent-complete"), + responses::ev_assistant_message("initial-parent-message", "initial parent complete"), + responses::ev_completed("initial-parent-complete"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + MockResponsesConfig::new(&server.uri()) + .with_model("gpt-5.4") + .with_root_config(&format!("developer_instructions = {PARENT_INSTRUCTIONS:?}")) + .with_extra_config("[features.multi_agent_v2]\nenabled = true") + .write(codex_home.path())?; + write_models_cache(codex_home.path())?; + + let thread_id = { + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let ThreadStartResponse { thread, .. } = app_server + .start_thread(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + timeout( + READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: INITIAL_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }), + ) + .await??; + + let initial_child_request = initial_child_request + .requests() + .into_iter() + .find(|request| !request.inputs_of_type("agent_message").is_empty()) + .expect("initial worker model request"); + let developer_texts = initial_child_request.message_input_texts("developer"); + assert!( + developer_texts + .iter() + .any(|text| text == PARENT_INSTRUCTIONS), + "worker did not inherit parent developer instructions: {developer_texts:?}" + ); + + let shutdown = timeout(READ_TIMEOUT, app_server.shutdown_gracefully()).await??; + assert!( + shutdown.success(), + "initial app-server shutdown failed: {shutdown}" + ); + thread.id + }; + + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + let body = String::from_utf8_lossy(&request.body); + body.contains(FOLLOWUP_PROMPT) && !body.contains(FOLLOWUP_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("resumed-parent-followup"), + responses::ev_function_call_with_namespace( + FOLLOWUP_CALL_ID, + NAMESPACE, + "followup_task", + &serde_json::to_string(&json!({ + "target": "worker", + "message": FOLLOWUP_TASK, + }))?, + ), + responses::ev_completed("resumed-parent-followup"), + ]), + ) + .await; + let resumed_child_request = responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + let body = String::from_utf8_lossy(&request.body); + body.contains(FOLLOWUP_TASK) && !body.contains(FOLLOWUP_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("resumed-child-work"), + responses::ev_assistant_message("resumed-child-message", "resumed child complete"), + responses::ev_completed("resumed-child-work"), + ]), + ) + .await; + responses::mount_sse_once_match( + &server, + |request: &wiremock::Request| { + String::from_utf8_lossy(&request.body).contains(FOLLOWUP_CALL_ID) + }, + responses::sse(vec![ + responses::ev_response_created("resumed-parent-complete"), + responses::ev_assistant_message("resumed-parent-message", "resumed parent complete"), + responses::ev_completed("resumed-parent-complete"), + ]), + ) + .await; + + let mut app_server = TestAppServer::builder() + .with_codex_home(codex_home.path()) + .build_initialized() + .await?; + let _: ThreadResumeResponse = app_server + .request(|request_id| ClientRequest::ThreadResume { + request_id, + params: ThreadResumeParams { + thread_id: thread_id.clone(), + ..Default::default() + }, + }) + .await?; + let _: TurnStartResponse = app_server + .request(|request_id| ClientRequest::TurnStart { + request_id, + params: TurnStartParams { + thread_id, + input: vec![UserInput::Text { + text: FOLLOWUP_PROMPT.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await?; + let resumed_child_request = timeout(READ_TIMEOUT, async { + loop { + if let Some(request) = resumed_child_request + .requests() + .into_iter() + .find(|request| { + request + .inputs_of_type("agent_message") + .iter() + .any(|message| { + message.get("recipient").and_then(serde_json::Value::as_str) + == Some("/root/worker") + }) + && request.body_contains_text(FOLLOWUP_TASK) + }) + { + break request; + } + tokio::task::yield_now().await; + } + }) + .await?; + + let developer_texts = resumed_child_request.message_input_texts("developer"); + assert!( + developer_texts + .iter() + .any(|text| text == PARENT_INSTRUCTIONS), + "resumed worker lost its inherited developer instructions: {developer_texts:?}" + ); + + Ok(()) +} diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7269561f88..af3e1fd474 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1209,6 +1209,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let harness = AgentControlHarness::new().await; let mut parent_config = harness.config.clone(); let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); parent_config.multi_agent_v2.root_agent_usage_hint_text = Some("Parent root guidance.".to_string()); parent_config.multi_agent_v2.subagent_usage_hint_text = @@ -1269,6 +1270,20 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { phase: None, internal_chat_message_metadata_passthrough: None, }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ + ContentItem::InputText { + text: "Parent developer instructions.".to_string(), + }, + ContentItem::InputText { + text: "Preserved developer context.".to_string(), + }, + ], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), assistant_message("parent unknown phase", /*phase*/ None), @@ -1333,8 +1348,24 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { let mut expected_final_answer = assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)); expected_final_answer.set_turn_id_if_missing(&turn_context.sub_id); + let mut expected_developer_message = ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ + ContentItem::InputText { + text: "Parent developer instructions.".to_string(), + }, + ContentItem::InputText { + text: "Preserved developer context.".to_string(), + }, + ], + phase: None, + internal_chat_message_metadata_passthrough: None, + }; + expected_developer_message.set_turn_id_if_missing(&turn_context.sub_id); let expected_history = [ expected_parent_seed, + expected_developer_message, expected_final_answer, ResponseItem::Message { id: None, @@ -1393,6 +1424,17 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { !history_contains_text(no_hint_history.raw_items(), "Child subagent guidance."), "full-history forked child should not add empty subagent guidance" ); + assert!( + history_contains_text( + no_hint_history.raw_items(), + "Parent developer instructions." + ), + "full-history forked child should inherit parent developer instructions" + ); + assert!( + history_contains_text(no_hint_history.raw_items(), "Preserved developer context."), + "full-history forked child should preserve unrelated developer fragments" + ); let expected = ( child_thread_id, @@ -1435,6 +1477,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { let harness = AgentControlHarness::new().await; let mut parent_config = harness.config.clone(); let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); parent_config.multi_agent_v2.root_agent_usage_hint_text = Some("Parent root guidance.".to_string()); parent_config.multi_agent_v2.subagent_usage_hint_text = @@ -1473,6 +1516,20 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { phase: None, internal_chat_message_metadata_passthrough: None, }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ + ContentItem::InputText { + text: "Parent developer instructions.".to_string(), + }, + ContentItem::InputText { + text: "Preserved compacted developer context.".to_string(), + }, + ], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, ]; parent_thread .session @@ -1532,6 +1589,17 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { !history_contains_text(history.raw_items(), "Parent root guidance."), "forked child history should strip stale parent hints from compacted replacement history" ); + assert!( + history_contains_text(history.raw_items(), "Parent developer instructions."), + "forked child history should inherit compacted parent developer instructions" + ); + assert!( + history_contains_text( + history.raw_items(), + "Preserved compacted developer context." + ), + "forked child history should preserve unrelated compacted developer fragments" + ); assert!( history_contains_text(history.raw_items(), "Child subagent guidance."), "full-history forked child should add the child subagent hint after compacted-history sanitization" @@ -1861,6 +1929,7 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { let harness = AgentControlHarness::new().await; let mut parent_config = harness.config.clone(); let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.developer_instructions = Some("Parent developer instructions.".to_string()); parent_config.multi_agent_v2.root_agent_usage_hint_text = Some("Parent root guidance.".to_string()); let mut child_config = harness.config.clone(); @@ -1893,6 +1962,20 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { phase: None, internal_chat_message_metadata_passthrough: None, }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ + ContentItem::InputText { + text: "Parent developer instructions.".to_string(), + }, + ContentItem::InputText { + text: "Preserved bounded developer context.".to_string(), + }, + ], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, spawn_agent_call(&parent_spawn_call_id), ], ) @@ -1940,6 +2023,14 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { !history_contains_text(history.raw_items(), "Parent root guidance."), "bounded fork should strip stale parent root hints before the child rebuilds startup context" ); + assert!( + history_contains_text(history.raw_items(), "Parent developer instructions."), + "bounded fork should inherit parent developer instructions from retained turns" + ); + assert!( + history_contains_text(history.raw_items(), "Preserved bounded developer context."), + "bounded fork should preserve unrelated developer fragments" + ); let _ = harness .control