Compare commits

...

29 Commits

Author SHA1 Message Date
Friel
9fd1a4b42b Merge remote-tracking branch 'upstream/dev/friel/tui-watchdog-and-subagent-behavior' into tui-watchdog-timer-countdown
# Conflicts:
#	codex-rs/tui/src/chatwidget/tests/app_server.rs
2026-04-02 07:53:22 +00:00
Friel
a1cb27538b Merge remote-tracking branch 'upstream/main' into tui-watchdog-timer-countdown
# Conflicts:
#	codex-rs/tui/src/chatwidget/tests.rs
2026-04-02 07:49:53 +00:00
Friel
00cbe40edd Merge remote-tracking branch 'upstream/dev/friel/tui-collab-foundation' into dev/friel/tui-watchdog-and-subagent-behavior 2026-04-02 07:10:26 +00:00
Friel
74644b13de tui: allow staged subagent helpers before wiring 2026-04-02 05:13:02 +00:00
Friel
9618e3002b test(tui): refresh chatwidget collab test fixtures 2026-04-02 05:01:49 +00:00
Friel
901b66441d Merge remote-tracking branch 'upstream/main' into dev/friel/tui-collab-foundation
# Conflicts:
#	codex-rs/tui/src/chatwidget/tests.rs
2026-04-02 04:35:59 +00:00
Friel
8cf3da357b ci: extend bazel watchdog timer timeout 2026-03-30 13:49:33 +00:00
Friel
c5475b2354 ci: bump linux arglint prebuilt timeout 2026-03-30 12:56:00 +00:00
Friel
dfdee7d6cb ci: extend watchdog timer and subagent behavior timeouts 2026-03-30 11:03:33 +00:00
Friel
9f0916586d ci: extend watchdog timer workflow timeouts 2026-03-30 10:03:34 +00:00
Friel
79d73b4a5c ci: extend watchdog timer branch workflow timeouts 2026-03-30 09:12:31 +00:00
Friel
484eb1ebfd ci: bump workflow timeouts 2026-03-30 08:45:49 +00:00
Friel
45bf65d007 Render watchdog countdown in subagent panel 2026-03-29 21:11:31 +00:00
Friel
a2f37d5548 fix(tui): preserve watchdog behavior on refreshed base
Resolve the remaining TUI-side merge drift after restacking the watchdog and subagent behavior branch onto the refreshed TUI foundation branch.

Co-authored-by: Codex <noreply@openai.com>
2026-03-28 23:09:20 -07:00
Friel
65db8195a5 test(nextest): extend schema fixture timeout on windows 2026-03-28 23:09:20 -07:00
Friel
86fe4f6be4 test(app-server): stabilize thread unsubscribe on slow runners 2026-03-28 23:09:20 -07:00
Friel
3bd69accc1 fix(tui): render live agent inbox notifications 2026-03-28 23:08:51 -07:00
Friel
b8be51fe7c fix(tui): annotate subagent panel test literals 2026-03-28 19:24:31 -07:00
Friel
0b72153645 fix(tui): annotate subagent panel render literals 2026-03-28 18:54:46 -07:00
Friel
510d5679a9 fix(tui): annotate subagent panel truncation literal 2026-03-28 18:43:02 -07:00
Friel
48b7f72829 fix(tui): drop stale voice transcription init on current main 2026-03-28 18:34:11 -07:00
Friel
fc7a5e3d0f fix(tui): restore subagent panel wiring on app-server TUI 2026-03-28 18:28:25 -07:00
Friel
90d339cf75 fix(tui): mount subagent panel independently of active cell 2026-03-28 18:24:50 -07:00
Friel
0af3f1f66e fix(tui): keep subagent panel out of transcript history 2026-03-28 18:24:50 -07:00
Friel
4ef8493fd2 test(tui): annotate subagent panel literals 2026-03-28 18:24:50 -07:00
Friel
5e41296824 fix(tui): format multi-agent status helper 2026-03-28 18:24:50 -07:00
Friel
3cfdc99ecc fix(tui): annotate watchdog panel truncation literals 2026-03-28 18:24:50 -07:00
Friel
77c69e35b7 fix(tui): adapt collab foundation to refreshed main
Preserve the collab transcript fixtures and current TUI style rules after rebasing onto the refreshed tui_app_server codebase.

