Add running-task exit choices to local daemon sessions (#38447)

## What changed

When `Ctrl-C` is pressed with an empty composer while a task is running in a local daemon session, show a menu that lets the user:

- cancel the task and stay in Codex;
- exit Codex while leaving the task running; or
- stop the task and exit Codex.

Do not offer background execution for side-thread tasks or when follow-up messages are queued. Preserve existing `Ctrl-C` behavior for non-daemon sessions, drafts, paste bursts, MCP startup, and returning from an active side thread.

## Testing

Add TUI tests for each exit action, menu dismissal, side threads, queued follow-ups, composer input, MCP startup, and non-daemon sessions.

GitOrigin-RevId: 539c33d3a4ff288a015bddbe9465d2bbdbe018d0
This commit is contained in:
Eric Traut
2026-08-13 22:38:46 +00:00
committed by copyberry
parent 6344a655a5
commit ca83f7908c
9 changed files with 593 additions and 11 deletions
+1
View File
@@ -14,6 +14,7 @@ use crate::app_event::PermissionProfileSelection;
use crate::app_event::PluginLocation;
use crate::app_event::PluginRemoteSectionError;
use crate::app_event::RateLimitRefreshOrigin;
use crate::app_event::RunningTaskExitAction;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
+55
View File
@@ -531,6 +531,61 @@ impl App {
}
return Ok(self.handle_exit_mode(app_server, mode).await);
}
AppEvent::RunningTaskExit { action, thread_id } => match action {
RunningTaskExitAction::RunInBackground => {
return Ok(self.handle_exit_mode(app_server, ExitMode::Immediate).await);
}
RunningTaskExitAction::CancelTask => {
if self.chat_widget.thread_id() == Some(thread_id)
&& self.chat_widget.is_agent_turn_running()
&& self.chat_widget.submit_op(AppCommand::interrupt())
{
self.chat_widget.pause_active_goal_for_interrupt();
} else if self.side_threads.contains_key(&thread_id)
&& let Err(error) = self
.try_submit_active_thread_op_via_app_server(
app_server,
thread_id,
&AppCommand::interrupt(),
)
.await
{
self.chat_widget
.add_error_message(format!("Failed to interrupt task: {error}"));
}
}
RunningTaskExitAction::Exit => {
if self.chat_widget.thread_id() == Some(thread_id)
&& self.chat_widget.is_active_goal_turn_running()
&& let Err(error) = app_server
.thread_goal_set(
thread_id,
/*objective*/ None,
Some(codex_app_server_protocol::ThreadGoalStatus::Paused),
/*token_budget*/ None,
)
.await
{
self.chat_widget
.add_error_message(format!("Failed to pause task goal: {error}"));
return Ok(AppRunControl::Continue);
}
let turn_id = self
.active_turn_id_for_thread(thread_id)
.await
.unwrap_or_default();
match app_server.turn_interrupt(thread_id, turn_id).await {
Ok(()) => {
self.app_event_tx
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
}
Err(error) => {
self.chat_widget
.add_error_message(format!("Failed to interrupt task: {error}"));
}
}
}
},
AppEvent::Logout => match app_server.logout_account().await {
Ok(()) => {
self.show_shutdown_feedback(tui)?;
+78
View File
@@ -212,6 +212,84 @@ impl App {
}
return;
}
if matches!(self.app_server_target, AppServerTarget::LocalDaemon { .. })
&& self.overlay.is_none()
&& self.chat_widget.no_modal_or_popup_active()
&& self.chat_widget.composer_is_empty()
&& self.active_side_parent_thread_id().is_none()
&& matches!(
key_event,
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
)
{
let mut running_thread_id = if self.chat_widget.is_agent_turn_running() {
self.chat_widget.thread_id()
} else {
None
};
let mut running_side_thread_id =
running_thread_id.filter(|thread_id| self.side_threads.contains_key(thread_id));
if running_side_thread_id.is_none() {
for thread_id in self.side_threads.keys().copied() {
if self.active_turn_id_for_thread(thread_id).await.is_some() {
running_side_thread_id = Some(thread_id);
break;
}
}
}
if running_thread_id.is_none() {
running_thread_id = running_side_thread_id;
}
if let Some(thread_id) = running_thread_id {
let allow_background = running_side_thread_id.is_none()
&& !self.chat_widget.has_queued_follow_up_messages();
self.chat_widget.show_selection_view(SelectionViewParams {
title: Some("Task is still running".to_string()),
subtitle: Some("Choose what happens to the current task.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items: [
(
"Cancel task",
"Stop the current task and stay in Codex",
RunningTaskExitAction::CancelTask,
),
(
"Run in background",
"Exit Codex and leave the task running",
RunningTaskExitAction::RunInBackground,
),
(
"Exit",
"Stop the current task and exit Codex",
RunningTaskExitAction::Exit,
),
]
.into_iter()
.filter(|(_, _, action)| {
allow_background || *action != RunningTaskExitAction::RunInBackground
})
.map(|(name, description, action)| SelectionItem {
name: name.to_string(),
description: Some(description.to_string()),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::RunningTaskExit { action, thread_id });
})],
dismiss_on_select: true,
..Default::default()
})
.collect(),
..Default::default()
});
return;
}
}
if side_return_shortcut_matches(key_event)
&& self.maybe_return_from_side(tui, app_server).await
{
+2
View File
@@ -2,6 +2,8 @@
#[path = "tests/advanced_reasoning_tests.rs"]
mod advanced_reasoning_tests;
#[path = "tests/background_exit_tests.rs"]
mod background_exit_tests;
#[path = "tests/key_chords.rs"]
mod key_chords;
#[path = "tests/mcp_startup.rs"]
@@ -0,0 +1,427 @@
use super::*;
use pretty_assertions::assert_eq;
fn prepare_local_daemon_thread(app: &mut App) -> Result<ThreadId> {
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
},
};
let thread_id = ThreadId::new();
app.active_thread_id = Some(thread_id);
app.chat_widget.handle_thread_session(test_thread_session(
thread_id,
test_path_buf("/tmp/project"),
));
Ok(thread_id)
}
fn prepare_running_local_daemon(app: &mut App) -> Result<ThreadId> {
let thread_id = prepare_local_daemon_thread(app)?;
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, "turn-1"),
/*replay_kind*/ None,
);
Ok(thread_id)
}
async fn open_running_task_exit_menu(
app: &mut App,
tui: &mut tui::Tui,
app_server: &mut AppServerSession,
) {
app.handle_key_event(
tui,
app_server,
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
)
.await;
}
async fn prepare_background_exit_test(
app: &App,
app_event_rx: &mut mpsc::UnboundedReceiver<AppEvent>,
op_rx: &mut mpsc::UnboundedReceiver<Op>,
) -> Result<(AppServerSession, tui::Tui)> {
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
let app_server =
crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?;
Ok((app_server, crate::tui::test_support::make_test_tui()?))
}
#[tokio::test]
async fn daemon_ctrl_c_shows_background_exit_menu_and_escape_dismisses_it() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(!app.chat_widget.no_modal_or_popup_active());
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r"
Task is still running
Choose what happens to the current task.
› 1. Cancel task Stop the current task and stay in Codex
2. Run in background Exit Codex and leave the task running
3. Exit Stop the current task and exit Codex
Press enter to confirm or esc to go back
");
app.handle_key_event(
&mut tui,
&mut app_server,
KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
)
.await;
assert!(app.chat_widget.no_modal_or_popup_active());
assert!(op_rx.try_recv().is_err());
assert!(app_event_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn cancel_task_interrupts_without_exiting() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = app_event_rx.try_recv()?;
assert_matches!(
event,
AppEvent::RunningTaskExit {
action: RunningTaskExitAction::CancelTask,
..
}
);
let control = app.handle_event(&mut tui, &mut app_server, event).await?;
assert_matches!(control, AppRunControl::Continue);
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
assert!(app_event_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn run_in_background_detaches_without_interrupting_main_or_side_threads() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let parent_thread_id = prepare_running_local_daemon(&mut app)?;
let side_thread_id = ThreadId::new();
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = app_event_rx.try_recv()?;
assert_matches!(
event,
AppEvent::RunningTaskExit {
action: RunningTaskExitAction::RunInBackground,
thread_id,
} if thread_id == parent_thread_id
);
let control = app.handle_event(&mut tui, &mut app_server, event).await?;
assert_matches!(control, AppRunControl::Exit(ExitReason::UserRequested));
assert!(app.side_threads.contains_key(&side_thread_id));
assert!(op_rx.try_recv().is_err());
assert!(app_event_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn exit_interrupts_before_requesting_shutdown() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
app.chat_widget
.set_feature_enabled(Feature::Goals, /*enabled*/ true);
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let thread_id = started.session.thread_id;
app.active_thread_id = Some(thread_id);
app.chat_widget
.handle_thread_session(started.session.clone());
let goal = app_server
.thread_goal_set(
thread_id,
Some("test goal".to_string()),
Some(codex_app_server_protocol::ThreadGoalStatus::Paused),
/*token_budget*/ None,
)
.await?
.goal;
app.chat_widget.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: None,
goal: codex_app_server_protocol::ThreadGoal {
status: codex_app_server_protocol::ThreadGoalStatus::Active,
..goal
},
},
),
/*replay_kind*/ None,
);
let command = if cfg!(windows) {
"Start-Sleep -Seconds 30"
} else {
"sleep 30"
};
app_server
.thread_shell_command(thread_id, command.to_string())
.await?;
let turn_id = loop {
let event = time::timeout(Duration::from_secs(/*secs*/ 5), app_server.next_event())
.await
.expect("app-server should emit a turn/start event")
.expect("app-server event stream should remain open");
if let codex_app_server_client::AppServerEvent::ServerNotification(notification) = event
&& let ServerNotification::TurnStarted(notification) = notification.as_ref()
&& notification.thread_id == thread_id.to_string()
{
break notification.turn.id.clone();
}
};
app.thread_event_channels.insert(
thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
started.session,
vec![test_turn(&turn_id, TurnStatus::InProgress, Vec::new())],
),
);
app.chat_widget.handle_server_notification(
turn_started_notification(thread_id, &turn_id),
/*replay_kind*/ None,
);
while app_event_rx.try_recv().is_ok() {}
while op_rx.try_recv().is_ok() {}
let control = app
.handle_event(
&mut tui,
&mut app_server,
AppEvent::RunningTaskExit {
action: RunningTaskExitAction::Exit,
thread_id,
},
)
.await?;
assert_matches!(control, AppRunControl::Continue);
assert!(op_rx.try_recv().is_err());
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::Exit(ExitMode::ShutdownFirst))
);
Ok(())
}
#[tokio::test]
async fn daemon_ctrl_c_closes_running_side_thread_and_returns_to_parent() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let side_thread_id = prepare_running_local_daemon(&mut app)?;
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
let started = app_server
.start_thread(app.chat_widget.config_ref())
.await?;
let parent_thread_id = started.session.thread_id;
app.primary_thread_id = Some(parent_thread_id);
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.thread_event_channels.insert(
parent_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
started.session,
started.turns,
),
);
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(app.chat_widget.no_modal_or_popup_active());
assert_eq!(app.active_thread_id, Some(parent_thread_id));
assert!(!app.side_threads.contains_key(&side_thread_id));
assert!(op_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn daemon_ctrl_c_hides_background_exit_for_running_background_side_thread() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let parent_thread_id = prepare_local_daemon_thread(&mut app)?;
let side_thread_id = ThreadId::new();
app.side_threads
.insert(side_thread_id, SideThreadState::new(parent_thread_id));
app.thread_event_channels.insert(
side_thread_id,
ThreadEventChannel::new_with_session(
THREAD_EVENT_CHANNEL_CAPACITY,
test_thread_session(side_thread_id, test_path_buf("/tmp/project")),
vec![test_turn("side-turn", TurnStatus::InProgress, Vec::new())],
),
);
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert_snapshot!(render_bottom_popup(&app.chat_widget, /*width*/ 90), @r"
Task is still running
Choose what happens to the current task.
› 1. Cancel task Stop the current task and stay in Codex
2. Exit Stop the current task and exit Codex
Press enter to confirm or esc to go back
");
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let event = app_event_rx.try_recv()?;
assert_matches!(
event,
AppEvent::RunningTaskExit {
action: RunningTaskExitAction::Exit,
thread_id,
} if thread_id == side_thread_id
);
assert!(op_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn daemon_ctrl_c_hides_background_exit_with_queued_follow_up() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
app.chat_widget
.apply_external_edit("queued follow-up".to_string());
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(
app.chat_widget.queued_user_message_texts(),
vec!["queued follow-up".to_string()]
);
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(!render_bottom_popup(&app.chat_widget, /*width*/ 90).contains("Run in background"));
assert!(op_rx.try_recv().is_err());
assert!(app_event_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn non_daemon_ctrl_c_keeps_interrupt_behavior() -> Result<()> {
for target in [
AppServerTarget::Embedded,
AppServerTarget::Remote {
endpoint: crate::RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:4500".to_string(),
auth_token: None,
},
},
] {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
app.app_server_target = target;
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(app.chat_widget.no_modal_or_popup_active());
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
assert!(app_event_rx.try_recv().is_err());
}
Ok(())
}
#[tokio::test]
async fn daemon_ctrl_c_with_draft_preserves_composer_cancellation() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
app.chat_widget.apply_external_edit("draft".to_string());
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(app.chat_widget.no_modal_or_popup_active());
assert!(app.chat_widget.composer_is_empty());
assert!(op_rx.try_recv().is_err());
assert_matches!(
app_event_rx.try_recv(),
Ok(AppEvent::AppendMessageHistoryEntry { text, .. }) if text == "draft"
);
Ok(())
}
#[tokio::test]
async fn daemon_ctrl_c_during_paste_burst_does_not_show_exit_menu() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
prepare_running_local_daemon(&mut app)?;
app.chat_widget
.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert!(app.chat_widget.composer_text_with_pending().is_empty());
assert!(!app.chat_widget.composer_is_empty());
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(app.chat_widget.no_modal_or_popup_active());
assert!(!app.chat_widget.composer_is_empty());
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
assert!(app_event_rx.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn daemon_ctrl_c_during_mcp_startup_does_not_show_background_exit_menu() -> Result<()> {
let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await;
let thread_id = prepare_local_daemon_thread(&mut app)?;
app.chat_widget
.set_mcp_startup_expected_servers(["slow".to_string()]);
app.chat_widget.handle_server_notification(
ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification {
thread_id: Some(thread_id.to_string()),
name: "slow".to_string(),
status: McpServerStartupState::Starting,
error: None,
failure_reason: None,
}),
/*replay_kind*/ None,
);
assert!(app.chat_widget.is_task_running_for_test());
let (mut app_server, mut tui) =
prepare_background_exit_test(&app, &mut app_event_rx, &mut op_rx).await?;
open_running_task_exit_menu(&mut app, &mut tui, &mut app_server).await;
assert!(app.chat_widget.no_modal_or_popup_active());
assert_matches!(op_rx.try_recv(), Ok(Op::Interrupt));
assert!(app_event_rx.try_recv().is_err());
Ok(())
}
+14
View File
@@ -342,6 +342,12 @@ pub(crate) enum AppEvent {
/// background tasks, rollout flush, or child process cleanup).
Exit(ExitMode),
/// Apply a choice from the running-task exit menu to its originating thread.
RunningTaskExit {
action: RunningTaskExitAction,
thread_id: ThreadId,
},
/// Request app-server account logout, then exit after it succeeds.
Logout,
@@ -1213,6 +1219,14 @@ pub(crate) enum ExitMode {
Immediate,
}
/// Choice made when leaving a daemon-backed task that is still running.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RunningTaskExitAction {
CancelTask,
RunInBackground,
Exit,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FeedbackCategory {
BadResult,
+1 -1
View File
@@ -1686,7 +1686,7 @@ impl ChatWidget {
}
pub(crate) fn composer_is_empty(&self) -> bool {
self.bottom_pane.composer_is_empty()
self.bottom_pane.composer_is_empty() && !self.bottom_pane.is_in_paste_burst()
}
#[cfg(test)]
+14 -9
View File
@@ -496,15 +496,20 @@ impl ChatWidget {
self.bottom_pane.is_task_running() || self.review.is_review_mode
}
fn pause_active_goal_for_interrupt(&self) {
if !self.turn_lifecycle.agent_turn_running {
return;
}
if !self
.current_goal_status
.as_ref()
.is_some_and(GoalStatusState::is_active)
{
pub(crate) fn is_agent_turn_running(&self) -> bool {
self.turn_lifecycle.agent_turn_running
}
pub(crate) fn is_active_goal_turn_running(&self) -> bool {
self.turn_lifecycle.agent_turn_running
&& self
.current_goal_status
.as_ref()
.is_some_and(GoalStatusState::is_active)
}
pub(crate) fn pause_active_goal_for_interrupt(&self) {
if !self.is_active_goal_turn_running() {
return;
}
let Some(thread_id) = self.thread_id else {
+1 -1
View File
@@ -294,7 +294,7 @@ impl ChatWidget {
None
}
pub(super) fn has_queued_follow_up_messages(&self) -> bool {
pub(crate) fn has_queued_follow_up_messages(&self) -> bool {
self.input_queue.has_queued_follow_up_messages()
}