mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
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:
@@ -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;
|
||||
|
||||
+28
@@ -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
|
||||
+1
-1
@@ -6,4 +6,4 @@ expression: terminal.backend()
|
||||
"› /ar "
|
||||
" "
|
||||
" "
|
||||
" /archive archive this session and exit "
|
||||
" /archive archive this session "
|
||||
|
||||
+1
-1
@@ -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
-3
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user