Restore agent navigation after TUI reconnects (#41918)

## What changed

- Keep the agent command center visible with its cached inventory and draft while reconnecting, then refresh it without losing the selected task.
- Allow cached, unavailable conversations to reattach when selected while preserving their drafts, recap progress, and runtime permissions.
- Report an unsubmitted rename when its target disappears during refresh, and continue to quarantine queued input instead of resending it automatically.

## Testing

- Add a Unix socket replacement test covering command-center recovery with and without an active conversation, failed reconnects, inventory changes, and subsequent live task input.

GitOrigin-RevId: 774aca2dfb8358e9abd6a2e2ef0d509646648f66
This commit is contained in:
Eric Traut
2026-08-31 21:59:56 +00:00
committed by copyberry
parent 0a2e2bf957
commit 746798b2f7
11 changed files with 752 additions and 43 deletions
+19 -3
View File
@@ -34,6 +34,7 @@ pub(super) struct AgentsOverviewState {
pub(super) refresh_pending: bool,
pub(super) rendered_full_screen: bool,
pub(super) visible_thread_ids: Vec<ThreadId>,
pub(super) threads: Vec<Thread>,
pub(super) view_state:
Arc<std::sync::Mutex<super::agents_overview_view::AgentsOverviewViewState>>,
pub(super) input_states: HashMap<ThreadId, ThreadInputState>,
@@ -93,7 +94,10 @@ impl App {
self.agents_overview.request_id = None;
self.agents_overview.refresh_pending = false;
let view = self.agents_overview_view(Vec::new(), /*selected_thread_id*/ None);
let view = self.agents_overview_view(
self.agents_overview.threads.clone(),
/*selected_thread_id*/ None,
);
self.agents_overview.visible_thread_ids = view.thread_ids();
self.chat_widget.show_bottom_pane_view(Box::new(view));
self.refresh_agents_overview_threads(app_server);
@@ -240,6 +244,7 @@ impl App {
return;
}
};
self.agents_overview.threads = threads.clone();
let view = self.agents_overview_view(threads, selected_thread_id);
self.agents_overview.visible_thread_ids = view.thread_ids();
if selected_thread_id
@@ -247,6 +252,13 @@ impl App {
&& let Ok(mut state) = self.agents_overview.view_state.lock()
&& state.renaming
{
self.chat_widget.add_info_message(
format!(
"The rename target disappeared. Unsubmitted title: {}",
state.input
),
/*hint*/ None,
);
state.renaming = false;
state.input.clear();
}
@@ -320,7 +332,9 @@ impl App {
app_server: &mut AppServerSession,
root_thread_id: ThreadId,
) -> color_eyre::Result<AppRunControl> {
if self.current_displayed_thread_id() == Some(root_thread_id) {
if self.current_displayed_thread_id() == Some(root_thread_id)
&& !self.thread_unavailable(root_thread_id)
{
return Ok(AppRunControl::Continue);
}
@@ -505,7 +519,9 @@ impl App {
}
}
if self.current_displayed_thread_id() != Some(root_thread_id) {
if self.current_displayed_thread_id() != Some(root_thread_id)
|| self.thread_unavailable(root_thread_id)
{
self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id)
.await?;
}
+46 -24
View File
@@ -1,7 +1,6 @@
//! TUI-only out-of-process reconnection. Each attempt initializes a fresh client and rejoins existing
//! threads using ordinary resume/history semantics; no user operation is retried.
//! Offline input and old async completions are quarantined. Cached conversations stay read-only
//! until they can be reattached; command-center recovery is handled separately.
//! Offline input and old async completions are quarantined.
use super::*;
use crate::app_server_session::ResumeModelSettings;
@@ -113,6 +112,36 @@ pub(super) async fn reconnect(
}
impl App {
// Preserve local choices for future input, without replaying failed settings writes or
// changing the server's authorization for work that was already admitted.
pub(super) fn restore_runtime_permissions(
&self,
session: &mut ThreadSessionState,
cached: &ThreadSessionState,
) {
if self.current_displayed_thread_id() != Some(session.thread_id)
&& self.primary_thread_id != Some(session.thread_id)
{
return;
}
// Side conversations can replace the app-wide overrides. Only copy choices
// that still match this conversation's own cached settings.
if let Some(policy) = self.runtime_approval_policy_override
&& policy == cached.approval_policy
{
session.approval_policy = policy;
}
if let Some(profile) = &self.runtime_permission_profile_override
&& profile.permission_profile == cached.permission_profile
&& profile.active_permission_profile == cached.active_permission_profile
&& self.config.approvals_reviewer == cached.approvals_reviewer
{
session.permission_profile = profile.permission_profile.clone();
session.active_permission_profile = profile.active_permission_profile.clone();
session.approvals_reviewer = self.config.approvals_reviewer;
}
}
pub(super) fn thread_unavailable(&self, id: ThreadId) -> bool {
!matches!(self.app_server_target, AppServerTarget::Embedded)
&& self
@@ -146,7 +175,7 @@ impl App {
.is_some()
{
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.connection_notice = Some("Disconnected — agent list is stale");
state.connection_notice = Some("Reconnecting — agent list is stale");
if let Some(task) = state.refresh_task.take() {
task.abort();
}
@@ -181,6 +210,10 @@ impl App {
bootstrap,
thread,
} = connected;
let selected = self
.chat_widget
.selected_index_for_present_view(agents_overview::AGENTS_OVERVIEW_VIEW_ID)
.and_then(|index| self.agents_overview.visible_thread_ids.get(index).copied());
let displayed = self.current_displayed_thread_id();
let mut input = self.chat_widget.capture_thread_input_state();
if let Some(input) = input.as_mut() {
@@ -249,29 +282,10 @@ impl App {
}
if let Some(mut started) = thread {
let id = started.session.thread_id;
// A lost settings response can leave the server snapshot behind the user's choice.
// Side conversations can replace the app-wide overrides, so only restore choices
// that still match this conversation's own cached settings.
if (self.current_displayed_thread_id() == Some(id)
|| self.primary_thread_id == Some(id))
&& let Some(channel) = self.thread_event_channels.get(&id)
if let Some(channel) = self.thread_event_channels.get(&id)
&& let Some(cached) = channel.store.lock().await.session.as_ref()
{
if let Some(policy) = self.runtime_approval_policy_override
&& policy == cached.approval_policy
{
started.session.approval_policy = policy;
}
if let Some(profile) = &self.runtime_permission_profile_override
&& profile.permission_profile == cached.permission_profile
&& profile.active_permission_profile == cached.active_permission_profile
&& self.config.approvals_reviewer == cached.approvals_reviewer
{
started.session.permission_profile = profile.permission_profile.clone();
started.session.active_permission_profile =
profile.active_permission_profile.clone();
started.session.approvals_reviewer = self.config.approvals_reviewer;
}
self.restore_runtime_permissions(&mut started.session, cached);
}
self.agents_overview.input_states.remove(&id);
if started
@@ -329,6 +343,14 @@ impl App {
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.chat_widget.restore_reconnected_input(input);
}
if self.reconnect.presentation == ReconnectPresentation::Overview {
if let Ok(mut state) = self.agents_overview.view_state.lock() {
state.connection_notice = None;
}
let view = self.agents_overview_view(self.agents_overview.threads.clone(), selected);
self.chat_widget.show_bottom_pane_view(Box::new(view));
self.refresh_agents_overview_threads(app_server);
}
// Only accept fresh task-tool calls once this connection and its event queue are adopted.
if let ThreadToolTransport::Mcp(server) = app_server.thread_tool_transport() {
server.reconnect(app_server.request_handle(), self.app_event_tx.clone());
+75 -10
View File
@@ -367,7 +367,11 @@ impl App {
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<bool> {
if self.thread_event_channels.contains_key(&thread_id) {
if self
.thread_event_channels
.get(&thread_id)
.is_some_and(|channel| channel.attachment() == ThreadEventAttachment::Live)
{
return Ok(true);
}
@@ -380,6 +384,14 @@ impl App {
.await
{
Ok(started) => {
if let Some(entry) = self.agent_navigation.get(&thread_id).cloned() {
self.upsert_agent_picker_thread(
thread_id,
entry.agent_nickname,
entry.agent_role,
/*is_closed*/ false,
);
}
if started.blocks_direct_input {
self.agent_navigation.mark_parent_owned(thread_id);
}
@@ -417,18 +429,40 @@ impl App {
));
}
let mut session = self.session_state_for_thread_read(thread_id, &thread).await;
// Reads have no settings. Keep this cached thread's permissions rather than
// inferring them from the conversation that is currently displayed.
if let Some(channel) = self.thread_event_channels.get(&thread_id)
&& let Some(cached) = channel.store.lock().await.session.as_ref()
{
session.approval_policy = cached.approval_policy;
session.permission_profile = cached.permission_profile.clone();
session.active_permission_profile = cached.active_permission_profile.clone();
session.approvals_reviewer = cached.approvals_reviewer;
}
// `thread/read` can seed replay state, but it does not attach the app-server
// listener that `thread/resume` establishes, so treat this path as replay-only.
session.model.clear();
(session, turns, false)
}
};
let recap_progress =
if live_attached && let Some(channel) = self.thread_event_channels.remove(&thread_id) {
let store = channel.store.lock().await;
if let Some(input) = store.input_state.clone() {
self.agents_overview.input_states.insert(thread_id, input);
}
store.recap_progress()
} else {
Default::default()
};
let channel = self.ensure_thread_channel(thread_id);
if !live_attached {
channel.mark_replay_only();
}
let mut store = channel.store.lock().await;
store.set_session(session, turns);
store.merge_recap_progress(recap_progress);
store.rebase_buffer_after_session_refresh();
Ok(live_attached)
}
@@ -471,8 +505,22 @@ impl App {
app_server: &mut AppServerSession,
thread_id: ThreadId,
) -> Result<()> {
let cached_session = if self.thread_unavailable(thread_id) {
self.thread_event_channels[&thread_id]
.store
.lock()
.await
.session
.clone()
} else {
None
};
if self.active_thread_id == Some(thread_id) {
return Ok(());
if !self.thread_unavailable(thread_id) {
return Ok(());
}
// Detach the cached receiver before a successful attachment replaces its channel.
self.store_active_thread_receiver().await;
}
// A tracked side thread stays loaded until it is explicitly discarded and already has a
@@ -491,7 +539,6 @@ impl App {
.agent_navigation
.get(&thread_id)
.is_some_and(|entry| entry.is_closed);
is_replay_only |= self.thread_unavailable(thread_id);
let mut attached_replay_only = false;
if self.should_attach_live_thread_for_selection(thread_id) {
match self
@@ -500,7 +547,11 @@ impl App {
{
Ok(live_attached) => {
attached_replay_only = !live_attached;
is_replay_only |= attached_replay_only;
is_replay_only = attached_replay_only;
}
Err(_) if self.thread_event_channels.contains_key(&thread_id) => {
is_replay_only = true;
attached_replay_only = true;
}
Err(err) => {
self.chat_widget.add_error_message(format!(
@@ -541,10 +592,22 @@ impl App {
.add_error_message(format!("Agent thread {thread_id} is no longer available."));
return Ok(());
};
let recap_progress = channel.store.lock().await.recap_progress();
if snapshot.input_state.is_none() {
snapshot.input_state = self.agents_overview.input_states.remove(&thread_id);
}
let recap_progress = {
let mut store = channel.store.lock().await;
if let (Some(cached), Some(session)) =
(cached_session.as_ref(), snapshot.session.as_mut())
{
self.restore_runtime_permissions(session, cached);
store.session = Some(session.clone());
if !is_replay_only && self.primary_thread_id == Some(thread_id) {
self.primary_session_configured = Some(session.clone());
}
}
store.recap_progress()
};
snapshot.input_state = snapshot
.input_state
.or(self.agents_overview.input_states.remove(&thread_id));
self.active_thread_id = Some(thread_id);
self.active_thread_rx = Some(receiver);
@@ -604,11 +667,13 @@ impl App {
}
pub(super) fn should_attach_live_thread_for_selection(&self, thread_id: ThreadId) -> bool {
!self.thread_event_channels.contains_key(&thread_id)
self.thread_event_channels
.get(&thread_id)
.is_none_or(|channel| channel.attachment() != ThreadEventAttachment::Live)
&& self
.agent_navigation
.get(&thread_id)
.is_none_or(|entry| !entry.is_closed)
.is_none_or(|entry| !entry.is_closed || self.thread_unavailable(thread_id))
}
pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> {
+1 -5
View File
@@ -716,11 +716,7 @@ See the Codex keymap documentation for supported actions and examples."
Ok(exit_reason)
} else {
loop {
if app.reconnect.offline
&& !app.reconnect.failed
&& reconnect.is_none()
&& app.reconnect.presentation == reconnect::ReconnectPresentation::Conversation
{
if app.reconnect.offline && !app.reconnect.failed && reconnect.is_none() {
reconnect = Some(Box::pin(reconnect::reconnect(
app.app_server_target.clone(),
app.config.clone(),
+4
View File
@@ -8638,3 +8638,7 @@ async fn start_config_write_test_app_server(app: &App) -> Result<AppServerSessio
#[path = "tests/active_reconnect_tests.rs"]
mod active_reconnect;
#[cfg(unix)]
#[path = "tests/navigation_reconnect_tests.rs"]
mod navigation_reconnect;
@@ -0,0 +1,511 @@
//! Command-center inventory and cached navigation recover independently of active work.
use super::active_reconnect::drain_history;
use super::disconnect::serve_reconnect_requests;
use super::*;
use crate::app::reconnect::ReconnectPresentation;
use crate::app::reconnect::reconnect;
use crate::app_server_session::ThreadParamsMode;
use pretty_assertions::assert_eq;
use serde_json::json;
#[tokio::test]
async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conversation()
-> Result<()> {
use super::super::agents_overview::AGENTS_OVERVIEW_VIEW_ID;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadStatus;
use tokio::net::UnixListener;
// Losing an optional, previously opened thread must not strand the command center either.
for (previous_thread, changed_child_permissions) in [
(None, false),
(Some(ThreadId::new()), false),
(Some(ThreadId::new()), true),
] {
let (mut app, mut events, _) = make_test_app_with_channels().await;
let child = ThreadId::new();
let child_recap = recap::RecapProgress {
completed_turns: 3,
last_recapped_turn_count: Some(3),
};
app.config.model = Some("gpt-test".into());
app.active_thread_id = previous_thread;
app.primary_thread_id = previous_thread;
if let Some(id) = previous_thread {
app.config
.permissions
.set_permission_profile(PermissionProfile::read_only())?;
app.runtime_approval_policy_override = Some(AskForApproval::OnRequest);
app.runtime_permission_profile_override =
Some(RuntimePermissionProfileOverride::from_config(&app.config));
let mut session = test_thread_session(id, app.config.cwd.to_path_buf());
session.approval_policy = AskForApproval::OnRequest;
session.permission_profile = PermissionProfile::read_only();
app.primary_session_configured = Some(session.clone());
app.chat_widget.handle_thread_session(session.clone());
app.ensure_thread_channel(id)
.store
.lock()
.await
.set_session(
session,
vec![test_turn(
"cached",
TurnStatus::Completed,
vec![ThreadItem::Plan {
id: "cached-plan".into(),
text: "Cached previous conversation".into(),
}],
)],
);
app.chat_widget
.restore_user_message_to_composer("old draft".into());
app.store_active_thread_receiver().await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(app.chat_widget.composer_is_empty());
app.chat_widget
.restore_user_message_to_composer("latest draft".into());
}
if previous_thread.is_some() {
let child_session = test_thread_session(child, app.config.cwd.to_path_buf());
app.ensure_thread_channel(child)
.store
.lock()
.await
.set_session(child_session, Vec::new());
app.thread_event_channels[&child]
.store
.lock()
.await
.merge_recap_progress(child_recap);
app.upsert_agent_picker_thread(
child, /*agent_nickname*/ None, /*agent_role*/ None,
/*is_closed*/ false,
);
}
let selected = ThreadId::from_string("00000000-0000-0000-0000-000000000001")?;
let vanished = ThreadId::new();
let added = ThreadId::new();
let make_thread = |id: ThreadId, name: &str, status: ThreadStatus| -> Result<Thread> {
Ok(serde_json::from_value(json!({
"id": id.to_string(), "sessionId": id.to_string(), "preview": name, "name": name,
"ephemeral": false, "modelProvider": "test-provider", "createdAt": 1,
"updatedAt": chrono::Utc::now().timestamp() - 120, "status": status,
"cwd": app.config.cwd, "cliVersion": "0.0.0", "source": "cli", "turns": []
}))?)
};
let child_thread = make_thread(child, "Cached child", ThreadStatus::NotLoaded)?;
let stale = vec![
make_thread(
selected,
"Selected task",
ThreadStatus::Active {
active_flags: Vec::new(),
},
)?,
make_thread(vanished, "Vanished task", ThreadStatus::Idle)?,
];
let fresh = [
make_thread(selected, "Selected task", ThreadStatus::Idle)?,
make_thread(
added,
"New task",
ThreadStatus::Active {
active_flags: Vec::new(),
},
)?,
];
app.agents_overview.threads = stale.clone();
let view = app.agents_overview_view(
stale.clone(),
Some(if previous_thread.is_some() {
vanished
} else {
selected
}),
);
app.agents_overview.visible_thread_ids = view.thread_ids();
app.chat_widget.show_bottom_pane_view(Box::new(view));
app.agents_overview.view_state.lock().unwrap().input = "Keep this task draft".into();
app.agents_overview.view_state.lock().unwrap().renaming = previous_thread.is_some();
let stale_request = Uuid::new_v4();
app.agents_overview.request_id = Some(stale_request);
app.agents_overview.refresh_pending = true;
let refresh = tokio::spawn(std::future::pending::<()>());
app.agents_overview.view_state.lock().unwrap().refresh_task = Some(refresh.abort_handle());
let directory = tempfile::tempdir()?;
let socket_path = directory.path().join("daemon.sock");
let listener = UnixListener::bind(&socket_path)?;
let endpoint = crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: codex_utils_absolute_path::AbsolutePathBuf::try_from(socket_path.clone())?,
};
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: endpoint.clone(),
};
let available = Arc::new(std::sync::atomic::AtomicBool::new(false));
let server_available = Arc::clone(&available);
let restored_previous = previous_thread
.map(|id| make_thread(id, "Recovered task", ThreadStatus::NotLoaded))
.transpose()?;
let server = tokio::spawn(async move {
let mut listener = listener;
let mut methods = Vec::new();
let mut child_opened = false;
for connection in 0..(2 + usize::from(previous_thread.is_some())) {
let (stream, _) = listener.accept().await?;
let socket = tokio_tungstenite::accept_async(stream).await?;
methods.extend(serve_reconnect_requests(socket, |request| std::future::ready({
match request.method.as_str() {
"thread/loaded/list" if connection == 0 => None,
"thread/resume" if connection == 1 && previous_thread.is_some() => None,
"thread/resume" if request.params.as_ref().unwrap()["threadId"] != child.to_string() && !server_available.load(std::sync::atomic::Ordering::SeqCst) => Some(json!({"error": {"code": -32600, "message": "thread no longer exists"}})),
"thread/resume" => {
let mut thread = if request.params.as_ref().unwrap()["threadId"] == child.to_string() {
child_opened = true;
child_thread.clone()
} else {
restored_previous.clone().unwrap()
};
if changed_child_permissions && thread.id != child.to_string() {
thread.turns = vec![test_turn("saved", TurnStatus::InProgress, Vec::new())];
}
let (approval, sandbox) = if changed_child_permissions && thread.id != child.to_string() {
("on-request", "readOnly")
} else {
("never", "dangerFullAccess")
};
Some(json!({"result": {"thread": thread, "model": "gpt-test", "modelProvider": "test-provider", "cwd": thread.cwd, "approvalPolicy": approval, "approvalsReviewer": "user", "sandbox": {"type": sandbox}, "reasoningEffort": null}}))
}
"turn/start" => {
let params = request.params.as_ref().unwrap();
assert_eq!(params["approvalPolicy"], "on-request");
assert_eq!(params["sandboxPolicy"]["type"], if changed_child_permissions { json!(null) } else { json!("readOnly") });
assert_eq!(params["input"][0]["text"], "latest draft!");
Some(json!({"result": {"turn": {"id": "fresh", "items": [], "status": "inProgress"}}}))
}
"thread/loaded/list" => Some(json!({"result": {"data": [selected, added], "nextCursor": null}})),
"thread/turns/list" => Some(json!({"result": {"data": [], "nextCursor": null}})),
"thread/goal/get" => Some(json!({"result": {"goal": null}})),
"thread/read" if request.params.as_ref().unwrap()["threadId"] == child.to_string() => Some(json!({"result": {"thread": child_thread}})),
"thread/read" if previous_thread.is_some_and(|id| request.params.as_ref().unwrap()["threadId"] == id.to_string()) => Some(if changed_child_permissions && child_opened {
let mut thread = restored_previous.clone().unwrap();
let status = if server_available.load(std::sync::atomic::Ordering::SeqCst) { TurnStatus::InProgress } else { TurnStatus::Completed };
thread.turns = vec![test_turn("saved", status, Vec::new())];
json!({"result": {"thread": thread}})
} else if server_available.load(std::sync::atomic::Ordering::SeqCst) { json!({"result": {"thread": restored_previous}}) } else { json!({"error": {"code": -32600, "message": "thread no longer exists"}}) }),
"thread/read" => Some(json!({"result": {"thread": fresh.iter().find(|thread| request.params.as_ref().unwrap()["threadId"] == thread.id).unwrap()}})),
method => panic!("unexpected daemon reconnect request: {method}"),
}
})).await?);
if connection == 0 {
drop(listener);
std::fs::remove_file(&socket_path)?;
listener = UnixListener::bind(&socket_path)?;
}
}
Ok::<_, color_eyre::Report>(methods)
});
let mut session = AppServerSession::new(
crate::connect_remote_app_server(endpoint).await?,
ThreadParamsMode::Embedded,
);
assert!(
session
.thread_loaded_list(ThreadLoadedListParams {
cursor: None,
limit: Some(100)
})
.await
.is_err()
);
let disconnected = session.next_event().await.unwrap();
app.handle_app_server_event(&session, disconnected).await;
assert!(app.reconnect.offline);
assert!(refresh.await.unwrap_err().is_cancelled());
assert_eq!(
(
app.agents_overview.request_id,
app.agents_overview.refresh_pending
),
(None, false)
);
app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(Vec::new()));
assert_eq!(app.agents_overview.visible_thread_ids.len(), 2);
let mut tui = crate::tui::test_support::make_test_tui()?;
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.await?;
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
.await?;
assert_eq!(
app.agents_overview.view_state.lock().unwrap().input,
"Keep this task draft!"
);
if previous_thread.is_none() {
assert_snapshot!(
"daemon_command_center_reconnecting",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
);
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice =
Some("Reconnect failed — agent list is stale; relaunch to retry");
assert_snapshot!(
"daemon_command_center_failed",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
);
}
let connected = reconnect(
app.app_server_target.clone(),
app.config.clone(),
previous_thread,
/*remote_cwd*/ None,
session.thread_tool_transport(),
ReconnectPresentation::Overview,
)
.await?;
app.finish_reconnect(&mut tui, &mut session, &mut events, connected)
.await?;
assert!(!app.reconnect.offline);
assert_eq!(app.current_displayed_thread_id(), previous_thread);
tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async {
let mut deferred = Vec::new();
while app.agents_overview.request_id.is_some() {
let event = events.recv().await.expect("inventory refresh event");
if matches!(event, AppEvent::AgentsOverviewThreadsLoaded { .. }) {
app.handle_event(&mut tui, &mut session, event).await?;
} else {
deferred.push(event);
}
}
for event in deferred {
app.app_event_tx.send(event);
}
Ok::<_, color_eyre::Report>(())
})
.await??;
let index = app
.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID)
.unwrap();
if previous_thread.is_none() {
assert_eq!(app.agents_overview.visible_thread_ids[index], selected);
}
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
assert!(app.agents_overview.visible_thread_ids.contains(&added));
assert_eq!(
app.agents_overview.view_state.lock().unwrap().input,
if previous_thread.is_some() {
""
} else {
"Keep this task draft!"
}
);
if previous_thread.is_some() {
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
assert!(history.contains("Unsubmitted title: Keep this task draft!"));
assert_snapshot!(
"daemon_command_center_vanished_rename",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
);
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.await?;
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::DispatchAgentsOverviewTask { .. }))
);
}
assert!(
app.agents_overview
.view_state
.lock()
.unwrap()
.connection_notice
.is_none()
);
app.apply_agents_overview_thread_refresh(&session, stale_request, Ok(stale));
assert!(!app.agents_overview.visible_thread_ids.contains(&vanished));
if let Some(id) = previous_thread {
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
assert!(!app.chat_widget.has_active_view());
assert_eq!(app.current_displayed_thread_id(), Some(id));
let history = drain_history(&mut app, &mut tui, &mut session, &mut events).await?;
assert!(history.contains("Cached previous conversation"));
let content = &history[history.find("Cached previous conversation").unwrap()..];
assert_snapshot!(
"reconnected_unavailable_conversation",
format!(
"{content}\n{}",
render_bottom_popup(&app.chat_widget, /*width*/ 80)
)
);
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.await?;
assert_eq!(app.chat_widget.composer_text_with_pending(), "latest draft");
assert!(
!std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })))
);
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Paste("!".into()))
.await?;
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"latest draft!"
);
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)),
)
.await?;
assert!(app.chat_widget.has_active_view());
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
)
.await?;
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"latest draft!"
);
available.store(
!changed_child_permissions,
std::sync::atomic::Ordering::SeqCst,
);
app.select_agent_thread(&mut tui, &mut session, child)
.await?;
assert_eq!(app.recap.progress(), child_recap);
assert_eq!(app.primary_thread_id, Some(id));
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.permission_profile(),
&PermissionProfile::Disabled
);
if changed_child_permissions {
app.runtime_approval_policy_override = Some(AskForApproval::Never);
app.runtime_permission_profile_override = Some(
RuntimePermissionProfileOverride::from_config(app.chat_widget.config_ref()),
);
app.select_agent_thread(&mut tui, &mut session, id).await?;
assert!(app.thread_unavailable(id));
assert_eq!(app.current_displayed_thread_id(), Some(id));
assert_eq!(
app.chat_widget
.config_ref()
.permissions
.permission_profile(),
&PermissionProfile::read_only()
);
}
available.store(true, std::sync::atomic::Ordering::SeqCst);
app.handle_event(
&mut tui,
&mut session,
AppEvent::SelectAgentsOverviewThread { thread_id: id },
)
.await?;
assert!(!app.thread_unavailable(id));
assert_eq!(
app.primary_session_configured,
app.thread_event_channels[&id].store.lock().await.session
);
assert!(!app.agent_navigation.get(&id).unwrap().is_closed);
app.agent_navigation.mark_running(id);
assert!(app.agent_navigation.get(&id).unwrap().is_running);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"latest draft!"
);
if changed_child_permissions {
assert!(app.chat_widget.is_task_running_for_test());
// From an empty draft, daemon Ctrl+C offers the existing Cancel task action.
for key in [
KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
] {
app.handle_tui_event(&mut tui, &mut session, TuiEvent::Key(key))
.await?;
}
assert!(std::iter::from_fn(|| events.try_recv().ok())
.any(|event| matches!(event, AppEvent::RunningTaskExit { action: RunningTaskExitAction::CancelTask, thread_id } if thread_id == id)));
app.handle_app_server_event(
&session,
codex_app_server_client::AppServerEvent::ServerNotification(Box::new(
turn_completed_notification(id, "saved", TurnStatus::Completed),
)),
)
.await;
app.drain_active_thread_events(&mut tui).await?;
app.chat_widget
.restore_user_message_to_composer("latest draft!".into());
}
app.handle_tui_event(
&mut tui,
&mut session,
TuiEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
)
.await?;
while let Ok(event) = events.try_recv() {
if matches!(event, AppEvent::CodexOp(AppCommand::UserTurn { .. })) {
app.handle_event(&mut tui, &mut session, event).await?;
}
}
let channel = app.thread_event_channels.get(&id).unwrap();
let event = ThreadBufferedEvent::Notification(Box::new(
agent_message_delta_notification(id, "new-turn", "new-item", "fresh delivery"),
));
channel.sender.send(event).await?;
assert!(app.active_thread_rx.as_mut().unwrap().try_recv().is_ok());
}
session.shutdown().await?;
let methods = server.await??;
assert_eq!(
methods
.iter()
.filter(|method| *method == "turn/start")
.count(),
usize::from(previous_thread.is_some())
);
assert_eq!(
methods.iter().any(|method| method == "thread/resume"),
previous_thread.is_some()
);
assert_eq!(
methods
.iter()
.filter(|method| *method == "initialize")
.count(),
2 + usize::from(previous_thread.is_some())
);
}
Ok(())
}
@@ -3,7 +3,7 @@ source: tui/src/app/tests/disconnect_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
Disconnected — agent list is stale
Reconnecting — agent list is stale
────────────────────────────────────────────────────────────────────────────────────────────────
│
│
@@ -0,0 +1,28 @@
---
source: tui/src/app/tests/navigation_reconnect_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
Reconnect failed — agent list is stale; relaunch to retry
────────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
› ● Selected task Working │
○ Vanished task Ready │ Selected task
│ ● Working
│
│ Project
│ /tmp/project
│
│ Latest activity
│ Selected task
│
│
│
│
│
│
│
│
│
New task › Keep this task draft!
ctrl+c quit · actions paused until the list is refreshed
@@ -0,0 +1,28 @@
---
source: tui/src/app/tests/navigation_reconnect_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
Reconnecting — agent list is stale
────────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
› ● Selected task Working │
○ Vanished task Ready │ Selected task
│ ● Working
│
│ Project
│ /tmp/project
│
│ Latest activity
│ Selected task
│
│
│
│
│
│
│
│
│
New task › Keep this task draft!
ctrl+c quit · actions paused until the list is refreshed
@@ -0,0 +1,28 @@
---
source: tui/src/app/tests/navigation_reconnect_tests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
0 need input 1 working 1 ready
────────────────────────────────────────────────────────────────────────────────────────────────
/tmp/project 2 │ Task details
› ● New task Working │
○ Selected task Ready │ New task
│ ● Working
│
│ Project
│ /tmp/project
│
│ Latest activity
│ New task
│
│
│
│
│
│
│
│
│
New task › Describe a task and press enter to dispatch it
↑↓ navigate enter open ctrl+f search ctrl+s group ctrl+r rename ctrl+x stop esc back
@@ -0,0 +1,11 @@
---
source: tui/src/app/tests/navigation_reconnect_tests.rs
expression: "format!(\"{content}\\n{}\", render_bottom_popup(&app.chat_widget, 80))"
---
Cached previous conversation
• This conversation is unavailable. Its cached transcript and draft remain here; input is paused. Open the agent picker or return to the parent to continue.
• Reconnected. No input was resent. Review uncertain submissions before retrying; recovered queues remain paused.
› latest draft
gpt-test default · /tmp/project