Co-authored-by: Codex <noreply@openai.com>
2026-03-28 18:24:49 -07:00
Friel
ed0cff78a6 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.
2026-03-28 18:24:49 -07:00
22 changed files with 1584 additions and 22 deletions

View File

@@ -17,7 +17,7 @@ concurrency:
cancel-in-progress: ${{ github.ref_name != 'main' }}
jobs:
test:
timeout-minutes: 30
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -99,7 +99,7 @@ jobs:
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
clippy:
timeout-minutes: 30
timeout-minutes: 60
strategy:
fail-fast: false
matrix:

View File

@@ -137,10 +137,10 @@ jobs:
include:
- name: Linux
runner: ubuntu-24.04
timeout_minutes: 30
timeout_minutes: 120
- name: macOS
runner: macos-15-xlarge
timeout_minutes: 30
timeout_minutes: 90
- name: Windows
runner: windows-x64
timeout_minutes: 30

View File

@@ -10,7 +10,7 @@ jobs:
runs-on:
group: codex-runners
labels: codex-linux-x64
timeout-minutes: 10
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

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 }

View File

@@ -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,
}),
];

View File

@@ -192,6 +192,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 {

View File

@@ -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;

View File

@@ -149,6 +149,7 @@ impl ToolHandler for Handler {
prompt,
model: effective_model,
reasoning_effort: effective_reasoning_effort,
spawn_mode: AgentSpawnMode::Spawn,
status,
}
.into(),

View File

@@ -6,6 +6,7 @@ use crate::agent::next_thread_spawn_depth;
use crate::agent::role::DEFAULT_ROLE_NAME;
use crate::agent::role::apply_role_to_config;
use codex_protocol::AgentPath;
use codex_protocol::protocol::AgentSpawnMode;
use codex_protocol::protocol::InterAgentCommunication;
use codex_protocol::protocol::Op;
@@ -173,6 +174,7 @@ impl ToolHandler for Handler {
prompt,
model: effective_model,
reasoning_effort: effective_reasoning_effort,
spawn_mode: AgentSpawnMode::Spawn,
status,
}
.into(),

View File

@@ -100,6 +100,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)]
@@ -3398,6 +3419,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.
@@ -3445,6 +3476,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,
}

View File

@@ -23,6 +23,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;
@@ -31,6 +32,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;
@@ -48,6 +52,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;
@@ -57,6 +62,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::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
@@ -107,7 +115,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;
@@ -119,6 +137,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;
@@ -132,10 +153,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;
@@ -212,6 +235,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
@@ -701,6 +738,531 @@ 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));
}
#[cfg_attr(not(test), allow(dead_code))]
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
}
#[cfg_attr(not(test), allow(dead_code))]
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(),
watchdog_countdown_started_at: info
.is_watchdog()
.then_some(info.running_started_at()),
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()
}
}
#[cfg_attr(not(test), allow(dead_code))]
fn is_terminal_status(status: &AgentStatus) -> bool {
matches!(
status,
AgentStatus::Completed(_)
| AgentStatus::Errored(_)
| AgentStatus::Shutdown
| AgentStatus::NotFound
)
}
#[cfg_attr(not(test), allow(dead_code))]
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,
@@ -959,6 +1521,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.
@@ -992,6 +1556,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>>,
@@ -1564,6 +2129,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) {
@@ -1600,6 +2166,160 @@ 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
}
#[cfg_attr(not(test), allow(dead_code))]
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);
}
#[cfg_attr(not(test), allow(dead_code))]
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) {
@@ -3184,7 +3904,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;
@@ -3193,6 +3915,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();
}
@@ -3685,6 +4408,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,
@@ -3704,6 +4428,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(),
@@ -3717,6 +4442,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,
@@ -4192,6 +4918,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);
}
@@ -5628,6 +6386,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);
@@ -7097,6 +7858,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;
@@ -8992,6 +9811,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(),
@@ -9011,6 +9831,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(),
@@ -9024,6 +9845,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,
@@ -9045,6 +9867,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 {
@@ -9065,6 +9888,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(),
@@ -9078,6 +9902,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,

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;
@@ -278,6 +280,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>),

