merge upstream/dev/friel/tui-watchdog-and-subagent-behavior into collab stack

This commit is contained in:
Friel
2026-03-30 01:53:49 +00:00
14 changed files with 1557 additions and 45 deletions
+6
View File
@@ -27,3 +27,9 @@ test-group = 'app_server_protocol_codegen'
# Keep the library unit tests parallel.
filter = 'package(codex-app-server) & kind(test)'
test-group = 'app_server_integration'
[[profile.default.overrides]]
# Schema fixture generation can take longer than the default timeout on slower
# Windows runners when app-server protocol fixture sets grow.
filter = 'test(schema_fixtures_match_generated)'
slow-timeout = { period = "1m", terminate-after = 2 }
@@ -195,6 +195,10 @@ async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed(
wait_for_command_execution_item_started(&mut mcp),
)
.await??;
// `item/started` can arrive before the spawned command reports a process id.
// Give the runtime a brief moment to finish wiring the command so unsubscribe
// consistently exercises the shutdown path on slower CI runners.
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
let unsubscribe_id = mcp
.send_thread_unsubscribe_request(ThreadUnsubscribeParams {
@@ -21,6 +21,7 @@ use async_trait::async_trait;
use codex_protocol::ThreadId;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AgentSpawnMode;
use codex_protocol::protocol::CollabAgentInteractionBeginEvent;
use codex_protocol::protocol::CollabAgentInteractionEndEvent;
use codex_protocol::protocol::CollabAgentRef;
+1
View File
@@ -99,6 +99,7 @@ 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 {
+816
View File
@@ -20,6 +20,7 @@ use crate::chatwidget::ChatWidget;
use crate::chatwidget::ExternalEditorState;
use crate::chatwidget::ReplayKind;
use crate::chatwidget::ThreadInputState;
use crate::chatwidget::extract_first_bold;
use crate::cwd_prompt::CwdPromptAction;
use crate::diff_render::DiffSummary;
use crate::exec_command::split_command_string;
@@ -28,6 +29,9 @@ use crate::external_editor;
use crate::file_search::FileSearchManager;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::history_cell::SubagentPanelAgent;
use crate::history_cell::SubagentPanelState;
use crate::history_cell::SubagentStatusCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
use crate::model_catalog::ModelCatalog;
@@ -45,6 +49,7 @@ use crate::render::renderable::Renderable;
use crate::resume_picker::SessionSelection;
#[cfg(test)]
use crate::test_support::PathBufExt;
use crate::text_formatting::truncate_text;
use crate::tui;
use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
@@ -54,6 +59,9 @@ use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollabAgentState;
use codex_app_server_protocol::CollabAgentStatus;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -101,7 +109,17 @@ use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::AgentSpawnMode as CollabAgentSpawnMode;
use codex_protocol::protocol::AgentStatus;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::CollabAgentSpawnEndEvent;
use codex_protocol::protocol::CollabCloseEndEvent;
use codex_protocol::protocol::CollabWaitingEndEvent;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FinalOutput;
use codex_protocol::protocol::GetHistoryEntryResponseEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
@@ -112,6 +130,9 @@ use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::SkillErrorInfo;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_terminal_detection::user_agent;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
@@ -125,10 +146,12 @@ use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::thread;
@@ -205,6 +228,20 @@ fn command_execution_decision_to_review_decision(
}
}
fn app_server_collab_state_to_agent_status(state: &CollabAgentState) -> AgentStatus {
match state.status {
CollabAgentStatus::PendingInit => AgentStatus::PendingInit,
CollabAgentStatus::Running => AgentStatus::Running,
CollabAgentStatus::Completed => AgentStatus::Completed(state.message.clone()),
CollabAgentStatus::Errored => {
AgentStatus::Errored(state.message.clone().unwrap_or_default())
}
CollabAgentStatus::Interrupted => AgentStatus::Interrupted,
CollabAgentStatus::Shutdown => AgentStatus::Shutdown,
CollabAgentStatus::NotFound => AgentStatus::NotFound,
}
}
/// Extracts `receiver_thread_ids` from collab agent tool-call notifications.
///
/// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry
@@ -693,6 +730,524 @@ impl ThreadEventChannel {
}
}
const SUBAGENT_PROMPT_PREVIEW_BUDGET: usize = 120;
const SUBAGENT_UPDATE_PREVIEW_BUDGET: usize = 160;
const SUBAGENT_PENDING_EVENT_CAPACITY: usize = 12;
const SUBAGENT_ANIMATION_TICK: Duration = Duration::from_millis(100);
const SUBAGENT_SHIMMER_WINDOW: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
struct SubagentInfo {
ordinal: i32,
name: String,
prompt_preview: String,
spawn_mode: CollabAgentSpawnMode,
status: AgentStatus,
spawned_at: Instant,
started_at: Option<Instant>,
latest_summary: String,
latest_preview: String,
latest_update_at: Instant,
inflight_message: String,
reasoning_buffer: String,
notified_terminal: bool,
}
impl SubagentInfo {
fn new(
ordinal: i32,
name: String,
prompt_preview: String,
spawn_mode: CollabAgentSpawnMode,
) -> Self {
let now = Instant::now();
Self {
ordinal,
name,
prompt_preview: prompt_preview.clone(),
spawn_mode,
status: AgentStatus::PendingInit,
spawned_at: now,
started_at: None,
latest_summary: String::new(),
latest_preview: prompt_preview,
latest_update_at: now,
inflight_message: String::new(),
reasoning_buffer: String::new(),
notified_terminal: false,
}
}
fn is_running(&self) -> bool {
matches!(self.status, AgentStatus::PendingInit | AgentStatus::Running)
}
fn is_watchdog(&self) -> bool {
self.spawn_mode == CollabAgentSpawnMode::Watchdog
}
fn is_visible_in_panel(&self) -> bool {
if self.is_watchdog() {
return matches!(self.status, AgentStatus::PendingInit | AgentStatus::Running);
}
self.is_running()
}
fn is_running_for_panel(&self) -> bool {
if self.is_watchdog() {
return matches!(self.status, AgentStatus::Running);
}
self.is_running()
}
fn running_started_at(&self) -> Instant {
self.started_at.unwrap_or(self.spawned_at)
}
fn update_preview(&mut self, preview: String) {
self.latest_preview = preview;
self.latest_update_at = Instant::now();
}
fn update_reasoning_summary(&mut self, delta: &str) {
self.reasoning_buffer.push_str(delta);
if let Some(summary) = extract_first_bold(&self.reasoning_buffer) {
self.latest_summary = truncate_text(summary.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
self.latest_update_at = Instant::now();
}
}
fn clear_turn_buffers(&mut self) {
self.inflight_message.clear();
self.reasoning_buffer.clear();
self.latest_summary.clear();
}
fn should_shimmer(&self, now: Instant) -> bool {
if self.is_watchdog() && matches!(self.status, AgentStatus::PendingInit) {
return false;
}
self.is_running()
&& now.saturating_duration_since(self.latest_update_at) <= SUBAGENT_SHIMMER_WINDOW
}
}
#[derive(Debug, Default)]
struct SubagentRegistry {
root_thread_id: Option<ThreadId>,
agents: HashMap<ThreadId, SubagentInfo>,
order: Vec<ThreadId>,
pending_events: HashMap<ThreadId, Vec<EventMsg>>,
pending_history: Vec<Box<dyn HistoryCell>>,
panel_state: Option<Arc<StdMutex<SubagentPanelState>>>,
panel_cell: Option<Arc<SubagentStatusCell>>,
animations_enabled: bool,
}
impl SubagentRegistry {
fn new(animations_enabled: bool) -> Self {
Self {
animations_enabled,
..Self::default()
}
}
fn set_root_thread(&mut self, thread_id: ThreadId) {
self.root_thread_id = Some(thread_id);
}
fn is_root_thread(&self, thread_id: ThreadId) -> bool {
self.root_thread_id == Some(thread_id)
}
fn contains(&self, thread_id: ThreadId) -> bool {
self.agents.contains_key(&thread_id)
}
fn on_spawn_end(&mut self, event: &CollabAgentSpawnEndEvent) -> Option<Box<dyn HistoryCell>> {
let new_thread_id = event.new_thread_id?;
if event.spawn_mode == CollabAgentSpawnMode::Watchdog {
self.prune_superseded_watchdogs(new_thread_id);
}
if self.contains(new_thread_id) {
return None;
}
let ordinal = i32::try_from(self.order.len())
.unwrap_or(i32::MAX - 1)
.saturating_add(1);
let prompt_preview = prompt_preview(&event.prompt);
let name = derive_subagent_name(&event.prompt, ordinal);
let mut info = SubagentInfo::new(ordinal, name.clone(), prompt_preview, event.spawn_mode);
info.status = event.status.clone();
info.latest_preview = info.prompt_preview.clone();
info.latest_update_at = Instant::now();
self.order.push(new_thread_id);
self.agents.insert(new_thread_id, info);
let early_events = self
.pending_events
.remove(&new_thread_id)
.unwrap_or_default();
let mut follow_up = Vec::new();
for msg in early_events {
follow_up.extend(self.on_agent_event(new_thread_id, &msg));
}
for cell in follow_up {
self.queue_history(cell);
}
let prompt_line = prompt_first_line(&event.prompt);
Some(Box::new(history_cell::new_subagent_spawned_cell(
&name,
&prompt_line,
)))
}
fn prune_superseded_watchdogs(&mut self, keep_thread_id: ThreadId) {
let superseded: HashSet<ThreadId> = self
.agents
.iter()
.filter_map(|(thread_id, info)| {
(info.spawn_mode == CollabAgentSpawnMode::Watchdog && *thread_id != keep_thread_id)
.then_some(*thread_id)
})
.collect();
if superseded.is_empty() {
return;
}
self.order
.retain(|thread_id| !superseded.contains(thread_id));
self.agents
.retain(|thread_id, _| !superseded.contains(thread_id));
self.pending_events
.retain(|thread_id, _| !superseded.contains(thread_id));
}
fn on_close_end(&mut self, event: &CollabCloseEndEvent) -> Option<Box<dyn HistoryCell>> {
let receiver_id = event.receiver_thread_id;
let info = self.agents.get_mut(&receiver_id)?;
info.status = event.status.clone();
info.latest_update_at = Instant::now();
if is_terminal_status(&info.status) && !info.notified_terminal {
info.notified_terminal = true;
let summary = terminal_summary(&info.status);
return Some(Box::new(history_cell::new_subagent_update_cell(
&info.name,
&info.status,
summary.as_str(),
)));
}
None
}
fn on_wait_end(&mut self, event: &CollabWaitingEndEvent) {
for (thread_id, status) in &event.statuses {
let Some(info) = self.agents.get_mut(thread_id) else {
continue;
};
info.status = status.clone();
info.latest_update_at = Instant::now();
}
}
fn on_agent_event(&mut self, thread_id: ThreadId, msg: &EventMsg) -> Vec<Box<dyn HistoryCell>> {
let Some(info) = self.agents.get_mut(&thread_id) else {
self.buffer_pending_event(thread_id, msg.clone());
return Vec::new();
};
let mut history = Vec::new();
match msg {
EventMsg::TurnStarted(TurnStartedEvent { .. }) => {
info.clear_turn_buffers();
info.status = AgentStatus::Running;
if info.started_at.is_none() {
info.started_at = Some(Instant::now());
}
}
EventMsg::AgentReasoningDelta(ev) => {
info.update_reasoning_summary(ev.delta.as_str());
}
EventMsg::AgentReasoningRawContentDelta(ev) => {
info.update_reasoning_summary(ev.delta.as_str());
}
EventMsg::AgentReasoningRawContent(ev) => {
info.update_reasoning_summary(ev.text.as_str());
info.reasoning_buffer.clear();
}
EventMsg::AgentReasoning(_) | EventMsg::AgentReasoningSectionBreak(_) => {
info.reasoning_buffer.clear();
}
EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }) => {
info.inflight_message.push_str(delta);
let preview =
truncate_text(info.inflight_message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
info.update_preview(preview);
}
EventMsg::AgentMessage(AgentMessageEvent { message, .. }) => {
info.inflight_message.clear();
let preview = truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
info.update_preview(preview);
}
EventMsg::TurnComplete(TurnCompleteEvent {
last_agent_message, ..
}) => {
info.inflight_message.clear();
info.status = AgentStatus::Completed(last_agent_message.clone());
if !info.notified_terminal {
info.notified_terminal = true;
let summary = last_agent_message
.as_deref()
.map(|message| {
truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET)
})
.unwrap_or_else(|| "completed".to_string());
history.push(Box::new(history_cell::new_subagent_update_cell(
&info.name,
&info.status,
summary.as_str(),
)) as Box<dyn HistoryCell>);
}
}
EventMsg::TurnAborted(TurnAbortedEvent { reason, .. }) => {
info.inflight_message.clear();
let reason_text = format!("{reason:?}").to_lowercase();
info.status = AgentStatus::Errored(reason_text.clone());
if !info.notified_terminal {
info.notified_terminal = true;
history.push(Box::new(history_cell::new_subagent_update_cell(
&info.name,
&info.status,
reason_text.as_str(),
)) as Box<dyn HistoryCell>);
}
}
EventMsg::Error(ErrorEvent { message, .. }) => {
info.inflight_message.clear();
let summary = truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
info.status = AgentStatus::Errored(summary.clone());
if !info.notified_terminal {
info.notified_terminal = true;
history.push(Box::new(history_cell::new_subagent_update_cell(
&info.name,
&info.status,
summary.as_str(),
)) as Box<dyn HistoryCell>);
}
}
EventMsg::ShutdownComplete => {
info.inflight_message.clear();
info.status = AgentStatus::Shutdown;
if !info.notified_terminal {
info.notified_terminal = true;
history.push(Box::new(history_cell::new_subagent_update_cell(
&info.name,
&info.status,
"shutdown",
)) as Box<dyn HistoryCell>);
}
}
_ => {}
}
if history.is_empty() && matches!(msg, EventMsg::TurnStarted(_)) {
info.latest_update_at = Instant::now();
}
history
}
fn buffer_pending_event(&mut self, thread_id: ThreadId, msg: EventMsg) {
if self.is_root_thread(thread_id) {
return;
}
let entry = self.pending_events.entry(thread_id).or_default();
entry.push(msg);
if entry.len() > SUBAGENT_PENDING_EVENT_CAPACITY {
let excess = entry.len() - SUBAGENT_PENDING_EVENT_CAPACITY;
entry.drain(0..excess);
}
}
fn queue_history(&mut self, cell: Box<dyn HistoryCell>) {
self.pending_history.push(cell);
}
fn take_pending_history(&mut self) -> Vec<Box<dyn HistoryCell>> {
std::mem::take(&mut self.pending_history)
}
fn has_animating_agents(&self) -> bool {
let now = Instant::now();
self.agents.values().any(|info| info.should_shimmer(now))
}
fn rebuild_panel_state(&mut self) {
let mut running_infos: Vec<&SubagentInfo> = self
.agents
.values()
.filter(|info| info.is_visible_in_panel())
.collect();
running_infos.sort_by_key(|info| info.ordinal);
if running_infos.is_empty() {
self.panel_state = None;
self.panel_cell = None;
return;
}
let started_at = running_infos
.iter()
.map(|info| info.running_started_at())
.min()
.unwrap_or_else(Instant::now);
let running_count = i32::try_from(
running_infos
.iter()
.filter(|info| info.is_running_for_panel())
.count(),
)
.unwrap_or(i32::MAX);
let total_agents = i32::try_from(running_infos.len()).unwrap_or(i32::MAX);
let running_agents = running_infos
.into_iter()
.map(|info| SubagentPanelAgent {
ordinal: info.ordinal,
name: info.name.clone(),
status: info.status.clone(),
is_watchdog: info.is_watchdog(),
preview: running_preview(info),
latest_update_at: info.latest_update_at,
})
.collect();
let state = SubagentPanelState {
started_at,
total_agents,
running_count,
running_agents,
};
match &self.panel_state {
Some(existing) => {
let mut guard = existing
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = state;
}
None => {
self.panel_state = Some(Arc::new(StdMutex::new(state)));
}
}
if let Some(panel_state) = &self.panel_state {
self.panel_cell = Some(Arc::new(SubagentStatusCell::new(
Arc::clone(panel_state),
self.animations_enabled,
)));
}
}
fn panel_cell(&self) -> Option<Arc<SubagentStatusCell>> {
self.panel_cell.clone()
}
}
fn is_terminal_status(status: &AgentStatus) -> bool {
matches!(
status,
AgentStatus::Completed(_)
| AgentStatus::Errored(_)
| AgentStatus::Shutdown
| AgentStatus::NotFound
)
}
fn terminal_summary(status: &AgentStatus) -> String {
match status {
AgentStatus::Completed(Some(message)) => {
truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET)
}
AgentStatus::Completed(None) => "completed".to_string(),
AgentStatus::Errored(message) => {
truncate_text(message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET)
}
AgentStatus::Interrupted => "interrupted".to_string(),
AgentStatus::Shutdown => "shutdown".to_string(),
AgentStatus::NotFound => "not found".to_string(),
AgentStatus::PendingInit | AgentStatus::Running => "running".to_string(),
}
}
fn prompt_first_line(prompt: &str) -> String {
prompt
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.unwrap_or_default()
.to_string()
}
fn prompt_preview(prompt: &str) -> String {
let first_line = prompt_first_line(prompt);
truncate_text(first_line.trim(), SUBAGENT_PROMPT_PREVIEW_BUDGET)
}
fn running_preview(info: &SubagentInfo) -> String {
if !info.latest_summary.trim().is_empty() {
return truncate_text(info.latest_summary.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
}
if !info.inflight_message.trim().is_empty() {
return truncate_text(info.inflight_message.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
}
if !info.latest_preview.trim().is_empty() {
return truncate_text(info.latest_preview.trim(), SUBAGENT_UPDATE_PREVIEW_BUDGET);
}
truncate_text(info.prompt_preview.trim(), SUBAGENT_PROMPT_PREVIEW_BUDGET)
}
fn derive_subagent_name(prompt: &str, ordinal: i32) -> String {
let first_line = prompt_first_line(prompt);
let stripped = first_line
.strip_prefix("Task:")
.or_else(|| first_line.strip_prefix("task:"))
.unwrap_or(&first_line)
.trim();
let stopwords = [
"the", "a", "an", "to", "and", "or", "of", "for", "from", "in", "on", "with", "read",
"file", "task",
];
let tokens: Vec<String> = stripped
.split_whitespace()
.map(clean_token)
.filter(|token| !token.is_empty())
.filter(|token| !stopwords.contains(&token.as_str()))
.take(4)
.collect();
if tokens.is_empty() {
return format!("agent-{ordinal}");
}
let joined = tokens.join("-");
truncate_text(&joined, /*max_graphemes*/ 40)
}
fn clean_token(token: &str) -> String {
token
.chars()
.map(|ch| ch.to_ascii_lowercase())
.filter(|ch| ch.is_ascii_alphanumeric() || *ch == '-')
.collect()
}
fn should_show_model_migration_prompt(
current_model: &str,
target_model: &str,
@@ -951,6 +1506,8 @@ pub(crate) struct App {
/// Controls the animation thread that sends CommitTick events.
pub(crate) commit_anim_running: Arc<AtomicBool>,
/// Controls the animation thread that updates the live subagent panel.
pub(crate) subagent_anim_running: Arc<AtomicBool>,
// Shared across ChatWidget instances so invalid status-line config warnings only emit once.
status_line_invalid_items_warned: Arc<AtomicBool>,
// Shared across ChatWidget instances so invalid terminal-title config warnings only emit once.
@@ -984,6 +1541,7 @@ pub(crate) struct App {
thread_event_channels: HashMap<ThreadId, ThreadEventChannel>,
thread_event_listener_tasks: HashMap<ThreadId, JoinHandle<()>>,
subagents: SubagentRegistry,
agent_navigation: AgentNavigationState,
active_thread_id: Option<ThreadId>,
active_thread_rx: Option<mpsc::Receiver<ThreadBufferedEvent>>,
@@ -1557,6 +2115,7 @@ impl App {
self.active_thread_id = Some(thread_id);
self.active_thread_rx = receiver;
self.refresh_pending_thread_approvals().await;
self.sync_subagent_panel_state();
}
async fn store_active_thread_receiver(&mut self) {
@@ -1593,6 +2152,158 @@ impl App {
}
self.active_thread_rx = None;
self.refresh_pending_thread_approvals().await;
self.sync_subagent_panel_state();
}
fn subagents_root_active(&self) -> bool {
self.primary_thread_id.is_some() && self.active_thread_id == self.primary_thread_id
}
fn emit_or_queue_subagent_history(&mut self, cell: Box<dyn HistoryCell>) {
if self.subagents_root_active() {
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
} else {
self.subagents.queue_history(cell);
}
}
fn flush_subagent_history_if_root_active(&mut self) {
if !self.subagents_root_active() {
return;
}
let pending = self.subagents.take_pending_history();
for cell in pending {
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
}
fn update_subagent_animation(&mut self, root_active: bool) {
let should_run = root_active && self.subagents.has_animating_agents();
let is_running = self.subagent_anim_running.load(Ordering::Relaxed);
if should_run && !is_running {
self.app_event_tx.send(AppEvent::StartSubagentAnimation);
} else if !should_run && is_running {
self.app_event_tx.send(AppEvent::StopSubagentAnimation);
}
}
fn sync_subagent_panel_state(&mut self) {
let root_active = self.subagents_root_active();
self.subagents.rebuild_panel_state();
if root_active {
self.flush_subagent_history_if_root_active();
if let Some(panel) = self.subagents.panel_cell() {
self.app_event_tx.send(AppEvent::UpdateSubagentPanel(panel));
} else {
self.app_event_tx.send(AppEvent::ClearSubagentPanel);
}
} else {
self.app_event_tx.send(AppEvent::ClearSubagentPanel);
}
self.update_subagent_animation(root_active);
}
fn process_subagent_side_effects(&mut self, thread_id: ThreadId, event: &Event) {
if self.primary_thread_id == Some(thread_id) {
self.subagents.set_root_thread(thread_id);
}
if self.subagents.is_root_thread(thread_id) {
match &event.msg {
EventMsg::CollabAgentSpawnEnd(ev) => {
let _ = self.subagents.on_spawn_end(ev);
}
EventMsg::CollabWaitingEnd(ev) => {
self.subagents.on_wait_end(ev);
}
EventMsg::CollabCloseEnd(ev) => {
let _ = self.subagents.on_close_end(ev);
}
_ => {}
}
} else {
let updates = self.subagents.on_agent_event(thread_id, &event.msg);
for cell in updates {
self.emit_or_queue_subagent_history(cell);
}
}
self.sync_subagent_panel_state();
}
fn process_subagent_notification_side_effects(
&mut self,
thread_id: ThreadId,
notification: &ServerNotification,
) {
if self.primary_thread_id == Some(thread_id) {
self.subagents.set_root_thread(thread_id);
}
if !self.subagents.is_root_thread(thread_id) {
return;
}
let item = match notification {
ServerNotification::ItemStarted(notification) => &notification.item,
ServerNotification::ItemCompleted(notification) => &notification.item,
_ => {
self.sync_subagent_panel_state();
return;
}
};
let ThreadItem::CollabAgentToolCall {
id,
tool: CollabAgentTool::SpawnAgent,
sender_thread_id,
receiver_thread_ids,
prompt,
agents_states,
..
} = item
else {
self.sync_subagent_panel_state();
return;
};
let Some(new_thread_id) = receiver_thread_ids
.first()
.and_then(|thread_id| ThreadId::from_string(thread_id).ok())
else {
self.sync_subagent_panel_state();
return;
};
let sender_thread_id = ThreadId::from_string(sender_thread_id).unwrap_or(thread_id);
let entry = self.agent_navigation.get(&new_thread_id);
let spawn_mode = if entry.and_then(|entry| entry.agent_role.as_deref()) == Some("watchdog")
{
CollabAgentSpawnMode::Watchdog
} else {
CollabAgentSpawnMode::Spawn
};
let status = agents_states
.get(&new_thread_id.to_string())
.map(app_server_collab_state_to_agent_status)
.unwrap_or(AgentStatus::PendingInit);
let _ = self.subagents.on_spawn_end(&CollabAgentSpawnEndEvent {
call_id: id.clone(),
sender_thread_id,
new_thread_id: Some(new_thread_id),
new_agent_nickname: entry.and_then(|entry| entry.agent_nickname.clone()),
new_agent_role: entry.and_then(|entry| entry.agent_role.clone()),
prompt: prompt.clone().unwrap_or_default(),
model: String::new(),
reasoning_effort: ReasoningEffortConfig::Medium,
spawn_mode,
status,
});
self.sync_subagent_panel_state();
}
async fn note_thread_outbound_op(&mut self, thread_id: ThreadId, op: &AppCommand) {
@@ -3051,7 +3762,9 @@ impl App {
fn reset_thread_event_state(&mut self) {
self.abort_all_thread_event_listeners();
self.subagent_anim_running.store(false, Ordering::Release);
self.thread_event_channels.clear();
self.subagents = SubagentRegistry::new(self.config.animations);
self.agent_navigation.clear();
self.active_thread_id = None;
self.active_thread_rx = None;
@@ -3060,6 +3773,7 @@ impl App {
self.primary_session_configured = None;
self.pending_primary_events.clear();
self.pending_app_server_requests.clear();
self.chat_widget.clear_subagent_panel();
self.chat_widget.set_pending_thread_approvals(Vec::new());
self.sync_active_agent_label();
}
@@ -3555,6 +4269,7 @@ impl App {
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
let animations_enabled = config.animations;
let mut app = Self {
model_catalog,
@@ -3574,6 +4289,7 @@ impl App {
deferred_history_lines: Vec::new(),
has_emitted_history_lines: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
subagent_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: status_line_invalid_items_warned.clone(),
terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(),
backtrack: BacktrackState::default(),
@@ -3587,6 +4303,7 @@ impl App {
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
subagents: SubagentRegistry::new(animations_enabled),
agent_navigation: AgentNavigationState::default(),
active_thread_id: None,
active_thread_rx: None,
@@ -4062,6 +4779,38 @@ impl App {
AppEvent::CommitTick => {
self.chat_widget.on_commit_tick();
}
AppEvent::StartSubagentAnimation => {
if self
.subagent_anim_running
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.is_ok()
{
let tx = self.app_event_tx.clone();
let running = self.subagent_anim_running.clone();
thread::spawn(move || {
while running.load(Ordering::Relaxed) {
thread::sleep(SUBAGENT_ANIMATION_TICK);
tx.send(AppEvent::SubagentTick);
}
});
}
}
AppEvent::StopSubagentAnimation => {
self.subagent_anim_running.store(false, Ordering::Release);
}
AppEvent::SubagentTick => {
let root_active = self.subagents_root_active();
self.update_subagent_animation(root_active);
if root_active && self.subagents.has_animating_agents() {
self.chat_widget.on_subagent_tick();
}
}
AppEvent::UpdateSubagentPanel(panel) => {
self.chat_widget.on_subagent_panel_updated(panel);
}
AppEvent::ClearSubagentPanel => {
self.chat_widget.clear_subagent_panel();
}
AppEvent::Exit(mode) => {
return Ok(self.handle_exit_mode(app_server, mode).await);
}
@@ -5462,6 +6211,9 @@ impl App {
if let ThreadBufferedEvent::Notification(notification) = &event {
self.hydrate_collab_agent_metadata_for_notification(app_server, notification)
.await;
if let Some(active_thread_id) = self.active_thread_id {
self.process_subagent_notification_side_effects(active_thread_id, notification);
}
}
self.handle_thread_event_now(event);
@@ -6876,6 +7628,64 @@ mod tests {
}
}
#[tokio::test]
async fn queued_subagent_panel_update_mounts_on_fresh_chat_widget_after_thread_switch() {
let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await;
let root_thread_id = ThreadId::new();
let subagent_thread_id = ThreadId::new();
app.primary_thread_id = Some(root_thread_id);
app.active_thread_id = Some(root_thread_id);
app.subagents.set_root_thread(root_thread_id);
let mut info = SubagentInfo::new(
/*ordinal*/ 1,
"watchdog-agent".to_string(),
"watchdog idle".to_string(),
CollabAgentSpawnMode::Watchdog,
);
info.status = AgentStatus::PendingInit;
info.latest_preview = "watchdog idle".to_string();
info.latest_update_at = Instant::now();
app.subagents.order.push(subagent_thread_id);
app.subagents.agents.insert(subagent_thread_id, info);
app.sync_subagent_panel_state();
let queued_panel = match app_event_rx.try_recv() {
Ok(AppEvent::UpdateSubagentPanel(panel)) => panel,
other => panic!("expected queued subagent panel update, got {other:?}"),
};
let (fresh_chat_widget, _fresh_app_event_tx, _fresh_rx, _fresh_op_rx) =
make_chatwidget_manual_with_sender().await;
app.chat_widget = fresh_chat_widget;
app.chat_widget.set_composer_text(
"back on the root thread".to_string(),
Vec::new(),
Vec::new(),
);
app.chat_widget.on_subagent_panel_updated(queued_panel);
let width = 80;
let height = app.chat_widget.desired_height(width);
let mut terminal =
ratatui::Terminal::new(crate::test_backend::VT100Backend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(ratatui::prelude::Rect::new(0, 0, width, height));
terminal
.draw(|f| app.chat_widget.render(f.area(), f.buffer_mut()))
.expect("render fresh widget with queued subagent panel");
let screen = terminal.backend().vt100().screen().contents();
assert!(
screen.contains("Subagents"),
"queued subagent panel update should mount on the fresh widget"
);
assert!(screen.contains("watchdog-agent"));
}
#[tokio::test]
async fn replay_thread_snapshot_restores_collaboration_mode_for_draft_submit() {
let (mut app, _app_event_rx, _op_rx) = make_test_app_with_channels().await;
@@ -8718,6 +9528,7 @@ guardian_approval = true
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
let animations_enabled = config.animations;
App {
model_catalog: chat_widget.model_catalog(),
@@ -8737,6 +9548,7 @@ guardian_approval = true
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
subagent_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
@@ -8750,6 +9562,7 @@ guardian_approval = true
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
subagents: SubagentRegistry::new(animations_enabled),
agent_navigation: AgentNavigationState::default(),
active_thread_id: None,
active_thread_rx: None,
@@ -8771,6 +9584,7 @@ guardian_approval = true
let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let session_telemetry = test_session_telemetry(&config, model.as_str());
let animations_enabled = config.animations;
(
App {
@@ -8791,6 +9605,7 @@ guardian_approval = true
has_emitted_history_lines: false,
enhanced_keys_supported: false,
commit_anim_running: Arc::new(AtomicBool::new(false)),
subagent_anim_running: Arc::new(AtomicBool::new(false)),
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
backtrack: BacktrackState::default(),
@@ -8804,6 +9619,7 @@ guardian_approval = true
windows_sandbox: WindowsSandboxState::default(),
thread_event_channels: HashMap::new(),
thread_event_listener_tasks: HashMap::new(),
subagents: SubagentRegistry::new(animations_enabled),
agent_navigation: AgentNavigationState::default(),
active_thread_id: None,
active_thread_rx: None,
+7
View File
@@ -9,6 +9,7 @@
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
use std::path::PathBuf;
use std::sync::Arc;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::PluginInstallResponse;
@@ -30,6 +31,7 @@ use crate::bottom_pane::ApprovalRequest;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::TerminalTitleItem;
use crate::history_cell::HistoryCell;
use crate::history_cell::SubagentStatusCell;
use codex_core::config::types::ApprovalsReviewer;
use codex_features::Feature;
@@ -271,6 +273,11 @@ pub(crate) enum AppEvent {
StartCommitAnimation,
StopCommitAnimation,
CommitTick,
StartSubagentAnimation,
StopSubagentAnimation,
SubagentTick,
UpdateSubagentPanel(Arc<SubagentStatusCell>),
ClearSubagentPanel,
/// Update the current reasoning effort in the running app and widget.
UpdateReasoningEffort(Option<ReasoningEffort>),
+133 -16
View File
@@ -51,6 +51,7 @@ use crate::bottom_pane::StatusLinePreviewData;
use crate::bottom_pane::StatusLineSetupView;
use crate::bottom_pane::TerminalTitleItem;
use crate::bottom_pane::TerminalTitleSetupView;
use crate::history_cell::SubagentStatusCell;
use crate::mention_codec::LinkedMention;
use crate::mention_codec::encode_history_mentions;
use crate::model_catalog::ModelCatalog;
@@ -125,11 +126,16 @@ use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::AgentMessageItem;
use codex_protocol::models::ContentItem;
use codex_protocol::models::MessagePhase;
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;
use codex_protocol::protocol::AGENT_INBOX_KIND;
use codex_protocol::protocol::AGENT_INBOX_MESSAGE_PREFIX;
use codex_protocol::protocol::AgentInboxPayload;
#[cfg(test)]
use codex_protocol::protocol::AgentMessageDeltaEvent;
#[cfg(test)]
@@ -185,6 +191,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;
@@ -445,6 +453,37 @@ fn is_unified_exec_source(source: ExecCommandSource) -> bool {
)
}
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
@@ -739,6 +778,7 @@ pub(crate) struct ChatWidget {
codex_op_target: CodexOpTarget,
bottom_pane: BottomPane,
active_cell: Option<Box<dyn HistoryCell>>,
subagent_panel: Option<SubagentStatusCell>,
/// Monotonic-ish counter used to invalidate transcript overlay caching.
///
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
@@ -951,6 +991,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)>,
}
@@ -3790,6 +3832,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,
@@ -4155,6 +4223,35 @@ impl ChatWidget {
self.run_commit_tick();
}
pub(crate) fn on_subagent_panel_updated(&mut self, panel: Arc<SubagentStatusCell>) {
let state_handle = panel.state_handle();
if let Some(existing) = self.subagent_panel.as_mut() {
if existing.matches_state(&state_handle) {
self.request_redraw();
return;
}
*existing = panel.as_ref().clone();
self.request_redraw();
return;
}
self.subagent_panel = Some(panel.as_ref().clone());
self.request_redraw();
}
pub(crate) fn clear_subagent_panel(&mut self) {
if self.subagent_panel.take().is_some() {
self.request_redraw();
}
}
pub(crate) fn on_subagent_tick(&mut self) {
if self.subagent_panel.is_some() {
self.request_redraw();
}
}
/// Runs a regular periodic commit tick.
fn run_commit_tick(&mut self) {
self.run_commit_tick_with_scope(CommitTickScope::AnyMode);
@@ -4634,7 +4731,6 @@ impl ChatWidget {
pub(crate) fn new_with_app_event(common: ChatWidgetInit) -> Self {
Self::new_with_op_target(common, CodexOpTarget::AppEvent)
}
#[allow(dead_code)]
pub(crate) fn new_with_op_sender(
common: ChatWidgetInit,
@@ -4710,6 +4806,7 @@ impl ChatWidget {
skills: None,
}),
active_cell,
subagent_panel: None,
active_cell_revision: 0,
config,
skills_all: Vec::new(),
@@ -4809,10 +4906,13 @@ 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,
};
widget.prefetch_rate_limits();
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
@@ -4831,20 +4931,11 @@ impl ChatWidget {
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
#[cfg(target_os = "windows")]
widget.bottom_pane.set_windows_degraded_sandbox_active(
codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
&& matches!(
WindowsSandboxLevel::from_config(&widget.config),
WindowsSandboxLevel::RestrictedToken
),
);
widget.update_collaboration_mode_indicator();
widget
.bottom_pane
.set_connectors_enabled(widget.connectors_enabled());
widget.refresh_status_surfaces();
widget.refresh_terminal_title();
widget.refresh_terminal_title();
widget
}
@@ -5615,6 +5706,14 @@ impl ChatWidget {
fn flush_active_cell(&mut self) {
if let Some(active) = self.active_cell.take() {
// Subagent status is a transient panel, not transcript history. If we
// flush it into history every time another cell is inserted, the
// transcript gets spammed with repeated identical "Subagents ..." blocks.
// Keep the panel mounted so later transcript cells do not make it disappear.
if active.as_any().is::<SubagentStatusCell>() {
self.active_cell = Some(active);
return;
}
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
}
@@ -6594,7 +6693,6 @@ impl ChatWidget {
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
@@ -6607,6 +6705,15 @@ impl ChatWidget {
| ServerNotification::WindowsWorldWritableWarning(_)
| ServerNotification::WindowsSandboxSetupCompleted(_)
| ServerNotification::AccountLoginCompleted(_) => {}
ServerNotification::RawResponseItemCompleted(notification) => {
if let Some((sender, message)) = agent_inbox_message_from_item(&notification.item) {
let hint = sender.map(|sender| format!("from {sender}"));
self.add_to_history(history_cell::new_info_event(
format!("Agent message: {message}"),
hint,
));
}
}
}
}
@@ -6885,6 +6992,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(_)
@@ -7092,8 +7202,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(_)
@@ -10835,8 +10945,15 @@ impl ChatWidget {
)),
None => RenderableItem::Owned(Box::new(())),
};
let subagent_panel_renderable = match &self.subagent_panel {
Some(panel) => RenderableItem::Borrowed(panel).inset(Insets::tlbr(
/*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0,
)),
None => RenderableItem::Owned(Box::new(())),
};
let mut flex = FlexRenderable::new();
flex.push(/*flex*/ 1, active_cell_renderable);
flex.push(/*flex*/ 0, subagent_panel_renderable);
flex.push(
/*flex*/ 0,
RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr(
@@ -11037,7 +11154,7 @@ const PLACEHOLDERS: [&str; 8] = [
// Extract the first bold (Markdown) element in the form **...** from `s`.
// Returns the inner text if found; otherwise `None`.
fn extract_first_bold(s: &str) -> Option<String> {
pub(crate) fn extract_first_bold(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut i = 0usize;
while i + 1 < bytes.len() {
@@ -1,18 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 9607
expression: popup
---
Update Model Permissions
› 1. Default Codex can read and edit files in the current
workspace, and run commands. Approval is required to
access the internet or edit other files.
2. Guardian Approvals Same workspace-write permissions as Default, but
eligible `on-request` approvals are routed through
the guardian reviewer subagent.
3. Full Access Codex can edit files outside this workspace and
access the internet without asking for approval.
Exercise caution when using.
Press enter to confirm or esc to go back
@@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
---
╭───────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ model: loading /model to change │
│ directory: /tmp/project │
╰───────────────────────────────────────╯
• Subagents (0s • no subagents running • esc to interrupt)
• [#1] [watchdog] watchdog-agent idle — watchdog idle
› show current subagent state
gpt-5.3-codex default · 100% left · /tmp/project
@@ -1,10 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 12789
expression: combined
---
• Running UserPromptSubmit hook: checking go-workflow input policy
UserPromptSubmit hook (stopped)
warning: go-workflow must start from PlanMode
stop: prompt blocked
@@ -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
+204
View File
@@ -14,6 +14,9 @@ use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::LocalImageAttachment;
use crate::bottom_pane::MentionBinding;
use crate::chatwidget::realtime::RealtimeConversationPhase;
use crate::history_cell::SubagentPanelAgent;
use crate::history_cell::SubagentPanelState;
use crate::history_cell::SubagentStatusCell;
use crate::history_cell::UserHistoryCell;
use crate::model_catalog::ModelCatalog;
use crate::test_backend::VT100Backend;
@@ -63,6 +66,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RawResponseItemCompletedNotification;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SkillSummary;
@@ -108,7 +112,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;
@@ -116,6 +123,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;
@@ -151,6 +160,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;
@@ -193,6 +203,7 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Mutex as StdMutex;
use tempfile::NamedTempFile;
use tempfile::tempdir;
use tokio::sync::mpsc::error::TryRecvError;
@@ -294,6 +305,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;
@@ -399,6 +431,78 @@ 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(/*model_override*/ 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 live_raw_response_item_completed_renders_agent_inbox_message() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ 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_server_notification(
ServerNotification::RawResponseItemCompleted(RawResponseItemCompletedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: "turn-1".to_string(),
item: agent_inbox_function_call_output(sender, message),
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(
cells.len(),
1,
"expected live agent inbox item to render one history row"
);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Agent message: Please review the latest diff"),
"expected rendered agent inbox message, got {rendered:?}"
);
assert!(
rendered.contains("from 019cbff7"),
"expected rendered sender hint, got {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;
@@ -2099,6 +2203,7 @@ async fn make_chatwidget_manual(
codex_op_target: super::CodexOpTarget::Direct(op_tx),
bottom_pane: bottom,
active_cell: None,
subagent_panel: None,
active_cell_revision: 0,
config: cfg,
current_collaboration_mode,
@@ -2199,6 +2304,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,
};
@@ -2351,6 +2457,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() {
spawn_mode: codex_protocol::protocol::AgentSpawnMode::Spawn,
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
spawn_mode: codex_protocol::protocol::AgentSpawnMode::Spawn,
status: AgentStatus::PendingInit,
}),
});
@@ -2372,6 +2479,103 @@ fn status_line_text(chat: &ChatWidget) -> Option<String> {
chat.status_line_text()
}
async fn subagent_panel_is_not_flushed_into_transcript_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let state = Arc::new(StdMutex::new(SubagentPanelState {
started_at: Instant::now(),
total_agents: 1,
running_count: 0,
running_agents: vec![SubagentPanelAgent {
ordinal: 1,
name: "user-request-derisk-implement".to_string(),
status: AgentStatus::PendingInit,
is_watchdog: true,
preview: "watchdog idle".to_string(),
latest_update_at: Instant::now(),
}],
}));
chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(
Arc::clone(&state),
/*animations_enabled*/ true,
)));
chat.add_to_history(history_cell::new_error_event("follow-up cell".to_string()));
let inserted = drain_insert_history(&mut rx);
assert_eq!(
inserted.len(),
1,
"subagent panel should remain transient and not be inserted into transcript history"
);
let rendered = lines_to_single_string(&inserted[0]);
assert!(rendered.contains("follow-up cell"));
assert!(!rendered.contains("Subagents"));
assert!(
chat.subagent_panel
.as_ref()
.is_some_and(|panel| panel.matches_state(&state)),
"subagent panel should stay mounted after other history cells are inserted"
);
}
#[tokio::test]
async fn subagent_panel_mounts_while_placeholder_active_cell_exists_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.active_cell = Some(ChatWidget::placeholder_session_header_cell(
chat.config_ref(),
));
chat.bottom_pane.set_composer_text(
"show current subagent state".to_string(),
Vec::new(),
Vec::new(),
);
let state = Arc::new(StdMutex::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: "watchdog idle".to_string(),
latest_update_at: Instant::now(),
}],
}));
chat.on_subagent_panel_updated(Arc::new(SubagentStatusCell::new(
Arc::clone(&state),
/*animations_enabled*/ false,
)));
assert!(
chat.active_cell
.as_ref()
.is_some_and(|cell| cell.as_any().is::<history_cell::SessionHeaderHistoryCell>()),
"placeholder session header should remain the active cell"
);
assert!(
chat.subagent_panel
.as_ref()
.is_some_and(|panel| panel.matches_state(&state)),
"subagent panel should mount even when another active cell already exists"
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal =
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("render chat with placeholder header and subagent panel");
assert_snapshot!(
"subagent_panel_mounts_while_placeholder_active_cell_exists",
terminal.backend().vt100().screen().contents()
);
}
fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo {
fn usage(total_tokens: i64) -> TokenUsage {
TokenUsage {
+360 -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,271 @@ 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 = {
let guard = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.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 = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::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)
}
}
impl Renderable for SubagentStatusCell {
fn render(&self, area: Rect, buf: &mut Buffer) {
let lines = self.display_lines(area.width);
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
let y = if area.height == 0 {
0
} else {
let overflow = paragraph
.line_count(area.width)
.saturating_sub(usize::from(area.height));
u16::try_from(overflow).unwrap_or(u16::MAX)
};
paragraph.scroll((y, 0)).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
HistoryCell::desired_height(self, width)
}
}
#[cfg_attr(not(test), 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(), /*max_graphemes*/ 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(), /*max_graphemes*/ 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".magenta(),
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".magenta(),
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 +3049,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 +3059,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 +3627,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, /*animations_enabled*/ true);
let lines = render_lines(&cell.display_lines(/*width*/ 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, /*animations_enabled*/ 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, /*animations_enabled*/ 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, /*animations_enabled*/ true);
assert_eq!(cell.transcript_animation_tick(), None);
}
#[test]
fn prefixed_wrapped_history_cell_indents_wrapped_lines() {
let summary = Line::from(vec![
+2
View File
@@ -613,6 +613,7 @@ mod tests {
spawn_mode: AgentSpawnMode::Spawn,
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
spawn_mode: AgentSpawnMode::Spawn,
status: AgentStatus::PendingInit,
},
Some(&SpawnRequestSummary {
@@ -752,6 +753,7 @@ mod tests {
spawn_mode: AgentSpawnMode::Spawn,
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
spawn_mode: AgentSpawnMode::Spawn,
status: AgentStatus::PendingInit,
},
Some(&SpawnRequestSummary {