Track parent turns for nested Codex requests (#35835)

## What changed

- Propagate the initiating turn ID through agent spawns, follow-up tasks, reviews, and delegated Codex sessions.
- Add `parent_turn_id` to Responses client and turn metadata while keeping it out of external MCP metadata.
- Preserve parent-turn provenance across queued agent messages when their triggering parent is unambiguous.

## Testing

- Cover spawned, resumed, nested, reviewed, delegated, and WebSocket request metadata.
- Verify queued messages do not claim ambiguous or queue-only parent turns.

GitOrigin-RevId: 481fdebbe7df2031880fe259509273cce50b20a8
This commit is contained in:
Eddie Chen authored and copyberry committed 2026-07-28 21:58:33 +00:00
1 parent 12b3e88028
commit 1def0a8925
34 files changed
+635 -165

No files matched your search

+32 -7
View File
@@ -70,6 +70,7 @@ pub(crate) struct SpawnAgentOptions {
pub(crate) fork_parent_spawn_call_id: Option<String>, pub(crate) fork_parent_spawn_call_id: Option<String>,
pub(crate) fork_mode: Option<SpawnAgentForkMode>, pub(crate) fork_mode: Option<SpawnAgentForkMode>,
pub(crate) parent_thread_id: Option<ThreadId>, pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) parent_turn_id: Option<String>,
pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>, pub(crate) environments: Option<Vec<TurnEnvironmentSelection>>,
} }
@@ -143,11 +144,12 @@ impl AgentControl {
&self, &self,
agent_id: ThreadId, agent_id: ThreadId,
input: Vec<UserInput>, input: Vec<UserInput>,
parent_turn_id: Option<String>,
) -> CodexResult<String> { ) -> CodexResult<String> {
let state = self.upgrade()?; let state = self.upgrade()?;
self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true) self.ensure_execution_capacity_for_turn_start(agent_id, /*starts_turn*/ true)
.await?; .await?;
self.send_input_after_capacity_check(agent_id, &state, input) self.send_input_after_capacity_check(agent_id, &state, input, parent_turn_id)
.await .await
} }
@@ -156,11 +158,12 @@ impl AgentControl {
agent_id: ThreadId, agent_id: ThreadId,
state: &Arc<ThreadManagerState>, state: &Arc<ThreadManagerState>,
input: Vec<UserInput>, input: Vec<UserInput>,
parent_turn_id: Option<String>,
) -> CodexResult<String> { ) -> CodexResult<String> {
self.handle_thread_request_result( self.handle_thread_request_result(
agent_id, agent_id,
state, state,
state.send_op(agent_id, input.into()).await, state.send_op(agent_id, input.into(), parent_turn_id).await,
) )
.await .await
} }
@@ -170,6 +173,7 @@ impl AgentControl {
agent_id: ThreadId, agent_id: ThreadId,
communication: InterAgentCommunication, communication: InterAgentCommunication,
agent_communication_context: AgentCommunicationContext, agent_communication_context: AgentCommunicationContext,
parent_turn_id: Option<String>,
) -> CodexResult<String> { ) -> CodexResult<String> {
let state = self.upgrade()?; let state = self.upgrade()?;
self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn) self.ensure_execution_capacity_for_turn_start(agent_id, communication.trigger_turn)
@@ -179,6 +183,7 @@ impl AgentControl {
&state, &state,
communication, communication,
agent_communication_context, agent_communication_context,
parent_turn_id,
) )
.await .await
} }
@@ -189,9 +194,16 @@ impl AgentControl {
state: &Arc<ThreadManagerState>, state: &Arc<ThreadManagerState>,
communication: InterAgentCommunication, communication: InterAgentCommunication,
context: AgentCommunicationContext, context: AgentCommunicationContext,
parent_turn_id: Option<String>,
) -> CodexResult<String> { ) -> CodexResult<String> {
self.submit_inter_agent_communication(agent_id, state, communication, context) self.submit_inter_agent_communication(
.await agent_id,
state,
communication,
context,
parent_turn_id,
)
.await
} }
async fn submit_inter_agent_communication( async fn submit_inter_agent_communication(
@@ -200,15 +212,21 @@ impl AgentControl {
state: &Arc<ThreadManagerState>, state: &Arc<ThreadManagerState>,
communication: InterAgentCommunication, communication: InterAgentCommunication,
context: AgentCommunicationContext, context: AgentCommunicationContext,
parent_turn_id: Option<String>,
) -> CodexResult<String> { ) -> CodexResult<String> {
let communication_for_log = let communication_for_log =
crate::agent_communication::logging_enabled().then(|| communication.clone()); crate::agent_communication::logging_enabled().then(|| communication.clone());
let parent_turn_id = parent_turn_id.filter(|_| communication.trigger_turn);
let result = self let result = self
.handle_thread_request_result( .handle_thread_request_result(
agent_id, agent_id,
state, state,
state state
.send_op(agent_id, Op::InterAgentCommunication { communication }) .send_op(
agent_id,
Op::InterAgentCommunication { communication },
parent_turn_id,
)
.await, .await,
) )
.await; .await;
@@ -231,7 +249,9 @@ impl AgentControl {
self.handle_thread_request_result( self.handle_thread_request_result(
agent_id, agent_id,
&state, &state,
state.send_op(agent_id, Op::Interrupt).await, state
.send_op(agent_id, Op::Interrupt, /*parent_turn_id*/ None)
.await,
) )
.await .await
} }
@@ -507,7 +527,12 @@ impl AgentControl {
let context = let context =
AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id); AgentCommunicationContext::new(AgentCommunicationKind::Result, child_thread_id);
let _ = control let _ = control
.send_inter_agent_communication(parent_thread_id, communication, context) .send_inter_agent_communication(
parent_thread_id,
communication,
context,
/*parent_turn_id*/ None,
)
.await; .await;
return; return;
} }
+6 -2
View File
@@ -12,12 +12,16 @@ impl AgentControl {
let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) {
Ok(String::new()) Ok(String::new())
} else { } else {
state.send_op(agent_id, Op::Shutdown {}).await state
.send_op(agent_id, Op::Shutdown {}, /*parent_turn_id*/ None)
.await
}; };
thread.wait_until_terminated().await; thread.wait_until_terminated().await;
result result
} else { } else {
state.send_op(agent_id, Op::Shutdown {}).await state
.send_op(agent_id, Op::Shutdown {}, /*parent_turn_id*/ None)
.await
}; };
let _ = state.remove_thread(&agent_id).await; let _ = state.remove_thread(&agent_id).await;
self.forget_v2_residency(agent_id); self.forget_v2_residency(agent_id);
+8 -2
View File
@@ -527,8 +527,13 @@ impl AgentControl {
match initial_input { match initial_input {
SpawnInitialInput::UserInput(input) => { SpawnInitialInput::UserInput(input) => {
self.send_input_after_capacity_check(new_thread.thread_id, &state, input) self.send_input_after_capacity_check(
.await?; new_thread.thread_id,
&state,
input,
options.parent_turn_id,
)
.await?;
} }
SpawnInitialInput::InterAgentCommunication(communication, context) => { SpawnInitialInput::InterAgentCommunication(communication, context) => {
self.send_inter_agent_communication_after_capacity_check( self.send_inter_agent_communication_after_capacity_check(
@@ -536,6 +541,7 @@ impl AgentControl {
&state, &state,
communication, communication,
context, context,
options.parent_turn_id,
) )
.await?; .await?;
} }
+5
View File
@@ -369,6 +369,7 @@ async fn send_input_errors_when_manager_dropped() {
text: "hello".to_string(), text: "hello".to_string(),
text_elements: Vec::new(), text_elements: Vec::new(),
}], }],
/*parent_turn_id*/ None,
) )
.await .await
.expect_err("send_input should fail without a manager"); .expect_err("send_input should fail without a manager");
@@ -483,6 +484,7 @@ async fn send_input_errors_when_thread_missing() {
text: "hello".to_string(), text: "hello".to_string(),
text_elements: Vec::new(), text_elements: Vec::new(),
}], }],
/*parent_turn_id*/ None,
) )
.await .await
.expect_err("send_input should fail for missing thread"); .expect_err("send_input should fail for missing thread");
@@ -555,6 +557,7 @@ async fn send_input_submits_user_message() {
text: "hello from tests".to_string(), text: "hello from tests".to_string(),
text_elements: Vec::new(), text_elements: Vec::new(),
}], }],
/*parent_turn_id*/ None,
) )
.await .await
.expect("send_input should succeed"); .expect("send_input should succeed");
@@ -598,6 +601,7 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig
thread_id, thread_id,
communication.clone(), communication.clone(),
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()), AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
/*parent_turn_id*/ None,
) )
.await .await
.expect("send_inter_agent_communication should succeed"); .expect("send_inter_agent_communication should succeed");
@@ -729,6 +733,7 @@ async fn ensure_v2_agent_loaded_reloads_registered_unloaded_agent() {
spawned_agent.thread_id, spawned_agent.thread_id,
communication.clone(), communication.clone(),
AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()), AgentCommunicationContext::new(AgentCommunicationKind::Message, ThreadId::new()),
/*parent_turn_id*/ None,
) )
.await .await
.expect("send_inter_agent_communication should succeed after reload"); .expect("send_inter_agent_communication should succeed after reload");
+13 -7
View File
@@ -218,6 +218,7 @@ pub(crate) async fn run_codex_thread_one_shot(
// Use a child token so we can stop the delegate after completion without // Use a child token so we can stop the delegate after completion without
// requiring the caller to cancel the parent token. // requiring the caller to cancel the parent token.
let child_cancel = cancel_token.child_token(); let child_cancel = cancel_token.child_token();
let parent_turn_id = parent_ctx.sub_id.clone();
let (session, io) = Box::pin(run_codex_thread_interactive( let (session, io) = Box::pin(run_codex_thread_interactive(
config, config,
auth_manager, auth_manager,
@@ -233,13 +234,17 @@ pub(crate) async fn run_codex_thread_one_shot(
.await?; .await?;
// Send the initial input to kick off the one-shot turn. // Send the initial input to kick off the one-shot turn.
io.submit(Op::UserInput { io.submit_with_trace(
items: input, Op::UserInput {
final_output_json_schema, items: input,
responsesapi_client_metadata: None, final_output_json_schema,
additional_context: Default::default(), responsesapi_client_metadata: None,
thread_settings: Default::default(), additional_context: Default::default(),
}) thread_settings: Default::default(),
},
/*trace*/ None,
Some(parent_turn_id),
)
.await?; .await?;
// Bridge events so we can observe completion and shut down automatically. // Bridge events so we can observe completion and shut down automatically.
@@ -262,6 +267,7 @@ pub(crate) async fn run_codex_thread_one_shot(
op: Op::Shutdown {}, op: Op::Shutdown {},
client_user_message_id: None, client_user_message_id: None,
trace: None, trace: None,
parent_turn_id: None,
}) })
.await; .await;
child_cancel.cancel(); child_cancel.cancel();
@@ -175,6 +175,7 @@ async fn forward_ops_preserves_submission_trace_context() {
), ),
tracestate: Some("vendor=state".to_string()), tracestate: Some("vendor=state".to_string()),
}), }),
parent_turn_id: Some("parent-turn".to_string()),
}; };
tx_ops.send(submission.clone()).await.unwrap(); tx_ops.send(submission.clone()).await.unwrap();
drop(tx_ops); drop(tx_ops);
@@ -186,6 +187,7 @@ async fn forward_ops_preserves_submission_trace_context() {
assert_eq!(submission.id, forwarded.id); assert_eq!(submission.id, forwarded.id);
assert_eq!(submission.op, forwarded.op); assert_eq!(submission.op, forwarded.op);
assert_eq!(submission.trace, forwarded.trace); assert_eq!(submission.trace, forwarded.trace);
assert_eq!(submission.parent_turn_id, forwarded.parent_turn_id);
timeout(Duration::from_secs(1), forward) timeout(Duration::from_secs(1), forward)
.await .await
+5 -2
View File
@@ -275,7 +275,9 @@ impl CodexThread {
op: Op, op: Op,
trace: Option<W3cTraceContext>, trace: Option<W3cTraceContext>,
) -> CodexResult<String> { ) -> CodexResult<String> {
self.io.submit_with_trace(op, trace).await self.io
.submit_with_trace(op, trace, /*parent_turn_id*/ None)
.await
} }
pub async fn submit_user_input_with_client_user_message_id( pub async fn submit_user_input_with_client_user_message_id(
@@ -427,7 +429,8 @@ impl CodexThread {
} }
/// Use sparingly: this is intended to be removed soon. /// Use sparingly: this is intended to be removed soon.
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> { pub async fn submit_with_id(&self, mut sub: Submission) -> CodexResult<()> {
sub.parent_turn_id = None;
self.io.submit_with_id(sub).await self.io.submit_with_id(sub).await
} }
+10 -5
View File
@@ -806,10 +806,8 @@ async fn run_review_on_session(
.and_then(|environment| environment.cwd().to_abs_path().ok()) .and_then(|environment| environment.cwd().to_abs_path().ok())
.unwrap_or_else(|| params.parent_turn.config.cwd.clone()); .unwrap_or_else(|| params.parent_turn.config.cwd.clone());
let submit_result = run_before_review_deadline( let submission = review_session.io.submit_with_trace(
deadline, Op::UserInput {
params.external_cancel.as_ref(),
Box::pin(review_session.io.submit(Op::UserInput {
items: prompt_items.items, items: prompt_items.items,
final_output_json_schema: Some(params.schema.clone()), final_output_json_schema: Some(params.schema.clone()),
responsesapi_client_metadata: None, responsesapi_client_metadata: None,
@@ -834,7 +832,14 @@ async fn run_review_on_session(
}), }),
..Default::default() ..Default::default()
}, },
})), },
/*trace*/ None,
Some(params.parent_turn.sub_id.clone()),
);
let submit_result = run_before_review_deadline(
deadline,
params.external_cancel.as_ref(),
Box::pin(submission),
) )
.await; .await;
let child_turn_id = match submit_result { let child_turn_id = match submit_result {
+4 -2
View File
@@ -1476,6 +1476,7 @@ async fn guardian_request_model_for_auto_review(
.auto_review_model_override = auto_review_model_override; .auto_review_model_override = auto_review_model_override;
let parent_model = turn.model_info.slug.clone(); let parent_model = turn.model_info.slug.clone();
let preferred_model = turn.provider.approval_review_preferred_model().to_string(); let preferred_model = turn.provider.approval_review_preferred_model().to_string();
let parent_turn_id = turn.sub_id.clone();
seed_guardian_parent_history(&session, &turn).await; seed_guardian_parent_history(&session, &turn).await;
let (outcome, analytics_result) = run_guardian_review_session_for_test( let (outcome, analytics_result) = run_guardian_review_session_for_test(
@@ -1500,8 +1501,9 @@ async fn guardian_request_model_for_auto_review(
}; };
let request = request_log.single_request(); let request = request_log.single_request();
let request_model = request let request_body = request.body_json();
.body_json() core_test_support::responses::assert_parent_turn(&request_body, Some(parent_turn_id.as_str()))?;
let request_model = request_body
.get("model") .get("model")
.and_then(|value| value.as_str()) .and_then(|value| value.as_str())
.expect("guardian request should include a model") .expect("guardian request should include a model")
+10
View File
@@ -36,6 +36,7 @@ pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms";
pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id";
pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id"; pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id";
pub(crate) const PARENT_TURN_ID_KEY: &str = "parent_turn_id";
pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind"; pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind";
pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source"; pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source";
pub(crate) const SANDBOX_KEY: &str = "sandbox"; pub(crate) const SANDBOX_KEY: &str = "sandbox";
@@ -60,6 +61,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[
TURN_STARTED_AT_UNIX_MS_KEY, TURN_STARTED_AT_UNIX_MS_KEY,
FORKED_FROM_THREAD_ID_KEY, FORKED_FROM_THREAD_ID_KEY,
PARENT_THREAD_ID_KEY, PARENT_THREAD_ID_KEY,
PARENT_TURN_ID_KEY,
SUBAGENT_KIND_KEY, SUBAGENT_KIND_KEY,
THREAD_SOURCE_KEY, THREAD_SOURCE_KEY,
SANDBOX_KEY, SANDBOX_KEY,
@@ -163,6 +165,7 @@ pub struct CodexResponsesMetadata {
pub(crate) request_kind: Option<CodexResponsesRequestKind>, pub(crate) request_kind: Option<CodexResponsesRequestKind>,
pub(crate) forked_from_thread_id: Option<ThreadId>, pub(crate) forked_from_thread_id: Option<ThreadId>,
pub(crate) parent_thread_id: Option<ThreadId>, pub(crate) parent_thread_id: Option<ThreadId>,
pub(crate) parent_turn_id: Option<String>,
pub(crate) subagent_header: Option<String>, pub(crate) subagent_header: Option<String>,
pub(crate) subagent_kind: Option<String>, pub(crate) subagent_kind: Option<String>,
pub(crate) thread_source: Option<ThreadSource>, pub(crate) thread_source: Option<ThreadSource>,
@@ -189,6 +192,7 @@ impl CodexResponsesMetadata {
request_kind: None, request_kind: None,
forked_from_thread_id: None, forked_from_thread_id: None,
parent_thread_id: None, parent_thread_id: None,
parent_turn_id: None,
subagent_header: None, subagent_header: None,
subagent_kind: None, subagent_kind: None,
thread_source: None, thread_source: None,
@@ -237,6 +241,9 @@ impl CodexResponsesMetadata {
parent_thread_id.to_string(), parent_thread_id.to_string(),
); );
} }
if let Some(parent_turn_id) = &self.parent_turn_id {
client_metadata.insert(PARENT_TURN_ID_KEY.to_string(), parent_turn_id.clone());
}
if self.has_turn_metadata() if self.has_turn_metadata()
&& let Some(turn_metadata_json) = self.turn_metadata_json() && let Some(turn_metadata_json) = self.turn_metadata_json()
{ {
@@ -296,6 +303,7 @@ impl CodexResponsesMetadata {
request_kind: request_kind_value, request_kind: request_kind_value,
forked_from_thread_id: self.forked_from_thread_id, forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id, parent_thread_id: self.parent_thread_id,
parent_turn_id: self.parent_turn_id.as_deref(),
subagent_kind: self.subagent_kind.as_deref(), subagent_kind: self.subagent_kind.as_deref(),
thread_source: self.thread_source.as_ref(), thread_source: self.thread_source.as_ref(),
sandbox: self.sandbox.as_deref(), sandbox: self.sandbox.as_deref(),
@@ -383,6 +391,8 @@ struct CodexTurnMetadataPayload<'a> {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
parent_thread_id: Option<ThreadId>, parent_thread_id: Option<ThreadId>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
parent_turn_id: Option<&'a str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
subagent_kind: Option<&'a str>, subagent_kind: Option<&'a str>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
thread_source: Option<&'a ThreadSource>, thread_source: Option<&'a ThreadSource>,
+23 -5
View File
@@ -83,8 +83,9 @@ pub async fn user_input_or_turn(
sub_id: String, sub_id: String,
op: Op, op: Op,
client_user_message_id: Option<String>, client_user_message_id: Option<String>,
parent_turn_id: Option<String>,
) { ) {
user_input_or_turn_inner(sess, sub_id, op, client_user_message_id).await; user_input_or_turn_inner(sess, sub_id, op, client_user_message_id, parent_turn_id).await;
} }
pub async fn update_thread_settings( pub async fn update_thread_settings(
@@ -178,6 +179,7 @@ pub(super) async fn user_input_or_turn_inner(
sub_id: String, sub_id: String,
op: Op, op: Op,
client_user_message_id: Option<String>, client_user_message_id: Option<String>,
parent_turn_id: Option<String>,
) { ) {
let Op::UserInput { let Op::UserInput {
items, items,
@@ -224,6 +226,9 @@ pub(super) async fn user_input_or_turn_inner(
current_context.session_telemetry.user_prompt(&items); current_context.session_telemetry.user_prompt(&items);
} }
Err(SteerInputError::NoActiveTurn(items)) => { Err(SteerInputError::NoActiveTurn(items)) => {
if let Some(id) = parent_turn_id {
current_context.turn_metadata_state.set_parent_turn_id(id);
}
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata { if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
current_context current_context
.turn_metadata_state .turn_metadata_state
@@ -268,10 +273,11 @@ pub async fn inter_agent_communication(
sess: &Arc<Session>, sess: &Arc<Session>,
sub_id: String, sub_id: String,
communication: InterAgentCommunication, communication: InterAgentCommunication,
parent_turn_id: Option<String>,
) { ) {
let trigger_turn = communication.trigger_turn; let trigger_turn = communication.trigger_turn;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(communication) .enqueue_mailbox_communication(communication, parent_turn_id.filter(|_| trigger_turn))
.await; .await;
crate::agent_communication::emit_agent_communication_receive(&sub_id); crate::agent_communication::emit_agent_communication_receive(&sub_id);
if trigger_turn || sess.has_outstanding_durable_sleep() { if trigger_turn || sess.has_outstanding_durable_sleep() {
@@ -746,8 +752,14 @@ pub(super) async fn submission_loop(
false false
} }
Op::UserInput { .. } => { Op::UserInput { .. } => {
user_input_or_turn(&sess, sub.id.clone(), sub.op, sub.client_user_message_id) user_input_or_turn(
.await; &sess,
sub.id.clone(),
sub.op,
sub.client_user_message_id,
sub.parent_turn_id,
)
.await;
false false
} }
Op::ThreadSettings { thread_settings } => { Op::ThreadSettings { thread_settings } => {
@@ -755,7 +767,13 @@ pub(super) async fn submission_loop(
false false
} }
Op::InterAgentCommunication { communication } => { Op::InterAgentCommunication { communication } => {
inter_agent_communication(&sess, sub.id.clone(), communication).await; inter_agent_communication(
&sess,
sub.id.clone(),
communication,
sub.parent_turn_id,
)
.await;
false false
} }
Op::ExecApproval { Op::ExecApproval {
+8 -2
View File
@@ -5,6 +5,7 @@ use crate::codex_thread::TryStartTurnIfIdleError;
use crate::codex_thread::TryStartTurnIfIdleRejectionReason; use crate::codex_thread::TryStartTurnIfIdleRejectionReason;
use crate::state::ActiveTurn; use crate::state::ActiveTurn;
use crate::state::TurnState; use crate::state::TurnState;
use crate::tasks::MailboxParentProvenance;
use crate::tasks::RegularTask; use crate::tasks::RegularTask;
use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ModeKind;
use codex_protocol::models::ResponseItem; use codex_protocol::models::ResponseItem;
@@ -124,8 +125,13 @@ impl Session {
input.into_iter().map(TurnInput::ResponseItem).collect(), input.into_iter().map(TurnInput::ResponseItem).collect(),
) )
.await; .await;
self.start_task(turn_context, Vec::new(), RegularTask::new()) self.start_task(
.await; turn_context,
Vec::new(),
RegularTask::new(),
MailboxParentProvenance::Ignore,
)
.await;
Ok(()) Ok(())
} }
+90 -39
View File
@@ -34,7 +34,12 @@ pub(crate) struct TurnInputQueue {
/// Session-scoped pending input storage and active-turn mailbox delivery coordination. /// Session-scoped pending input storage and active-turn mailbox delivery coordination.
pub(crate) struct InputQueue { pub(crate) struct InputQueue {
activity_tx: watch::Sender<InputQueueActivity>, activity_tx: watch::Sender<InputQueueActivity>,
mailbox_pending_mails: Mutex<VecDeque<InterAgentCommunication>>, mailbox_pending_mails: Mutex<VecDeque<PendingMailboxCommunication>>,
}
struct PendingMailboxCommunication {
communication: InterAgentCommunication,
parent_turn_id: Option<String>,
} }
impl InputQueue { impl InputQueue {
@@ -72,11 +77,15 @@ impl InputQueue {
pub(crate) async fn enqueue_mailbox_communication( pub(crate) async fn enqueue_mailbox_communication(
&self, &self,
communication: InterAgentCommunication, communication: InterAgentCommunication,
parent_turn_id: Option<String>,
) { ) {
self.mailbox_pending_mails self.mailbox_pending_mails
.lock() .lock()
.await .await
.push_back(communication); .push_back(PendingMailboxCommunication {
communication,
parent_turn_id,
});
self.activity_tx.send_replace(InputQueueActivity::Mailbox); self.activity_tx.send_replace(InputQueueActivity::Mailbox);
} }
@@ -89,16 +98,27 @@ impl InputQueue {
.lock() .lock()
.await .await
.iter() .iter()
.any(|mail| mail.trigger_turn) .any(|mail| mail.communication.trigger_turn)
} }
pub(crate) async fn drain_mailbox_input_items(&self) -> Vec<TurnInput> { pub(crate) async fn drain_mailbox_input_items(&self) -> (Vec<TurnInput>, Option<String>) {
self.mailbox_pending_mails let pending_mails = self
.mailbox_pending_mails
.lock() .lock()
.await .await
.drain(..) .drain(..)
.map(TurnInput::InterAgentCommunication) .collect::<Vec<_>>();
.collect() let parent_turn_id = pending_mails
.iter()
.filter(|mail| mail.communication.trigger_turn)
.map(|mail| mail.parent_turn_id.as_deref())
.reduce(|expected, candidate| expected.filter(|id| candidate == Some(*id)))
.and_then(|id| id.filter(|id| !id.trim().is_empty()).map(str::to_string));
let items = pending_mails
.into_iter()
.map(|mail| TurnInput::InterAgentCommunication(mail.communication))
.collect();
(items, parent_turn_id)
} }
pub(crate) async fn turn_state_for_sub_id( pub(crate) async fn turn_state_for_sub_id(
@@ -204,7 +224,7 @@ impl InputQueue {
pub(crate) async fn get_pending_input( pub(crate) async fn get_pending_input(
&self, &self,
active_turn: &Mutex<Option<ActiveTurn>>, active_turn: &Mutex<Option<ActiveTurn>>,
) -> Vec<TurnInput> { ) -> (Vec<TurnInput>, Option<String>) {
let (pending_input, accepts_mailbox_delivery) = { let (pending_input, accepts_mailbox_delivery) = {
let mut active = active_turn.lock().await; let mut active = active_turn.lock().await;
match active.as_mut() { match active.as_mut() {
@@ -223,15 +243,15 @@ impl InputQueue {
} }
}; };
if !accepts_mailbox_delivery { if !accepts_mailbox_delivery {
return pending_input; return (pending_input, None);
} }
let mailbox_items = self.drain_mailbox_input_items().await.into_iter(); let (mailbox_items, parent_turn_id) = self.drain_mailbox_input_items().await;
if pending_input.is_empty() { if pending_input.is_empty() {
mailbox_items.collect() (mailbox_items, parent_turn_id)
} else { } else {
let mut pending_input = pending_input; let mut pending_input = pending_input;
pending_input.extend(mailbox_items); pending_input.extend(mailbox_items);
pending_input (pending_input, parent_turn_id)
} }
} }
@@ -299,21 +319,23 @@ mod tests {
input_queue.subscribe_activity(/*turn_state*/ None).await; input_queue.subscribe_activity(/*turn_state*/ None).await;
assert_eq!(pending_activity, None); assert_eq!(pending_activity, None);
let mail_one = make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"one",
/*trigger_turn*/ false,
);
input_queue input_queue
.enqueue_mailbox_communication(make_mail( .enqueue_mailbox_communication(mail_one, /*parent_turn_id*/ None)
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"one",
/*trigger_turn*/ false,
))
.await; .await;
let mail_two = make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"two",
/*trigger_turn*/ false,
);
input_queue input_queue
.enqueue_mailbox_communication(make_mail( .enqueue_mailbox_communication(mail_two, /*parent_turn_id*/ None)
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"two",
/*trigger_turn*/ false,
))
.await; .await;
activity_rx.changed().await.expect("mailbox update"); activity_rx.changed().await.expect("mailbox update");
@@ -388,14 +410,14 @@ mod tests {
); );
input_queue input_queue
.enqueue_mailbox_communication(mail_one.clone()) .enqueue_mailbox_communication(mail_one.clone(), /*parent_turn_id*/ None)
.await; .await;
input_queue input_queue
.enqueue_mailbox_communication(mail_two.clone()) .enqueue_mailbox_communication(mail_two.clone(), /*parent_turn_id*/ None)
.await; .await;
assert_eq!( assert_eq!(
input_queue.drain_mailbox_input_items().await, input_queue.drain_mailbox_input_items().await.0,
vec![ vec![
TurnInput::InterAgentCommunication(mail_one), TurnInput::InterAgentCommunication(mail_one),
TurnInput::InterAgentCommunication(mail_two) TurnInput::InterAgentCommunication(mail_two)
@@ -404,27 +426,56 @@ mod tests {
assert!(!input_queue.has_pending_mailbox_items().await); assert!(!input_queue.has_pending_mailbox_items().await);
} }
#[tokio::test]
async fn input_queue_requires_one_unambiguous_trigger_parent() {
for (pending_mails, expected_parent_turn_id) in [
(Vec::new(), None),
(vec![(false, Some("q"))], None),
(vec![(true, Some(""))], None),
(vec![(true, Some(" "))], None),
(vec![(true, None)], None),
(vec![(true, Some("a")), (true, Some("b"))], None),
(vec![(true, Some("a")), (true, None)], None),
(vec![(true, Some("a")), (true, Some("a"))], Some("a")),
(vec![(false, Some("q")), (true, Some("a"))], Some("a")),
] {
let input_queue = InputQueue::new();
for (trigger_turn, parent_turn_id) in pending_mails {
input_queue
.enqueue_mailbox_communication(
make_mail(AgentPath::root(), AgentPath::root(), "task", trigger_turn),
parent_turn_id.map(str::to_string),
)
.await;
}
let (_, parent_turn_id) = input_queue.drain_mailbox_input_items().await;
assert_eq!(parent_turn_id.as_deref(), expected_parent_turn_id);
}
}
#[tokio::test] #[tokio::test]
async fn input_queue_tracks_pending_trigger_turn_mail() { async fn input_queue_tracks_pending_trigger_turn_mail() {
let input_queue = InputQueue::new(); let input_queue = InputQueue::new();
let queued_mail = make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"queued",
/*trigger_turn*/ false,
);
input_queue input_queue
.enqueue_mailbox_communication(make_mail( .enqueue_mailbox_communication(queued_mail, /*parent_turn_id*/ None)
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"queued",
/*trigger_turn*/ false,
))
.await; .await;
assert!(!input_queue.has_trigger_turn_mailbox_items().await); assert!(!input_queue.has_trigger_turn_mailbox_items().await);
let trigger_mail = make_mail(
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"wake",
/*trigger_turn*/ true,
);
input_queue input_queue
.enqueue_mailbox_communication(make_mail( .enqueue_mailbox_communication(trigger_mail, /*parent_turn_id*/ None)
AgentPath::root(),
AgentPath::try_from("/root/worker").expect("agent path"),
"wake",
/*trigger_turn*/ true,
))
.await; .await;
assert!(input_queue.has_trigger_turn_mailbox_items().await); assert!(input_queue.has_trigger_turn_mailbox_items().await);
} }
+12 -2
View File
@@ -791,13 +791,15 @@ impl Session {
impl SessionIo { impl SessionIo {
/// Submit the `op` wrapped in a `Submission` with a unique ID. /// Submit the `op` wrapped in a `Submission` with a unique ID.
pub(crate) async fn submit(&self, op: Op) -> CodexResult<String> { pub(crate) async fn submit(&self, op: Op) -> CodexResult<String> {
self.submit_with_trace(op, /*trace*/ None).await self.submit_with_trace(op, /*trace*/ None, /*parent_turn_id*/ None)
.await
} }
pub(crate) async fn submit_with_trace( pub(crate) async fn submit_with_trace(
&self, &self,
op: Op, op: Op,
trace: Option<W3cTraceContext>, trace: Option<W3cTraceContext>,
parent_turn_id: Option<String>,
) -> CodexResult<String> { ) -> CodexResult<String> {
let id = new_submission_id(); let id = new_submission_id();
let sub = Submission { let sub = Submission {
@@ -805,6 +807,7 @@ impl SessionIo {
op, op,
client_user_message_id: None, client_user_message_id: None,
trace, trace,
parent_turn_id,
}; };
self.submit_with_id(sub).await?; self.submit_with_id(sub).await?;
Ok(id) Ok(id)
@@ -823,6 +826,7 @@ impl SessionIo {
op, op,
client_user_message_id, client_user_message_id,
trace, trace,
parent_turn_id: None,
}; };
self.submit_with_id(sub).await?; self.submit_with_id(sub).await?;
Ok(id) Ok(id)
@@ -1211,6 +1215,7 @@ impl Session {
thread_settings: Default::default(), thread_settings: Default::default(),
}, },
/*client_user_message_id*/ None, /*client_user_message_id*/ None,
/*parent_turn_id*/ None,
) )
.await; .await;
} }
@@ -1961,7 +1966,12 @@ impl Session {
if let Err(err) = self if let Err(err) = self
.services .services
.agent_control .agent_control
.send_inter_agent_communication(parent_thread_id, communication, context) .send_inter_agent_communication(
parent_thread_id,
communication,
context,
/*parent_turn_id*/ None,
)
.await .await
{ {
debug!("failed to notify parent thread {parent_thread_id}: {err}"); debug!("failed to notify parent thread {parent_thread_id}: {err}");
+47 -33
View File
@@ -6805,6 +6805,7 @@ async fn submit_with_id_captures_current_span_trace_context() {
id: "sub-1".into(), id: "sub-1".into(),
op: Op::Interrupt, op: Op::Interrupt,
client_user_message_id: None, client_user_message_id: None,
parent_turn_id: None,
trace: None, trace: None,
}) })
.await .await
@@ -6877,6 +6878,7 @@ fn submission_dispatch_span_prefers_submission_trace_context() {
id: "sub-1".into(), id: "sub-1".into(),
op: Op::Interrupt, op: Op::Interrupt,
client_user_message_id: None, client_user_message_id: None,
parent_turn_id: None,
trace: Some(submission_trace), trace: Some(submission_trace),
}) })
}); });
@@ -6904,6 +6906,7 @@ fn submission_dispatch_span_uses_debug_for_realtime_audio() {
}, },
}), }),
client_user_message_id: None, client_user_message_id: None,
parent_turn_id: None,
trace: None, trace: None,
}); });
@@ -6969,6 +6972,7 @@ async fn user_turn_updates_approvals_reviewer() {
}, },
}, },
/*client_user_message_id*/ None, /*client_user_message_id*/ None,
/*parent_turn_id*/ None,
) )
.await; .await;
@@ -7253,6 +7257,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
id: "sub-1".into(), id: "sub-1".into(),
op: Op::Interrupt, op: Op::Interrupt,
client_user_message_id: None, client_user_message_id: None,
parent_turn_id: None,
trace: Some(submission_trace.clone()), trace: Some(submission_trace.clone()),
}); });
let dispatch_span_id = dispatch_span.context().span().span_context().span_id(); let dispatch_span_id = dispatch_span.context().span().span_context().span_id();
@@ -10422,13 +10427,16 @@ async fn thread_idle_lifecycle_waits_for_trigger_turn_mailbox_work() {
session.services.extensions = Arc::new(builder.build()); session.services.extensions = Arc::new(builder.build());
session session
.input_queue .input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
AgentPath::root(), InterAgentCommunication::new(
AgentPath::root(), AgentPath::root(),
Vec::new(), AgentPath::root(),
"pending trigger".to_string(), Vec::new(),
/*trigger_turn*/ true, "pending trigger".to_string(),
)) /*trigger_turn*/ true,
),
/*parent_turn_id*/ None,
)
.await; .await;
session.emit_thread_idle_lifecycle_if_idle().await; session.emit_thread_idle_lifecycle_if_idle().await;
@@ -10458,7 +10466,7 @@ async fn try_start_turn_if_idle_rejects_active_turn_without_injecting() {
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input()); assert_eq!(vec![item], err.into_input());
assert_eq!( assert_eq!(
Vec::<TurnInput>::new(), (Vec::<TurnInput>::new(), None),
sess.input_queue.get_pending_input(&sess.active_turn).await sess.input_queue.get_pending_input(&sess.active_turn).await
); );
@@ -10485,7 +10493,7 @@ async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() {
assert_eq!(vec![item], err.into_input()); assert_eq!(vec![item], err.into_input());
assert!(sess.active_turn.lock().await.is_none()); assert!(sess.active_turn.lock().await.is_none());
assert_eq!( assert_eq!(
Vec::<TurnInput>::new(), (Vec::<TurnInput>::new(), None),
sess.input_queue.get_pending_input(&sess.active_turn).await sess.input_queue.get_pending_input(&sess.active_turn).await
); );
} }
@@ -10494,13 +10502,16 @@ async fn try_start_turn_if_idle_rejects_plan_mode_without_injecting() {
async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() { async fn try_start_turn_if_idle_rejects_pending_trigger_turn_without_injecting() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await; let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
AgentPath::root(), InterAgentCommunication::new(
AgentPath::root(), AgentPath::root(),
Vec::new(), AgentPath::root(),
"pending trigger".to_string(), Vec::new(),
/*trigger_turn*/ true, "pending trigger".to_string(),
)) /*trigger_turn*/ true,
),
/*parent_turn_id*/ None,
)
.await; .await;
let item = user_message("synthetic idle input"); let item = user_message("synthetic idle input");
@@ -10540,7 +10551,7 @@ async fn try_start_turn_if_idle_rejects_active_review_turn_without_injecting() {
assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason()); assert_eq!(TryStartTurnIfIdleRejectionReason::Busy, err.reason());
assert_eq!(vec![item], err.into_input()); assert_eq!(vec![item], err.into_input());
assert_eq!( assert_eq!(
Vec::<TurnInput>::new(), (Vec::<TurnInput>::new(), None),
sess.input_queue.get_pending_input(&sess.active_turn).await sess.input_queue.get_pending_input(&sess.active_turn).await
); );
@@ -10768,7 +10779,7 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
.await; .await;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(communication.clone()) .enqueue_mailbox_communication(communication.clone(), /*parent_turn_id*/ None)
.await; .await;
assert!( assert!(
@@ -10777,13 +10788,13 @@ async fn queue_only_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
); );
assert_eq!( assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await, sess.input_queue.get_pending_input(&sess.active_turn).await,
Vec::new() (Vec::new(), None)
); );
sess.abort_all_tasks(TurnAbortReason::Replaced).await; sess.abort_all_tasks(TurnAbortReason::Replaced).await;
assert_eq!( assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await, (sess.input_queue.get_pending_input(&sess.active_turn).await).0,
vec![TurnInput::InterAgentCommunication(communication)], vec![TurnInput::InterAgentCommunication(communication)],
); );
} }
@@ -10805,13 +10816,16 @@ async fn trigger_turn_mailbox_mail_waits_for_next_turn_after_answer_boundary() {
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
.await; .await;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
AgentPath::try_from("/root/worker").expect("worker path should parse"), InterAgentCommunication::new(
AgentPath::root(), AgentPath::try_from("/root/worker").expect("worker path should parse"),
Vec::new(), AgentPath::root(),
"late trigger update".to_string(), Vec::new(),
/*trigger_turn*/ true, "late trigger update".to_string(),
)) /*trigger_turn*/ true,
),
/*parent_turn_id*/ None,
)
.await; .await;
assert!( assert!(
@@ -10848,7 +10862,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
.await; .await;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(communication.clone()) .enqueue_mailbox_communication(communication.clone(), /*parent_turn_id*/ None)
.await; .await;
sess.steer_input( sess.steer_input(
vec![UserInput::Text { vec![UserInput::Text {
@@ -10864,7 +10878,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
.expect("steered input should be accepted"); .expect("steered input should be accepted");
assert_eq!( assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await, (sess.input_queue.get_pending_input(&sess.active_turn).await).0,
vec![ vec![
TurnInput::UserInput { TurnInput::UserInput {
content: vec![UserInput::Text { content: vec![UserInput::Text {
@@ -10902,7 +10916,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
.await; .await;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(communication.clone()) .enqueue_mailbox_communication(communication.clone(), /*parent_turn_id*/ None)
.await; .await;
sess.steer_input( sess.steer_input(
vec![UserInput::Text { vec![UserInput::Text {
@@ -10922,7 +10936,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
.await; .await;
assert_eq!( assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await, (sess.input_queue.get_pending_input(&sess.active_turn).await).0,
vec![ vec![
TurnInput::UserInput { TurnInput::UserInput {
content: vec![UserInput::Text { content: vec![UserInput::Text {
@@ -10960,7 +10974,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
.defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id) .defer_mailbox_delivery_to_next_turn(&sess.active_turn, &tc.sub_id)
.await; .await;
sess.input_queue sess.input_queue
.enqueue_mailbox_communication(communication.clone()) .enqueue_mailbox_communication(communication.clone(), /*parent_turn_id*/ None)
.await; .await;
let item = ResponseItem::FunctionCall { let item = ResponseItem::FunctionCall {
@@ -10986,7 +11000,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
assert!(output.needs_follow_up); assert!(output.needs_follow_up);
assert!(output.tool_future.is_some()); assert!(output.tool_future.is_some());
assert_eq!( assert_eq!(
sess.input_queue.get_pending_input(&sess.active_turn).await, (sess.input_queue.get_pending_input(&sess.active_turn).await).0,
vec![TurnInput::InterAgentCommunication(communication)], vec![TurnInput::InterAgentCommunication(communication)],
); );
} }
+4 -1
View File
@@ -274,7 +274,10 @@ pub(crate) async fn run_turn(
// submitted through the UI while the model was running. Though the UI // submitted through the UI while the model was running. Though the UI
// may support this, the model might not. // may support this, the model might not.
let pending_input = if can_drain_pending_input { let pending_input = if can_drain_pending_input {
sess.input_queue.get_pending_input(&sess.active_turn).await sess.input_queue
.get_pending_input(&sess.active_turn)
.await
.0
} else { } else {
Vec::new() Vec::new()
}; };
+22 -4
View File
@@ -69,6 +69,11 @@ const TASK_COMPACT_METRIC: &str = "codex.task.compact";
pub(crate) type SessionTaskResult = CodexResult<Option<String>>; pub(crate) type SessionTaskResult = CodexResult<Option<String>>;
pub(crate) enum MailboxParentProvenance {
Ignore,
Attribute,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InterruptedTurnHistoryMarker { pub(crate) enum InterruptedTurnHistoryMarker {
Disabled, Disabled,
@@ -319,7 +324,8 @@ impl Session {
) { ) {
self.abort_all_tasks(TurnAbortReason::Replaced).await; self.abort_all_tasks(TurnAbortReason::Replaced).await;
self.clear_connector_selection().await; self.clear_connector_selection().await;
self.start_task(turn_context, input, task).await; self.start_task(turn_context, input, task, MailboxParentProvenance::Ignore)
.await;
} }
pub(crate) async fn start_task<T: SessionTask>( pub(crate) async fn start_task<T: SessionTask>(
@@ -327,6 +333,7 @@ impl Session {
turn_context: Arc<TurnContext>, turn_context: Arc<TurnContext>,
input: Vec<TurnInput>, input: Vec<TurnInput>,
task: T, task: T,
mailbox_parent_provenance: MailboxParentProvenance,
) { ) {
let task: Arc<dyn AnySessionTask> = Arc::new(task); let task: Arc<dyn AnySessionTask> = Arc::new(task);
let task_kind = task.kind(); let task_kind = task.kind();
@@ -350,7 +357,13 @@ impl Session {
.await .await
.clear_turn(&turn_context.sub_id); .clear_turn(&turn_context.sub_id);
let pending_items = self.input_queue.get_pending_input(&self.active_turn).await; let (pending_items, parent_turn_id) =
self.input_queue.get_pending_input(&self.active_turn).await;
if let (MailboxParentProvenance::Attribute, Some(id)) =
(mailbox_parent_provenance, parent_turn_id)
{
turn_context.turn_metadata_state.set_parent_turn_id(id);
}
let turn_state = { let turn_state = {
let mut active = self.active_turn.lock().await; let mut active = self.active_turn.lock().await;
let turn = active.get_or_insert_with(ActiveTurn::default); let turn = active.get_or_insert_with(ActiveTurn::default);
@@ -502,8 +515,13 @@ impl Session {
let turn_context = self.new_default_turn_with_sub_id(sub_id).await; let turn_context = self.new_default_turn_with_sub_id(sub_id).await;
self.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) self.maybe_emit_model_warnings_for_turn(turn_context.as_ref())
.await; .await;
self.start_task(turn_context, Vec::new(), RegularTask::new()) self.start_task(
.await; turn_context,
Vec::new(),
RegularTask::new(),
MailboxParentProvenance::Attribute,
)
.await;
} }
pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) { pub async fn abort_all_tasks(self: &Arc<Self>, reason: TurnAbortReason) {
+5
View File
@@ -195,6 +195,11 @@ pub fn responses_metadata(
} }
} }
pub fn with_parent_turn(mut metadata: CodexResponsesMetadata, id: &str) -> CodexResponsesMetadata {
metadata.parent_turn_id = Some(id.to_string());
metadata
}
pub fn all_model_presets() -> &'static Vec<ModelPreset> { pub fn all_model_presets() -> &'static Vec<ModelPreset> {
&TEST_MODEL_PRESETS &TEST_MODEL_PRESETS
} }
+10 -2
View File
@@ -1330,14 +1330,22 @@ impl ThreadManagerState {
} }
/// Send an operation to a thread by ID. /// Send an operation to a thread by ID.
pub(crate) async fn send_op(&self, thread_id: ThreadId, op: Op) -> CodexResult<String> { pub(crate) async fn send_op(
&self,
thread_id: ThreadId,
op: Op,
parent_turn_id: Option<String>,
) -> CodexResult<String> {
let thread = self.get_thread(thread_id).await?; let thread = self.get_thread(thread_id).await?;
if let Some(ops_log) = &self.ops_log if let Some(ops_log) = &self.ops_log
&& let Ok(mut log) = ops_log.lock() && let Ok(mut log) = ops_log.lock()
{ {
log.push((thread_id, op.clone())); log.push((thread_id, op.clone()));
} }
thread.submit(op).await thread
.io
.submit_with_trace(op, /*trace*/ None, parent_turn_id)
.await
} }
/// Remove a thread from the manager by ID, returning it when present. /// Remove a thread from the manager by ID, returning it when present.
@@ -84,7 +84,7 @@ impl Handler {
.await; .await;
let agent_control = session.services.agent_control.clone(); let agent_control = session.services.agent_control.clone();
let result = agent_control let result = agent_control
.send_input(receiver_thread_id, input_items) .send_input(receiver_thread_id, input_items, Some(turn.sub_id.clone()))
.await .await
.map_err(|err| collab_agent_error(receiver_thread_id, err)); .map_err(|err| collab_agent_error(receiver_thread_id, err));
let status = session let status = session
@@ -127,6 +127,7 @@ async fn handle_spawn_agent(
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory),
parent_thread_id: Some(session.thread_id), parent_thread_id: Some(session.thread_id),
parent_turn_id: Some(turn.sub_id.clone()),
environments: Some(turn.environments.to_selections()), environments: Some(turn.environments.to_selections()),
}, },
)) ))
@@ -2977,13 +2977,16 @@ async fn multi_agent_v2_wait_agent_accepts_timeout_only_argument() {
session session
.input_queue .input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
worker_path, InterAgentCommunication::new(
AgentPath::root(), worker_path,
Vec::new(), AgentPath::root(),
"hello from worker".to_string(), Vec::new(),
/*trigger_turn*/ false, "hello from worker".to_string(),
)) /*trigger_turn*/ false,
),
/*parent_turn_id*/ None,
)
.await; .await;
let output = wait_task let output = wait_task
@@ -3461,13 +3464,16 @@ async fn multi_agent_v2_wait_agent_returns_summary_for_mailbox_activity() {
session session
.input_queue .input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
worker_path, InterAgentCommunication::new(
AgentPath::root(), worker_path,
Vec::new(), AgentPath::root(),
"completed".to_string(), Vec::new(),
/*trigger_turn*/ false, "completed".to_string(),
)) /*trigger_turn*/ false,
),
/*parent_turn_id*/ None,
)
.await; .await;
let wait_output = wait_task let wait_output = wait_task
@@ -3534,13 +3540,16 @@ async fn multi_agent_v2_wait_agent_returns_for_already_queued_mail() {
session session
.input_queue .input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
worker_path, InterAgentCommunication::new(
AgentPath::root(), worker_path,
Vec::new(), AgentPath::root(),
"already queued".to_string(), Vec::new(),
/*trigger_turn*/ false, "already queued".to_string(),
)) /*trigger_turn*/ false,
),
/*parent_turn_id*/ None,
)
.await; .await;
let output = timeout( let output = timeout(
@@ -3633,13 +3642,16 @@ async fn multi_agent_v2_wait_agent_wakes_on_any_mailbox_notification() {
session session
.input_queue .input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
worker_b_path, InterAgentCommunication::new(
AgentPath::root(), worker_b_path,
Vec::new(), AgentPath::root(),
"from worker b".to_string(), Vec::new(),
/*trigger_turn*/ false, "from worker b".to_string(),
)) /*trigger_turn*/ false,
),
/*parent_turn_id*/ None,
)
.await; .await;
let output = wait_task let output = wait_task
@@ -3721,13 +3733,16 @@ async fn multi_agent_v2_wait_agent_does_not_return_completed_content() {
session session
.input_queue .input_queue
.enqueue_mailbox_communication(InterAgentCommunication::new( .enqueue_mailbox_communication(
worker_path, InterAgentCommunication::new(
AgentPath::root(), worker_path,
Vec::new(), AgentPath::root(),
"sensitive child output".to_string(), Vec::new(),
/*trigger_turn*/ false, "sensitive child output".to_string(),
)) /*trigger_turn*/ false,
),
/*parent_turn_id*/ None,
)
.await; .await;
let output = wait_task let output = wait_task
@@ -107,10 +107,17 @@ pub(crate) async fn handle_message_string_tool(
MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup, MessageDeliveryMode::TriggerTurn => AgentCommunicationKind::Followup,
}; };
let context = AgentCommunicationContext::new(kind, session.thread_id); let context = AgentCommunicationContext::new(kind, session.thread_id);
let parent_turn_id =
matches!(mode, MessageDeliveryMode::TriggerTurn).then(|| turn.sub_id.clone());
let result = session let result = session
.services .services
.agent_control .agent_control
.send_inter_agent_communication(receiver_thread_id, mode.apply(communication), context) .send_inter_agent_communication(
receiver_thread_id,
mode.apply(communication),
context,
parent_turn_id,
)
.await .await
.map_err(|err| collab_agent_error(receiver_thread_id, err)); .map_err(|err| collab_agent_error(receiver_thread_id, err));
result?; result?;
@@ -118,6 +118,7 @@ async fn handle_spawn_agent(
fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()),
fork_mode, fork_mode,
parent_thread_id: Some(session.thread_id), parent_thread_id: Some(session.thread_id),
parent_turn_id: Some(turn.sub_id.clone()),
environments: Some(turn.environments.to_selections()), environments: Some(turn.environments.to_selections()),
}, },
), ),
+13
View File
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use std::sync::Mutex; use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::RwLock; use std::sync::RwLock;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@@ -12,6 +13,7 @@ use tokio::task::JoinHandle;
use crate::responses_metadata::CODE_MODE_TOOL_NAMES_KEY; use crate::responses_metadata::CODE_MODE_TOOL_NAMES_KEY;
use crate::responses_metadata::CodexResponsesMetadata; use crate::responses_metadata::CodexResponsesMetadata;
use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CodexResponsesRequestKind;
use crate::responses_metadata::PARENT_TURN_ID_KEY;
use crate::responses_metadata::TurnMetadataWorkspace; use crate::responses_metadata::TurnMetadataWorkspace;
use crate::responses_metadata::filter_extra_metadata; use crate::responses_metadata::filter_extra_metadata;
use crate::responses_metadata::subagent_header_value; use crate::responses_metadata::subagent_header_value;
@@ -92,6 +94,7 @@ pub(crate) struct TurnMetadataState {
thread_id: String, thread_id: String,
forked_from_thread_id: Option<ThreadId>, forked_from_thread_id: Option<ThreadId>,
parent_thread_id: Option<ThreadId>, parent_thread_id: Option<ThreadId>,
parent_turn_id: OnceLock<String>,
subagent_header: Option<String>, subagent_header: Option<String>,
subagent_kind: Option<String>, subagent_kind: Option<String>,
thread_source: Option<ThreadSource>, thread_source: Option<ThreadSource>,
@@ -136,6 +139,7 @@ impl TurnMetadataState {
thread_id, thread_id,
forked_from_thread_id, forked_from_thread_id,
parent_thread_id, parent_thread_id,
parent_turn_id: OnceLock::new(),
subagent_header: subagent_header_value(session_source), subagent_header: subagent_header_value(session_source),
subagent_kind: subagent_metadata_kind(session_source), subagent_kind: subagent_metadata_kind(session_source),
thread_source, thread_source,
@@ -160,6 +164,7 @@ impl TurnMetadataState {
return None; return None;
}; };
metadata.remove(CODE_MODE_TOOL_NAMES_KEY); // Precaution: avoid exposing tool data to external MCPs. metadata.remove(CODE_MODE_TOOL_NAMES_KEY); // Precaution: avoid exposing tool data to external MCPs.
metadata.remove(PARENT_TURN_ID_KEY);
metadata.insert( metadata.insert(
MODEL_KEY.to_string(), MODEL_KEY.to_string(),
Value::String(context.model.to_string()), Value::String(context.model.to_string()),
@@ -219,6 +224,13 @@ impl TurnMetadataState {
(!code_mode_tool_names.is_empty()).then_some(code_mode_tool_names); (!code_mode_tool_names.is_empty()).then_some(code_mode_tool_names);
} }
pub(crate) fn set_parent_turn_id(&self, parent_turn_id: String) {
if parent_turn_id.trim().is_empty() {
return;
}
let _ = self.parent_turn_id.set(parent_turn_id);
}
pub(crate) fn set_responsesapi_client_metadata( pub(crate) fn set_responsesapi_client_metadata(
&self, &self,
responsesapi_client_metadata: HashMap<String, String>, responsesapi_client_metadata: HashMap<String, String>,
@@ -243,6 +255,7 @@ impl TurnMetadataState {
turn_id: Some(self.turn_id.clone()), turn_id: Some(self.turn_id.clone()),
forked_from_thread_id: self.forked_from_thread_id, forked_from_thread_id: self.forked_from_thread_id,
parent_thread_id: self.parent_thread_id, parent_thread_id: self.parent_thread_id,
parent_turn_id: self.parent_turn_id.get().cloned(),
subagent_header: self.subagent_header.clone(), subagent_header: self.subagent_header.clone(),
subagent_kind: self.subagent_kind.clone(), subagent_kind: self.subagent_kind.clone(),
thread_source: self.thread_source.clone(), thread_source: self.thread_source.clone(),
+7
View File
@@ -4,6 +4,7 @@ use crate::responses_metadata::CODE_MODE_TOOL_NAMES_KEY;
use crate::responses_metadata::CodexResponsesRequestKind; use crate::responses_metadata::CodexResponsesRequestKind;
use crate::responses_metadata::CompactionTurnMetadata; use crate::responses_metadata::CompactionTurnMetadata;
use crate::responses_metadata::INSTALLATION_ID_KEY; use crate::responses_metadata::INSTALLATION_ID_KEY;
use crate::responses_metadata::PARENT_TURN_ID_KEY;
use crate::responses_metadata::WINDOW_ID_KEY; use crate::responses_metadata::WINDOW_ID_KEY;
use crate::sandbox_tags::permission_profile_sandbox_tag; use crate::sandbox_tags::permission_profile_sandbox_tag;
use codex_analytics::CompactionImplementation; use codex_analytics::CompactionImplementation;
@@ -560,6 +561,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
"parent_thread_id".to_string(), "parent_thread_id".to_string(),
"client-supplied".to_string(), "client-supplied".to_string(),
), ),
("parent_turn_id".to_string(), "client-supplied".to_string()),
("subagent_kind".to_string(), "client-supplied".to_string()), ("subagent_kind".to_string(), "client-supplied".to_string()),
])); ]));
@@ -570,6 +572,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() {
assert!(json.get("turn_started_at_unix_ms").is_none()); assert!(json.get("turn_started_at_unix_ms").is_none());
assert!(json.get("forked_from_thread_id").is_none()); assert!(json.get("forked_from_thread_id").is_none());
assert!(json.get("parent_thread_id").is_none()); assert!(json.get("parent_thread_id").is_none());
assert!(json.get("parent_turn_id").is_none());
assert!(json.get("subagent_kind").is_none()); assert!(json.get("subagent_kind").is_none());
} }
@@ -602,6 +605,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
WindowsSandboxLevel::Disabled, WindowsSandboxLevel::Disabled,
/*enforce_managed_network*/ false, /*enforce_managed_network*/ false,
); );
state.set_parent_turn_id("parent-turn-a".to_string());
state.set_responsesapi_client_metadata(HashMap::from([ state.set_responsesapi_client_metadata(HashMap::from([
("fiber_run_id".to_string(), "fiber-123".to_string()), ("fiber_run_id".to_string(), "fiber-123".to_string()),
("origin".to_string(), "東京".to_string()), ("origin".to_string(), "東京".to_string()),
@@ -634,6 +638,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
"parent_thread_id".to_string(), "parent_thread_id".to_string(),
"client-supplied".to_string(), "client-supplied".to_string(),
), ),
("parent_turn_id".to_string(), "client-supplied".to_string()),
("subagent_kind".to_string(), "client-supplied".to_string()), ("subagent_kind".to_string(), "client-supplied".to_string()),
( (
CODE_MODE_TOOL_NAMES_KEY.to_string(), CODE_MODE_TOOL_NAMES_KEY.to_string(),
@@ -694,6 +699,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
json["parent_thread_id"].as_str(), json["parent_thread_id"].as_str(),
Some("55555555-5555-4555-8555-555555555555") Some("55555555-5555-4555-8555-555555555555")
); );
assert_eq!(json["parent_turn_id"].as_str(), Some("parent-turn-a"));
assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn"));
assert_eq!(json["thread_source"].as_str(), Some("automation")); assert_eq!(json["thread_source"].as_str(), Some("automation"));
assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert_eq!(json["turn_id"].as_str(), Some("turn-a"));
@@ -727,6 +733,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields(
assert_eq!(meta["model"].as_str(), Some("gpt-5.4")); assert_eq!(meta["model"].as_str(), Some("gpt-5.4"));
assert_eq!(meta["reasoning_effort"].as_str(), Some("high")); assert_eq!(meta["reasoning_effort"].as_str(), Some("high"));
assert!(meta.get(CODE_MODE_TOOL_NAMES_KEY).is_none()); assert!(meta.get(CODE_MODE_TOOL_NAMES_KEY).is_none());
assert!(meta.get(PARENT_TURN_ID_KEY).is_none());
assert!(meta.get(WINDOW_ID_KEY).is_none()); assert!(meta.get(WINDOW_ID_KEY).is_none());
assert_eq!(state.workspace_kind().as_deref(), Some("projectless")); assert_eq!(state.workspace_kind().as_deref(), Some("projectless"));
} }
+13
View File
@@ -80,6 +80,19 @@ impl ResponseMock {
} }
} }
pub fn assert_parent_turn(body: &Value, expected: Option<&str>) -> Result<()> {
let metadata = &body["client_metadata"];
let payload = metadata["x-codex-turn-metadata"]
.as_str()
.expect("canonical turn metadata");
let canonical: Value = serde_json::from_str(payload)?;
let expected = expected.map(Value::from);
let key = "parent_turn_id";
let actual = (metadata.get(key), canonical.get(key));
assert_eq!(actual, (expected.as_ref(), expected.as_ref()));
Ok(())
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ResponsesRequest(wiremock::Request); pub struct ResponsesRequest(wiremock::Request);
@@ -7,6 +7,7 @@ use codex_core::ModelClientSession;
use codex_core::Prompt; use codex_core::Prompt;
use codex_core::ResponseEvent; use codex_core::ResponseEvent;
use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
use codex_core::test_support::with_parent_turn;
use codex_features::Feature; use codex_features::Feature;
use codex_http_client::OutboundProxyPolicy; use codex_http_client::OutboundProxyPolicy;
use codex_login::CodexAuth; use codex_login::CodexAuth;
@@ -1754,8 +1755,10 @@ async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_c
prior_assistant_output, prior_assistant_output,
message_item("second"), message_item("second"),
]); ]);
let first_responses_metadata = turn_metadata(&harness, Some("turn-123")); let first_responses_metadata =
let second_responses_metadata = turn_metadata(&harness, Some("turn-456")); with_parent_turn(turn_metadata(&harness, Some("turn-123")), "parent-turn-123");
let second_responses_metadata =
with_parent_turn(turn_metadata(&harness, Some("turn-456")), "parent-turn-456");
stream_until_complete_with_metadata( stream_until_complete_with_metadata(
&mut client_session, &mut client_session,
@@ -1797,6 +1800,10 @@ async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_c
assert_eq!(first_metadata["turn_id"].as_str(), Some("turn-123")); assert_eq!(first_metadata["turn_id"].as_str(), Some("turn-123"));
assert_eq!(second_metadata["turn_id"].as_str(), Some("turn-456")); assert_eq!(second_metadata["turn_id"].as_str(), Some("turn-456"));
core_test_support::responses::assert_parent_turn(&first, Some("parent-turn-123"))
.expect("initial WebSocket parent-turn metadata");
core_test_support::responses::assert_parent_turn(&second, Some("parent-turn-456"))
.expect("incremental WebSocket parent-turn metadata");
assert_eq!( assert_eq!(
first["client_metadata"]["turn_id"].as_str(), first["client_metadata"]["turn_id"].as_str(),
first_metadata["turn_id"].as_str() first_metadata["turn_id"].as_str()
+149 -9
View File
@@ -5,6 +5,10 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::Submission;
use codex_protocol::user_input::UserInput;
use core_test_support::responses::assert_parent_turn;
use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_function_call_with_namespace;
@@ -23,12 +27,17 @@ use tokio::time::sleep;
const COLLABORATION_NAMESPACE: &str = "collaboration"; const COLLABORATION_NAMESPACE: &str = "collaboration";
const SPAWN_CALL_ID: &str = "spawn-worker"; const SPAWN_CALL_ID: &str = "spawn-worker";
const NESTED_CALL_ID: &str = "spawn-grandchild";
const QUEUE_CALL_ID: &str = "queue-worker-message";
const FOLLOWUP_CALL_ID: &str = "followup-worker"; const FOLLOWUP_CALL_ID: &str = "followup-worker";
const SIBLING_SPAWN_CALL_ID: &str = "spawn-survivor"; const SIBLING_SPAWN_CALL_ID: &str = "spawn-survivor";
const SIBLING_FOLLOWUP_CALL_ID: &str = "followup-survivor"; const SIBLING_FOLLOWUP_CALL_ID: &str = "followup-survivor";
const INTERRUPT_CALL_ID: &str = "interrupt-worker"; const INTERRUPT_CALL_ID: &str = "interrupt-worker";
const INITIAL_PROMPT: &str = "spawn a durable worker"; const INITIAL_PROMPT: &str = "spawn a durable worker";
const INITIAL_TASK: &str = "inspect the repository"; const INITIAL_TASK: &str = "inspect the repository";
const NESTED_TASK: &str = "inspect the nested repository";
const QUEUE_PROMPT: &str = "queue context for the durable worker";
const QUEUED_MESSAGE: &str = "queue-only context from an earlier parent turn";
const FOLLOWUP_PROMPT: &str = "continue the durable worker"; const FOLLOWUP_PROMPT: &str = "continue the durable worker";
const FOLLOWUP_TASK: &str = "inspect the tests too"; const FOLLOWUP_TASK: &str = "inspect the tests too";
const SIBLING_PROMPT: &str = "spawn a second durable worker"; const SIBLING_PROMPT: &str = "spawn a second durable worker";
@@ -134,6 +143,7 @@ fn configure_multi_agent_v2_with_role(
.expect("test config should allow feature update"); .expect("test config should allow feature update");
config.multi_agent_v2.subagent_developer_instructions = config.multi_agent_v2.subagent_developer_instructions =
Some(SUBAGENT_DEVELOPER_INSTRUCTIONS.to_string()); Some(SUBAGENT_DEVELOPER_INSTRUCTIONS.to_string());
config.multi_agent_v2.max_concurrent_threads_per_session = 3;
let role_path = config.codex_home.join("durable-worker-role.toml"); let role_path = config.codex_home.join("durable-worker-role.toml");
std::fs::write( std::fs::write(
&role_path, &role_path,
@@ -185,11 +195,37 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
}, },
sse(vec![ sse(vec![
ev_response_created("resp-worker-1"), ev_response_created("resp-worker-1"),
ev_assistant_message("msg-worker-1", "initial task complete"), ev_function_call_with_namespace(
NESTED_CALL_ID,
COLLABORATION_NAMESPACE,
"spawn_agent",
r#"{"message":"inspect the nested repository","task_name":"grandchild","fork_turns":"none"}"#,
),
ev_completed("resp-worker-1"), ev_completed("resp-worker-1"),
]), ]),
) )
.await; .await;
let nested_mock = mount_sse_once_match(
&server,
|request: &wiremock::Request| {
body_contains(request, NESTED_TASK)
&& request_has_input_type(request, "agent_message")
&& !body_contains(request, NESTED_CALL_ID)
},
sse(vec![ev_completed("resp-parent-turn-assistant")]),
)
.await;
for (text, is_subagent) in [(NESTED_CALL_ID, true), (QUEUE_CALL_ID, false)] {
mount_sse_once_match(
&server,
move |request: &wiremock::Request| {
body_contains(request, text)
&& request_has_input_type(request, "agent_message") == is_subagent
},
sse(vec![ev_completed("resp-parent-turn-assistant")]),
)
.await;
}
mount_sse_once_match( mount_sse_once_match(
&server, &server,
|request: &wiremock::Request| { |request: &wiremock::Request| {
@@ -215,16 +251,46 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
.rollout_path() .rollout_path()
.expect("root rollout path") .expect("root rollout path")
.to_path_buf(); .to_path_buf();
initial.submit_turn(INITIAL_PROMPT).await?; let mut op = vec![UserInput::Text {
text: INITIAL_PROMPT.to_string(),
text_elements: Vec::new(),
}]
.into();
if let Op::UserInput {
thread_settings, ..
} = &mut op
{
thread_settings.permission_profile = Some(PermissionProfile::Disabled);
}
initial
.codex
.submit_with_id(Submission {
id: "spoofed-root-turn".to_string(),
op,
client_user_message_id: None,
trace: None,
parent_turn_id: Some("spoofed-parent-turn".to_string()),
})
.await?;
wait_for_event(&initial.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
let worker_thread_id = loop { let worker_thread_id = loop {
if let Some(thread_id) = initial if let Some(thread_id) = initial_child_request
.thread_manager .requests()
.list_thread_ids()
.await
.into_iter() .into_iter()
.find(|thread_id| *thread_id != root_thread_id) .find_map(|request| {
let body = request.body_json();
if body["client_metadata"]["x-codex-parent-thread-id"] != json!(root_thread_id) {
return None;
}
body["client_metadata"]["thread_id"]
.as_str()
.and_then(|thread_id| codex_protocol::ThreadId::from_string(thread_id).ok())
})
{ {
break thread_id; break thread_id;
} }
@@ -299,12 +365,14 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
.await; .await;
initial.submit_turn(SIBLING_PROMPT).await?; initial.submit_turn(SIBLING_PROMPT).await?;
let grandchild = nested_mock.last_request().expect("grandchild").body_json();
let nested_id = &grandchild["client_metadata"]["thread_id"];
let sibling_thread_id = initial let sibling_thread_id = initial
.thread_manager .thread_manager
.list_thread_ids() .list_thread_ids()
.await .await
.into_iter() .into_iter()
.find(|thread_id| *thread_id != root_thread_id && *thread_id != worker_thread_id) .find(|id| ![root_thread_id, worker_thread_id].contains(id) && &json!(id) != nested_id)
.ok_or_else(|| anyhow::anyhow!("spawned sibling should be registered"))?; .ok_or_else(|| anyhow::anyhow!("spawned sibling should be registered"))?;
let sibling_thread = initial.thread_manager.get_thread(sibling_thread_id).await?; let sibling_thread = initial.thread_manager.get_thread(sibling_thread_id).await?;
wait_for_event(sibling_thread.as_ref(), |event| { wait_for_event(sibling_thread.as_ref(), |event| {
@@ -343,6 +411,7 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
request_has_model(request, ROLE_MODEL) request_has_model(request, ROLE_MODEL)
&& request_has_input_type(request, "agent_message") && request_has_input_type(request, "agent_message")
&& body_contains(request, FOLLOWUP_TASK) && body_contains(request, FOLLOWUP_TASK)
&& body_contains(request, QUEUED_MESSAGE)
}, },
sse(vec![ sse(vec![
ev_response_created("resp-worker-2"), ev_response_created("resp-worker-2"),
@@ -388,13 +457,29 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
.is_err() .is_err()
); );
mount_sse_once_match(
&server,
|request: &wiremock::Request| body_contains(request, QUEUE_PROMPT),
sse(vec![
ev_response_created("resp-queue"),
ev_function_call_with_namespace(
QUEUE_CALL_ID,
COLLABORATION_NAMESPACE,
"send_message",
r#"{"target":"worker","message":"queue-only context from an earlier parent turn"}"#,
),
ev_completed("resp-queue"),
]),
)
.await;
resumed.submit_turn(QUEUE_PROMPT).await?;
resumed.submit_turn(FOLLOWUP_PROMPT).await?; resumed.submit_turn(FOLLOWUP_PROMPT).await?;
let reloaded_worker = resumed let reloaded_worker = resumed
.thread_manager .thread_manager
.get_thread(worker_thread_id) .get_thread(worker_thread_id)
.await .await
.expect("follow-up should lazily reload the original worker"); .expect("queued message should lazily reload the original worker");
let deadline = Instant::now() + Duration::from_secs(2); let deadline = Instant::now() + Duration::from_secs(2);
loop { loop {
if matches!( if matches!(
@@ -413,6 +498,61 @@ async fn cold_root_resume_restores_agent_identity_and_role_on_followup() -> Resu
&& request.body_contains_text(ROLE_DEVELOPER_INSTRUCTIONS) && request.body_contains_text(ROLE_DEVELOPER_INSTRUCTIONS)
&& !request.body_contains_text(SUBAGENT_DEVELOPER_INSTRUCTIONS) && !request.body_contains_text(SUBAGENT_DEVELOPER_INSTRUCTIONS)
})); }));
let requests = server
.received_requests()
.await
.expect("captured response requests");
assert!(!followup_child_request.requests().iter().any(|request| {
request.body_json()["client_metadata"]["thread_id"] == json!(worker_thread_id)
&& request.body_contains_text(QUEUED_MESSAGE)
&& !request.body_contains_text(FOLLOWUP_TASK)
}));
let body_for = |text: &str, thread: codex_protocol::ThreadId| {
requests
.iter()
.find_map(|request| {
let body: Value = serde_json::from_slice(&decoded_body(request)?).ok()?;
(body_contains(request, text)
&& body["client_metadata"]["thread_id"] == json!(thread))
.then_some(body)
})
.expect("matching model request for expected thread")
};
let initial_root = body_for(INITIAL_PROMPT, root_thread_id);
let queue_root = body_for(QUEUE_PROMPT, root_thread_id);
let followup_root = body_for(FOLLOWUP_PROMPT, root_thread_id);
let initial_child = body_for(INITIAL_TASK, worker_thread_id);
let followup_child = body_for(FOLLOWUP_TASK, worker_thread_id);
let initial_parent = initial_root["client_metadata"]["turn_id"]
.as_str()
.expect("initial parent turn");
let queue_parent = queue_root["client_metadata"]["turn_id"]
.as_str()
.expect("queue-only parent turn");
let followup_parent = followup_root["client_metadata"]["turn_id"]
.as_str()
.expect("follow-up parent turn");
assert_ne!(followup_parent, initial_parent);
assert_ne!(followup_parent, queue_parent);
let nested_parent = initial_child["client_metadata"]["turn_id"]
.as_str()
.expect("nested worker parent turn");
for (body, parent_thread, parent_turn) in [
(&initial_root, None, None),
(&queue_root, None, None),
(&followup_root, None, None),
(&initial_child, Some(root_thread_id), Some(initial_parent)),
(&followup_child, Some(root_thread_id), Some(followup_parent)),
(&grandchild, Some(worker_thread_id), Some(nested_parent)),
] {
if let Some(parent_thread) = parent_thread {
assert_eq!(
body["client_metadata"]["x-codex-parent-thread-id"],
json!(parent_thread)
);
}
assert_parent_turn(body, parent_turn)?;
}
let reloaded_worker_config = reloaded_worker.config_snapshot().await; let reloaded_worker_config = reloaded_worker.config_snapshot().await;
let reloaded_worker_role_config = ( let reloaded_worker_role_config = (
reloaded_worker_config.model, reloaded_worker_config.model,
+2
View File
@@ -212,6 +212,8 @@ async fn review_op_emits_lifecycle_and_review_output() {
turn_metadata["parent_thread_id"].as_str(), turn_metadata["parent_thread_id"].as_str(),
Some(parent_thread_id.as_str()) Some(parent_thread_id.as_str())
); );
responses::assert_parent_turn(&request.body_json(), Some(review_turn_id.as_str()))
.expect("review request parent turn metadata");
// Also verify that a user message with the header and a formatted finding // Also verify that a user message with the header and a formatted finding
// was recorded back in the parent session's rollout. // was recorded back in the parent session's rollout.
@@ -8,6 +8,7 @@ use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::PermissionProfile; use codex_protocol::models::PermissionProfile;
use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op; use codex_protocol::protocol::Op;
@@ -17,6 +18,7 @@ use codex_protocol::protocol::ThreadHistoryMode;
use codex_protocol::user_input::UserInput; use codex_protocol::user_input::UserInput;
use core_test_support::hooks::trust_discovered_hooks; use core_test_support::hooks::trust_discovered_hooks;
use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ResponsesRequest;
use core_test_support::responses::assert_parent_turn;
use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed;
use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_function_call_with_namespace;
@@ -951,17 +953,22 @@ async fn spawned_child_receives_forked_parent_context(
config.agent_default_subagent_model = Some(REQUESTED_MODEL.to_string()); config.agent_default_subagent_model = Some(REQUESTED_MODEL.to_string());
config.agent_default_subagent_reasoning_effort = Some(REQUESTED_REASONING_EFFORT); config.agent_default_subagent_reasoning_effort = Some(REQUESTED_REASONING_EFFORT);
}); });
let test = builder.build(&server).await?; let test = builder.build_with_auto_env(&server).await?;
test.submit_turn(TURN_0_FORK_PROMPT).await?; test.submit_turn(TURN_0_FORK_PROMPT).await?;
let _ = seed_turn.single_request(); let _ = seed_turn.single_request();
test.submit_turn(TURN_1_PROMPT).await?; test.submit_turn(TURN_1_PROMPT).await?;
let _ = spawn_turn.single_request(); let parent_body = spawn_turn.single_request().body_json();
let child_request = wait_for_request_with_model(&child_request_log, REQUESTED_MODEL).await?; let child_request = wait_for_request_with_model(&child_request_log, REQUESTED_MODEL).await?;
assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT)); assert!(child_request.body_contains_text(TURN_0_FORK_PROMPT));
let child_body = child_request.body_json(); let child_body = child_request.body_json();
let original_parent_turn_id = parent_body["client_metadata"]["turn_id"]
.as_str()
.expect("legacy spawn parent turn id");
assert_parent_turn(&parent_body, /*expected*/ None)?;
assert_parent_turn(&child_body, Some(original_parent_turn_id))?;
assert_eq!( assert_eq!(
( (
child_body["model"].clone(), child_body["model"].clone(),
@@ -972,7 +979,59 @@ async fn spawned_child_receives_forked_parent_context(
json!(REQUESTED_REASONING_EFFORT.to_string()), json!(REQUESTED_REASONING_EFFORT.to_string()),
) )
); );
let child_thread_id = ThreadId::from_string(
child_body["client_metadata"]["thread_id"]
.as_str()
.expect("legacy child thread id"),
)?;
let child_thread = test.thread_manager.get_thread(child_thread_id).await?;
tokio::time::timeout(Duration::from_secs(2), async {
while !matches!(child_thread.agent_status().await, AgentStatus::Completed(_)) {
sleep(Duration::from_millis(10)).await;
}
})
.await?;
let args = serde_json::to_string(&json!({
"target": child_thread_id.to_string(),
"message": "legacy child follow-up",
}))?;
let parent = mount_sse_once_match(
&server,
|request: &wiremock::Request| body_contains(request, "reuse the legacy child"),
sse(vec![
ev_response_created("resp-legacy-reuse"),
ev_function_call_with_namespace(
"legacy-reuse-call",
MULTI_AGENT_V1_NAMESPACE,
"send_input",
&args,
),
ev_completed("resp-legacy-reuse"),
]),
)
.await;
let followup = mount_sse_sequence(
&server,
vec![
sse(vec![ev_completed("resp-legacy-child-reuse")]),
sse(vec![ev_completed("resp-legacy-reuse-complete")]),
],
)
.await;
test.submit_turn("reuse the legacy child").await?;
let followup_parent_body = parent.single_request().body_json();
let reused_child_body = wait_for_request_with_model(&followup, REQUESTED_MODEL)
.await?
.body_json();
let followup_parent_turn_id = followup_parent_body["client_metadata"]["turn_id"]
.as_str()
.expect("legacy follow-up parent turn id");
assert_ne!(followup_parent_turn_id, original_parent_turn_id);
let metadata = &reused_child_body["client_metadata"];
assert_eq!(metadata["thread_id"], json!(child_thread_id));
assert_parent_turn(&followup_parent_body, /*expected*/ None)?;
assert_parent_turn(&reused_child_body, Some(followup_parent_turn_id))?;
Ok(()) Ok(())
} }
@@ -119,6 +119,7 @@ pub async fn run_codex_tool_session(
}, },
client_user_message_id: None, client_user_message_id: None,
trace: None, trace: None,
parent_turn_id: None,
}; };
if let Err(e) = thread.submit_with_id(submission).await { if let Err(e) = thread.submit_with_id(submission).await {
@@ -560,6 +560,7 @@ impl MessageProcessor {
op: codex_protocol::protocol::Op::Interrupt, op: codex_protocol::protocol::Op::Interrupt,
client_user_message_id: None, client_user_message_id: None,
trace: None, trace: None,
parent_turn_id: None,
}) })
.await .await
{ {
+2
View File
@@ -182,6 +182,8 @@ pub struct Submission {
pub client_user_message_id: Option<String>, pub client_user_message_id: Option<String>,
/// Optional W3C trace carrier propagated across async submission handoffs. /// Optional W3C trace carrier propagated across async submission handoffs.
pub trace: Option<W3cTraceContext>, pub trace: Option<W3cTraceContext>,
/// Core-provided ID of the parent turn that directly initiated this submission.
pub parent_turn_id: Option<String>,
} }
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]