View File

@@ -52,6 +52,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;
@@ -128,11 +129,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)]
@@ -145,6 +151,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)]
@@ -188,6 +195,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;
@@ -447,6 +456,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
@@ -740,6 +780,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
@@ -953,6 +994,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)>,
}
@@ -3700,6 +3743,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,
@@ -3762,6 +3831,10 @@ impl ChatWidget {
prompt: prompt.unwrap_or_default(),
model: String::new(),
reasoning_effort: ReasoningEffortConfig::Medium,
// Thread history items do not carry spawn_mode yet, so the
// replay path must choose an explicit fallback for reconstructed
// spawn rows. Plain spawn is the least surprising default.
spawn_mode: AgentSpawnMode::Spawn,
status: first_receiver
.as_ref()
.and_then(|thread_id| agents_states.get(&thread_id.to_string()))
@@ -4061,6 +4134,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);
@@ -4540,7 +4642,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,
@@ -4615,6 +4716,7 @@ impl ChatWidget {
skills: None,
}),
active_cell,
subagent_panel: None,
active_cell_revision: 0,
config,
skills_all: Vec::new(),
@@ -4715,10 +4817,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());
@@ -4737,20 +4842,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
}
@@ -5532,6 +5628,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));
}
@@ -6511,7 +6615,6 @@ impl ChatWidget {
| ServerNotification::ThreadStatusChanged(_)
| ServerNotification::ThreadArchived(_)
| ServerNotification::ThreadUnarchived(_)
| ServerNotification::RawResponseItemCompleted(_)
| ServerNotification::CommandExecOutputDelta(_)
| ServerNotification::McpToolCallProgress(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
@@ -6524,6 +6627,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,
));
}
}
}
}
@@ -6802,6 +6914,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(_)
@@ -7009,8 +7124,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(_)
@@ -10788,8 +10903,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(
@@ -10990,7 +11112,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() {

View File

@@ -0,0 +1,20 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 2568
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 (59s) — watchdog idle
show current subagent state
gpt-5.3-codex default · 100% left · /tmp/project

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 464
expression: rendered
---
• Agent message: Please review the latest diff from 019cbff7-558b-77d3-8653-8238ab5361ec

View File

@@ -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

View File

@@ -13,6 +13,9 @@ pub(super) use crate::app_event_sender::AppEventSender;
pub(super) use crate::bottom_pane::LocalImageAttachment;
pub(super) use crate::bottom_pane::MentionBinding;
pub(super) use crate::chatwidget::realtime::RealtimeConversationPhase;
pub(super) use crate::history_cell::SubagentPanelAgent;
pub(super) use crate::history_cell::SubagentPanelState;
pub(super) use crate::history_cell::SubagentStatusCell;
pub(super) use crate::history_cell::UserHistoryCell;
pub(super) use crate::model_catalog::ModelCatalog;
pub(super) use crate::test_backend::VT100Backend;
@@ -203,6 +206,7 @@ pub(super) use std::collections::BTreeMap;
pub(super) use std::collections::HashMap;
pub(super) use std::collections::HashSet;
pub(super) use std::path::PathBuf;
pub(super) use std::sync::Mutex as StdMutex;
pub(super) use tempfile::NamedTempFile;
pub(super) use tempfile::tempdir;
pub(super) use tokio::sync::mpsc::error::TryRecvError;

View File

@@ -28,6 +28,7 @@ async fn collab_spawn_end_shows_requested_model_and_effort() {
prompt: "Explore the repo".to_string(),
model: "gpt-5".to_string(),
reasoning_effort: ReasoningEffortConfig::High,
spawn_mode: AgentSpawnMode::Spawn,
status: AgentStatus::PendingInit,
}),
});

