feat(tui): add subagent inbox foundation

Preserve the subagent inbox foundation behavior on the current origin/main base and collapse the branch back to a single commit for easier future restacks.
This commit is contained in:
Friel
2026-03-28 18:24:49 -07:00
parent 3bbc1ce003
commit ed0cff78a6
9 changed files with 530 additions and 3 deletions
@@ -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,
}),
];
@@ -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;
@@ -147,6 +147,7 @@ impl ToolHandler for Handler {
prompt,
model: effective_model,
reasoning_effort: effective_reasoning_effort,
spawn_mode: AgentSpawnMode::Spawn,
status,
}
.into(),
+34
View File
@@ -98,6 +98,27 @@ pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = "</realtime_conversation>";
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,
}
+81 -2
View File
@@ -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>, 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>, String)>,
last_rendered_user_message_event: Option<RenderedUserMessageEvent>,
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(_)
@@ -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
+66
View File
@@ -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::<Vec<_>>()
.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,
};
+337 -1
View File
@@ -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<SubagentPanelAgent>,
}
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<Mutex<SubagentPanelState>>,
animations_enabled: bool,
}
impl SubagentStatusCell {
#[cfg_attr(not(test), allow(dead_code))]
pub(crate) fn new(
state: Arc<Mutex<SubagentPanelState>>,
animations_enabled: bool,
) -> SubagentStatusCell {
SubagentStatusCell {
state,
animations_enabled,
}
}
#[allow(dead_code)]
pub(crate) fn state_handle(&self) -> Arc<Mutex<SubagentPanelState>> {
Arc::clone(&self.state)
}
#[allow(dead_code)]
pub(crate) fn matches_state(&self, other: &Arc<Mutex<SubagentPanelState>>) -> bool {
Arc::ptr_eq(&self.state, other)
}
}
impl HistoryCell for SubagentStatusCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
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<Span<'static>> =
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<u64> {
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<Span<'static>> = 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![
+3
View File
@@ -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 {