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
This commit is contained in:
Adam Perry @ OpenAI
2026-07-27 18:12:42 +00:00
committed by copyberry
parent 462ed19a05
commit 9f82ffed1b
3 changed files with 347 additions and 0 deletions
@@ -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;
@@ -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(())
}
+91
View File
@@ -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