mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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
This commit is contained in:
committed by
GitHub
Unverified
parent
4e9e6ca243
commit
6cf61725d0
@@ -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 {
|
||||
|
||||
@@ -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/")]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2700,6 +2700,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, 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<Session>) {
|
||||
sess.close_unified_exec_processes().await;
|
||||
}
|
||||
|
||||
pub async fn override_turn_context(
|
||||
sess: &Session,
|
||||
sub_id: String,
|
||||
|
||||
@@ -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::<ResponseInputItem>::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()
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -530,6 +530,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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<PathBuf>) -> 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-2
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
+1
-2
@@ -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,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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())]
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
+1
-1
@@ -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 "
|
||||
|
||||
@@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user