diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d687..a26e9ec88b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2527,6 +2527,7 @@ mod tests { prompt: "inspect the repo".into(), model: "gpt-5.4-mini".into(), reasoning_effort: codex_protocol::openai_models::ReasoningEffort::Medium, + spawn_mode: codex_protocol::protocol::AgentSpawnMode::Spawn, status: AgentStatus::Running, }), ]; diff --git a/codex-rs/core/src/tools/handlers/multi_agents.rs b/codex-rs/core/src/tools/handlers/multi_agents.rs index 166dbd287d..e0353275a6 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents.rs @@ -21,6 +21,8 @@ use async_trait::async_trait; use codex_protocol::ThreadId; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::ReasoningEffortPreset; +use codex_protocol::protocol::AgentSpawnMode; use codex_protocol::protocol::CollabAgentInteractionBeginEvent; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 9bb7b6055c..47ea42d516 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -147,6 +147,7 @@ impl ToolHandler for Handler { prompt, model: effective_model, reasoning_effort: effective_reasoning_effort, + spawn_mode: AgentSpawnMode::Spawn, status, } .into(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index aa9f319ffd..064310e7b1 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -98,6 +98,27 @@ pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; +pub const AGENT_INBOX_KIND: &str = "agent_inbox"; +pub const AGENT_INBOX_MESSAGE_PREFIX: &str = "[agent_inbox:"; + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +pub struct AgentInboxPayload { + pub injected: bool, + pub kind: String, + pub sender_thread_id: ThreadId, + pub message: String, +} + +impl AgentInboxPayload { + pub fn new(sender_thread_id: ThreadId, message: String) -> Self { + Self { + injected: true, + kind: AGENT_INBOX_KIND.to_string(), + sender_thread_id, + message, + } + } +} /// Submission Queue Entry - requests from user #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -3396,6 +3417,16 @@ pub struct CollabAgentSpawnBeginEvent { pub reasoning_effort: ReasoningEffortConfig, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS, Default)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum AgentSpawnMode { + #[default] + Spawn, + Fork, + Watchdog, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct CollabAgentRef { /// Thread ID of the receiver/new agent. @@ -3443,6 +3474,9 @@ pub struct CollabAgentSpawnEndEvent { pub model: String, /// Effective reasoning effort used by the spawned agent after inheritance and role overrides. pub reasoning_effort: ReasoningEffortConfig, + /// Spawn mode used for this agent. + #[serde(default)] + pub spawn_mode: AgentSpawnMode, /// Last known status of the new agent reported to the sender agent. pub status: AgentStatus, } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ba05bdbb74..b6c2402043 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -122,12 +122,22 @@ use codex_protocol::config_types::Settings; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; +#[cfg(test)] +use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; +#[cfg(test)] +use codex_protocol::models::ResponseItem; use codex_protocol::models::local_image_label_text; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg; use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus; #[cfg(test)] +use codex_protocol::protocol::AGENT_INBOX_KIND; +#[cfg(test)] +use codex_protocol::protocol::AGENT_INBOX_MESSAGE_PREFIX; +#[cfg(test)] +use codex_protocol::protocol::AgentInboxPayload; +#[cfg(test)] use codex_protocol::protocol::AgentMessageDeltaEvent; #[cfg(test)] use codex_protocol::protocol::AgentMessageEvent; @@ -139,6 +149,7 @@ use codex_protocol::protocol::AgentReasoningEvent; use codex_protocol::protocol::AgentReasoningRawContentDeltaEvent; #[cfg(test)] use codex_protocol::protocol::AgentReasoningRawContentEvent; +use codex_protocol::protocol::AgentSpawnMode; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; #[cfg(test)] @@ -181,6 +192,8 @@ use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; +#[cfg(test)] +use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata; @@ -441,6 +454,38 @@ fn is_unified_exec_source(source: ExecCommandSource) -> bool { ) } +#[cfg(test)] +fn agent_inbox_message_from_item(item: &ResponseItem) -> Option<(Option, String)> { + match item { + ResponseItem::FunctionCallOutput { output, .. } => { + let text = output.body.to_text()?; + let payload: AgentInboxPayload = serde_json::from_str(&text).ok()?; + if !payload.injected || payload.kind != AGENT_INBOX_KIND { + return None; + } + Some((Some(payload.sender_thread_id.to_string()), payload.message)) + } + ResponseItem::Message { content, .. } => { + let text = content.iter().find_map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Some(text.as_str()) + } + _ => None, + })?; + let rest = text.strip_prefix(AGENT_INBOX_MESSAGE_PREFIX)?; + let (sender, message) = rest.split_once(']')?; + let message = message.trim_start().to_string(); + let sender = sender.trim().to_string(); + if sender.is_empty() { + Some((None, message)) + } else { + Some((Some(sender), message)) + } + } + _ => None, + } +} + fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool { !parsed_cmd.is_empty() && parsed_cmd @@ -937,6 +982,8 @@ pub(crate) struct ChatWidget { status_line_branch_lookup_complete: bool, external_editor_state: ExternalEditorState, realtime_conversation: RealtimeConversationUiState, + #[cfg(test)] + last_replayed_agent_inbox_message: Option<(Option, String)>, last_rendered_user_message_event: Option, last_non_retry_error: Option<(String, String)>, } @@ -3603,6 +3650,32 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(test)] + fn on_raw_response_item(&mut self, event: RawResponseItemEvent, from_replay: bool) { + let Some((sender, message)) = agent_inbox_message_from_item(&event.item) else { + if from_replay { + self.last_replayed_agent_inbox_message = None; + } + return; + }; + + let replay_key = (sender.clone(), message.clone()); + if from_replay { + if self.last_replayed_agent_inbox_message.as_ref() == Some(&replay_key) { + return; + } + self.last_replayed_agent_inbox_message = Some(replay_key); + } else { + self.last_replayed_agent_inbox_message = None; + } + + let hint = sender.map(|sender| format!("from {sender}")); + self.add_to_history(history_cell::new_info_event( + format!("Agent message: {message}"), + hint, + )); + } + fn on_collab_agent_tool_call(&mut self, item: ThreadItem) { let ThreadItem::CollabAgentToolCall { id, @@ -3665,6 +3738,7 @@ impl ChatWidget { prompt: prompt.unwrap_or_default(), model: String::new(), reasoning_effort: ReasoningEffortConfig::Medium, + spawn_mode: AgentSpawnMode::Spawn, status: first_receiver .as_ref() .and_then(|thread_id| agents_states.get(&thread_id.to_string())) @@ -4613,6 +4687,8 @@ impl ChatWidget { status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), + #[cfg(test)] + last_replayed_agent_inbox_message: None, last_rendered_user_message_event: None, last_non_retry_error: None, }; @@ -6687,6 +6763,9 @@ impl ChatWidget { if !is_resume_initial_replay && !is_stream_error { self.restore_retry_status_header_if_present(); } + if !from_replay || !matches!(&msg, EventMsg::RawResponseItem(_)) { + self.last_replayed_agent_inbox_message = None; + } match msg { EventMsg::AgentMessageDelta(_) @@ -6894,8 +6973,8 @@ impl ChatWidget { }); } } - EventMsg::RawResponseItem(_) - | EventMsg::ItemStarted(_) + EventMsg::RawResponseItem(ev) => self.on_raw_response_item(ev, from_replay), + EventMsg::ItemStarted(_) | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap new file mode 100644 index 0000000000..10417e9fbb --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__thread_snapshot_replay_agent_inbox_dedupes_compatibility_items.snap @@ -0,0 +1,5 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: rendered +--- +• Agent message: Please review the latest diff from 019cbff7-558b-77d3-8653-8238ab5361ec diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a7119d962c..3c9297fb9f 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -106,7 +106,10 @@ use codex_protocol::items::AgentMessageItem; use codex_protocol::items::PlanItem; use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::default_input_modalities; @@ -114,6 +117,8 @@ use codex_protocol::parse_command::ParsedCommand; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AGENT_INBOX_MESSAGE_PREFIX; +use codex_protocol::protocol::AgentInboxPayload; use codex_protocol::protocol::AgentMessageDeltaEvent; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AgentReasoningDeltaEvent; @@ -149,6 +154,7 @@ use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::PatchApplyEndEvent; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitWindow; +use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::RealtimeConversationClosedEvent; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; @@ -292,6 +298,27 @@ fn snapshot(percent: f64) -> RateLimitSnapshot { } } +fn agent_inbox_function_call_output(sender: ThreadId, message: &str) -> ResponseItem { + let payload = serde_json::to_string(&AgentInboxPayload::new(sender, message.to_string())) + .expect("agent inbox payload should serialize"); + ResponseItem::FunctionCallOutput { + call_id: "call-agent-inbox".to_string(), + output: FunctionCallOutputPayload::from_text(payload), + } +} + +fn agent_inbox_message(sender: ThreadId, message: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![ContentItem::OutputText { + text: format!("{AGENT_INBOX_MESSAGE_PREFIX}{sender}] {message}"), + }], + end_turn: None, + phase: None, + } +} + #[tokio::test] async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -397,6 +424,44 @@ async fn thread_snapshot_replay_does_not_duplicate_agent_message_history() { ); } +#[tokio::test] +async fn thread_snapshot_replay_deduplicates_agent_inbox_compatibility_items() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(None).await; + + let sender = + ThreadId::from_string("019cbff7-558b-77d3-8653-8238ab5361ec").expect("valid thread id"); + let message = "Please review the latest diff"; + + chat.handle_codex_event_replay(Event { + id: "evt-agent-output".into(), + msg: EventMsg::RawResponseItem(RawResponseItemEvent { + item: agent_inbox_function_call_output(sender, message), + }), + }); + chat.handle_codex_event_replay(Event { + id: "evt-agent-message".into(), + msg: EventMsg::RawResponseItem(RawResponseItemEvent { + item: agent_inbox_message(sender, message), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!( + cells.len(), + 1, + "expected replayed agent inbox compatibility items to render one history row" + ); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert_snapshot!( + "thread_snapshot_replay_agent_inbox_dedupes_compatibility_items", + rendered + ); +} + #[tokio::test] async fn replayed_user_message_preserves_text_elements_and_local_images() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; @@ -2192,6 +2257,7 @@ async fn make_chatwidget_manual( status_line_branch_lookup_complete: false, external_editor_state: ExternalEditorState::Closed, realtime_conversation: RealtimeConversationUiState::default(), + last_replayed_agent_inbox_message: None, last_rendered_user_message_event: None, last_non_retry_error: None, }; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 4aadbf06d1..8a16ef4b98 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -25,6 +25,8 @@ use crate::render::line_utils::line_to_static; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; +use crate::shimmer::shimmer_spans; +use crate::status_indicator_widget::fmt_elapsed_compact; use crate::style::proposed_plan_style; use crate::style::user_message_style; #[cfg(test)] @@ -62,6 +64,7 @@ use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; +use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; @@ -85,8 +88,8 @@ use std::collections::HashMap; use std::io::Cursor; use std::path::Path; use std::path::PathBuf; -#[cfg(test)] use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use std::time::Instant; use tracing::error; @@ -497,6 +500,248 @@ impl HistoryCell for PlainHistoryCell { } } +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug)] +pub(crate) struct SubagentPanelAgent { + pub(crate) ordinal: i32, + pub(crate) name: String, + pub(crate) status: AgentStatus, + pub(crate) is_watchdog: bool, + pub(crate) preview: String, + pub(crate) latest_update_at: Instant, +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug)] +pub(crate) struct SubagentPanelState { + pub(crate) started_at: Instant, + pub(crate) total_agents: i32, + pub(crate) running_count: i32, + pub(crate) running_agents: Vec, +} + +impl SubagentPanelState { + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn running_count(&self) -> i32 { + self.running_count + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn has_animating_agents(&self, now: Instant) -> bool { + self.running_agents + .iter() + .any(|agent| should_shimmer(agent, now)) + } +} + +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Clone, Debug)] +pub(crate) struct SubagentStatusCell { + state: Arc>, + animations_enabled: bool, +} + +impl SubagentStatusCell { + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn new( + state: Arc>, + animations_enabled: bool, + ) -> SubagentStatusCell { + SubagentStatusCell { + state, + animations_enabled, + } + } + + #[allow(dead_code)] + pub(crate) fn state_handle(&self) -> Arc> { + Arc::clone(&self.state) + } + + #[allow(dead_code)] + pub(crate) fn matches_state(&self, other: &Arc>) -> bool { + Arc::ptr_eq(&self.state, other) + } +} + +impl HistoryCell for SubagentStatusCell { + fn display_lines(&self, width: u16) -> Vec> { + let state = match self.state.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }; + if state.running_agents.is_empty() { + return Vec::new(); + } + + let elapsed = fmt_elapsed_compact(state.started_at.elapsed().as_secs()); + let running_count = state.running_count(); + let total_agents = state.total_agents.max(running_count); + let count_label = subagent_count_label(total_agents, running_count); + let header_suffix = format!("({elapsed} • {count_label} • esc to interrupt)"); + + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + "• ".dim(), + "Subagents".bold(), + " ".into(), + header_suffix.dim(), + ])); + + let mut running_agents = state.running_agents; + running_agents.sort_by(|left, right| left.ordinal.cmp(&right.ordinal)); + let preview_budget = running_preview_budget(width); + let now = Instant::now(); + lines.extend(running_agents.into_iter().map(|agent| { + let preview = truncate_text(agent.preview.trim(), preview_budget); + let mut spans: Vec> = + vec!["• ".dim(), format!("[#{}] ", agent.ordinal).dim()]; + if agent.is_watchdog { + spans.push("[watchdog] ".magenta().dim()); + } + spans.push(Span::from(agent.name.clone())); + spans.push(" ".into()); + spans.push(status_span_for_panel(&agent)); + spans.push(" — ".dim()); + if self.animations_enabled && should_shimmer(&agent, now) { + spans.extend(shimmer_spans(&preview)); + } else { + spans.push(Span::from(preview)); + } + Line::from(spans) + })); + + lines + } + + fn transcript_animation_tick(&self) -> Option { + if !self.animations_enabled { + return None; + } + let guard = match self.state.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + let now = Instant::now(); + if !guard.has_animating_agents(now) { + return None; + } + Some((now.duration_since(guard.started_at).as_millis() / 100) as u64) + } +} + +#[allow(dead_code)] +pub(crate) fn new_subagent_spawned_cell(name: &str, prompt_preview: &str) -> PlainHistoryCell { + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + "• ".dim(), + "Spawned subagent ".into(), + Span::from(name.to_string()).bold(), + ])); + + let preview = truncate_text(prompt_preview.trim(), 240); + if !preview.is_empty() { + lines.push(Line::from(vec![ + " └ ".dim(), + Span::from(format!("\"{preview}\"" )).dim(), + ])); + } + + PlainHistoryCell::new(lines) +} + +#[allow(dead_code)] +pub(crate) fn new_subagent_update_cell( + name: &str, + status: &AgentStatus, + summary: &str, +) -> PlainHistoryCell { + let mut spans: Vec> = vec![ + "• ".dim(), + "Subagent update: ".into(), + Span::from(name.to_string()).bold(), + " ".into(), + status_label_span(status), + ]; + + let summary = truncate_text(summary.trim(), 240); + if !summary.is_empty() { + spans.push(" — ".dim()); + spans.push(Span::from(summary)); + } + + PlainHistoryCell::new(vec![Line::from(spans)]) +} + +#[cfg_attr(not(test), allow(dead_code))] +fn running_preview_budget(width: u16) -> usize { + let width = width as usize; + width.saturating_sub(24).clamp(60, 160) +} + +#[cfg_attr(not(test), allow(dead_code))] +fn is_running_status(status: &AgentStatus) -> bool { + matches!(status, AgentStatus::PendingInit | AgentStatus::Running) +} + +#[cfg_attr(not(test), allow(dead_code))] +fn status_span_for_panel(agent: &SubagentPanelAgent) -> Span<'static> { + match &agent.status { + AgentStatus::PendingInit if agent.is_watchdog => "idle".dim(), + AgentStatus::PendingInit | AgentStatus::Running => "running".cyan().bold(), + AgentStatus::Interrupted => "interrupted".yellow(), + AgentStatus::Completed(_) => "completed".green(), + AgentStatus::Errored(_) => "errored".red(), + AgentStatus::Shutdown => "shutdown".dim(), + AgentStatus::NotFound => "not found".red(), + } +} + +#[cfg_attr(not(test), allow(dead_code))] +const SUBAGENT_SHIMMER_WINDOW: Duration = Duration::from_secs(1); + +#[cfg_attr(not(test), allow(dead_code))] +fn should_shimmer(agent: &SubagentPanelAgent, now: Instant) -> bool { + if agent.is_watchdog && matches!(agent.status, AgentStatus::PendingInit) { + return false; + } + is_running_status(&agent.status) + && now.saturating_duration_since(agent.latest_update_at) <= SUBAGENT_SHIMMER_WINDOW +} + +#[allow(dead_code)] +fn status_label_span(status: &AgentStatus) -> Span<'static> { + match status { + AgentStatus::PendingInit | AgentStatus::Running => "running".cyan().bold(), + AgentStatus::Interrupted => "interrupted".yellow(), + AgentStatus::Completed(_) => "completed".green(), + AgentStatus::Errored(_) => "errored".red(), + AgentStatus::Shutdown => "shutdown".dim(), + AgentStatus::NotFound => "not found".red(), + } +} + +#[cfg_attr(not(test), allow(dead_code))] +fn subagent_count_label(total: i32, running: i32) -> String { + if total <= 0 || running <= 0 { + return "no subagents running".to_string(); + } + let total_label = subagent_pluralize(total, "subagent"); + if running >= total { + return format!("{total_label} running"); + } + format!("{total_label}, {running} running") +} + +#[cfg_attr(not(test), allow(dead_code))] +fn subagent_pluralize(count: i32, singular: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{count} {singular}s") + } +} + #[cfg_attr(debug_assertions, allow(dead_code))] #[derive(Debug)] pub(crate) struct UpdateAvailableHistoryCell { @@ -2781,6 +3026,7 @@ mod tests { use codex_protocol::account::PlanType; use codex_protocol::models::WebSearchAction; use codex_protocol::parse_command::ParsedCommand; + use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::SandboxPolicy; @@ -2790,6 +3036,8 @@ mod tests { use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; + use std::sync::Arc; + use std::sync::Mutex; use codex_protocol::mcp::CallToolResult; use codex_protocol::mcp::Tool; @@ -3356,6 +3604,94 @@ mod tests { assert_eq!(cell.desired_transcript_height(/*width*/ 80), 1); } + #[test] + fn subagent_panel_renders_watchdog_handle_as_idle() { + let state = Arc::new(Mutex::new(SubagentPanelState { + started_at: Instant::now(), + total_agents: 1, + running_count: 0, + running_agents: vec![SubagentPanelAgent { + ordinal: 1, + name: "watchdog-agent".to_string(), + status: AgentStatus::PendingInit, + is_watchdog: true, + preview: "monitor parent progress".to_string(), + latest_update_at: Instant::now(), + }], + })); + let cell = SubagentStatusCell::new(state, true); + let lines = render_lines(&cell.display_lines(120)); + + assert!(lines[0].contains("no subagents running")); + assert!(lines[1].contains("[watchdog] watchdog-agent idle")); + } + + #[test] + fn subagent_panel_animation_tick_ignores_idle_watchdogs() { + let state = Arc::new(Mutex::new(SubagentPanelState { + started_at: Instant::now(), + total_agents: 1, + running_count: 0, + running_agents: vec![SubagentPanelAgent { + ordinal: 1, + name: "watchdog-agent".to_string(), + status: AgentStatus::PendingInit, + is_watchdog: true, + preview: "monitor parent progress".to_string(), + latest_update_at: Instant::now(), + }], + })); + let cell = SubagentStatusCell::new(state, true); + + assert_eq!(cell.transcript_animation_tick(), None); + } + + #[test] + fn subagent_panel_animation_tick_runs_for_recent_running_updates() { + let state = Arc::new(Mutex::new(SubagentPanelState { + started_at: Instant::now(), + total_agents: 1, + running_count: 1, + running_agents: vec![SubagentPanelAgent { + ordinal: 1, + name: "worker-agent".to_string(), + status: AgentStatus::Running, + is_watchdog: false, + preview: "working".to_string(), + latest_update_at: Instant::now(), + }], + })); + let cell = SubagentStatusCell::new(state, true); + + assert!( + cell.transcript_animation_tick().is_some(), + "recent running updates should animate" + ); + } + + #[test] + fn subagent_panel_animation_tick_stops_when_updates_are_stale() { + let stale_update = Instant::now() + .checked_sub(Duration::from_secs(2)) + .unwrap_or_else(Instant::now); + let state = Arc::new(Mutex::new(SubagentPanelState { + started_at: Instant::now(), + total_agents: 1, + running_count: 1, + running_agents: vec![SubagentPanelAgent { + ordinal: 1, + name: "worker-agent".to_string(), + status: AgentStatus::Running, + is_watchdog: false, + preview: "working".to_string(), + latest_update_at: stale_update, + }], + })); + let cell = SubagentStatusCell::new(state, true); + + assert_eq!(cell.transcript_animation_tick(), None); + } + #[test] fn prefixed_wrapped_history_cell_indents_wrapped_lines() { let summary = Line::from(vec![ diff --git a/codex-rs/tui/src/multi_agents.rs b/codex-rs/tui/src/multi_agents.rs index 293c80fcf0..d56e79019d 100644 --- a/codex-rs/tui/src/multi_agents.rs +++ b/codex-rs/tui/src/multi_agents.rs @@ -9,6 +9,7 @@ use crate::render::line_utils::prefix_lines; use crate::text_formatting::truncate_text; use codex_protocol::ThreadId; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::AgentSpawnMode; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::CollabAgentInteractionEndEvent; use codex_protocol::protocol::CollabAgentRef; @@ -611,6 +612,7 @@ mod tests { prompt: "Compute 11! and reply with just the integer result.".to_string(), model: "gpt-5".to_string(), reasoning_effort: ReasoningEffortConfig::High, + spawn_mode: AgentSpawnMode::Spawn, status: AgentStatus::PendingInit, }, Some(&SpawnRequestSummary { @@ -749,6 +751,7 @@ mod tests { prompt: String::new(), model: "gpt-5".to_string(), reasoning_effort: ReasoningEffortConfig::High, + spawn_mode: AgentSpawnMode::Spawn, status: AgentStatus::PendingInit, }, Some(&SpawnRequestSummary {