Files
codex/codex-rs/tui/src/app/agent_status_feed.rs
T
jifandGitHub fae2709320 multi-agent: add path-based v2 activity tracking (#27007)
## Why

Multi-agent v2 identifies agents by canonical paths, but its tool
handlers still emitted the larger legacy collaboration begin/end events
built around nickname and role metadata. App-server, rollout-trace,
analytics, and TUI consumers therefore lacked one compact path-based
completion signal that behaved consistently across live events and
replay.

The TUI also needs a bounded `/agent` status surface for v2 agents. It
should use recent local activity for previews, refresh liveness without
loading full histories, and keep the legacy picker available when no
path-backed v2 agent is known.

## What changed

- Replace the v2 `spawn_agent`, `send_message`, `followup_task`, and
`interrupt_agent` legacy lifecycle emissions with a success-only
`SubAgentActivity` event. The event records the tool call ID, occurrence
time, affected thread, canonical agent path, and `started`,
`interacted`, or `interrupted` kind.
- Expose the activity as a completion-only app-server v2
`subAgentActivity` thread item in live notifications and reconstructed
history, regenerate the protocol schemas, and count it in sub-agent tool
analytics.
- Track canonical paths from live activity and loaded-thread metadata in
the TUI, and render the activity in live and replayed transcripts.
- Make `/agent` list running path-backed agents with summaries from
bounded local event buffers. Each summary is capped at 240 graphemes,
the scan is capped at six recent items, only the last three wrapped
lines are shown, and command output is omitted. Liveness falls back to
metadata-only `thread/read` when local turn state is unavailable.
- Persist the activity as a terminal rollout-trace runtime payload and
reduce it to the corresponding spawn, send, follow-up, or close
interaction edge. `interrupt_agent` is classified as a close-edge
operation.
- Preserve the legacy picker when no path-backed v2 agent is known.

## Compatibility

App-server v2 clients that consumed `collabAgentToolCall` begin/end
pairs for these tools must handle the new completion-only
`subAgentActivity` item. Legacy v1 collaboration behavior is unchanged.

## Screenshot

<img width="684" height="288" alt="Screenshot 2026-06-08 at 15 40 47"
src="https://github.com/user-attachments/assets/194b3cd0-619d-45fb-b587-cf3e2b1b8a1d"
/>

## Testing

- `just test -p codex-app-server-protocol`
- `just test -p codex-rollout-trace`
- Added focused coverage for activity analytics, terminal trace
serialization, spawn-edge reduction, `interrupt_agent` classification,
TUI status rendering without aggregated command output, and clearing
stale running state after a completed turn.
2026-06-09 12:14:48 +02:00

210 lines
7.5 KiB
Rust

//! Bounded, best-effort previews for the v2 `/agent` status output.
use super::ThreadBufferedEvent;
use super::ThreadEventStore;
use crate::history_cell::HistoryCell;
use crate::history_cell::plain_lines;
use crate::text_formatting::truncate_text;
use codex_app_server_protocol::CollabAgentTool;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::SubAgentActivityKind;
use codex_app_server_protocol::ThreadItem;
use ratatui::style::Stylize;
use ratatui::text::Line;
use std::collections::HashSet;
const AGENT_STATUS_PREVIEW_LINES: usize = 3;
const AGENT_STATUS_PREVIEW_ITEMS: usize = 6;
const AGENT_STATUS_PREVIEW_GRAPHEMES: usize = 240;
const AGENT_STATUS_PREVIEW_INDENT: u16 = 4;
#[derive(Debug)]
pub(super) struct AgentStatusHistoryCell {
entries: Vec<AgentStatusThreadPreview>,
}
impl AgentStatusHistoryCell {
pub(super) fn new(entries: Vec<AgentStatusThreadPreview>) -> Self {
Self { entries }
}
}
impl HistoryCell for AgentStatusHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![
"/agent".magenta().into(),
"Sub-agents running".bold().into(),
"".into(),
];
if self.entries.is_empty() {
lines.push(" • No sub-agents running.".italic().into());
return lines;
}
for entry in &self.entries {
lines.push(entry.title_line());
let preview_width = width.saturating_sub(AGENT_STATUS_PREVIEW_INDENT).max(1);
let preview_lines = entry.preview_lines(preview_width);
if preview_lines.is_empty() {
lines.push(vec![" ".into(), "No recent activity yet.".dim().italic()].into());
} else {
lines.extend(preview_lines.into_iter().map(indent_preview_line));
}
lines.push("".into());
}
let _ = lines.pop();
lines
}
fn raw_lines(&self) -> Vec<Line<'static>> {
plain_lines(self.display_lines(u16::MAX))
}
}
#[derive(Debug)]
pub(super) struct AgentStatusThreadPreview {
agent_path: String,
activity: Vec<String>,
}
impl AgentStatusThreadPreview {
pub(super) fn from_store(agent_path: String, store: &ThreadEventStore) -> Self {
Self::from_events(agent_path, store.buffer.iter().rev())
}
pub(super) fn empty(agent_path: String) -> Self {
Self::from_events(agent_path, std::iter::empty())
}
fn from_events<'a>(
agent_path: String,
events: impl Iterator<Item = &'a ThreadBufferedEvent>,
) -> Self {
let mut seen_item_ids = HashSet::new();
let mut activity = Vec::new();
for event in events {
let item = match event {
ThreadBufferedEvent::Notification(ServerNotification::ItemCompleted(event)) => {
&event.item
}
ThreadBufferedEvent::Notification(ServerNotification::ItemStarted(event)) => {
&event.item
}
ThreadBufferedEvent::Notification(_)
| ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::HistoryEntryResponse(_)
| ThreadBufferedEvent::FeedbackSubmission(_) => continue,
};
if !seen_item_ids.insert(item.id().to_string()) {
continue;
}
if let Some(summary) = activity_summary(item) {
activity.push(summary);
if activity.len() == AGENT_STATUS_PREVIEW_ITEMS {
break;
}
}
}
activity.reverse();
Self {
agent_path,
activity,
}
}
fn title_line(&self) -> Line<'static> {
vec![" • ".dim(), format!("`{}`", self.agent_path).cyan()].into()
}
fn preview_lines(&self, width: u16) -> Vec<Line<'static>> {
let mut lines = self
.activity
.iter()
.flat_map(|activity| textwrap::wrap(activity, width as usize))
.filter(|line| !line.trim().is_empty())
.map(|line| line.into_owned().dim().into())
.collect::<Vec<_>>();
if lines.len() > AGENT_STATUS_PREVIEW_LINES {
lines.drain(..lines.len() - AGENT_STATUS_PREVIEW_LINES);
}
lines
}
}
fn activity_summary(item: &ThreadItem) -> Option<String> {
let summary = match item {
ThreadItem::AgentMessage { text, .. } | ThreadItem::Plan { text, .. } => text,
ThreadItem::Reasoning { summary, .. } => summary.last()?,
ThreadItem::CommandExecution { command, .. } => {
let command = truncate_text(
command,
AGENT_STATUS_PREVIEW_GRAPHEMES.saturating_sub("$ ".len()),
);
return bounded_summary(&format!("$ {command}"));
}
ThreadItem::FileChange { changes, .. } => {
return bounded_summary(&format!("Updated {} file(s)", changes.len()));
}
ThreadItem::McpToolCall { server, tool, .. } => {
return bounded_summary(&format!("MCP {server}/{tool}"));
}
ThreadItem::DynamicToolCall {
namespace, tool, ..
} => {
let tool = namespace
.as_ref()
.map(|namespace| format!("{namespace}/{tool}"))
.unwrap_or_else(|| tool.clone());
return bounded_summary(&format!("Tool {tool}"));
}
ThreadItem::CollabAgentToolCall { tool, .. } => {
let action = match tool {
CollabAgentTool::SpawnAgent => "Spawned an agent",
CollabAgentTool::SendInput => "Sent input to an agent",
CollabAgentTool::ResumeAgent => "Resumed an agent",
CollabAgentTool::Wait => "Waited for an agent",
CollabAgentTool::CloseAgent => "Closed an agent",
};
return Some(action.to_string());
}
ThreadItem::SubAgentActivity {
kind, agent_path, ..
} => {
let action = match kind {
SubAgentActivityKind::Started => "Started",
SubAgentActivityKind::Interacted => "Contacted",
SubAgentActivityKind::Interrupted => "Interrupted",
};
return bounded_summary(&format!("{action} {agent_path}"));
}
ThreadItem::WebSearch { query, .. } => {
return bounded_summary(&format!("Web search: {query}"));
}
ThreadItem::ImageView { path, .. } => {
return bounded_summary(&format!("Viewed {}", path.display()));
}
ThreadItem::ImageGeneration { .. } => return Some("Generated an image".to_string()),
ThreadItem::EnteredReviewMode { .. } => return Some("Entered review mode".to_string()),
ThreadItem::ExitedReviewMode { .. } => return Some("Exited review mode".to_string()),
ThreadItem::ContextCompaction { .. } => return Some("Compacted context".to_string()),
ThreadItem::UserMessage { .. } | ThreadItem::HookPrompt { .. } => return None,
};
bounded_summary(summary)
}
fn bounded_summary(summary: &str) -> Option<String> {
let summary = truncate_text(summary, AGENT_STATUS_PREVIEW_GRAPHEMES);
let summary = summary.split_whitespace().collect::<Vec<_>>().join(" ");
(!summary.is_empty()).then_some(summary)
}
fn indent_preview_line(mut line: Line<'static>) -> Line<'static> {
line.spans.insert(0, " ".into());
line
}
#[cfg(test)]
#[path = "agent_status_feed_tests.rs"]
mod tests;