mirror of
https://github.com/openai/codex.git
synced 2026-10-21 18:51:31 +00:00
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:
committed by
copyberry
parent
462ed19a05
commit
9f82ffed1b
@@ -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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user