diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 282e29c48af..7b2910db1d0 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -5,8 +5,10 @@ use crate::agent::role::DEFAULT_ROLE_NAME; use crate::agent::role::resolve_role_config; use crate::agent::status::is_final; use crate::codex_thread::ThreadConfigSnapshot; +use crate::context_manager::is_user_turn_boundary; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::event_mapping::parse_turn_item; use crate::find_archived_thread_path_by_id_str; use crate::find_thread_path_by_id_str; use crate::rollout::RolloutRecorder; @@ -18,7 +20,10 @@ use crate::thread_manager::ThreadManagerState; use codex_features::Feature; use codex_protocol::AgentPath; use codex_protocol::ThreadId; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; @@ -29,6 +34,7 @@ use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::user_input::UserInput; use codex_state::DirectionalThreadSpawnEdgeStatus; +use serde::Serialize; use std::collections::HashMap; use std::collections::VecDeque; use std::sync::Arc; @@ -51,6 +57,13 @@ pub(crate) struct LiveAgent { pub(crate) status: AgentStatus, } +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub(crate) struct ListedAgent { + pub(crate) agent_name: String, + pub(crate) agent_status: AgentStatus, + pub(crate) last_task_message: Option, +} + fn default_agent_nickname_list() -> Vec<&'static str> { AGENT_NAMES .lines() @@ -456,21 +469,28 @@ impl AgentControl { agent_id: ThreadId, items: Vec, ) -> CodexResult { + let last_task_message = render_input_preview(&items); let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state - .send_op( - agent_id, - Op::UserInput { - items, - final_output_json_schema: None, - }, - ) - .await, - ) - .await + let result = self + .handle_thread_request_result( + agent_id, + &state, + state + .send_op( + agent_id, + Op::UserInput { + items, + final_output_json_schema: None, + }, + ) + .await, + ) + .await; + if result.is_ok() { + self.state + .update_last_task_message(agent_id, last_task_message); + } + result } /// Append a prebuilt message to an existing agent thread outside the normal user-input path. @@ -494,15 +514,22 @@ impl AgentControl { agent_id: ThreadId, communication: InterAgentCommunication, ) -> CodexResult { + let last_task_message = communication.content.clone(); let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state - .send_op(agent_id, Op::InterAgentCommunication { communication }) - .await, - ) - .await + let result = self + .handle_thread_request_result( + agent_id, + &state, + state + .send_op(agent_id, Op::InterAgentCommunication { communication }) + .await, + ) + .await; + if result.is_ok() { + self.state + .update_last_task_message(agent_id, last_task_message); + } + result } /// Interrupt the current task for an existing agent thread. @@ -680,6 +707,70 @@ impl AgentControl { .join("\n") } + pub(crate) async fn list_agents( + &self, + current_session_source: &SessionSource, + path_prefix: Option<&str>, + ) -> CodexResult> { + let state = self.upgrade()?; + let resolved_prefix = path_prefix + .map(|prefix| { + current_session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root) + .resolve(prefix) + .map_err(CodexErr::UnsupportedOperation) + }) + .transpose()?; + + let mut live_agents = self.state.live_agents(); + live_agents.sort_by(|left, right| { + left.agent_path + .as_deref() + .unwrap_or_default() + .cmp(right.agent_path.as_deref().unwrap_or_default()) + .then_with(|| { + left.agent_id + .map(|id| id.to_string()) + .unwrap_or_default() + .cmp(&right.agent_id.map(|id| id.to_string()).unwrap_or_default()) + }) + }); + + let mut agents = Vec::with_capacity(live_agents.len()); + for metadata in live_agents { + let Some(thread_id) = metadata.agent_id else { + continue; + }; + if resolved_prefix + .as_ref() + .is_some_and(|prefix| !agent_matches_prefix(metadata.agent_path.as_ref(), prefix)) + { + continue; + } + + let Ok(thread) = state.get_thread(thread_id).await else { + continue; + }; + let agent_name = metadata + .agent_path + .as_ref() + .map(ToString::to_string) + .unwrap_or_else(|| thread_id.to_string()); + let last_task_message = match metadata.last_task_message.clone() { + Some(last_task_message) => Some(last_task_message), + None => last_task_message_for_thread(thread.as_ref()).await, + }; + agents.push(ListedAgent { + agent_name, + agent_status: thread.agent_status().await, + last_task_message, + }); + } + + Ok(agents) + } + /// Starts a detached watcher for sub-agents spawned from another thread. /// /// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and @@ -800,6 +891,7 @@ impl AgentControl { agent_path, agent_nickname, agent_role, + last_task_message: None, }; Ok((session_source, agent_metadata)) } @@ -963,6 +1055,95 @@ fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option, prefix: &AgentPath) -> bool { + if prefix.is_root() { + return true; + } + + agent_path.is_some_and(|agent_path| { + agent_path == prefix + || agent_path + .as_str() + .strip_prefix(prefix.as_str()) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +async fn last_task_message_for_thread(thread: &crate::CodexThread) -> Option { + let pending_input = thread.codex.session.pending_input_snapshot().await; + if let Some(message) = pending_input + .iter() + .rev() + .find_map(last_task_message_from_input_item) + { + return Some(message); + } + + let queued_input = thread + .codex + .session + .queued_response_items_for_next_turn_snapshot() + .await; + if let Some(message) = queued_input + .iter() + .rev() + .find_map(last_task_message_from_input_item) + { + return Some(message); + } + + let history = thread.codex.session.clone_history().await; + history + .raw_items() + .iter() + .rev() + .find_map(last_task_message_from_item) +} + +fn last_task_message_from_input_item(item: &ResponseInputItem) -> Option { + let response_item: ResponseItem = item.clone().into(); + last_task_message_from_item(&response_item) +} + +fn last_task_message_from_item(item: &ResponseItem) -> Option { + if !is_user_turn_boundary(item) { + return None; + } + + match item { + ResponseItem::Message { role, content, .. } if role == "user" => { + let Some(TurnItem::UserMessage(message)) = parse_turn_item(item) else { + return None; + }; + Some(render_input_preview(&message.content)) + } + ResponseItem::Message { content, .. } => match content.as_slice() { + [ContentItem::InputText { text }] | [ContentItem::OutputText { text }] => { + serde_json::from_str::(text) + .ok() + .map(|communication| communication.content) + } + _ => None, + }, + _ => None, + } +} + +fn render_input_preview(items: &[UserInput]) -> String { + items + .iter() + .map(|item| match item { + UserInput::Text { text, .. } => text.clone(), + UserInput::Image { .. } => "[image]".to_string(), + UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), + UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), + UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + _ => "[input]".to_string(), + }) + .collect::>() + .join("\n") +} + fn thread_spawn_depth(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => Some(*depth), diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index e25a58d784e..38fbcbed51f 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -965,7 +965,6 @@ async fn spawn_child_completion_notifies_parent_history() { } #[tokio::test] -#[ignore = "flaky on: rust-ci / Tests — windows-arm64 - aarch64-pc-windows-msvc"] async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() { let harness = AgentControlHarness::new().await; let (root_thread_id, _) = harness.start_thread().await; @@ -1022,6 +1021,13 @@ async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() ) .await; + let expected = InterAgentCommunication::new( + tester_path.clone(), + worker_path.clone(), + Vec::new(), + "done".to_string(), + ); + timeout(MULTI_AGENT_EVENTUAL_TIMEOUT, async { loop { let delivered = harness @@ -1033,10 +1039,7 @@ async fn multi_agent_v2_completion_sends_inter_agent_message_to_direct_parent() && matches!( op, Op::InterAgentCommunication { communication } - if communication.author == tester_path - && communication.recipient == worker_path - && communication.other_recipients.is_empty() - && communication.content == "done" + if communication == expected ) }); if delivered { diff --git a/codex-rs/core/src/agent/registry.rs b/codex-rs/core/src/agent/registry.rs index af545e8c97c..f78c8d08bc4 100644 --- a/codex-rs/core/src/agent/registry.rs +++ b/codex-rs/core/src/agent/registry.rs @@ -38,6 +38,7 @@ pub(crate) struct AgentMetadata { pub(crate) agent_path: Option, pub(crate) agent_nickname: Option, pub(crate) agent_role: Option, + pub(crate) last_task_message: Option, } fn format_agent_nickname(name: &str, nickname_reset_count: usize) -> String { @@ -151,6 +152,34 @@ impl AgentRegistry { .cloned() } + pub(crate) fn live_agents(&self) -> Vec { + self.active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .agent_tree + .values() + .filter(|metadata| { + metadata.agent_id.is_some() + && !metadata.agent_path.as_ref().is_some_and(AgentPath::is_root) + }) + .cloned() + .collect() + } + + pub(crate) fn update_last_task_message(&self, thread_id: ThreadId, last_task_message: String) { + let mut active_agents = self + .active_agents + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(metadata) = active_agents + .agent_tree + .values_mut() + .find(|metadata| metadata.agent_id == Some(thread_id)) + { + metadata.last_task_message = Some(last_task_message); + } + } + fn register_spawned_thread(&self, agent_metadata: AgentMetadata) { let Some(thread_id) = agent_metadata.agent_id else { return; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5e428362f22..d305a1e1082 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3956,6 +3956,17 @@ impl Session { } } + pub(crate) async fn pending_input_snapshot(&self) -> Vec { + let active = self.active_turn.lock().await; + match active.as_ref() { + Some(at) => { + let ts = at.turn_state.lock().await; + ts.pending_input_snapshot() + } + None => Vec::with_capacity(0), + } + } + /// Queue response items to be injected into the next active turn created for this session. pub(crate) async fn queue_response_items_for_next_turn(&self, items: Vec) { if items.is_empty() { @@ -3970,6 +3981,12 @@ impl Session { std::mem::take(&mut *self.idle_pending_input.lock().await) } + pub(crate) async fn queued_response_items_for_next_turn_snapshot( + &self, + ) -> Vec { + self.idle_pending_input.lock().await.clone() + } + pub(crate) async fn has_queued_response_items_for_next_turn(&self) -> bool { !self.idle_pending_input.lock().await.is_empty() } diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 07d040ca21d..ebfa5a8bb81 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -198,6 +198,10 @@ impl TurnState { } } + pub(crate) fn pending_input_snapshot(&self) -> Vec { + self.pending_input.clone() + } + pub(crate) fn has_pending_input(&self) -> bool { !self.pending_input.is_empty() } 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 5afc3682a0b..80e5e5c2e8d 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -21,6 +21,7 @@ use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; use crate::tools::context::ToolOutput; +use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; @@ -156,6 +157,18 @@ where } } +#[derive(Debug, Deserialize)] +struct ListAgentsResult { + agents: Vec, +} + +#[derive(Debug, Deserialize)] +struct ListedAgentResult { + agent_name: String, + agent_status: serde_json::Value, + last_task_message: Option, +} + #[tokio::test] async fn handler_rejects_non_function_payloads() { let (session, turn) = make_session_and_context().await; @@ -413,6 +426,226 @@ async fn multi_agent_v2_spawn_returns_path_and_send_input_accepts_relative_path( })); } +#[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; + 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(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker" + })), + )) + .await + .expect("spawn_agent should succeed"); + let _ = expect_text_output(spawn_output); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + let child_thread = manager + .get_thread(agent_id) + .await + .expect("child thread should exist"); + let child_turn = child_thread.codex.session.new_default_turn().await; + child_thread + .codex + .session + .send_event( + child_turn.as_ref(), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: child_turn.sub_id.clone(), + last_agent_message: Some("done".to_string()), + }), + ) + .await; + + let output = ListAgentsHandlerV2 + .handle(invocation( + session, + turn, + "list_agents", + function_payload(json!({})), + )) + .await + .expect("list_agents should succeed"); + let (content, success) = expect_text_output(output); + 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"})); + assert_eq!( + result.agents[0].last_task_message.as_deref(), + Some("inspect this repo") + ); + assert_eq!(success, Some(true)); +} + +#[tokio::test] +async fn multi_agent_v2_list_agents_filters_by_relative_path_prefix() { + 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(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config.clone()); + + let researcher_path = AgentPath::from_string("/root/researcher".to_string()).expect("path"); + let worker_path = AgentPath::from_string("/root/researcher/worker".to_string()).expect("path"); + session + .services + .agent_control + .spawn_agent_with_metadata( + config.clone(), + vec![UserInput::Text { + text: "research".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(researcher_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("researcher agent should spawn"); + session + .services + .agent_control + .spawn_agent_with_metadata( + config, + vec![UserInput::Text { + text: "build".to_string(), + text_elements: Vec::new(), + }], + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 2, + agent_path: Some(worker_path.clone()), + agent_nickname: None, + agent_role: None, + })), + crate::agent::control::SpawnAgentOptions::default(), + ) + .await + .expect("worker agent should spawn"); + + turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: root.thread_id, + depth: 1, + agent_path: Some(researcher_path), + agent_nickname: None, + agent_role: None, + }); + + let output = ListAgentsHandlerV2 + .handle(invocation( + Arc::new(session), + Arc::new(turn), + "list_agents", + function_payload(json!({ + "path_prefix": "worker" + })), + )) + .await + .expect("list_agents should succeed"); + let (content, _) = expect_text_output(output); + 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, worker_path.as_str()); + assert_eq!(result.agents[0].last_task_message.as_deref(), Some("build")); +} + +#[tokio::test] +async fn multi_agent_v2_list_agents_omits_closed_agents() { + 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(); + let _ = config.features.enable(Feature::MultiAgentV2); + turn.config = Arc::new(config); + + let session = Arc::new(session); + let turn = Arc::new(turn); + let spawn_output = SpawnAgentHandlerV2 + .handle(invocation( + session.clone(), + turn.clone(), + "spawn_agent", + function_payload(json!({ + "message": "inspect this repo", + "task_name": "worker" + })), + )) + .await + .expect("spawn_agent should succeed"); + let _ = expect_text_output(spawn_output); + + let agent_id = session + .services + .agent_control + .resolve_agent_reference(session.conversation_id, &turn.session_source, "worker") + .await + .expect("worker path should resolve"); + session + .services + .agent_control + .close_agent(agent_id) + .await + .expect("close_agent should succeed"); + + let output = ListAgentsHandlerV2 + .handle(invocation( + session, + turn, + "list_agents", + function_payload(json!({})), + )) + .await + .expect("list_agents should succeed"); + let (content, _) = expect_text_output(output); + let result: ListAgentsResult = + serde_json::from_str(&content).expect("list_agents result should be json"); + + assert!(result.agents.is_empty()); +} + #[tokio::test] async fn multi_agent_v2_send_input_accepts_structured_items() { let (mut session, mut turn) = make_session_and_context().await; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs index 931622fa0bb..6e0e517dde9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2.rs @@ -30,10 +30,12 @@ use serde::Deserialize; use serde::Serialize; use serde_json::Value as JsonValue; +pub(crate) use list_agents::Handler as ListAgentsHandler; pub(crate) use send_input::Handler as SendInputHandler; pub(crate) use spawn::Handler as SpawnAgentHandler; pub(crate) use wait::Handler as WaitAgentHandler; +mod list_agents; mod send_input; mod spawn; pub(crate) mod wait; diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs new file mode 100644 index 00000000000..e18547db923 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/list_agents.rs @@ -0,0 +1,68 @@ +use super::*; +use crate::agent::control::ListedAgent; + +pub(crate) struct Handler; + +#[async_trait] +impl ToolHandler for Handler { + type Output = ListAgentsResult; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + fn matches_kind(&self, payload: &ToolPayload) -> bool { + matches!(payload, ToolPayload::Function { .. }) + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ListAgentsArgs = parse_arguments(&arguments)?; + session + .services + .agent_control + .register_session_root(session.conversation_id, &turn.session_source); + let agents = session + .services + .agent_control + .list_agents(&turn.session_source, args.path_prefix.as_deref()) + .await + .map_err(collab_spawn_error)?; + + Ok(ListAgentsResult { agents }) + } +} + +#[derive(Debug, Deserialize)] +struct ListAgentsArgs { + path_prefix: Option, +} + +#[derive(Debug, Serialize)] +pub(crate) struct ListAgentsResult { + agents: Vec, +} + +impl ToolOutput for ListAgentsResult { + fn log_preview(&self) -> String { + tool_output_json_text(self, "list_agents") + } + + fn success_for_logging(&self) -> bool { + true + } + + fn to_response_item(&self, call_id: &str, payload: &ToolPayload) -> ResponseInputItem { + tool_output_response_item(call_id, payload, self, Some(true), "list_agents") + } + + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { + tool_output_code_mode_result(self, "list_agents") + } +} diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5556c9092bc..fdbbfa1eb7b 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -167,6 +167,39 @@ fn send_input_output_schema() -> JsonValue { }) } +fn list_agents_output_schema() -> JsonValue { + json!({ + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "description": "Canonical task name for the agent when available, otherwise the agent id." + }, + "agent_status": { + "description": "Last known status of the agent.", + "allOf": [agent_status_output_schema()] + }, + "last_task_message": { + "type": ["string", "null"], + "description": "Most recent user or inter-agent instruction received by the agent, when available." + } + }, + "required": ["agent_name", "agent_status", "last_task_message"], + "additionalProperties": false + }, + "description": "Live agents visible in the current root thread tree." + } + }, + "required": ["agents"], + "additionalProperties": false + }) +} + fn resume_agent_output_schema() -> JsonValue { json!({ "type": "object", @@ -1492,6 +1525,32 @@ fn create_wait_agent_tool_v2() -> ToolSpec { }) } +fn create_list_agents_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "path_prefix".to_string(), + JsonSchema::String { + description: Some( + "Optional task-path prefix. Accepts the same relative or absolute task-path syntax as other MultiAgentV2 agent targets." + .to_string(), + ), + }, + )]); + + ToolSpec::Function(ResponsesApiTool { + name: "list_agents".to_string(), + description: "List live agents in the current root thread tree. Optionally filter by task-path prefix." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + output_schema: Some(list_agents_output_schema()), + }) +} + fn create_request_user_input_tool( collaboration_modes_config: CollaborationModesConfig, ) -> ToolSpec { @@ -2636,6 +2695,7 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::multi_agents::SendInputHandler; use crate::tools::handlers::multi_agents::SpawnAgentHandler; use crate::tools::handlers::multi_agents::WaitAgentHandler; + use crate::tools::handlers::multi_agents_v2::ListAgentsHandler as ListAgentsHandlerV2; use crate::tools::handlers::multi_agents_v2::SendInputHandler as SendInputHandlerV2; use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2; use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2; @@ -3055,9 +3115,16 @@ pub(crate) fn build_specs_with_discoverable_tools( config.code_mode_enabled, ); if config.multi_agent_v2 { + push_tool_spec( + &mut builder, + create_list_agents_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandlerV2)); builder.register_handler("send_input", Arc::new(SendInputHandlerV2)); builder.register_handler("wait_agent", Arc::new(WaitAgentHandlerV2)); + builder.register_handler("list_agents", Arc::new(ListAgentsHandlerV2)); } else { builder.register_handler("spawn_agent", Arc::new(SpawnAgentHandler)); builder.register_handler("send_input", Arc::new(SendInputHandler)); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 387ca6f6709..9daaab9c6b2 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -525,6 +525,7 @@ fn test_build_specs_collab_tools_enabled() { &["spawn_agent", "send_input", "wait_agent", "close_agent"], ); assert_lacks_tool_name(&tools, "spawn_agents_on_csv"); + assert_lacks_tool_name(&tools, "list_agents"); } #[test] @@ -545,6 +546,16 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_contains_tool_names( + &tools, + &[ + "spawn_agent", + "send_input", + "wait_agent", + "close_agent", + "list_agents", + ], + ); let spawn_agent = find_tool(&tools, "spawn_agent"); let ToolSpec::Function(ResponsesApiTool { @@ -614,6 +625,33 @@ fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { output_schema["properties"]["message"]["description"], json!("Brief wait summary without the agent's final content.") ); + + let list_agents = find_tool(&tools, "list_agents"); + let ToolSpec::Function(ResponsesApiTool { + parameters, + output_schema, + .. + }) = &list_agents.spec + else { + panic!("list_agents should be a function tool"); + }; + let JsonSchema::Object { + properties, + required, + .. + } = parameters + else { + panic!("list_agents should use object params"); + }; + assert!(properties.contains_key("path_prefix")); + assert_eq!(required.as_ref(), None); + let output_schema = output_schema + .as_ref() + .expect("list_agents should define output schema"); + assert_eq!( + output_schema["properties"]["agents"]["items"]["required"], + json!(["agent_name", "agent_status", "last_task_message"]) + ); assert_lacks_tool_name(&tools, "resume_agent"); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8192724da09..cd1d0921d7e 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -80,7 +80,6 @@ use std::sync::Arc; use std::time::Duration; use std::time::Instant; use tracing::error; -use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; /// Represents an event to display in the conversation history. Returns its @@ -686,40 +685,27 @@ impl HistoryCell for UnifiedExecProcessesCell { break; } let command = &process.command_display; - let (snippet, snippet_truncated) = { - let (first_line, has_more_lines) = match command.split_once('\n') { - Some((first, _)) => (first, true), - None => (command.as_str(), false), - }; - let max_graphemes = 80; - let mut graphemes = first_line.grapheme_indices(true); - if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { - (first_line[..byte_index].to_string(), true) - } else { - (first_line.to_string(), has_more_lines) - } + let (first_line, has_more_lines) = match command.split_once('\n') { + Some((first, _)) => (first, true), + None => (command.as_str(), false), + }; + let snippet = if has_more_lines { + format!("{first_line}{truncation_suffix}") + } else { + first_line.to_string() }; if wrap_width <= prefix_width { out.push(Line::from(prefix.dim())); shown += 1; continue; } - let budget = wrap_width.saturating_sub(prefix_width); - let mut needs_suffix = snippet_truncated; - if !needs_suffix { - let (_, remainder, _) = take_prefix_by_width(&snippet, budget); - if !remainder.is_empty() { - needs_suffix = true; - } - } - if needs_suffix && budget > truncation_suffix_width { - let available = budget.saturating_sub(truncation_suffix_width); - let (truncated, _, _) = take_prefix_by_width(&snippet, available); - out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into()); - } else { - let (truncated, _, _) = take_prefix_by_width(&snippet, budget); - out.push(vec![prefix.dim(), truncated.cyan()].into()); - } + out.extend(wrap_with_prefix( + &snippet, + wrap_width, + prefix.dim(), + " ".dim(), + Style::default().fg(Color::Cyan), + )); let chunk_prefix_first = " ↳ "; let chunk_prefix_next = " "; diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap index 2bd4bf9fb97..713e2f97a9d 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__ps_output_long_command_snapshot.snap @@ -6,5 +6,8 @@ expression: rendered Background terminals - • rg "foo" src --glob '**/*. [...] + • rg "foo" src --glob '**/*.rs' + --max-count 1000 --no-ignore + --hidden --follow --glob '! + target/**' ↳ searching... diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 38937406b71..c19f995f8cd 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -86,7 +86,6 @@ use std::sync::Arc; use std::time::Duration; use std::time::Instant; use tracing::error; -use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; /// Represents an event to display in the conversation history. Returns its @@ -692,40 +691,27 @@ impl HistoryCell for UnifiedExecProcessesCell { break; } let command = &process.command_display; - let (snippet, snippet_truncated) = { - let (first_line, has_more_lines) = match command.split_once('\n') { - Some((first, _)) => (first, true), - None => (command.as_str(), false), - }; - let max_graphemes = 80; - let mut graphemes = first_line.grapheme_indices(true); - if let Some((byte_index, _)) = graphemes.nth(max_graphemes) { - (first_line[..byte_index].to_string(), true) - } else { - (first_line.to_string(), has_more_lines) - } + let (first_line, has_more_lines) = match command.split_once('\n') { + Some((first, _)) => (first, true), + None => (command.as_str(), false), + }; + let snippet = if has_more_lines { + format!("{first_line}{truncation_suffix}") + } else { + first_line.to_string() }; if wrap_width <= prefix_width { out.push(Line::from(prefix.dim())); shown += 1; continue; } - let budget = wrap_width.saturating_sub(prefix_width); - let mut needs_suffix = snippet_truncated; - if !needs_suffix { - let (_, remainder, _) = take_prefix_by_width(&snippet, budget); - if !remainder.is_empty() { - needs_suffix = true; - } - } - if needs_suffix && budget > truncation_suffix_width { - let available = budget.saturating_sub(truncation_suffix_width); - let (truncated, _, _) = take_prefix_by_width(&snippet, available); - out.push(vec![prefix.dim(), truncated.cyan(), truncation_suffix.dim()].into()); - } else { - let (truncated, _, _) = take_prefix_by_width(&snippet, budget); - out.push(vec![prefix.dim(), truncated.cyan()].into()); - } + out.extend(wrap_with_prefix( + &snippet, + wrap_width, + prefix.dim(), + " ".dim(), + Style::default().fg(Color::Cyan), + )); let chunk_prefix_first = " ↳ "; let chunk_prefix_next = " "; diff --git a/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__ps_output_long_command_snapshot.snap b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__ps_output_long_command_snapshot.snap index b279858581f..192d26e1280 100644 --- a/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__ps_output_long_command_snapshot.snap +++ b/codex-rs/tui_app_server/src/snapshots/codex_tui_app_server__history_cell__tests__ps_output_long_command_snapshot.snap @@ -6,5 +6,8 @@ expression: rendered Background terminals - • rg "foo" src --glob '**/*. [...] + • rg "foo" src --glob '**/*.rs' + --max-count 1000 --no-ignore + --hidden --follow --glob '! + target/**' ↳ searching...