From 970386e8b2a776d47ef6ac6c2bd67a3c0ed86744 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Thu, 26 Mar 2026 17:57:34 +0000 Subject: [PATCH] fix: root as std agent (#15881) --- codex-rs/core/src/agent/control.rs | 23 ++- .../src/tools/handlers/multi_agents_tests.rs | 153 +++++++++++++++++- .../handlers/multi_agents_v2/close_agent.rs | 9 ++ 3 files changed, 173 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index a3b989768..1efd514f6 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -44,6 +44,7 @@ use tracing::warn; const AGENT_NAMES: &str = include_str!("agent_names.txt"); const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context."; +const ROOT_LAST_TASK_MESSAGE: &str = "Main thread"; #[derive(Clone, Debug, Default)] pub(crate) struct SpawnAgentOptions { @@ -650,12 +651,6 @@ impl AgentControl { let agent_path = current_agent_path .resolve(agent_reference) .map_err(CodexErr::UnsupportedOperation)?; - if agent_path.is_root() { - return Err(CodexErr::UnsupportedOperation( - "root is not a spawned agent".to_string(), - )); - } - if let Some(thread_id) = self.state.agent_id_for_path(&agent_path) { return Ok(thread_id); } @@ -737,7 +732,21 @@ impl AgentControl { }) }); - let mut agents = Vec::with_capacity(live_agents.len()); + let root_path = AgentPath::root(); + let mut agents = Vec::with_capacity(live_agents.len().saturating_add(1)); + if resolved_prefix + .as_ref() + .is_none_or(|prefix| agent_matches_prefix(Some(&root_path), prefix)) + && let Some(root_thread_id) = self.state.agent_id_for_path(&root_path) + && let Ok(root_thread) = state.get_thread(root_thread_id).await + { + agents.push(ListedAgent { + agent_name: root_path.to_string(), + agent_status: root_thread.agent_status().await, + last_task_message: Some(ROOT_LAST_TASK_MESSAGE.to_string()), + }); + } + for metadata in live_agents { let Some(thread_id) = metadata.agent_id else { continue; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index ab40b2058..e57491762 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -430,6 +430,81 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat })); } +#[tokio::test] +async fn multi_agent_v2_send_message_accepts_root_target_from_child() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let child_path = AgentPath::try_from("/root/worker").expect("agent path"); + let child_thread_id = session + .services + .agent_control + .spawn_agent_with_metadata( + (*turn.config).clone(), + vec![UserInput::Text { + text: "inspect this repo".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker spawn should succeed") + .thread_id; + session.conversation_id = child_thread_id; + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(child_path.clone()), + agent_nickname: None, + agent_role: None, + }); + + SendMessageHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "send_message", + function_payload(json!({ + "target": "/root", + "items": [{"type": "text", "text": "done"}] + })), + )) + .await + .expect("send_message should accept the root agent path"); + + assert!(manager.captured_ops().iter().any(|(id, op)| { + *id == root.thread_id + && matches!( + op, + Op::InterAgentCommunication { communication } + if communication.author == child_path + && communication.recipient == AgentPath::root() + && communication.other_recipients.is_empty() + && communication.content == "done" + && !communication.trigger_turn + ) + })); +} + #[tokio::test] async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_message() { let (mut session, mut turn) = make_session_and_context().await; @@ -496,11 +571,26 @@ async fn multi_agent_v2_list_agents_returns_completed_status_and_last_task_messa let result: ListAgentsResult = serde_json::from_str(&content).expect("list_agents result should be json"); - assert_eq!(result.agents.len(), 1); - assert_eq!(result.agents[0].agent_name, "/root/worker"); - assert_eq!(result.agents[0].agent_status, json!({"completed": "done"})); + let agent_names = result + .agents + .iter() + .map(|agent| agent.agent_name.as_str()) + .collect::>(); + assert_eq!(agent_names, vec!["/root", "/root/worker"]); + let root_agent = result + .agents + .iter() + .find(|agent| agent.agent_name == "/root") + .expect("root agent should be listed"); + assert_eq!(root_agent.last_task_message.as_deref(), Some("Main thread")); + let worker = result + .agents + .iter() + .find(|agent| agent.agent_name == "/root/worker") + .expect("worker agent should be listed"); + assert_eq!(worker.agent_status, json!({"completed": "done"})); assert_eq!( - result.agents[0].last_task_message.as_deref(), + worker.last_task_message.as_deref(), Some("inspect this repo") ); assert_eq!(success, Some(true)); @@ -647,7 +737,12 @@ async fn multi_agent_v2_list_agents_omits_closed_agents() { let result: ListAgentsResult = serde_json::from_str(&content).expect("list_agents result should be json"); - assert!(result.agents.is_empty()); + assert_eq!(result.agents.len(), 1); + assert_eq!(result.agents[0].agent_name, "/root"); + assert_eq!( + result.agents[0].last_task_message.as_deref(), + Some("Main thread") + ); } #[tokio::test] @@ -2036,6 +2131,54 @@ async fn multi_agent_v2_close_agent_accepts_task_name_target() { ); } +#[tokio::test] +async fn multi_agent_v2_close_agent_rejects_root_target_and_id() { + let (mut session, mut turn) = make_session_and_context().await; + let manager = thread_manager(); + let root = manager + .start_thread((*turn.config).clone()) + .await + .expect("root thread should start"); + session.services.agent_control = manager.agent_control(); + session.conversation_id = root.thread_id; + let mut config = (*turn.config).clone(); + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let root_path_error = CloseAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "close_agent", + function_payload(json!({"target": "/root"})), + )) + .await + .expect_err("close_agent should reject the root path"); + assert_eq!( + root_path_error, + FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) + ); + + let root_id_error = CloseAgentHandlerV2 + .handle(invocation( + session, + turn, + "close_agent", + function_payload(json!({"target": root.thread_id.to_string()})), + )) + .await + .expect_err("close_agent should reject the root thread id"); + assert_eq!( + root_id_error, + FunctionCallError::RespondToModel("root is not a spawned agent".to_string()) + ); +} + #[tokio::test] async fn close_agent_submits_shutdown_and_returns_previous_status() { let (mut session, turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index e2b43e6e1..2fe704823 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -30,6 +30,15 @@ impl ToolHandler for Handler { .agent_control .get_agent_metadata(agent_id) .unwrap_or_default(); + if receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(FunctionCallError::RespondToModel( + "root is not a spawned agent".to_string(), + )); + } session .send_event( &turn,