mirror of
https://github.com/openai/codex.git
synced 2026-10-27 18:48:37 +00:00
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:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
+5
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user