mirror of
https://github.com/openai/codex.git
synced 2026-05-12 23:32:44 +00:00
Compare commits
29 Commits
rust-v0.12
...
tui-watchd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd1a4b42b | ||
|
|
a1cb27538b | ||
|
|
00cbe40edd | ||
|
|
74644b13de | ||
|
|
9618e3002b | ||
|
|
901b66441d | ||
|
|
8cf3da357b | ||
|
|
c5475b2354 | ||
|
|
dfdee7d6cb | ||
|
|
9f0916586d | ||
|
|
79d73b4a5c | ||
|
|
484eb1ebfd | ||
|
|
45bf65d007 | ||
|
|
a2f37d5548 | ||
|
|
65db8195a5 | ||
|
|
86fe4f6be4 | ||
|
|
3bd69accc1 | ||
|
|
b8be51fe7c | ||
|
|
0b72153645 | ||
|
|
510d5679a9 | ||
|
|
48b7f72829 | ||
|
|
fc7a5e3d0f | ||
|
|
90d339cf75 | ||
|
|
0af3f1f66e | ||
|
|
4ef8493fd2 | ||
|
|
5e41296824 | ||
|
|
3cfdc99ecc | ||
|
|
77c69e35b7 | ||
|
|
ed0cff78a6 |
4
.github/workflows/bazel.yml
vendored
4
.github/workflows/bazel.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/workflows/rust-ci.yml
vendored
4
.github/workflows/rust-ci.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/sdk.yml
vendored
2
.github/workflows/sdk.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -149,6 +149,7 @@ impl ToolHandler for Handler {
|
||||
prompt,
|
||||
model: effective_model,
|
||||
reasoning_effort: effective_reasoning_effort,
|
||||
spawn_mode: AgentSpawnMode::Spawn,
|
||||
status,
|
||||
}
|
||||
.into(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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) => ¬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) {
|
||||
@@ -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,
|
||||
|
||||
@@ -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>),
|
||||
|
||||
@@ -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(¬ification.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() {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user