View File

@@ -210,6 +210,7 @@ pub(super) async fn make_chatwidget_manual(
pending_turn_copyable_output: None,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
subagent_panel: None,
pending_collab_spawn_requests: HashMap::new(),
suppressed_exec_calls: HashSet::new(),
skills_all: Vec::new(),
@@ -286,6 +287,7 @@ pub(super) async fn make_chatwidget_manual(
external_editor_state: ExternalEditorState::Closed,
realtime_conversation: RealtimeConversationUiState::default(),
last_rendered_user_message_event: None,
last_replayed_agent_inbox_message: None,
last_non_retry_error: None,
};
widget.set_model(&resolved_model);

View File

@@ -111,6 +111,107 @@ async fn turn_started_uses_runtime_context_window_before_first_token_count() {
"expected /status to avoid raw config context window, got: {context_line}"
);
}
#[tokio::test]
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,
watchdog_countdown_started_at: Some(Instant::now()),
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,
watchdog_countdown_started_at: Some(Instant::now()),
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_chatwidget_snapshot!(
"subagent_panel_mounts_while_placeholder_active_cell_exists",
terminal.backend().vt100().screen().contents()
);
}
#[tokio::test]
async fn helpers_are_available_and_do_not_panic() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();

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,299 @@ 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) watchdog_countdown_started_at: Option<Instant>,
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) || has_watchdog_countdown(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, now));
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, now: Instant) -> Span<'static> {
match &agent.status {
AgentStatus::PendingInit if agent.is_watchdog => {
if let Some(countdown) = watchdog_countdown_remaining(agent, now) {
format!("idle ({})", fmt_elapsed_compact(countdown.as_secs())).dim()
} else {
"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);
const WATCHDOG_COUNTDOWN: Duration = Duration::from_secs(60);
#[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
}
#[cfg_attr(not(test), allow(dead_code))]
fn has_watchdog_countdown(agent: &SubagentPanelAgent, now: Instant) -> bool {
watchdog_countdown_remaining(agent, now).is_some_and(|remaining| remaining > Duration::ZERO)
}
#[cfg_attr(not(test), allow(dead_code))]
fn watchdog_countdown_remaining(agent: &SubagentPanelAgent, now: Instant) -> Option<Duration> {
if !agent.is_watchdog {
return None;
}
if !matches!(agent.status, AgentStatus::PendingInit) {
return None;
}
let Some(started_at) = agent.watchdog_countdown_started_at else {
return None;
};
let elapsed = now.saturating_duration_since(started_at);
Some(WATCHDOG_COUNTDOWN.saturating_sub(elapsed))
}
#[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 {
@@ -2778,6 +3074,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;
@@ -2787,6 +3084,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;
@@ -3353,6 +3652,124 @@ 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,
watchdog_countdown_started_at: Some(Instant::now()),
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"));
assert!(lines[1].contains("idle ("));
}
#[test]
fn subagent_panel_animation_tick_ticks_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,
watchdog_countdown_started_at: Some(Instant::now()),
preview: "monitor parent progress".to_string(),
latest_update_at: Instant::now(),
}],
}));
let cell = SubagentStatusCell::new(state, /*animations_enabled*/ true);
assert!(cell.transcript_animation_tick().is_some());
}
#[test]
fn subagent_panel_animation_tick_stops_after_countdown_expires() {
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,
watchdog_countdown_started_at: Some(
Instant::now()
.checked_sub(Duration::from_secs(61))
.unwrap_or_else(Instant::now),
),
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,
watchdog_countdown_started_at: None,
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,
watchdog_countdown_started_at: None,
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![

View File

@@ -583,6 +583,7 @@ fn status_summary_spans(status: &AgentStatus) -> Vec<Span<'static>> {
mod tests {
use super::*;
use crate::history_cell::HistoryCell;
use codex_protocol::protocol::AgentSpawnMode;
#[cfg(target_os = "macos")]
use crossterm::event::KeyEvent;
#[cfg(target_os = "macos")]
@@ -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 {