Files
codex/codex-rs/tui/src/app/agents_overview_tests.rs
T
Eric Traut 025f6d715d Show unread results in the TUI command center (#58263)
## What changed

- Display blue filled dots for unread results and gray hollow dots for read idle tasks, preserving status text on narrow rows.
- Load unread state with thread listings and update markers from read-state notifications and refreshes.
- Acknowledge the observed receipt revision when a conversation is rendered in a focused, unobstructed view, including read-only snapshots, without clearing newer results.

## Testing

Extend rendering snapshots and tests for stale unread markers and read-only snapshot receipts.

GitOrigin-RevId: 2dc676c3ffe1e887a5bbf131c88891057386ffc8
2026-10-30 06:43:51 +00:00

3148 lines
120 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#[path = "agents_overview_discovery_tests.rs"]
mod discovery;
use crate::app::app_server_requests::BufferedServerRequest;
#[path = "agent_center_tests.rs"]
mod command_center;
use super::super::agents_overview_view::AgentsOverviewGroup;
use super::super::agents_overview_view::AgentsOverviewGrouping;
use super::*;
use crate::app::tests::make_test_app_with_channels;
use crate::chatwidget::tests::helpers::normalize_agent_center_snapshot;
use codex_config::types::AgentsOverviewGrouping as PersistedAgentsOverviewGrouping;
#[tokio::test]
async fn overview_worktree_creation_busy_state() {
let mut app = make_test_app().await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
app.agents_overview
.view_state
.lock()
.unwrap()
.creating_worktree = true;
{
let mut state = app.agents_overview.view_state.lock().unwrap();
state.server_version_notice = Some("Older server".into());
}
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
for key in ['n', 'w', 'o', 'r', 'x', 'h', 'a'] {
view.handle_key_event(KeyCode::Char(key).into());
}
assert!(rx.try_recv().is_err());
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(normalize_agent_center_snapshot(render_bottom_popup(
&app.chat_widget,
/*width*/ 80,
)));
}
#[tokio::test]
async fn overview_thread_colors_match_footer_and_respect_color_suppression() {
let mut app = make_test_app().await;
let id = ThreadId::from_u128(/*value*/ 42);
let selected_id = ThreadId::from_u128(/*value*/ 43);
app.agents_overview.unread.extend([id, selected_id]);
let mut snapshot = Vec::new();
for enabled in [true, false] {
app.local_settings.tui.status_line_use_colors = enabled;
let mut thread = overview_thread(
id,
/*parent_thread_id*/ None,
"Original prompt",
ThreadStatus::Idle,
);
thread.name = Some("Named task".into());
let mut selected = thread.clone();
selected.id = selected_id.to_string();
selected.name = Some("Selected task".into());
let view = app.agents_overview_view(vec![thread, selected], Some(selected_id));
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
terminal
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
.unwrap();
let buffer = terminal.backend().buffer();
for row in buffer.content.chunks(100) {
let text: String = row.iter().map(ratatui::buffer::Cell::symbol).collect();
if let Some(x) = text.find("● Selected task") {
let x = text[..x].chars().count();
snapshot.push(format!(
"selected dot {:?} | title {:?}",
row[x].style(),
row[x + 2].style(),
));
}
if let Some(x) = text.find("Named task") {
let x = text[..x].chars().count();
snapshot.push(format!(
"{} | unread: {:?} | title style: {:?}",
normalize_agent_center_snapshot(&text),
row[x - 2].fg,
row[x].style()
));
}
}
}
insta::assert_snapshot!(snapshot.join("\n"));
}
#[tokio::test]
async fn older_server_notice_is_visible_in_agents_overview() {
let mut app = make_test_app().await;
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().last().unwrap());
}
#[tokio::test]
async fn server_version_overview_notice_updates_and_clears() {
let mut app = make_test_app().await;
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
app.update_server_version_overview_notice("0.153.0", Some("0.151.0"));
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
insta::assert_snapshot!(rendered.lines().last().unwrap(), @" Service v0.151.0 < Codex CLI v0.153.0");
app.update_server_version_overview_notice("0.153.0", /*server_version*/ None);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 80);
assert!(!rendered.contains("Service v"));
}
#[tokio::test]
async fn older_server_notice_truncates_in_narrow_overview() {
let mut app = make_test_app().await;
app.update_server_version_overview_notice("0.153.0", Some("0.152.1"));
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(
"older_server_narrow_overview",
render_bottom_popup(&app.chat_widget, /*width*/ 12)
);
}
use crate::app::test_support::make_test_app;
use crate::app_event::AgentsOverviewThreadRefresh;
use crate::bottom_pane::BottomPaneView;
use crate::chatwidget::tests::helpers::render_bottom_popup;
use crate::render::renderable::Renderable;
use crate::test_support::PathBufExt;
use crate::test_support::test_path_buf;
use crate::test_support::test_path_display;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::CurrentTimeReadParams;
use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::SessionSource;
use codex_app_server_protocol::ThreadActiveFlag;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadDeletedNotification;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadSource;
use codex_app_server_protocol::ThreadStartedNotification;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
use codex_app_server_protocol::ToolRequestUserInputParams;
use codex_app_server_protocol::ToolRequestUserInputQuestion;
use codex_config::types::KeybindingSpec;
use codex_config::types::KeybindingsSpec;
use codex_config::types::TuiKeymap;
use codex_protocol::models::PermissionProfile;
use codex_protocol::protocol::SubAgentSource;
use pretty_assertions::assert_eq;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
static OVERVIEW_TIMESTAMP: std::sync::LazyLock<i64> =
std::sync::LazyLock::new(|| chrono::Utc::now().timestamp() - 120);
#[tokio::test]
async fn overview_right_opens_current_or_highlighted_task() {
let mut app = make_test_app().await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let current = ThreadId::new();
let other = ThreadId::new();
app.primary_thread_id = Some(current);
let threads = vec![
overview_thread(
current,
/*parent_thread_id*/ None,
"Current",
ThreadStatus::Idle,
),
overview_thread(
other,
/*parent_thread_id*/ None,
"Other",
ThreadStatus::Idle,
),
];
let view = app.agents_overview_view(threads.clone(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.chat_widget.handle_key_event(KeyCode::Right.into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == current)
);
assert!(!app.chat_widget.no_modal_or_popup_active());
let mut view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Down.into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.chat_widget.handle_key_event(KeyCode::Right.into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == other)
);
assert!(!app.chat_widget.no_modal_or_popup_active());
}
#[tokio::test]
async fn overview_right_preserves_editors_and_offline_state() {
let mut app = make_test_app().await;
app.config.disable_paste_burst = true;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let thread_id = ThreadId::new();
let mut view = app.agents_overview_view(
vec![overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
Some(thread_id),
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
view.handle_key_event(KeyCode::Right.into());
assert!(
app.agents_overview
.view_state
.lock()
.unwrap()
.rename_target
.is_some()
);
view.handle_key_event(KeyCode::Esc.into());
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice = Some("Offline");
view.handle_key_event(KeyCode::Right.into());
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
view.on_ctrl_c();
view.handle_key_event(KeyCode::Right.into());
assert!(!view.is_complete());
assert!(rx.try_recv().is_err());
}
pub(in crate::app) fn overview_thread(
thread_id: ThreadId,
parent_thread_id: Option<ThreadId>,
name: &str,
status: ThreadStatus,
) -> Thread {
Thread {
originator: None,
environments: None,
id: thread_id.to_string(),
extra: None,
project_id: None,
daybreak_enabled: None,
session_id: parent_thread_id.unwrap_or(thread_id).to_string(),
forked_from_id: None,
parent_thread_id: None,
preview: name.to_string(),
ephemeral: false,
section: None,
section_entered_at: None,
history_mode: Default::default(),
model_provider: "openai".to_string(),
model: None,
reasoning_effort: None,
created_at: *OVERVIEW_TIMESTAMP,
updated_at: *OVERVIEW_TIMESTAMP,
recency_at: Some(*OVERVIEW_TIMESTAMP),
status,
path: None,
cwd: test_path_buf("/tmp/project").abs(),
cli_version: "0.0.0".to_string(),
source: parent_thread_id.map_or(SessionSource::Cli, |parent_thread_id| {
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id,
depth: 1,
agent_path: None,
agent_nickname: None,
agent_role: None,
})
}),
can_accept_direct_input: Some(parent_thread_id.is_none()),
thread_source: None,
agent_nickname: None,
agent_role: None,
git_info: None,
name: Some(name.to_string()),
turns: Vec::new(),
}
}
#[tokio::test]
async fn shared_overview_keeps_rows_and_replays_changes_over_stale_reads() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let [retained, archived, deleted, created] = std::array::from_fn(|_| ThreadId::new());
let [
mut retained_thread,
archived_thread,
deleted_thread,
created_thread,
] = [
(retained, "Old name"),
(archived, "Archived"),
(deleted, "Deleted"),
(created, "Created in another client"),
]
.map(|(id, name)| {
overview_thread(id, /*parent_thread_id*/ None, name, ThreadStatus::Idle)
});
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
// The seed is still in flight and the command center has never been opened.
for notification in [
ServerNotification::ThreadNameUpdated(ThreadNameUpdatedNotification {
thread_id: retained.to_string(),
thread_name: Some("New name".to_string()),
}),
ServerNotification::ThreadClosed(ThreadClosedNotification {
thread_id: retained.to_string(),
}),
ServerNotification::ThreadArchived(ThreadArchivedNotification {
thread_id: archived.to_string(),
}),
ServerNotification::ThreadDeleted(ThreadDeletedNotification {
thread_id: deleted.to_string(),
}),
] {
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(notification)),
)
.await;
}
// Tool responses can arrive without thread/started or after newer updates/removals.
for thread in [
retained_thread.clone(),
created_thread.clone(),
archived_thread.clone(),
deleted_thread.clone(),
] {
let (registered, registration) = tokio::sync::oneshot::channel();
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::DynamicToolThreadStarted {
thread,
task_tools_available: false,
registered,
},
))
.await?;
registration.await?;
}
assert_eq!(
app.agents_overview.threads,
HashMap::from([
(retained, Some(retained_thread.clone())),
(created, Some(created_thread.clone())),
])
);
// A successful read supersedes registration; queued notifications supersede the read.
retained_thread.preview = "Fresh preview from the read".to_string();
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
last_messages: HashMap::new(),
threads: HashMap::from([
(retained, Some((retained_thread.clone(), false))),
(archived, Some((archived_thread, false))),
(deleted, Some((deleted_thread, false))),
]),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
retained_thread.name = Some("New name".to_string());
retained_thread.status = ThreadStatus::NotLoaded;
let expected = HashMap::from([
(retained, Some(retained_thread)),
(created, Some(created_thread)),
]);
assert_eq!(app.agents_overview.threads, expected);
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
last_messages: HashMap::new(),
threads: HashMap::from([(retained, None)]),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(app.agents_overview.threads, expected);
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Err("temporarily unavailable".to_string()),
);
assert_eq!(app.agents_overview.threads, expected);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let stale_messages = HashMap::from([(retained, "Removed answer".into())]);
for event in [
AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadReverted(
codex_app_server_protocol::ThreadRevertedNotification {
thread_id: retained.to_string(),
},
))),
AppServerEvent::Lagged { skipped: 1 },
] {
let old_request = Uuid::new_v4();
app.agents_overview.request_id = Some(old_request);
app.agents_overview.activity.entry(retained).or_default();
app.agents_overview.last_messages = stale_messages.clone();
app.handle_app_server_event(&app_server, event).await;
assert!(!app.agents_overview.activity.contains_key(&retained));
assert!(app.agents_overview.last_messages.is_empty());
// Fresh activity must survive an older read arriving after invalidation.
app.track_agents_overview_notification(&reasoning_delta(
retained,
"new-reasoning",
"**Checking the revised task**",
));
let current_request = app.agents_overview.request_id.unwrap();
for (request_id, last_messages) in [
(old_request, stale_messages.clone()),
(current_request, HashMap::new()),
] {
app.apply_agents_overview_thread_refresh(
&app_server,
request_id,
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
threads: HashMap::new(),
last_messages,
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert!(app.agents_overview.last_messages.is_empty());
}
assert!(app.agents_overview.activity.contains_key(&retained));
assert!(app.agents_overview.request_id.is_none());
}
app_server.shutdown().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Result<()> {
let mut app = make_test_app().await;
trust_fixture_folders(&mut app);
let mut ids = Vec::new();
for day in 1..=12 {
let source = match day {
3 => codex_protocol::protocol::SessionSource::Custom("atlas".to_string()),
4 => codex_protocol::protocol::SessionSource::Custom("chatgpt".to_string()),
5 => codex_protocol::protocol::SessionSource::Exec,
6 => codex_protocol::protocol::SessionSource::Mcp,
_ => codex_protocol::protocol::SessionSource::Cli,
};
ids.push(ThreadId::from_string(
&app_test_support::create_fake_rollout_with_source(
&app.config.codex_home,
&format!("2025-01-{day:02}T12-00-00"),
&format!("2025-01-{day:02}T12:00:00Z"),
&format!("Task {day}"),
Some(if day == 10 {
"other-provider"
} else {
&app.config.model_provider_id
}),
/*git_info*/ None,
source,
)
.expect("materialize historical session"),
)?);
}
let message_path = app_test_support::rollout_path(
&app.config.codex_home,
"2025-01-11T12-00-00",
&ids[10].to_string(),
);
let mut history = std::fs::read_to_string(&message_path)?;
history.push_str(
&serde_json::json!({
"timestamp": "2025-01-11T12:00:01Z",
"type": "event_msg",
"payload": { "type": "agent_message", "message": "Found the regression in the parser." }
})
.to_string(),
);
history.push('\n');
std::fs::write(message_path, history)?;
let config = app.config.clone();
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
for thread_id in [ids[0], ids[11]] {
app_server
.resume_thread(
&app.local_settings,
config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
}
// A newer rollout missing from the index must not trigger a startup filesystem scan.
app_test_support::create_fake_rollout_with_source(
&app.config.codex_home,
"2025-01-13T12-00-00",
"2025-01-13T12:00:00Z",
"Unindexed task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
codex_protocol::protocol::SessionSource::Cli,
)
.expect("materialize unindexed session");
app.app_server_target = AppServerTarget::LocalDaemon {
allow_embedded_fallback: true,
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: test_path_buf("/tmp/unused.sock").abs(),
},
};
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
app.refresh_agents_overview_threads(&app_server);
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
let recent_ids: HashSet<_> = ids[2..].iter().copied().collect();
let mut expected = recent_ids.clone();
expected.insert(ids[0]);
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
assert_eq!(
app.agents_overview.last_messages,
HashMap::from([(ids[10], "Found the regression in the parser.".to_string())])
);
let thread = app.agents_overview.threads[&ids[10]].as_ref().unwrap();
assert_eq!(thread.status, ThreadStatus::NotLoaded);
let created = app_server.start_thread(&config).await?.session.thread_id;
// Closing the view must not cancel a metadata refresh or forget unloaded entries.
app.primary_thread_id = Some(ids[0]);
app.open_agents_overview(&app_server);
let visible: HashSet<_> = app
.agents_overview
.visible_thread_ids
.iter()
.copied()
.collect();
assert_eq!(visible, expected);
app.agents_overview.view_state.lock().unwrap().completion =
Some(crate::bottom_pane::ViewCompletion::Accepted);
app.chat_widget.pre_draw_tick();
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
// A creation notification adds a session without attaching a local view.
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let event = app_server.next_event().await.expect("server notification");
let is_created = matches!(&event.event, AppServerEvent::ServerNotification(notification)
if matches!(notification.as_ref(), ServerNotification::ThreadStarted(started) if started.thread.id == created.to_string()));
app.handle_backend_event(&app_server, event).await;
if is_created { break; }
}
}).await.expect("creation notification received");
expected.insert(created);
let retained: HashSet<_> = app.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, expected);
// Opening older history is a local addition, even without starting a turn.
let resumed = app_server
.resume_thread(
&app.local_settings,
config.clone(),
ids[1],
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
app.enqueue_primary_thread_session(resumed.session, resumed.turns)
.await?;
app.open_agents_overview(&app_server);
finish_overview_refresh(&mut app, &app_server, &mut event_rx).await;
expected.insert(ids[1]);
let visible: HashSet<_> = app
.agents_overview
.visible_thread_ids
.iter()
.copied()
.collect();
assert_eq!(visible, expected);
// An unloaded row can be selected through the same flow as a loaded row.
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&test_path_buf("/"),
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, ids[2])).await?;
assert_eq!(app.primary_thread_id, Some(ids[2]));
app_server.shutdown().await?;
// A fresh TUI/server has no in-memory additions; read-only resumes did not promote history.
let mut restarted = make_test_app().await;
restarted.app_server_target = app.app_server_target.clone();
let app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
restarted.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
restarted.refresh_agents_overview_threads(&app_server);
finish_overview_refresh(&mut restarted, &app_server, &mut event_rx).await;
let retained: HashSet<_> = restarted.agents_overview.threads.keys().copied().collect();
assert_eq!(retained, recent_ids);
restarted.open_agents_overview(&app_server);
let rendered = render_bottom_popup(&restarted.chat_widget, /*width*/ 80)
.replace(&test_path_display("/"), "/");
let age = regex_lite::Regex::new(r" +[0-9]+d ago").unwrap();
insta::assert_snapshot!(
"agents_overview_recent_sessions",
age.replace_all(&rendered, " [age]")
);
assert!(restarted.agents_overview.discovery.has_more());
// Opening starts a metadata refresh; Show more queues behind it.
restarted.show_more_agents_overview(&app_server);
finish_overview_refresh(&mut restarted, &app_server, &mut event_rx).await;
finish_overview_refresh(&mut restarted, &app_server, &mut event_rx).await;
assert_eq!(
restarted
.agents_overview
.threads
.keys()
.copied()
.collect::<HashSet<_>>(),
ids.into_iter().collect()
);
assert!(!restarted.agents_overview.discovery.has_more());
app_server.shutdown().await?;
Ok(())
}
async fn finish_overview_refresh(
app: &mut App,
app_server: &AppServerSession,
events: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
) {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
if let AppEvent::AgentsOverviewThreadsLoaded { request_id, result } =
events.recv().await.expect("overview event")
{
// Aborting a refresh task cannot retract an already queued response.
if app.agents_overview.request_id != Some(request_id) {
continue;
}
assert!(result.is_ok(), "{result:?}");
app.apply_agents_overview_thread_refresh(app_server, request_id, result);
return;
}
}
})
.await
.expect("overview refresh completed");
}
#[tokio::test]
async fn hidden_system_thread_does_not_refresh_shared_overview() {
check_hidden_thread_does_not_refresh_shared_overview("system").await;
}
#[tokio::test]
async fn hidden_title_thread_does_not_refresh_shared_overview() {
check_hidden_thread_does_not_refresh_shared_overview("thread_title").await;
}
async fn check_hidden_thread_does_not_refresh_shared_overview(source: &str) {
let mut app = make_test_app().await;
let app_server = crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref())
.await
.expect("embedded app server");
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let request_id = uuid::Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
let parent_thread_id = ThreadId::new();
app.primary_thread_id = Some(parent_thread_id);
app.active_thread_id = Some(parent_thread_id);
app.ensure_thread_channel(parent_thread_id);
app.agents_overview
.dispatched_requests
.insert(parent_thread_id, Vec::new());
let hidden_thread_id = ThreadId::new();
let mut thread = overview_thread(
hidden_thread_id,
Some(parent_thread_id),
"Generate thread title",
ThreadStatus::Idle,
);
thread.ephemeral = true;
thread.thread_source = Some(ThreadSource::Feature(source.to_string()));
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadStarted(
ThreadStartedNotification { thread },
))),
)
.await;
assert_eq!(
(
app.agents_overview.request_id,
app.agents_overview.refresh_pending,
),
(Some(request_id), false)
);
assert!(!app.thread_event_channels.contains_key(&hidden_thread_id));
assert!(
!app.agents_overview
.dispatched_requests
.contains_key(&hidden_thread_id)
);
let visible_thread_id = ThreadId::new();
let mut thread = overview_thread(
visible_thread_id,
Some(parent_thread_id),
"Persisted system thread",
ThreadStatus::Idle,
);
thread.thread_source = Some(ThreadSource::Feature(source.to_string()));
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(ServerNotification::ThreadStarted(
ThreadStartedNotification { thread },
))),
)
.await;
assert_eq!(
(
app.agents_overview.request_id,
app.agents_overview.refresh_pending,
),
(Some(request_id), true)
);
assert!(app.thread_event_channels.contains_key(&visible_thread_id));
assert!(
app.agents_overview
.dispatched_requests
.contains_key(&visible_thread_id)
);
app_server.shutdown().await.expect("shutdown app server");
}
#[tokio::test]
async fn agents_overview_details_show_available_attention_without_expanding_rows() -> Result<()> {
let mut app = make_test_app().await;
let app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let [root, failed_agent, child, unloaded] = std::array::from_fn(|_| ThreadId::new());
let waiting = ThreadStatus::Active {
active_flags: vec![
ThreadActiveFlag::WaitingOnApproval,
ThreadActiveFlag::WaitingOnUserInput,
],
};
// The child question takes priority over the generic waiting parent.
let mut threads = [
(root, None, "Repair authentication", waiting.clone()),
(child, Some(failed_agent), "Check dependencies", waiting),
(
unloaded,
None,
"Investigate parser",
ThreadStatus::NotLoaded,
),
(
failed_agent,
Some(root),
"Failed agent",
ThreadStatus::SystemError,
),
]
.map(|(id, parent, title, status)| overview_thread(id, parent, title, status))
.to_vec();
threads[0].name = None;
threads[2].name = None;
threads[2].preview = "Investigate parser\nInclude edge cases\n".repeat(8);
threads[2].model = Some("provider/".repeat(12));
threads[2].reasoning_effort = Some(codex_protocol::openai_models::ReasoningEffort::Custom(
"deliberate".repeat(12),
));
app.agents_overview.threads = threads
.iter()
.cloned()
.map(|thread| (ThreadId::from_string(&thread.id).unwrap(), Some(thread)))
.collect();
app.agents_overview.last_messages.insert(
unloaded,
super::super::agents_overview_details::preview_text(
&"Found the regression\nin the parser. ".repeat(20),
),
);
app.agents_overview.dispatched_requests.insert(
child,
vec![ServerRequest::ToolRequestUserInput {
request_id: RequestId::Integer(42),
params: ToolRequestUserInputParams {
thread_id: child.to_string(),
turn_id: "turn".into(),
item_id: "question".into(),
questions: vec![ToolRequestUserInputQuestion {
id: "version".into(),
header: "Version".into(),
question: "Which dependency version should I use?".into(),
is_other: false,
is_secret: false,
options: None,
}],
is_blocking: true,
auto_resolution_ms: None,
},
}]
.into_iter()
.map(|request| BufferedServerRequest::new(request, &app_server.request_handle()))
.collect(),
);
let view = app.agents_overview_view(threads.clone(), Some(root));
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project = test_path_display("/tmp/project");
let normalized_group = format!(
"/tmp/project 2{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_attention", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 2"), &normalized_group).replace(&project, "/tmp/project"));
});
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerNotification(Box::new(ServerNotification::ServerRequestResolved(
codex_app_server_protocol::ServerRequestResolvedNotification {
thread_id: child.to_string(),
request_id: RequestId::Integer(42),
},
))),
)
.await;
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(!rendered.contains("Which dependency version"));
assert!(rendered.contains("Waiting for approval."));
assert!(app.thread_event_channels.is_empty());
assert!(app.agents_overview.request_id.is_none());
let view = app.agents_overview_view(threads, Some(unloaded));
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_last_message", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 2"), &normalized_group).replace(&project, "/tmp/project"));
});
app.track_agents_overview_notification(&ServerNotification::ThreadReverted(
codex_app_server_protocol::ThreadRevertedNotification {
thread_id: unloaded.to_string(),
},
));
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Last message"));
app_server.shutdown().await?;
Ok(())
}
fn reasoning_delta(thread_id: ThreadId, item_id: &str, delta: &str) -> ServerNotification {
ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification {
thread_id: thread_id.to_string(),
turn_id: "turn".into(),
item_id: item_id.into(),
summary_index: 0,
delta: delta.into(),
})
}
#[tokio::test]
async fn agents_overview_details_render_markdown() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let mut thread = overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Review parser",
ThreadStatus::Idle,
);
thread.preview = "Review **parser** and `token` handling.".into();
app.agents_overview
.threads
.insert(thread_id, Some(thread.clone()));
let message = "## Findings\n\n- Fixed **parsing** and `tokens` with a long explanation that wraps.\n- Kept *compatibility*.\n\n```rust\nlet token = 1;\n```";
app.agents_overview.last_messages.insert(
thread_id,
super::super::agents_overview_details::preview_markdown(message),
);
let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id));
let mut terminal = Terminal::new(TestBackend::new(/*width*/ 96, /*height*/ 40)).unwrap();
terminal
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
.unwrap();
let cached = terminal.backend().to_string();
insta::assert_snapshot!(
"agents_overview_markdown",
normalize_agent_center_snapshot(&cached)
);
app.agents_overview.last_messages.clear();
app.track_agents_overview_activity(
thread_id,
&ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification::new(
thread.id.clone(),
"turn".into(),
ThreadItemPayload::AgentMessage {
id: "answer".into(),
text: message.into(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
),
);
let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id));
terminal
.draw(|frame| view.render(frame.area(), frame.buffer_mut()))
.unwrap();
assert_eq!(terminal.backend().to_string(), cached);
thread.preview = format!("```\n{}\n```", "long prompt ".repeat(25));
app.agents_overview.activity.clear();
app.agents_overview.last_messages.insert(
thread_id,
"```\nlet explanation = \"A long code line should wrap inside the task details panel.\";\n```".into(),
);
let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id));
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(
"agents_overview_markdown_long_lines",
normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widget, /*width*/ 96))
);
app.agents_overview.last_messages.insert(
thread_id,
"```md\n| Check | Result |\n| --- | --- |\n| Parser | Fixed |\n```".into(),
);
let view = app.agents_overview_view(vec![thread], Some(thread_id));
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(
"agents_overview_markdown_table",
normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widget, /*width*/ 96))
);
}
#[tokio::test]
async fn agents_overview_preview_links_survive_wrapping_and_clipping() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let mut thread = overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Review links",
ThreadStatus::Idle,
);
let destination = "https://example.com/a/long/path/to/the/pull/request";
thread.preview = "[Prompt](https://example.com/prompt)".into();
app.agents_overview.last_messages.insert(
thread_id,
format!("Opened [a pull request with a label that wraps across rows]({destination}).\n\nMore context that should be clipped in a short preview.\n\nAdditional details."),
);
let view = app.agents_overview_view(vec![thread], Some(thread_id));
for height in [40, 22] {
let area =
ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, /*width*/ 96, height);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let mut linked_rows = Vec::new();
for y in 0..height {
let linked = (0..area.width)
.map(|x| buffer[(x, y)].symbol())
.filter(|symbol| symbol.starts_with(&format!("\x1b]8;;{destination}\x07")))
.map(crate::terminal_hyperlinks::strip_osc8)
.collect::<String>();
if !linked.is_empty() {
linked_rows.push(linked);
}
}
assert!(linked_rows.len() >= 2, "link must span multiple rows");
assert!(buffer.content.iter().all(|cell| {
crate::terminal_hyperlinks::strip_osc8(cell.symbol()) != "…"
|| !cell.symbol().contains("\x1b]8;;")
}));
if height == 40 {
insta::assert_snapshot!("agents_overview_link_rows", linked_rows.join("\n"));
assert!(buffer.content.iter().any(|cell| {
cell.symbol()
.starts_with("\x1b]8;;https://example.com/prompt\x07")
}));
} else {
assert!(buffer.content.iter().any(|cell| cell.symbol() == "…"));
}
}
}
#[test]
fn agents_overview_markdown_preview_preserves_layout_and_bounds() {
let text = format!("a\r\n\t\u{1b}{}", "界".repeat(600));
assert_eq!(
super::super::agents_overview_details::preview_markdown(&text),
format!("a\n\t{}", "界".repeat(509))
);
}
#[tokio::test]
async fn agents_overview_reasoning_uses_existing_events_and_expires_with_attachment() {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
let mut thread = overview_thread(
thread_id,
/*parent_thread_id*/ None,
"Check startup performance",
ThreadStatus::Active {
active_flags: Vec::new(),
},
);
thread.preview.clear();
app.agents_overview
.threads
.insert(thread_id, Some(thread.clone()));
app.thread_event_channels
.insert(thread_id, ThreadEventChannel::new(/*capacity*/ 8));
let view = app.agents_overview_view(vec![thread.clone()], Some(thread_id));
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
for delta in ["**Checking", " cold-start regressions**\nFurther reasoning"] {
app.track_agents_overview_notification(&reasoning_delta(thread_id, "reasoning", delta));
}
let project = test_path_display("/tmp/project");
let normalized_group = format!(
"/tmp/project 1{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_live_activity", render_bottom_popup(&app.chat_widget, /*width*/ 96).replace(&format!("{project} 1"), &normalized_group).replace(&project, "/tmp/project"));
});
assert!(app.agents_overview.request_id.is_none());
// A working child can stream through a server attachment without a local channel.
let parent = overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Parent",
thread.status.clone(),
);
let parent_id = ThreadId::from_string(&parent.id).unwrap();
app.agents_overview
.threads
.insert(parent_id, Some(parent.clone()));
app.track_agents_overview_notification(&ServerNotification::ItemCompleted(
codex_app_server_protocol::ItemCompletedNotification::new(
parent.id.clone(),
"previous-turn".into(),
ThreadItemPayload::AgentMessage {
id: "answer".into(),
text: "Previous answer".into(),
phase: None,
memory_citation: None,
delivery: None,
questions: None,
},
/*started_at_ms*/ None,
/*completed_at_ms*/ 0,
),
));
let channel = app.thread_event_channels.remove(&thread_id).unwrap();
let details = app.agents_overview_details(
&parent,
&HashMap::from([(parent.id.clone(), vec![&thread])]),
);
assert!(
details
.lines
.iter()
.any(|line| line.to_string().contains("Checking cold-start regressions"))
);
app.thread_event_channels.insert(thread_id, channel);
// A new, oversized header clears the previous one and cannot grow the parsing buffer indefinitely.
for delta in [format!("**{}", "界".repeat(10_000)), "**".into()] {
app.track_agents_overview_notification(&reasoning_delta(thread_id, "oversized", &delta));
}
let details = app.agents_overview_details(&thread, &HashMap::new());
assert_eq!((details.lines, details.last_message), (Vec::new(), None));
app.track_agents_overview_notification(&reasoning_delta(
thread_id,
"next",
"**Running tests**",
));
app.thread_event_channels
.get_mut(&thread_id)
.unwrap()
.mark_replay_only();
let details = app.agents_overview_details(&thread, &HashMap::new());
assert_eq!((details.lines, details.last_message), (Vec::new(), None));
app.track_agents_overview_notification(&ServerNotification::ThreadClosed(
ThreadClosedNotification {
thread_id: thread_id.to_string(),
},
));
assert!(!app.agents_overview.activity.contains_key(&thread_id));
}
#[tokio::test]
async fn worktrees_overview_groups_by_project_or_directory() {
let mut app = make_test_app().await;
let root = tempfile::tempdir().unwrap();
let primary = root.path().join("primary");
let linked = root.path().join("linked");
let primary_subdir = primary.join("shared/child");
let linked_subdir = linked.join("shared/child");
let nested = primary.join("subdir");
let nested_child = nested.join("child");
let admin = primary.join(".git/worktrees/linked");
std::fs::create_dir_all(&admin).unwrap();
std::fs::create_dir_all(&primary_subdir).unwrap();
std::fs::create_dir_all(&linked_subdir).unwrap();
std::fs::create_dir_all(nested.join(".git")).unwrap();
std::fs::create_dir_all(&nested_child).unwrap();
std::fs::write(primary.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(nested.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
std::fs::write(admin.join("commondir"), "../..").unwrap();
std::fs::write(
admin.join("gitdir"),
linked.join(".git").display().to_string(),
)
.unwrap();
std::fs::write(linked.join(".git"), format!("gitdir: {}", admin.display())).unwrap();
let primary = dunce::canonicalize(primary).unwrap();
let primary_subdir = dunce::canonicalize(primary_subdir).unwrap();
let linked = dunce::canonicalize(linked).unwrap();
let linked_subdir = dunce::canonicalize(linked_subdir).unwrap();
let nested_child = dunce::canonicalize(nested_child).unwrap();
let threads = [
("Primary README", primary.clone()),
("Primary tree", primary.clone()),
("Linked README", linked.clone()),
("Linked tree", linked.clone()),
("Primary child", primary_subdir.clone()),
("Linked child", linked_subdir.clone()),
("Nested repository", nested_child.clone()),
]
.into_iter()
.map(|(title, cwd)| {
let mut thread = overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
title,
ThreadStatus::Idle,
);
thread.cwd = AbsolutePathBuf::from_absolute_path(cwd).unwrap();
thread
})
.collect::<Vec<_>>();
let directory_headings = [
(&primary, "/tmp/worktree-root/primary"),
(&linked, "/tmp/worktree-root/linked"),
(&primary_subdir, "/tmp/worktree-root/primary/shared/child"),
(&linked_subdir, "/tmp/worktree-root/linked/shared/child"),
(&nested_child, "/tmp/worktree-root/primary/subdir/child"),
]
.map(|(path, stable)| {
(
crate::text_formatting::center_truncate_path(
&crate::status::format_directory_display(path, /*max_width*/ None),
/*max_width*/ 64,
),
stable,
)
});
let width = 160;
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project_popup = render_bottom_popup(&app.chat_widget, width);
let project_root = primary.join("");
let project_headings = [
(&project_root, "/tmp/worktree-root/primary"),
(&primary_subdir, "/tmp/worktree-root/primary/shared/child"),
(&nested_child, "/tmp/worktree-root/primary/subdir/child"),
]
.map(|(path, stable)| {
(
crate::text_formatting::center_truncate_path(
&crate::status::format_directory_display(path, /*max_width*/ None),
/*max_width*/ 64,
),
stable,
)
});
assert!(
project_popup.contains(&format!("{} 4", project_headings[0].0)),
"{project_popup}"
);
assert!(
project_popup.contains(&format!("{} 2", project_headings[1].0)),
"{project_popup}"
);
let mut project_grouping = project_popup
.lines()
.map(|line| line.split('│').next().unwrap_or(line).trim_end())
.collect::<Vec<_>>()
.join("\n");
for (heading, stable) in project_headings.iter().rev() {
project_grouping = project_grouping.replace(heading, stable);
}
let project_grouping = project_grouping.replace('\\', "/");
app.agents_overview.view_state.lock().unwrap().grouping = AgentsOverviewGrouping::Directory;
let directory_popup = render_bottom_popup(&app.chat_widget, width);
for (heading, _) in &directory_headings[..2] {
assert!(
directory_popup.contains(&format!("{heading} 2")),
"{directory_popup}"
);
}
let mut directory_grouping = directory_popup
.lines()
.map(|line| line.split('│').next().unwrap_or(line).trim_end())
.collect::<Vec<_>>()
.join("\n");
for (heading, stable) in directory_headings.iter().rev() {
directory_grouping = directory_grouping.replace(heading, stable);
}
let directory_grouping = directory_grouping.replace('\\', "/");
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!(
"agents_overview_worktree_grouping",
normalize_agent_center_snapshot(project_grouping)
);
insta::assert_snapshot!(
"agents_overview_directory_grouping",
normalize_agent_center_snapshot(directory_grouping)
);
});
}
#[cfg(unix)]
#[test]
fn overview_directory_group_keys_normalize_aliases() {
use super::super::agents_overview_view::directory_group_key;
let root = tempfile::tempdir().unwrap();
let checkout = root.path().join("checkout");
let alias = root.path().join("alias");
std::fs::create_dir(&checkout).unwrap();
std::os::unix::fs::symlink(&checkout, &alias).unwrap();
assert_eq!(
directory_group_key(&checkout, /*worktrees_enabled*/ true),
directory_group_key(&alias, /*worktrees_enabled*/ true),
);
let worktrees_enabled = false;
assert_eq!(directory_group_key(&alias, worktrees_enabled), alias);
}
#[tokio::test]
async fn overview_model_grouping_shows_details_and_preserves_selection() {
let mut app = make_test_app().await;
let threads = [
("Older task", Some("model-a"), 1),
("Other model", Some("model-b"), 2),
("Recent task", Some("model-a"), 3),
("Legacy task", None, 4),
]
.into_iter()
.map(|(name, model, index)| {
let mut thread = overview_thread(
ThreadId::from_u128(index),
/*parent_thread_id*/ None,
name,
ThreadStatus::Idle,
);
thread.model = model.map(str::to_string);
thread.updated_at = *OVERVIEW_TIMESTAMP + index as i64 - 30;
thread
})
.collect();
let selected = ThreadId::from_u128(/*value*/ 1);
let mut view = app.agents_overview_view(threads, Some(selected));
for _ in 0..3 {
view.handle_key_event(KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE));
}
assert_eq!(
app.agents_overview.view_state.lock().unwrap().grouping,
AgentsOverviewGrouping::Model
);
assert_eq!(
view.rows[view.selected_index().unwrap()].thread_id,
selected
);
// Within a model, newer tasks come first; navigation then crosses model groups.
for (key, expected) in [
(KeyCode::Up, 3),
(KeyCode::Down, 1),
(KeyCode::Down, 2),
(KeyCode::Down, 4),
] {
view.handle_key_event(key.into());
assert_eq!(
view.rows[view.selected_index().unwrap()].thread_id,
ThreadId::from_u128(expected)
);
}
app.chat_widget.show_bottom_pane_view(Box::new(view));
insta::assert_snapshot!(
"agents_overview_model_grouping",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
.replace(&test_path_display("/tmp/project"), "/tmp/project")
.replace("fwd del", "del")
);
}
#[tokio::test]
async fn shared_overview_shows_only_root_sessions() {
let mut app = make_test_app().await;
let first_root = ThreadId::from_string("00000000-0000-0000-0000-000000000101").unwrap();
let unloaded_root = ThreadId::from_string("00000000-0000-0000-0000-000000000102").unwrap();
let [child, second_root, side_thread] = std::array::from_fn(|_| ThreadId::new());
app.primary_thread_id = Some(first_root);
let mut threads = vec![
overview_thread(
first_root,
/*parent_thread_id*/ None,
"Build the dashboard",
ThreadStatus::Idle,
),
overview_thread(
child,
Some(first_root),
"Inspect keyboard shortcuts",
ThreadStatus::Active {
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
},
),
overview_thread(
second_root,
/*parent_thread_id*/ None,
"Repair authentication",
ThreadStatus::Active {
active_flags: Vec::new(),
},
),
overview_thread(
unloaded_root,
/*parent_thread_id*/ None,
"Review yesterday's changes",
ThreadStatus::NotLoaded,
),
];
let mut side = overview_thread(
side_thread,
/*parent_thread_id*/ None,
"Ephemeral side",
ThreadStatus::Idle,
);
side.ephemeral = true;
threads.push(side);
let default_model = app
.model_catalog
.models
.first()
.expect("test model catalog");
threads[0].model = Some(default_model.model.clone());
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
let mut action_view = AgentsOverviewView::new(
view.rows.clone(),
Some(first_root),
/*worktrees_enabled*/ false,
/*use_theme_colors*/ true,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
Arc::clone(&app.agents_overview.view_state),
);
let state = &app.agents_overview.view_state;
assert_eq!(
state.lock().unwrap().grouping,
AgentsOverviewGrouping::Project
);
action_view.handle_key_event(KeyCode::Char('/').into());
assert!(action_view.handle_paste("Repair authentication".into()));
action_view.handle_key_event(KeyCode::Enter.into());
assert!(
matches!(event_rx.try_recv(), Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second_root)
);
crate::chatwidget::tests::helpers::set_active_cell(
&mut app.chat_widget,
Box::new(crate::history_cell::PlainHistoryCell::new(vec![
Line::from("Previous session status"),
])),
);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let project = test_path_display("/tmp/project");
let normalized_project_group = format!(
"/tmp/project 3{}",
" ".repeat(project.len().saturating_sub("/tmp/project".len()))
);
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!(
"agents_overview",
render_bottom_popup(&app.chat_widget, /*width*/ 96)
.replace(&format!("{project} 3"), &normalized_project_group)
.replace(&project, "/tmp/project")
);
});
let threads = (0..20)
.map(|index| {
let thread_id = if index == 0 {
first_root
} else {
ThreadId::new()
};
let status = if index == 0 {
ThreadStatus::Active {
active_flags: vec![ThreadActiveFlag::WaitingOnApproval],
}
} else {
ThreadStatus::Idle
};
let mut candidate = overview_thread(
thread_id,
/*parent_thread_id*/ None,
&format!("Task {index}"),
status,
);
if index == 0 {
candidate.name = None;
candidate.preview = "Inspect unnamed task".to_string();
}
candidate.updated_at = index;
candidate.cwd = if index == 0 {
test_path_buf("/tmp/project-selected").abs()
} else {
test_path_buf(&format!("/tmp/project-{}", index % 3)).abs()
};
candidate
})
.collect();
app.agents_overview.view_state.lock().unwrap().completion =
Some(crate::bottom_pane::ViewCompletion::Accepted);
app.chat_widget.pre_draw_tick();
let view = app.agents_overview_view(threads, /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(
rendered
.lines()
.any(|line| line.contains("› ● Inspect unnamed task"))
);
app.transcript_cells.push(std::sync::Arc::new(
crate::history_cell::PlainHistoryCell::new(vec![ratatui::text::Line::from(
"Previous conversation",
)]),
));
let mut tui = crate::tui::test_support::make_test_tui().expect("test terminal");
let screen_size = tui.terminal.last_known_screen_size;
app.render_chat_widget_frame(&mut tui, screen_size)
.expect("render full-screen dashboard");
assert_eq!(tui.terminal.viewport_area.height, screen_size.height);
app.agents_overview.view_state.lock().unwrap().completion =
Some(crate::bottom_pane::ViewCompletion::Accepted);
app.chat_widget.pre_draw_tick();
app.render_chat_widget_frame(&mut tui, screen_size)
.expect("restore conversation after closing dashboard");
assert!(tui.terminal.viewport_area.height < screen_size.height);
assert!(app.last_rendered_history_tail.is_some());
}
#[tokio::test]
async fn filtered_dashboard_actions_use_configured_shortcuts() {
let mut app = make_test_app().await;
app.chat_widget.toggle_vim_mode_and_notify();
let mut keymap = TuiKeymap::default();
keymap.agents.search = Some(KeybindingsSpec::One(KeybindingSpec("f6".to_string())));
keymap.agents.stop = Some(KeybindingsSpec::One(KeybindingSpec("f10".to_string())));
keymap.agents.resume = Some(KeybindingsSpec::One(KeybindingSpec("f8".to_string())));
keymap.list.cancel = Some(KeybindingsSpec::One(KeybindingSpec("f7".into())));
keymap.list.move_down = Some(KeybindingsSpec::One(KeybindingSpec("v".into())));
app.keymap = crate::keymap::RuntimeKeymap::from_config(&keymap).expect("runtime keymap");
let first = ThreadId::new();
let second = ThreadId::new();
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
let mut view = AgentsOverviewView::new(
app.agents_overview_view(
vec![
overview_thread(
first,
/*parent_thread_id*/ None,
"First task",
ThreadStatus::Idle,
),
overview_thread(
second,
/*parent_thread_id*/ None,
"Second task",
ThreadStatus::Active {
active_flags: Vec::new(),
},
),
],
Some(first),
)
.rows,
Some(first),
/*worktrees_enabled*/ false,
/*use_theme_colors*/ true,
crate::app_event_sender::AppEventSender::new(event_tx),
app.keymap.clone(),
Arc::clone(&app.agents_overview.view_state),
);
view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
assert!(event_rx.try_recv().is_err());
for (key, expected) in [('v', second), ('k', first), ('j', first)] {
view.handle_key_event(KeyCode::Char(key).into());
let selected = &view.rows[view.selected_index().unwrap()];
assert_eq!(selected.thread_id, expected);
}
view.handle_key_event(KeyEvent::new(KeyCode::F(6), KeyModifiers::NONE));
assert!(view.handle_paste("Second task".to_string()));
view.handle_key_event(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
assert!(!view.is_complete());
view.handle_key_event(KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::StopAgentsOverviewThread { thread_id }) if thread_id == second
));
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::SelectAgentsOverviewThread { thread_id }) if thread_id == second
));
assert!(event_rx.try_recv().is_err());
for offline in [false, true] {
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice = offline.then_some("Offline");
view.handle_key_event(KeyCode::F(6).into());
view.handle_paste("no matching task".into());
assert_eq!(view.selected_index(), Some(usize::MAX));
view.handle_key_event(KeyCode::F(7).into());
assert_eq!(view.selected_index(), Some(0));
}
}
#[tokio::test]
async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Result<()> {
let mut app = make_test_app().await;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
app.primary_thread_id = Some(ThreadId::new());
app.ensure_thread_channel(ThreadId::new())
.store
.lock()
.await
.active_turn_id = Some("running-turn".to_string());
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, ThreadId::new())
.await?;
assert!(app.agents_overview.dispatched_requests.is_empty());
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn root_switch_preserves_vim_line_yank() -> Result<()> {
// Keep the large setup and root-switch futures off the test thread's stack.
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.local_settings.user_config_path.as_path(),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await?;
let previous = Box::pin(app_server.start_thread(&app.config)).await?;
app.enqueue_primary_thread_session(previous.session, previous.turns)
.await?;
let target_thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Target task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize target rollout"),
)?;
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("saved line");
for code in [KeyCode::Esc, KeyCode::Char('d'), KeyCode::Char('d')] {
app.chat_widget
.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
}
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id))
.await?;
assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id));
app.chat_widget.toggle_vim_mode_and_notify();
app.chat_widget.insert_str("new line");
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"new line\nsaved line"
);
let composer_lines = render_bottom_popup(&app.chat_widget, /*width*/ 80)
.lines()
.take(2)
.collect::<Vec<_>>()
.join("\n");
insta::assert_snapshot!(composer_lines, @"› new line\n saved line");
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn root_switch_loads_local_preferences_from_disk() -> Result<()> {
// Keep the large setup and root-switch futures off the test thread's stack.
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await?;
let previous = Box::pin(app_server.start_thread(&app.config)).await?;
app.enqueue_primary_thread_session(previous.session, previous.turns)
.await?;
let target_thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Target task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize target rollout"),
)?;
std::fs::write(
app.local_settings.user_config_path.as_path(),
"[tui]\ntheme = \"dracula\"\nresume_cwd = \"session\"\n[history]\npersistence = \"none\"\n",
)?;
let config = Box::pin(app.rebuild_config_for_cwd(app.config.cwd.to_path_buf())).await?;
let expected = crate::local_settings::LocalSettings::from(&config);
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id))
.await?;
assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id));
assert_eq!(app.local_settings, expected);
assert_eq!(app.chat_widget.local_settings, expected);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> {
let mut app = make_test_app().await;
trust_fixture_folders(&mut app);
let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(
app.chat_widget.config_ref(),
))
.await?;
let previous = app_server.start_thread(&app.config).await?;
let previous_root_id = previous.session.thread_id;
app.enqueue_primary_thread_session(previous.session, previous.turns)
.await?;
let target_thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Target task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("materialize target rollout"),
)?;
let target = app_server
.resume_thread(
&app.local_settings,
app.config.clone(),
target_thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
let child_id = ThreadId::new();
let idle_child_id = ThreadId::new();
app.ensure_thread_channel(idle_child_id);
app.upsert_agent_picker_thread(
child_id, /*agent_nickname*/ None, /*agent_role*/ None, /*is_closed*/ false,
);
app.agent_navigation.mark_running(child_id);
app.active_thread_id = Some(child_id);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, target.session.thread_id)
.await?;
assert!(
app.agents_overview
.dispatched_requests
.contains_key(&idle_child_id)
);
app.handle_app_server_event(
&app_server,
AppServerEvent::ServerRequest(Box::new(ServerRequest::CurrentTimeRead {
request_id: RequestId::Integer(99),
params: CurrentTimeReadParams {
thread_id: idle_child_id.to_string(),
},
})),
)
.await;
assert!(app.agents_overview.dispatched_requests[&idle_child_id].is_empty());
let response: ThreadUnsubscribeResponse = app_server
.request_handle()
.request_typed(ClientRequest::ThreadUnsubscribe {
request_id: RequestId::String("verify-root-subscription".to_string()),
params: ThreadUnsubscribeParams {
thread_id: previous_root_id.to_string(),
},
})
.await?;
assert_eq!(response.status, ThreadUnsubscribeStatus::Unsubscribed);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let mut server_config = app.config.clone();
server_config
.permissions
.set_permission_profile(PermissionProfile::workspace_write())?;
server_config
.permissions
.approval_policy
.set(codex_protocol::protocol::AskForApproval::Never)?;
let mut thread_ids = Vec::new();
for day in 1..=5 {
thread_ids.push(ThreadId::from_string(
&app_test_support::create_fake_rollout(
&server_config.codex_home,
&format!("2025-01-{day:02}T12-00-00"),
&format!("2025-01-{day:02}T12:00:00Z"),
"Historical session",
Some(&server_config.model_provider_id),
/*git_info*/ None,
)
.expect("create historical session"),
)?);
}
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&server_config)).await?;
let loaded = Box::pin(app_server.resume_thread(
&crate::local_settings::LocalSettings::from(&server_config),
server_config.clone(),
thread_ids[0],
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
))
.await?
.session;
app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview);
app.harness_overrides.sandbox_mode = Some(codex_protocol::config_types::SandboxMode::ReadOnly);
app.harness_overrides.approval_policy =
Some(codex_protocol::protocol::AskForApproval::UnlessTrusted);
app.config
.permissions
.set_permission_profile(PermissionProfile::read_only())?;
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
AskForApproval::UnlessTrusted,
));
let mut tui = crate::tui::test_support::make_test_tui()?;
for (thread_id, expected_permissions, expected_approval) in [
(
loaded.thread_id,
loaded.permission_profile,
loaded.approval_policy,
),
(
thread_ids[1],
PermissionProfile::read_only(),
codex_app_server_protocol::AskForApproval::UnlessTrusted,
),
(
thread_ids[2],
PermissionProfile::read_only(),
codex_app_server_protocol::AskForApproval::UnlessTrusted,
),
(
thread_ids[3],
PermissionProfile::read_only(),
codex_app_server_protocol::AskForApproval::UnlessTrusted,
),
] {
if thread_id == thread_ids[2] {
// A session-only /permissions choice must survive subsequent cold resumes.
std::fs::write(
app.config.codex_home.join("config.toml"),
"approvals_reviewer = \"auto_review\"\n[tui]\nresume_cwd = \"session\"\n",
)?;
app.harness_overrides.sandbox_mode =
Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite);
app.harness_overrides.approval_policy =
Some(codex_protocol::protocol::AskForApproval::Never);
app.runtime_approvals_reviewer_override = Some(app.config.approvals_reviewer);
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
AskForApproval::UnlessTrusted,
));
}
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id)).await?;
assert_eq!(
app.runtime_approvals_reviewer_override,
(thread_id == thread_ids[2] || thread_id == thread_ids[3])
.then_some(ApprovalsReviewer::User)
);
let observed = Box::pin(app_server.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?
.session;
assert_eq!(
(
app.primary_thread_id,
observed.permission_profile,
observed.approval_policy,
observed.approvals_reviewer,
),
(
Some(thread_id),
expected_permissions,
expected_approval,
ApprovalsReviewer::User
),
);
}
let requirements = app.config.codex_home.join("requirements.toml");
std::fs::write(
&requirements,
"allowed_approvals_reviewers = [\"auto_review\"]\n",
)?;
app.loader_overrides.system_requirements_path = Some(requirements.to_path_buf());
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4])).await?;
assert_eq!(
(
app.primary_thread_id,
app_server
.thread_read(thread_ids[4], /*include_turns*/ false)
.await?
.status
),
(
Some(thread_ids[3]),
codex_app_server_protocol::ThreadStatus::NotLoaded
),
);
let mut rendered =
crate::chatwidget::tests::helpers::render_bottom_popup(&app.chat_widget, /*width*/ 96);
let start = rendered.find("(set by ").expect("requirement source");
let end = start + rendered[start..].find(')').expect("source end") + 1;
rendered.replace_range(start..end, "(set by <requirements>)");
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_reviewer_conflict", rendered);
});
// The picker must also abort when a session-only choice violates destination policy.
std::fs::write(&requirements, "allowed_approval_policies = [\"never\"]\n")?;
app.harness_overrides.approval_policy = Some(codex_protocol::protocol::AskForApproval::Never);
let backend_id = app_server.backend_id();
Box::pin(app.resume_target_session(
&mut tui,
&mut app_server,
SessionTarget {
backend_id,
path: None,
thread_id: thread_ids[4],
cwd: None,
history_mode: None,
},
))
.await?;
assert_eq!(app.primary_thread_id, Some(thread_ids[3]));
assert_eq!(
app_server
.thread_read(thread_ids[4], /*include_turns*/ false)
.await?
.status,
codex_app_server_protocol::ThreadStatus::NotLoaded,
);
app.loader_overrides.system_requirements_path = None;
app.runtime_permission_profile_override = None;
Box::pin(app.handle_event(
&mut tui,
&mut app_server,
AppEvent::UpdateApprovalsReviewer(ApprovalsReviewer::AutoReview),
))
.await?;
app.select_agents_overview_thread(&mut tui, &mut app_server, thread_ids[4])
.await?;
let resumed = app_server
.resume_thread(
&app.local_settings,
app.config.clone(),
thread_ids[4],
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
)
.await?;
assert_eq!(
resumed.session.approvals_reviewer,
ApprovalsReviewer::AutoReview
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn overview_cold_resume_honors_working_directory_selection() -> Result<()> {
for (mode, cli_cwd, runtime_cwd) in [
("current", false, false),
("session", true, false),
("session", true, true),
("session", false, false),
] {
// Keep the large setup and cold-resume futures off the Windows test stack.
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
let chosen = app.config.codex_home.join("chosen");
let overridden = app.config.codex_home.join("overridden");
std::fs::create_dir(&chosen)?;
std::fs::create_dir(&overridden)?;
std::fs::write(
app.config.codex_home.join("config.toml"),
format!("[tui]\nresume_cwd = \"{mode}\"\n"),
)?;
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&chosen,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?;
let thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
&app.config.codex_home,
"2025-01-01T12-00-00",
"2025-01-01T12:00:00Z",
"Historical session",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("create historical session"),
)?;
let mut app_server =
Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app.launch_cwd = chosen.to_path_buf();
app.harness_overrides.cwd = cli_cwd.then(|| {
if runtime_cwd {
overridden.to_path_buf()
} else {
chosen.to_path_buf()
}
});
app.runtime_working_directory_override = runtime_cwd.then(|| chosen.to_path_buf());
let expected_cwd = if mode == "current" || cli_cwd || runtime_cwd {
chosen
} else {
test_path_buf("/").abs()
};
let mut tui = crate::tui::test_support::make_test_tui()?;
// On this current-thread runtime, the server cannot answer until we yield. Cancel the
// pending selection to release its terminal borrow and inspect what the user sees now.
let mut selection =
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id));
assert!(futures::poll!(&mut selection).is_pending());
drop(selection);
let pending_loading =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
Box::pin(app.select_agents_overview_thread(&mut tui, &mut app_server, thread_id)).await?;
// The widget replacement clears the terminal; feedback must remain until the next draw.
let loading =
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal).clone();
assert_eq!(pending_loading, loading);
let loading_text = loading
.content
.chunks(usize::from(loading.area.width))
.map(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
})
.map(|line| line.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
insta::allow_duplicates! {
insta::assert_snapshot!(loading_text.trim_end(), @"Loading task…");
}
Box::pin(app.handle_tui_event(&mut tui, &mut app_server, TuiEvent::Draw)).await?;
assert_ne!(
crate::custom_terminal::test_support::last_rendered_buffer(&tui.terminal),
&loading,
);
let observed = Box::pin(app_server.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?
.session;
assert_eq!(
(app.config.cwd.clone(), observed.cwd),
(expected_cwd.clone(), expected_cwd),
);
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test]
async fn restored_server_permission_profile_survives_cd_without_turn_override() -> Result<()> {
let mut app = make_test_app().await;
let destination = app.config.codex_home.join("destination");
std::fs::create_dir(&destination)?;
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&destination,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(|error| color_eyre::eyre::eyre!(error.to_string()))?;
let mut app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
let previous = app_server.start_thread(&app.config).await?;
app.enqueue_primary_thread_session(previous.session, previous.turns)
.await?;
let target = app_server.start_thread(&app.config).await?;
let target_thread_id = target.session.thread_id;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id)
.await?;
app.config
.permissions
.set_permission_profile(PermissionProfile::read_only())?;
app.chat_widget.set_permission_profile_with_active_profile(
PermissionProfile::read_only(),
/*active_permission_profile*/ None,
)?;
app.runtime_permission_profile_override = Some(
RuntimePermissionProfileOverride::from_restored_config(app.chat_widget.config_ref()),
);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.effective_permission_profile(),
PermissionProfile::read_only()
);
assert_eq!(
app.runtime_permission_profile_override
.as_ref()
.map(|profile| profile.turn_override),
Some(RuntimePermissionProfileTurnOverride::Preserve)
);
assert_eq!(
App::turn_permissions_override_from_config(
app.chat_widget.config_ref(),
/*active_permission_profile*/ None,
app.runtime_permission_profile_override
.as_ref()
.and_then(RuntimePermissionProfileOverride::turn_permission_profile),
),
TurnPermissionsOverride::Preserve
);
app.change_working_directory(&mut tui, &mut app_server, destination.clone())
.await;
assert_eq!(app.chat_widget.config_ref().cwd, destination);
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.effective_permission_profile(),
PermissionProfile::read_only()
);
app_server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn cancelling_resume_picker_preserves_command_center_state() -> Result<()> {
for primary_thread_id in [None, Some(ThreadId::new())] {
let mut app = make_test_app().await;
app.config.disable_paste_burst = true;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
app.primary_thread_id = primary_thread_id;
let threads = ["First task", "Second task"].map(|name| {
overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
name,
ThreadStatus::Idle,
)
});
let selected = ThreadId::from_string(&threads[1].id).unwrap();
let view = app.agents_overview_view(threads.into(), Some(selected));
app.chat_widget.show_bottom_pane_view(Box::new(view));
for key in [
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE),
KeyEvent::new(KeyCode::Char('S'), KeyModifiers::NONE),
KeyCode::Esc.into(),
] {
app.chat_widget.handle_key_event(key);
}
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
let selection = app
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID);
while event_rx.try_recv().is_ok() {}
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
assert!(event_rx.try_recv().is_err());
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
for selection in [SessionSelection::StartFresh, SessionSelection::Exit] {
assert!(matches!(
app.apply_resume_picker_selection(&mut tui, &mut server, selection)
.await?,
AppRunControl::Continue
));
app.chat_widget.pre_draw_tick();
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
}
server.shutdown().await?;
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
selection
);
}
Ok(())
}
#[tokio::test]
async fn command_center_cursor_tracks_fixed_footer() {
let app = make_test_app().await;
let mut view = app.agents_overview_view(
vec![overview_thread(
ThreadId::new(),
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
)],
/*selected_thread_id*/ None,
);
for (key, label) in [('/', "Search ›"), ('r', "Rename ›")] {
view.handle_key_event(KeyCode::Esc.into());
view.handle_key_event(KeyEvent::new(KeyCode::Char(key), KeyModifiers::NONE));
for width in [48, 96, 120] {
let area =
ratatui::layout::Rect::new(/*x*/ 0, /*y*/ 0, width, /*height*/ 24);
let mut buffer = ratatui::buffer::Buffer::empty(area);
view.render(area, &mut buffer);
let prompt_row = buffer
.content()
.chunks(usize::from(width))
.position(|row| {
row.iter()
.map(ratatui::buffer::Cell::symbol)
.collect::<String>()
.contains(label)
})
.expect("rendered prompt");
assert_eq!(
view.cursor_pos(area).map(|(_, y)| usize::from(y)),
Some(prompt_row)
);
assert!(area.contains(view.cursor_pos(area).unwrap().into()));
}
}
}
#[tokio::test]
async fn empty_command_center_can_open_resume_picker() {
let mut app = make_test_app().await;
let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = crate::app_event_sender::AppEventSender::new(event_tx);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
while event_rx.try_recv().is_ok() {}
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE));
assert!(matches!(
event_rx.try_recv(),
Ok(AppEvent::OpenResumePicker)
));
// Crossterm labels forward Delete as "fwd del" on macOS and "del" elsewhere.
let rendered =
normalize_agent_center_snapshot(render_bottom_popup(&app.chat_widget, /*width*/ 48))
.replace("fwd del", "del");
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_empty_narrow", rendered);
});
}
#[tokio::test]
async fn resuming_active_session_closes_command_center() -> Result<()> {
let mut app = make_test_app().await;
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.primary_thread_id = Some(thread_id);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let backend_id = server.backend_id();
assert!(matches!(
app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
backend_id,
path: None,
thread_id,
cwd: None,
history_mode: None,
})
)
.await?,
AppRunControl::Continue
));
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
None
);
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn resume_failure_keeps_command_center_available() {
let mut app = make_test_app().await;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
app.add_session_picker_error("The session is unavailable.".to_string());
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_resume_error", render_bottom_popup(&app.chat_widget, /*width*/ 96));
});
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
}
#[tokio::test]
async fn resume_picker_round_trip_preserves_each_threads_input() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let backend_id = server.backend_id();
let mut targets = Vec::new();
for (timestamp, name) in [
("2025-01-05T12-00-00", "First task"),
("2025-01-05T13-00-00", "Second task"),
] {
let id = app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
timestamp,
"2025-01-05T12:00:00Z",
name,
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved rollout");
targets.push(SessionTarget {
backend_id,
path: Some(app_test_support::rollout_path(
app.config.codex_home.as_path(),
timestamp,
&id,
)),
thread_id: ThreadId::from_string(&id)?,
cwd: None,
history_mode: None,
});
}
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut expected_states = Vec::new();
for target in targets.iter().chain(&targets) {
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
Box::pin(app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(target.clone()),
))
.await?;
assert_eq!(app.chat_widget.thread_id(), Some(target.thread_id));
if expected_states.len() < targets.len() {
assert_eq!(app.chat_widget.composer_text_with_pending(), "");
assert!(app.chat_widget.queued_user_message_texts().is_empty());
app.chat_widget.handle_server_notification(
ServerNotification::TurnStarted(
codex_app_server_protocol::TurnStartedNotification {
thread_id: target.thread_id.to_string(),
turn: codex_app_server_protocol::Turn {
id: "turn-with-follow-up".to_string(),
root_turn_id: None,
items_view: codex_app_server_protocol::TurnItemsView::Full,
items: Vec::new(),
status: codex_app_server_protocol::TurnStatus::InProgress,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
},
},
),
/*replay_kind*/ None,
);
let follow_up = format!("Follow-up for {}", target.thread_id);
app.chat_widget.apply_external_edit(follow_up.clone());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.chat_widget.queued_user_message_texts(), vec![follow_up]);
app.chat_widget
.apply_external_edit(format!("Draft for {}", target.thread_id));
let mut input_state = app.chat_widget.capture_thread_input_state().unwrap();
// Recovered follow-ups stay paused until the user explicitly submits them.
input_state.recovered_queue = true;
app.chat_widget.restore_thread_input_state(
Some(input_state),
crate::chatwidget::ThreadInputStateRestoreMode {
preserve_in_flight_turn: false,
},
);
expected_states.push(app.chat_widget.capture_thread_input_state());
} else {
let index = targets
.iter()
.position(|t| t.thread_id == target.thread_id)
.unwrap();
assert_eq!(
app.chat_widget.capture_thread_input_state(),
expected_states[index]
);
}
}
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_handles_resume_failure_and_success() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
let timestamp = "2025-01-05T12-00-00";
let id = app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
timestamp,
"2025-01-05T12:00:00Z",
"Saved task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved rollout");
let thread_id = ThreadId::from_string(&id)?;
let path = app_test_support::rollout_path(app.config.codex_home.as_path(), timestamp, &id);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let backend_id = server.backend_id();
assert!(matches!(
Box::pin(app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
backend_id,
path: None,
thread_id: ThreadId::new(),
cwd: None,
history_mode: None,
})
))
.await?,
AppRunControl::Continue
));
assert!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.is_some()
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Unable to resume session")
);
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(matches!(
Box::pin(app.apply_resume_picker_selection(
&mut tui,
&mut server,
SessionSelection::Resume(SessionTarget {
backend_id,
path: Some(path),
thread_id,
cwd: None,
history_mode: None,
})
))
.await?,
AppRunControl::Continue
));
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
None
);
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] {
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&cwd,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
}
let thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved task"),
)?;
let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
Box::pin(owner.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?;
let thread = owner
.thread_read(thread_id, /*include_turns*/ false)
.await?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app.app_server_target = AppServerTarget::LocalDaemon {
allow_embedded_fallback: true,
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
app.chat_widget.remote_connection =
crate::status::remote_connection::remote_connection_status_value(
&app.app_server_target,
/*server_version*/ None,
);
app.agents_overview
.threads
.insert(thread_id, Some(thread.clone()));
app.chat_widget.insert_str("Retained task draft");
app.agents_overview.input_states.insert(
thread_id,
app.chat_widget
.capture_thread_input_state()
.expect("task draft"),
);
app.agents_overview.dispatched_requests.insert(
thread_id,
vec![ServerRequest::ToolRequestUserInput {
request_id: RequestId::Integer(42),
params: ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: "turn".into(),
item_id: "question".into(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
}]
.into_iter()
.map(|request| BufferedServerRequest::new(request, &server.request_handle()))
.collect(),
);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let mut view = app.agents_overview_view(vec![thread], Some(thread_id));
view.handle_key_event(KeyCode::Esc.into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
let selection = app
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.config.approvals_reviewer = ApprovalsReviewer::AutoReview;
app.runtime_approvals_reviewer_override = Some(ApprovalsReviewer::AutoReview);
app.runtime_approval_policy_override = Some(RuntimeApprovalPolicyOverride::Explicit(
AskForApproval::Never,
));
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
let expected_overrides = (
app.runtime_approvals_reviewer_override,
app.runtime_approval_policy_override,
app.runtime_permission_profile_override.clone(),
);
app.chat_widget.handle_key_event(KeyCode::Right.into());
let event = rx.try_recv()?;
assert!(
matches!(event, AppEvent::SelectAgentsOverviewThread { thread_id: id } if id == thread_id)
);
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert!(app.chat_widget.is_external_writer_view());
assert_eq!(
app.thread_event_channels[&thread_id].attachment(),
ThreadEventAttachment::ExternalWriter
);
assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
let turns = app.thread_event_channels[&thread_id]
.store
.lock()
.await
.snapshot()
.turns;
assert!(serde_json::to_string(&turns)?.contains("Saved task"));
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_attach_conflict", render_bottom_popup(&app.chat_widget, /*width*/ 96));
});
for key in [KeyCode::Left, KeyCode::Esc] {
Box::pin(app.handle_tui_event(&mut tui, &mut server, TuiEvent::Key(key.into()))).await?;
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
selection
);
// Opening the displayed task returns to the same frozen snapshot without retrying.
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::SelectAgentsOverviewThread { thread_id },
))
.await?;
}
assert!(app.chat_widget.no_modal_or_popup_active());
assert!(app.chat_widget.is_external_writer_view());
app.chat_widget.handle_paste(" should be ignored".into());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
assert_eq!(
app.thread_event_channels[&thread_id]
.store
.lock()
.await
.snapshot()
.turns,
turns
);
// An explicit retry remains read-only while the other server owns the task.
Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await;
assert!(app.chat_widget.is_external_writer_view());
assert_eq!(
(
app.runtime_approvals_reviewer_override,
app.runtime_approval_policy_override,
app.runtime_permission_profile_override.clone()
),
expected_overrides,
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
assert!(
server
.thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams {
cursor: None,
limit: None,
})
.await?
.data
.is_empty()
);
// Buffered requests stay untouched while viewing; remove the synthetic request before retry.
assert_eq!(
app.agents_overview
.dispatched_requests
.remove(&thread_id)
.unwrap()
.len(),
1
);
owner.shutdown().await?;
Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await;
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert!(!app.chat_widget.is_external_writer_view());
assert_eq!(
app.thread_event_channels[&thread_id].attachment(),
ThreadEventAttachment::Live
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
assert!(app.chat_widget.no_modal_or_popup_active());
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_refresh_failure_is_inline_and_clears_on_success() -> Result<()> {
let mut app = make_test_app().await;
let server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Char('/').into());
view.handle_paste("Keep searching".into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 48);
for result in [
Err("unavailable".into()),
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
threads: HashMap::new(),
last_messages: HashMap::new(),
recent_seed_complete: false,
discovery: None,
pinned_thread_ids: None,
}),
] {
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(&server, request_id, result);
assert!(
app.agents_overview
.view_state
.lock()
.unwrap()
.refresh_failed
);
}
let notice = render_bottom_popup(&app.chat_widget, /*width*/ 32)
.lines()
.last()
.unwrap()
.to_owned();
insta::assert_snapshot!(notice, @r"
Error loading tasks
");
let request_id = Uuid::new_v4();
app.agents_overview.request_id = Some(request_id);
app.apply_agents_overview_thread_refresh(
&server,
request_id,
Ok(AgentsOverviewThreadRefresh {
cleared_unread: Vec::new(),
threads: HashMap::new(),
last_messages: HashMap::new(),
recent_seed_complete: true,
discovery: None,
pinned_thread_ids: None,
}),
);
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 48), before);
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_action_failures_remain_visible() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let thread_id = ThreadId::new();
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
for (event, expected) in [
(
AppEvent::SelectAgentsOverviewThread { thread_id },
"is unavailable",
),
(
AppEvent::RenameAgentsOverviewThread {
thread_id,
name: "New name".into(),
},
"Failed to rename task",
),
(
AppEvent::StopAgentsOverviewThread { thread_id },
"Failed to stop background task",
),
] {
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(rendered.contains("Unable to complete action"), "{rendered}");
assert!(rendered.contains(expected), "{rendered}");
app.chat_widget.handle_key_event(KeyCode::Esc.into());
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 96).contains("Agent command center")
);
}
// The retained primary task can also fail in the nested agent-attachment path.
app.primary_thread_id = Some(thread_id);
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::SelectAgentsOverviewThread { thread_id },
))
.await?;
let rendered = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(rendered.contains("Unable to complete action"), "{rendered}");
assert!(rendered.contains("is no longer available"), "{rendered}");
server.shutdown().await?;
Ok(())
}
#[path = "agents_overview_actions_tests.rs"]
mod actions;
#[tokio::test]
async fn overview_fork_preserves_an_open_side_conversation() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
let side = ThreadId::new();
app.active_thread_id = Some(side);
app.side_threads.insert(
side,
super::super::side::SideThreadState::new(ThreadId::new()),
);
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let RequestId::Integer(request) = server.next_request_id() else {
panic!("integer request ID")
};
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::ForkAgentsOverviewThread {
thread_id: ThreadId::new(),
},
))
.await?;
assert_eq!(app.current_displayed_thread_id(), Some(side));
assert!(app.side_threads.contains_key(&side));
assert!(!app.chat_widget.fork_in_progress);
assert_eq!(server.next_request_id(), RequestId::Integer(request + 1));
insta::assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 80));
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn overview_fork_keeps_idle_source_input_queued() -> Result<()> {
for cached_primary in [false, true] {
let (app, mut events, _) = make_test_app_with_channels().await;
let mut app = Box::pin(app);
trust_fixture_folders(&mut app);
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"session\"\n",
)?;
let source = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Source task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved rollout"),
)?;
let mut tui = crate::tui::test_support::make_test_tui()?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
if cached_primary {
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, source)).await?;
assert_eq!(app.current_displayed_thread_id(), Some(source));
app.store_active_thread_receiver().await;
}
app.chat_widget
.set_queue_autosend_suppressed(/*suppressed*/ true);
app.chat_widget.queue_user_message_with_options(
"Keep this on the source".into(),
crate::bottom_pane::QueuedInputAction::Plain,
Vec::new(),
);
let input = app.chat_widget.capture_thread_input_state().unwrap();
app.agents_overview
.input_states
.insert(source, input.clone());
if cached_primary {
app.ensure_thread_channel(source)
.store
.lock()
.await
.input_state = Some(input);
let child = ThreadId::new();
app.active_thread_id = Some(child);
let mut session = app
.ensure_thread_channel(source)
.store
.lock()
.await
.session
.clone()
.unwrap();
session.thread_id = child;
app.chat_widget.handle_thread_session(session);
}
while events.try_recv().is_ok() {}
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::ForkAgentsOverviewThread { thread_id: source },
))
.await?;
assert_eq!(
app.current_displayed_thread_id(),
Some(source),
"{}",
render_bottom_popup(&app.chat_widget, /*width*/ 96)
);
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["Keep this on the source"]
);
let retained = app.agents_overview.input_states[&source].clone();
let mut ready = None;
while let Ok(event) = events.try_recv() {
assert!(!matches!(
event,
AppEvent::CodexOp(AppCommand::UserTurn { .. })
));
if matches!(event, AppEvent::ForkAgentsOverviewThreadReady { .. }) {
ready = Some(event);
}
}
// Failed reattachment can leave the source displayed as a saved replay.
app.app_server_target = AppServerTarget::LocalDaemon {
allow_embedded_fallback: true,
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
app.ensure_thread_channel(source).mark_replay_only();
let RequestId::Integer(request) = server.next_request_id() else {
panic!("integer request ID")
};
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::ForkAgentsOverviewThreadReady { thread_id: source },
))
.await?;
assert_eq!(app.current_displayed_thread_id(), Some(source));
assert_eq!(server.next_request_id(), RequestId::Integer(request + 1));
assert!(!app.chat_widget.fork_in_progress);
app.app_server_target = AppServerTarget::Embedded;
Box::pin(app.handle_event(&mut tui, &mut server, ready.expect("fork continuation")))
.await?;
assert_ne!(app.current_displayed_thread_id(), Some(source));
assert_eq!(app.agents_overview.input_states[&source], retained);
assert!(app.chat_widget.queued_user_message_texts().is_empty());
assert!(!app.chat_widget.fork_in_progress);
server.shutdown().await?;
}
Ok(())
}
fn trust_fixture_folders(app: &mut App) {
let projects = serde_json::json!({
test_path_buf("/").display().to_string(): {"trust_level": "trusted"},
app.config.cwd.display().to_string(): {"trust_level": "trusted"},
});
app.cli_kv_overrides.push((
"projects".into(),
toml::Value::try_from(projects).expect("trust fixture"),
));
}
#[path = "agents_overview_usage_tests.rs"]
mod usage;
#[tokio::test]
async fn overview_grouping_persists_across_config_reloads() -> Result<()> {
let (mut app, mut events, _ops) = crate::app::tests::make_test_app_with_channels().await;
let home = tempfile::tempdir()?;
let config_path = home.path().join("work.config.toml");
std::fs::write(&config_path, "[tui]\nanimations = false\n")?;
app.local_settings.user_config_path = AbsolutePathBuf::try_from(config_path.clone())?;
let mut server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
for (selected, persisted) in [
(
AgentsOverviewGrouping::Directory,
PersistedAgentsOverviewGrouping::Project,
),
(
AgentsOverviewGrouping::Status,
PersistedAgentsOverviewGrouping::Status,
),
(
AgentsOverviewGrouping::Model,
PersistedAgentsOverviewGrouping::Model,
),
(
AgentsOverviewGrouping::Project,
PersistedAgentsOverviewGrouping::Project,
),
] {
let mut view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
view.handle_key_event(KeyCode::Char('g').into());
drop(view);
assert_eq!(
app.agents_overview.view_state.lock().unwrap().grouping,
selected
);
if selected == AgentsOverviewGrouping::Directory {
assert!(events.try_recv().is_err());
assert!(!std::fs::read_to_string(&config_path)?.contains("agents_overview_grouping"));
} else {
let event = std::iter::from_fn(|| events.try_recv().ok())
.find(|event| matches!(event, AppEvent::PersistAgentsOverviewGrouping(_)))
.expect("persisted grouping change emits persistence event");
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
}
let mut loader = codex_config::LoaderOverrides::without_managed_config_for_tests();
loader.user_config_path = Some(AbsolutePathBuf::try_from(config_path.clone())?);
let config = ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.loader_overrides(loader)
.build()
.await?;
app.local_settings = crate::local_settings::LocalSettings::from(&config);
assert_eq!(app.local_settings.tui.agents_overview_grouping, persisted);
assert!(!app.local_settings.tui.animations);
}
assert!(!home.path().join("config.toml").exists());
app.local_settings.user_config_path = AbsolutePathBuf::try_from(home.path())?;
let view = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.agents_overview.view_state.lock().unwrap().grouping = AgentsOverviewGrouping::Status;
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::PersistAgentsOverviewGrouping(PersistedAgentsOverviewGrouping::Status),
))
.await?;
assert_eq!(
app.agents_overview.view_state.lock().unwrap().grouping,
AgentsOverviewGrouping::Status
);
assert_eq!(
app.local_settings.tui.agents_overview_grouping,
PersistedAgentsOverviewGrouping::Status
);
assert!(
render_bottom_popup(&app.chat_widget, /*width*/ 100)
.contains("Failed to save Command Center grouping")
);
server.shutdown().await?;
Ok(())
}
#[tokio::test]
async fn command_center_new_actions_use_selection_and_leave_metadata_text_alone() {
let mut app = make_test_app().await;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let id = ThreadId::new();
let mut target = overview_thread(
id,
/*parent_thread_id*/ None,
"Task",
ThreadStatus::Idle,
);
target.cwd = test_path_buf("/tmp/checkout/subdir").abs();
app.agents_overview.pinned_thread_ids = Some(Vec::new());
let mut view = app.agents_overview_view(vec![target.clone()], Some(id));
view.handle_key_event(KeyCode::Char('p').into());
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::ToggleAgentsOverviewPin {
thread_id,
pinned: true,
}) if thread_id == id
));
view.handle_key_event(KeyCode::Char('p').into());
assert!(rx.try_recv().is_err());
for grouping_index in 0..3 {
view.handle_key_event(KeyCode::Char('n').into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewSession { cwd: Some(cwd) }) if cwd == target.cwd)
);
view.handle_key_event(KeyCode::Char('w').into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::NewAgentsOverviewWorktree { cwd: Some(cwd) }) if cwd == target.cwd)
);
view.handle_key_event(KeyCode::Char('g').into());
if grouping_index == 0 {
assert!(rx.try_recv().is_err());
} else {
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::PersistAgentsOverviewGrouping(_))
));
}
}
view.handle_key_event(KeyCode::Char('r').into());
for character in "nwogrxfhap".chars() {
view.handle_key_event(KeyCode::Char(character).into());
}
assert!(rx.try_recv().is_err());
view.handle_key_event(KeyCode::Enter.into());
assert!(
matches!(rx.try_recv(), Ok(AppEvent::RenameAgentsOverviewThread { name, .. }) if name.ends_with("nwogrxfhap"))
);
let mut empty = app.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
empty.handle_key_event(KeyCode::Char('n').into());
assert!(matches!(
rx.try_recv(),
Ok(AppEvent::NewAgentsOverviewSession { cwd: None })
));
}