mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
merge upstream/dev/friel/tui-watchdog-and-subagent-behavior into collab stack
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => ¬ification.item,
|
||||
ServerNotification::ItemCompleted(notification) => ¬ification.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,
|
||||
|
||||
@@ -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
@@ -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(¬ification.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() {
|
||||
|
||||
-18
@@ -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
|
||||
+18
@@ -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
|
||||
-10
@@ -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
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui_app_server/src/chatwidget/tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
• Agent message: Please review the latest diff from 019cbff7-558b-77d3-8653-8238ab5361ec
|
||||
@@ -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 {
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user