Preserve turn triggers across delegated agent work (#44659)

## Why

Delegated usage should retain the trigger of the turn that initiated it, including composer input and scheduled automation.

## What changed

Propagate `turn_trigger` through agent spawning and follow-up messages in both multi-agent tool implementations. Apply the supplied trigger when pending mailbox work starts a new turn.

## Testing

Extend tests to verify trigger propagation to spawned and nested agents, updated triggers on reused agents and peer follow-ups, and preservation of the active turn's trigger when steering.

GitOrigin-RevId: e8fea208587c633fec7cb3f1c0815ff91d1a44e5
This commit is contained in:
joeytrasatti-openai authored and copyberry committed 2026-09-10 22:26:26 +00:00
1 parent 4caa5d615d
commit e004dc6a4b
10 files changed
+85 -7

No files matched your search

@@ -2,6 +2,7 @@ use anyhow::Result;
use codex_core::StartThreadOptions;
use codex_core::ThreadConfigSnapshot;
use codex_core::TurnInputRequest;
use codex_core::TurnStartOptions;
use codex_core::config::AgentRoleConfig;
use codex_core::config::CurrentTimeReminderConfig;
use codex_features::Feature;
@@ -1058,7 +1059,7 @@ async fn spawned_child_receives_forked_parent_context(
test.submit_turn(TURN_0_FORK_PROMPT).await?;
let _ = seed_turn.single_request();
test.submit_turn(TURN_1_PROMPT).await?;
submit_turn_with_trigger(&test, TURN_1_PROMPT, "composer").await?;
let parent_body = spawn_turn.single_request().body_json();
let child_request = wait_for_request_with_model(&child_request_log, REQUESTED_MODEL).await?;
@@ -1069,7 +1070,13 @@ async fn spawned_child_receives_forked_parent_context(
.as_str()
.expect("child turn metadata"),
)?;
assert_eq!(child_metadata["thread_source"], "subagent");
assert_eq!(
(
&child_metadata["thread_source"],
&child_metadata["turn_trigger"]
),
(&json!("subagent"), &json!("composer")),
);
let original_parent_turn_id = parent_body["client_metadata"]["turn_id"]
.as_str()
.expect("legacy spawn parent turn id");
@@ -1127,7 +1134,7 @@ async fn spawned_child_receives_forked_parent_context(
)
.await;
test.submit_turn("reuse the legacy child").await?;
submit_turn_with_trigger(&test, "reuse the legacy child", "automation_cron_scheduled").await?;
let followup_parent_body = parent.single_request().body_json();
let reused_child_body = wait_for_request_with_model(&followup, REQUESTED_MODEL)
.await?
@@ -1142,6 +1149,32 @@ async fn spawned_child_receives_forked_parent_context(
assert_parent_turn(&reused_child_body, Some(followup_parent_turn_id))?;
assert_root_turn(&followup_parent_body, Some(followup_parent_turn_id))?;
assert_root_turn(&reused_child_body, Some(followup_parent_turn_id))?;
let reused_metadata: Value = serde_json::from_str(
reused_child_body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("reused child turn metadata"),
)?;
assert_eq!(reused_metadata["turn_trigger"], "automation_cron_scheduled");
Ok(())
}
async fn submit_turn_with_trigger(test: &TestCodex, prompt: &str, trigger: &str) -> Result<()> {
test.codex
.start_or_steer_turn(
TurnInputRequest::user_input(vec![UserInput::Text {
text: prompt.to_string(),
text_elements: Vec::new(),
}])
.on_start(TurnStartOptions {
turn_trigger: Some(trigger.to_string()),
..Default::default()
}),
)
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
Ok(())
}
@@ -1322,7 +1355,7 @@ async fn grandchild_full_fork_preserves_context_baseline(
.build_with_auto_env(&server)
.await?;
test.submit_turn(ROOT_PROMPT).await?;
submit_turn_with_trigger(&test, ROOT_PROMPT, "automation_heartbeat_scheduled").await?;
let root_request = root_log.single_request();
let mut descendant_requests = Vec::new();
for (mock, agent_name) in [
@@ -1363,6 +1396,15 @@ async fn grandchild_full_fork_preserves_context_baseline(
AgentStatus::Completed(Some("done".to_string()))
);
}
let metadata: Value = serde_json::from_str(
request.body_json()["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("descendant turn metadata"),
)?;
assert_eq!(
(&metadata["thread_source"], &metadata["turn_trigger"]),
(&json!("subagent"), &json!("automation_heartbeat_scheduled")),
);
descendant_requests.push(request);
}
let context_counts = [
@@ -2746,7 +2788,7 @@ async fn multi_agent_v2_peer_followup_completion_notifies_initiating_turn() -> R
)
.await;
test.submit_turn(SPAWN_WORKER_PROMPT).await?;
submit_turn_with_trigger(&test, SPAWN_WORKER_PROMPT, "automation_cron_scheduled").await?;
let worker_thread_id = created_threads.recv().await?;
let worker_thread = test.thread_manager.get_thread(worker_thread_id).await?;
wait_for_event(worker_thread.as_ref(), |event| {
@@ -2845,7 +2887,7 @@ async fn multi_agent_v2_peer_followup_completion_notifies_initiating_turn() -> R
);
}
test.submit_turn(SPAWN_REQUESTER_PROMPT).await?;
submit_turn_with_trigger(&test, SPAWN_REQUESTER_PROMPT, "composer").await?;
let requester_thread_id = created_threads.recv().await?;
let requester_thread = test.thread_manager.get_thread(requester_thread_id).await?;
let requester_turn_id = wait_for_event_match(requester_thread.as_ref(), |event| match event {
@@ -2874,6 +2916,13 @@ async fn multi_agent_v2_peer_followup_completion_notifies_initiating_turn() -> R
(body["client_metadata"]["thread_id"] == json!(worker_thread_id)
&& request.body_contains_text(WORKER_FOLLOWUP_TASK))
.then(|| {
let metadata: Value = serde_json::from_str(
body["client_metadata"]["x-codex-turn-metadata"]
.as_str()
.expect("worker turn metadata"),
)
.expect("worker turn metadata JSON");
assert_eq!(metadata["turn_trigger"], "composer");
body["client_metadata"]["turn_id"]
.as_str()
.expect("worker follow-up turn ID")