Return to the agent command center after archiving on shared servers (#44337)

## Why

`/archive` exits the TUI even when connected to a local daemon or remote app server, interrupting access to other tasks.

## What changed

- Keep the TUI running after a successful archive on local daemon and remote connections, and reopen the agent command center.
- Shut down side conversations before archiving, then clear the archived thread's local state, transcript, and unsent draft.
- Preserve exit-on-archive behavior for the embedded app server and remove the exit promise from `/archive` help and confirmation text.

## Testing

Add coverage for local daemon and remote connections, including side-thread cleanup, archive requests, cleared session state, and the resulting command center snapshot. Retain coverage for embedded archive success and failure.

GitOrigin-RevId: 36c61ec76a13a3fc1b5d6725e3b8f42a97fe6793
This commit is contained in:
Eric Traut
2026-09-09 23:50:28 +00:00
committed by copyberry
parent 0df6366a87
commit d390f0a09c
8 changed files with 227 additions and 82 deletions
+40 -7
View File
@@ -383,7 +383,7 @@ impl App {
}
}
AppEvent::ArchiveCurrentThread => {
return Ok(self.archive_current_thread(app_server).await);
return self.archive_current_thread(tui, app_server).await;
}
AppEvent::DeleteCurrentThread => {
return Ok(self.delete_current_thread(app_server).await);
@@ -3448,29 +3448,62 @@ impl App {
pub(super) async fn archive_current_thread(
&mut self,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
) -> AppRunControl {
) -> Result<AppRunControl> {
let Some(thread_id) = self.active_thread_id.or(self.chat_widget.thread_id()) else {
self.chat_widget
.add_error_message("A thread must start before it can be archived.".to_string());
return AppRunControl::Continue;
return Ok(AppRunControl::Continue);
};
if self.side_threads.contains_key(&thread_id) {
self.chat_widget.add_error_message(
"'/archive' is unavailable in side conversations. Press Ctrl+C to return to the main thread first."
.to_string(),
);
return AppRunControl::Continue;
return Ok(AppRunControl::Continue);
}
match app_server.thread_archive(thread_id).await {
Ok(()) => AppRunControl::Exit(ExitReason::Archived(thread_id)),
if !matches!(self.app_server_target, AppServerTarget::Embedded) {
self.shutdown_side_threads(app_server).await;
if !self.side_threads.is_empty() {
return Ok(AppRunControl::Continue);
}
}
Ok(match app_server.thread_archive(thread_id).await {
Ok(()) if matches!(self.app_server_target, AppServerTarget::Embedded) => {
AppRunControl::Exit(ExitReason::Archived(thread_id))
}
Ok(()) => {
self.track_agents_overview_notification(&ServerNotification::ThreadArchived(
codex_app_server_protocol::ThreadArchivedNotification {
thread_id: thread_id.to_string(),
},
));
self.discard_thread_local_state(thread_id).await;
self.agents_overview.input_states.remove(&thread_id);
self.agents_overview.dispatched_requests.remove(&thread_id);
self.reset_for_thread_switch(tui)?;
self.pending_thread_switch_resets += 1;
self.app_event_tx
.send(AppEvent::ResetTranscriptForThreadSwitch);
self.reset_thread_event_state();
let init = self.chatwidget_init_for_forked_or_resumed_thread(
tui,
self.config.clone(),
/*initial_user_message*/ None,
);
self.replace_chat_widget(ChatWidget::new_with_app_event(init));
self.open_agents_overview(app_server);
AppRunControl::Continue
}
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to archive current thread: {err}"));
AppRunControl::Continue
}
}
})
}
pub(super) async fn delete_current_thread(
@@ -585,72 +585,58 @@ async fn make_history_test_app() -> Result<(App, tempfile::TempDir)> {
}
#[tokio::test]
async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> {
for event in [
AppEvent::ArchiveCurrentThread,
AppEvent::DeleteCurrentThread,
] {
let (mut app, codex_home) = make_history_test_app().await?;
let thread_id = ThreadId::from_string(
&create_fake_rollout(
codex_home.path(),
"2026-01-01T00-00-00",
"2026-01-01T00:00:00Z",
"Saved user message",
Some(app.config.model_provider_id.as_str()),
/*git_info*/ None,
)
.expect("create rollout"),
)?;
let (mut server, _, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
async fn deleting_remote_thread_omits_disconnect_guidance() -> Result<()> {
let (mut app, codex_home) = make_history_test_app().await?;
let thread_id = ThreadId::from_string(
&create_fake_rollout(
codex_home.path(),
"2026-01-01T00-00-00",
"2026-01-01T00:00:00Z",
"Saved user message",
Some(app.config.model_provider_id.as_str()),
/*git_info*/ None,
)
.expect("create rollout"),
)?;
let (mut server, _, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let resumed = server
.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
let resumed = server
.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
app.active_thread_id = Some(thread_id);
app.chat_widget.handle_thread_session(resumed.session);
let mut tui = crate::tui::test_support::make_test_tui()?;
let archived = matches!(&event, AppEvent::ArchiveCurrentThread);
// Keep the large dispatcher future off the Windows test thread's stack.
let AppRunControl::Exit(reason) =
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?
else {
panic!("removing the current thread must exit");
};
if archived {
assert_matches!(reason, ExitReason::Archived(id) if id == thread_id);
} else {
assert_matches!(reason, ExitReason::ThreadRemoved);
}
let mut exit_info = app.exit_info(reason);
exit_info.token_usage = TokenUsage {
output_tokens: 2,
total_tokens: 2,
..Default::default()
};
let mut expected = vec!["Token usage: total=2 input=0 output=2".to_string()];
if archived {
expected.push(format!("Session archived: {thread_id}"));
}
assert_eq!(
exit_info.format_exit_messages(/*color_enabled*/ false),
expected
);
server.shutdown().await?;
proxy.await??;
}
app.app_server_target = AppServerTarget::Remote {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
app.active_thread_id = Some(thread_id);
app.chat_widget.handle_thread_session(resumed.session);
let mut tui = crate::tui::test_support::make_test_tui()?;
// Keep the large dispatcher future off the Windows test thread's stack.
let AppRunControl::Exit(reason) =
Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::DeleteCurrentThread)).await?
else {
panic!("removing the current thread must exit");
};
assert_matches!(reason, ExitReason::ThreadRemoved);
let mut exit_info = app.exit_info(reason);
exit_info.token_usage = TokenUsage {
output_tokens: 2,
total_tokens: 2,
..Default::default()
};
assert_eq!(
exit_info.format_exit_messages(/*color_enabled*/ false),
vec!["Token usage: total=2 input=0 output=2".to_string()]
);
server.shutdown().await?;
proxy.await??;
Ok(())
}
@@ -813,15 +799,17 @@ async fn archive_current_thread_reports_success_only_after_archiving() -> Result
)?;
let mut app_server = crate::start_embedded_app_server_for_picker(&app.config).await?;
let mut tui = crate::tui::test_support::make_test_tui()?;
app.active_thread_id = Some(ThreadId::new());
assert_matches!(
app.archive_current_thread(&mut app_server).await,
app.archive_current_thread(&mut tui, &mut app_server)
.await?,
AppRunControl::Continue
);
app.active_thread_id = Some(thread_id);
assert_matches!(
app.archive_current_thread(&mut app_server).await,
app.archive_current_thread(&mut tui, &mut app_server).await?,
AppRunControl::Exit(ExitReason::Archived(archived_id)) if archived_id == thread_id
);
@@ -829,6 +817,103 @@ async fn archive_current_thread_reports_success_only_after_archiving() -> Result
Ok(())
}
#[tokio::test]
async fn archive_current_thread_returns_shared_servers_to_agents() -> Result<()> {
let endpoint = crate::resolve_remote_addr("ws://127.0.0.1:4500")?;
for target in [
AppServerTarget::LocalDaemon {
endpoint: endpoint.clone(),
},
AppServerTarget::Remote { endpoint },
] {
let (mut app, _codex_home) = make_history_test_app().await?;
let thread_id =
create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "archive me")?;
let (mut server, requests, proxy) = start_recording_app_server(
&app.config,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
)
.await?;
let resumed = server
.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::RestoreFromThread,
)
.await?;
let mut side_config = app.config.clone();
side_config.ephemeral = true;
let side = server
.fork_side_thread(&app.local_settings, side_config, thread_id)
.await?;
let side_id = side.session.thread_id;
app.side_threads
.insert(side_id, SideThreadState::new(thread_id));
app.app_server_target = target;
app.enqueue_primary_thread_session(resumed.session.clone(), resumed.turns)
.await?;
app.chat_widget.handle_thread_session(resumed.session);
app.chat_widget.insert_str("Unsent archived draft");
let mut tui = crate::tui::test_support::make_test_tui()?;
let (tx, mut events) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
assert_matches!(
Box::pin(app.handle_event(&mut tui, &mut server, AppEvent::ArchiveCurrentThread))
.await?,
AppRunControl::Continue
);
loop {
let event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), events.recv())
.await?
.expect("expected command center refresh");
let refreshed = matches!(&event, AppEvent::AgentsOverviewThreadsLoaded { .. });
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
if refreshed {
break;
}
}
assert!(app.agents_overview.initialized);
assert_eq!(
(
app.active_thread_id,
app.primary_thread_id,
app.chat_widget.thread_id()
),
(None, None, None)
);
assert!(!app.agents_overview.threads.contains_key(&thread_id));
assert!(app.thread_event_channels.is_empty());
assert!(app.side_threads.is_empty());
assert_eq!(
recorded_params(&requests, "thread/unsubscribe"),
vec![serde_json::json!({"threadId": side_id.to_string()})]
);
assert!(app.chat_widget.composer_is_empty());
assert_eq!(
recorded_params(&requests, "thread/archive"),
vec![serde_json::json!({"threadId": thread_id.to_string()})]
);
assert!(
app.chat_widget
.selected_index_for_present_view(
crate::app::agents_overview::AGENTS_OVERVIEW_VIEW_ID
)
.is_some()
);
assert_snapshot!(
"agents_command_center_after_archive",
render_bottom_popup(&app.chat_widget, /*width*/ 100)
);
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
#[tokio::test]
async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() -> Result<()> {
let (mut app, events, _ops) = make_test_app_with_channels().await;
@@ -0,0 +1,28 @@
---
source: tui/src/app/tests/session_lifecycle_requests.rs
expression: "render_bottom_popup(&app.chat_widget, 100)"
---
Agent command center
0 need input 0 working 0 ready
────────────────────────────────────────────────────────────────────────────────────────────────
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
│
New task
› Describe a new task
enter create task ctrl+j newline esc tasks
@@ -6,4 +6,4 @@ expression: terminal.backend()
"› /ar "
" "
" "
" /archive archive this session and exit "
" /archive archive this session "
@@ -16,7 +16,7 @@ expression: commands
/review - review my current changes and find issues
/rename - rename the current thread
/new - start a new chat during a conversation
/archive - archive this session and exit
/archive - archive this session
/delete - permanently delete this session and exit
/resume - resume a saved chat
/fork - fork the current chat
@@ -188,8 +188,7 @@ impl ChatWidget {
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Archive this session?".to_string()),
subtitle: Some(
"Are you sure? This will archive the current session and exit Codex"
.to_string(),
"Are you sure? This will archive the current session".to_string(),
),
footer_hint: Some(standard_popup_hint_line()),
items: vec![
@@ -200,7 +199,7 @@ impl ChatWidget {
..Default::default()
},
SelectionItem {
name: "Yes, archive and exit".to_string(),
name: "Yes, archive".to_string(),
description: Some("Archive this session now".to_string()),
actions: vec![Box::new(|tx| {
tx.send(AppEvent::ArchiveCurrentThread);
@@ -3,9 +3,9 @@ source: tui/src/chatwidget/tests/slash_commands.rs
expression: popup
---
Archive this session?
Are you sure? This will archive the current session and exit Codex
Are you sure? This will archive the current session
› 1. No, don't archive Return to the current session
2. Yes, archive and exit Archive this session now
› 1. No, don't archive Return to the current session
2. Yes, archive Archive this session now
Press enter to confirm or esc to go back
+1 -1
View File
@@ -95,7 +95,7 @@ impl SlashCommand {
SlashCommand::Review => "review my current changes and find issues",
SlashCommand::Rename => "rename the current thread",
SlashCommand::Resume => "resume a saved chat",
SlashCommand::Archive => "archive this session and exit",
SlashCommand::Archive => "archive this session",
SlashCommand::Delete => "permanently delete this session and exit",
SlashCommand::Clear => "clear the terminal and start a new chat",
SlashCommand::Fork => "fork the current chat",