From 6cf61725d0c6a11ab887c0a7fd532d2b137f1708 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Mon, 9 Feb 2026 10:27:46 +0000 Subject: [PATCH] feat: do not close unified exec processes across turns (#10799) With this PR we do not close the unified exec processes (i.e. background terminals) at the end of a turn unless: * The user interrupt the turn * The user decide to clean the processes through `app-server` or `/clean` I made sure that `codex exec` correctly kill all the processes --- .../src/protocol/common.rs | 26 +++++ .../app-server-protocol/src/protocol/v2.rs | 12 ++ codex-rs/app-server/README.md | 12 ++ .../app-server/src/codex_message_processor.rs | 40 +++++++ codex-rs/core/src/codex.rs | 7 ++ codex-rs/core/src/tasks/mod.rs | 17 ++- codex-rs/core/tests/suite/unified_exec.rs | 110 ++++++++++++++---- codex-rs/exec/src/lib.rs | 11 +- codex-rs/protocol/src/protocol.rs | 3 + ...c_footer__tests__render_many_sessions.snap | 3 +- ...c_footer__tests__render_more_sessions.snap | 3 +- .../src/bottom_pane/unified_exec_footer.rs | 4 +- codex-rs/tui/src/chatwidget.rs | 21 +++- ...ed_exec_begin_restores_working_status.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 72 +++++++++++- codex-rs/tui/src/slash_command.rs | 3 + 16 files changed, 300 insertions(+), 46 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index dc9d97bf9..7b4db0d6d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -214,6 +214,11 @@ client_request_definitions! { params: v2::ThreadCompactStartParams, response: v2::ThreadCompactStartResponse, }, + #[experimental("thread/backgroundTerminals/clean")] + ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" { + params: v2::ThreadBackgroundTerminalsCleanParams, + response: v2::ThreadBackgroundTerminalsCleanResponse, + }, ThreadRollback => "thread/rollback" { params: v2::ThreadRollbackParams, response: v2::ThreadRollbackResponse, @@ -1120,6 +1125,27 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_background_terminals_clean() -> Result<()> { + let request = ClientRequest::ThreadBackgroundTerminalsClean { + request_id: RequestId::Integer(8), + params: v2::ThreadBackgroundTerminalsCleanParams { + thread_id: "thr_123".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "thread/backgroundTerminals/clean", + "id": 8, + "params": { + "threadId": "thr_123" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn mock_experimental_method_is_marked_experimental() { let request = ClientRequest::MockExperimentalMethod { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0d42f87f1..cdd0b4bcf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1554,6 +1554,18 @@ pub struct ThreadCompactStartParams { #[ts(export_to = "v2/")] pub struct ThreadCompactStartResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 368372f25..8946880f9 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -93,6 +93,7 @@ Example (from OpenAI's official VSCode extension): - `thread/name/set` — set or update a thread’s user-facing name; returns `{}` on success. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. +- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. - `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input. @@ -364,6 +365,17 @@ You can cancel a running Turn with `turn/interrupt`. The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done. +### Example: Clean background terminals + +Use `thread/backgroundTerminals/clean` to terminate all running background terminals associated with a thread. This method is experimental and requires `capabilities.experimentalApi = true`. + +```json +{ "method": "thread/backgroundTerminals/clean", "id": 35, "params": { + "threadId": "thr_123" +} } +{ "id": 35, "result": {} } +``` + ### Example: Steer an active turn Use `turn/steer` to append additional user input to the currently active turn. This does not emit diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a0dd4bbb1..e1bfe3a33 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -111,6 +111,8 @@ use codex_app_server_protocol::SkillsRemoteWriteResponse; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; use codex_app_server_protocol::ThreadCompactStartParams; use codex_app_server_protocol::ThreadCompactStartResponse; use codex_app_server_protocol::ThreadForkParams; @@ -525,6 +527,13 @@ impl CodexMessageProcessor { self.thread_compact_start(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadBackgroundTerminalsClean { request_id, params } => { + self.thread_background_terminals_clean( + to_connection_request_id(request_id), + params, + ) + .await; + } ClientRequest::ThreadRollback { request_id, params } => { self.thread_rollback(to_connection_request_id(request_id), params) .await; @@ -2309,6 +2318,37 @@ impl CodexMessageProcessor { } } + async fn thread_background_terminals_clean( + &self, + request_id: ConnectionRequestId, + params: ThreadBackgroundTerminalsCleanParams, + ) { + let ThreadBackgroundTerminalsCleanParams { thread_id } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match thread.submit(Op::CleanBackgroundTerminals).await { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadBackgroundTerminalsCleanResponse {}) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to clean background terminals: {err}"), + ) + .await; + } + } + } + async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) { let ThreadListParams { cursor, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a53e962e9..5d0c8c239 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2700,6 +2700,9 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv Op::Interrupt => { handlers::interrupt(&sess).await; } + Op::CleanBackgroundTerminals => { + handlers::clean_background_terminals(&sess).await; + } Op::OverrideTurnContext { cwd, approval_policy, @@ -2890,6 +2893,10 @@ mod handlers { sess.interrupt_task().await; } + pub async fn clean_background_terminals(sess: &Arc) { + sess.close_unified_exec_processes().await; + } + pub async fn override_turn_context( sess: &Session, sub_id: String, diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 0821ba7bc..9a12c4867 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -46,7 +46,7 @@ pub(crate) use user_shell::UserShellCommandTask; pub(crate) use user_shell::execute_user_shell_command; const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100; -const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; +const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes were terminated. If any tools/commands were aborted, they may have partially executed; verify current state before retrying."; /// Thin wrapper that exposes the parts of [`Session`] task runners need. #[derive(Clone)] @@ -181,7 +181,9 @@ impl Session { for task in self.take_all_running_tasks().await { self.handle_task_abort(task, reason.clone()).await; } - self.close_unified_exec_processes().await; + if reason == TurnAbortReason::Interrupted { + self.close_unified_exec_processes().await; + } } pub async fn on_task_finished( @@ -191,15 +193,15 @@ impl Session { ) { let mut active = self.active_turn.lock().await; let mut pending_input = Vec::::new(); - let mut should_close_processes = false; + let mut should_clear_active_turn = false; if let Some(at) = active.as_mut() && at.remove_task(&turn_context.sub_id) { let mut ts = at.turn_state.lock().await; pending_input = ts.take_pending_input(); - should_close_processes = true; + should_clear_active_turn = true; } - if should_close_processes { + if should_clear_active_turn { *active = None; } drop(active); @@ -211,9 +213,6 @@ impl Session { self.record_conversation_items(turn_context.as_ref(), &pending_response_items) .await; } - if should_close_processes { - self.close_unified_exec_processes().await; - } let event = EventMsg::TurnComplete(TurnCompleteEvent { last_agent_message }); self.send_event(turn_context.as_ref(), event).await; } @@ -237,7 +236,7 @@ impl Session { } } - async fn close_unified_exec_processes(&self) { + pub(crate) async fn close_unified_exec_processes(&self) { self.services .unified_exec_manager .terminate_all_processes() diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 10234cabb..c7b21413c 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -14,6 +14,7 @@ use codex_core::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; +use core_test_support::process::process_is_alive; use core_test_support::process::wait_for_pid_file; use core_test_support::process::wait_for_process_exit; use core_test_support::responses::ev_assistant_message; @@ -1873,7 +1874,7 @@ async fn unified_exec_emits_end_event_when_session_dies_via_stdin() -> Result<() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { +async fn unified_exec_keeps_long_running_session_after_turn_end() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -1921,7 +1922,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { codex .submit(Op::UserTurn { items: vec![UserInput::Text { - text: "close unified exec processes on turn end".into(), + text: "keep unified exec process after turn end".into(), text_elements: Vec::new(), }], final_output_json_schema: None, @@ -1942,7 +1943,7 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { }) .await; - let begin_process_id = begin_event + let _begin_process_id = begin_event .process_id .clone() .expect("expected process_id for long-running unified exec process"); @@ -1953,28 +1954,91 @@ async fn unified_exec_closes_long_running_session_at_turn_end() -> Result<()> { "expected numeric pid, got {pid:?}" ); - let mut end_event = None; - let mut task_complete = false; - loop { - let msg = wait_for_event(&codex, |_| true).await; - match msg { - EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => end_event = Some(ev), - EventMsg::TurnComplete(_) => task_complete = true, - _ => {} - } - if task_complete && end_event.is_some() { - break; - } - } + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; - let end_event = end_event.expect("expected ExecCommandEnd event for unified exec session"); - assert_eq!(end_event.call_id, call_id); - let end_process_id = end_event - .process_id - .clone() - .expect("expected process_id in unified exec end event"); - assert_eq!(end_process_id, begin_process_id); + assert!( + process_is_alive(&pid)?, + "expected unified exec process to remain alive after turn completion" + ); + codex.submit(Op::Shutdown).await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::ShutdownComplete)).await; + wait_for_process_exit(&pid).await?; + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_interrupt_terminates_long_running_session() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_windows!(Ok(())); + + let server = start_mock_server().await; + + let mut builder = test_codex().with_config(|config| { + config.use_experimental_unified_exec_tool = true; + config.features.enable(Feature::UnifiedExec); + }); + let TestCodex { + codex, + cwd, + session_configured, + .. + } = builder.build(&server).await?; + + let temp_dir = tempfile::tempdir()?; + let pid_path = temp_dir.path().join("uexec_pid_interrupt"); + let pid_path_str = pid_path.to_string_lossy(); + + let call_id = "uexec-long-running-interrupt"; + let command = format!("printf '%s' $$ > '{pid_path_str}' && exec sleep 3000"); + let args = json!({ + "cmd": command, + "yield_time_ms": 30000, + }); + + let responses = vec![sse(vec![ + ev_response_created("resp-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-1"), + ])]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "interrupt long-running unified exec".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: cwd.path().to_path_buf(), + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: session_model, + effort: None, + summary: ReasoningSummary::Auto, + collaboration_mode: None, + personality: None, + }) + .await?; + + let _begin_event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => Some(ev.clone()), + _ => None, + }) + .await; + + let pid = wait_for_pid_file(&pid_path).await?; + assert!( + pid.chars().all(|ch| ch.is_ascii_digit()), + "expected numeric pid, got {pid:?}" + ); + + codex.submit(Op::Interrupt).await?; + wait_for_event(&codex, |event| matches!(event, EventMsg::TurnAborted(_))).await; wait_for_process_exit(&pid).await?; Ok(()) diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 2384c42af..df0c5c64f 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -530,6 +530,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any thread, event, } = envelope; + if matches!(event.msg, EventMsg::Error(_)) { + error_seen = true; + } + if shutdown_requested + && !matches!(&event.msg, EventMsg::ShutdownComplete | EventMsg::Error(_)) + { + continue; + } if let EventMsg::ElicitationRequest(ev) = &event.msg { // Automatically cancel elicitation requests in exec mode. thread @@ -554,9 +562,6 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any shutdown_requested = true; } } - if matches!(event.msg, EventMsg::Error(_)) { - error_seen = true; - } if thread_id != primary_thread_id && matches!(&event.msg, EventMsg::TurnComplete(_)) { continue; } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c6686a33c..462730f98 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -91,6 +91,9 @@ pub enum Op { /// This server sends [`EventMsg::TurnAborted`] in response. Interrupt, + /// Terminate all running background terminal processes for this thread. + CleanBackgroundTerminals, + /// Legacy user input. /// /// Prefer [`Op::UserTurn`] so the caller provides full turn context diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap index 09567f9c3..b1c3b5919 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_many_sessions.snap @@ -5,10 +5,9 @@ expression: "format!(\"{buf:?}\")" Buffer { area: Rect { x: 0, y: 0, width: 50, height: 1 }, content: [ - " 123 background terminals running · /ps to view ", + " 123 background terminals running · /ps to view ·", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 48, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap index e707e2e81..26c16791c 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__unified_exec_footer__tests__render_more_sessions.snap @@ -5,10 +5,9 @@ expression: "format!(\"{buf:?}\")" Buffer { area: Rect { x: 0, y: 0, width: 50, height: 1 }, content: [ - " 1 background terminal running · /ps to view ", + " 1 background terminal running · /ps to view · /c", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM, - x: 45, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, ] } diff --git a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs index 736ce0111..8cde20fd7 100644 --- a/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs +++ b/codex-rs/tui/src/bottom_pane/unified_exec_footer.rs @@ -37,7 +37,9 @@ impl UnifiedExecFooter { let count = self.processes.len(); let plural = if count == 1 { "" } else { "s" }; - let message = format!(" {count} background terminal{plural} running · /ps to view"); + let message = format!( + " {count} background terminal{plural} running · /ps to view · /clean to close" + ); let (truncated, _, _) = take_prefix_by_width(&message, width as usize); vec![Line::from(truncated.dim())] } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2dfe7039e..644bcae8c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1354,7 +1354,6 @@ impl ChatWidget { self.suppressed_exec_calls.clear(); self.last_unified_wait = None; self.unified_exec_wait_streak = None; - self.clear_unified_exec_processes(); self.request_redraw(); if !from_replay && self.queued_user_messages.is_empty() { @@ -1575,7 +1574,6 @@ impl ChatWidget { self.suppressed_exec_calls.clear(); self.last_unified_wait = None; self.unified_exec_wait_streak = None; - self.clear_unified_exec_processes(); self.adaptive_chunking.reset(); self.stream_controller = None; self.plan_stream_controller = None; @@ -1690,6 +1688,9 @@ impl ChatWidget { fn on_interrupted_turn(&mut self, reason: TurnAbortReason) { // Finalize, log a gentle prompt, and clear running state. self.finalize_turn(); + if reason == TurnAbortReason::Interrupted { + self.clear_unified_exec_processes(); + } if reason != TurnAbortReason::ReviewEnded { self.add_to_history(history_cell::new_error_event( @@ -1819,9 +1820,12 @@ impl ChatWidget { fn on_exec_command_begin(&mut self, ev: ExecCommandBeginEvent) { self.flush_answer_stream_with_separator(); if is_unified_exec_source(ev.source) { + self.track_unified_exec_process_begin(&ev); + if !self.bottom_pane.is_task_running() { + return; + } // Unified exec may be parsed as Unknown; keep the working indicator visible regardless. self.bottom_pane.ensure_status_indicator(); - self.track_unified_exec_process_begin(&ev); if !is_standard_tool_call(&ev.parsed_cmd) { return; } @@ -1832,6 +1836,9 @@ impl ChatWidget { fn on_exec_command_output_delta(&mut self, ev: ExecCommandOutputDeltaEvent) { self.track_unified_exec_output_chunk(&ev.call_id, &ev.chunk); + if !self.bottom_pane.is_task_running() { + return; + } let Some(cell) = self .active_cell @@ -3381,6 +3388,9 @@ impl ChatWidget { SlashCommand::Ps => { self.add_ps_output(); } + SlashCommand::Clean => { + self.clean_background_terminals(); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -4467,6 +4477,11 @@ impl ChatWidget { self.add_to_history(history_cell::new_unified_exec_processes_output(processes)); } + fn clean_background_terminals(&mut self) { + self.submit_op(Op::CleanBackgroundTerminals); + self.add_info_message("Stopping all background terminals.".to_string(), None); + } + fn stop_rate_limit_poller(&mut self) { if let Some(handle) = self.rate_limit_poller.take() { handle.abort(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap index a2ab0f168..56b3ae4cf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__unified_exec_begin_restores_working_status.snap @@ -4,7 +4,7 @@ expression: terminal.backend() --- " " "• Working (0s • esc to interrupt) " -" 1 background terminal running · /ps to view " +" 1 background terminal running · /ps to view · /clean to close " " " " " "› Ask Codex to do anything " diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 43d2ddcb8..128d171ed 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -3216,6 +3216,22 @@ async fn slash_exit_requests_exit() { assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst))); } +#[tokio::test] +async fn slash_clean_submits_background_terminal_cleanup() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.dispatch_command(SlashCommand::Clean); + + assert_matches!(op_rx.try_recv(), Ok(Op::CleanBackgroundTerminals)); + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected cleanup confirmation message"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Stopping all background terminals."), + "expected cleanup confirmation, got {rendered:?}" + ); +} + #[tokio::test] async fn slash_resume_opens_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4600,6 +4616,42 @@ async fn interrupt_clears_unified_exec_processes() { let _ = drain_insert_history(&mut rx); } +#[tokio::test] +async fn review_ended_keeps_unified_exec_processes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); + begin_unified_exec_startup(&mut chat, "call-2", "process-2", "sleep 6"); + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.handle_codex_event(Event { + id: "turn-1".into(), + msg: EventMsg::TurnAborted(codex_core::protocol::TurnAbortedEvent { + reason: TurnAbortReason::ReviewEnded, + }), + }); + + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.add_ps_output(); + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + combined.contains("Background terminals"), + "expected /ps to remain available after review-ended abort; got {combined:?}" + ); + assert!( + combined.contains("sleep 5") && combined.contains("sleep 6"), + "expected /ps to list running unified exec processes; got {combined:?}" + ); + + let _ = drain_insert_history(&mut rx); +} + #[tokio::test] async fn interrupt_clears_unified_exec_wait_streak_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -4634,7 +4686,7 @@ async fn interrupt_clears_unified_exec_wait_streak_snapshot() { } #[tokio::test] -async fn turn_complete_clears_unified_exec_processes() { +async fn turn_complete_keeps_unified_exec_processes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; begin_unified_exec_startup(&mut chat, "call-1", "process-1", "sleep 5"); @@ -4648,7 +4700,23 @@ async fn turn_complete_clears_unified_exec_processes() { }), }); - assert!(chat.unified_exec_processes.is_empty()); + assert_eq!(chat.unified_exec_processes.len(), 2); + + chat.add_ps_output(); + let cells = drain_insert_history(&mut rx); + let combined = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!( + combined.contains("Background terminals"), + "expected /ps to remain available after turn complete; got {combined:?}" + ); + assert!( + combined.contains("sleep 5") && combined.contains("sleep 6"), + "expected /ps to list running unified exec processes; got {combined:?}" + ); let _ = drain_insert_history(&mut rx); } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 6bb41b469..1220c3aca 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -43,6 +43,7 @@ pub enum SlashCommand { Feedback, Rollout, Ps, + Clean, Personality, TestApproval, } @@ -68,6 +69,7 @@ impl SlashCommand { SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Ps => "list background terminals", + SlashCommand::Clean => "stop all background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Personality => "choose a communication style for Codex", SlashCommand::Plan => "switch to Plan mode", @@ -124,6 +126,7 @@ impl SlashCommand { | SlashCommand::Status | SlashCommand::DebugConfig | SlashCommand::Ps + | SlashCommand::Clean | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Feedback