diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c62acc883..bd9f6dded 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -527,6 +527,7 @@ server_notification_definitions! { ItemCompleted => "item/completed" (v2::ItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification), McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index dbef55ed1..db987e27d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1457,6 +1457,17 @@ pub struct ReasoningTextDeltaNotification { pub content_index: i64, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/main.rs b/codex-rs/app-server-test-client/src/main.rs index 92255cecd..924740896 100644 --- a/codex-rs/app-server-test-client/src/main.rs +++ b/codex-rs/app-server-test-client/src/main.rs @@ -553,6 +553,10 @@ impl CodexClient { print!("{}", delta.delta); std::io::stdout().flush().ok(); } + ServerNotification::TerminalInteraction(delta) => { + println!("[stdin sent: {}]", delta.stdin); + std::io::stdout().flush().ok(); + } ServerNotification::ItemStarted(payload) => { println!("\n< item started: {:?}", payload.item); } diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 2fda7bcf5..8956aedd1 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -37,6 +37,7 @@ use codex_app_server_protocol::ReasoningTextDeltaNotification; use codex_app_server_protocol::SandboxCommandAssessment as V2SandboxCommandAssessment; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::TerminalInteractionNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -573,6 +574,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::TerminalInteraction(terminal_event) => { + let item_id = terminal_event.call_id.clone(); + + let notification = TerminalInteractionNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + item_id, + process_id: terminal_event.process_id, + stdin: terminal_event.stdin, + }; + outgoing + .send_server_notification(ServerNotification::TerminalInteraction(notification)) + .await; + } EventMsg::ExecCommandEnd(exec_command_end_event) => { let ExecCommandEndEvent { call_id, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 58072f933..fc6e4b9af 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -62,6 +62,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::WebSearchBegin(_) | EventMsg::WebSearchEnd(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::ExecApprovalRequest(_) diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 93bce6048..cdfc575cd 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -134,7 +134,6 @@ impl ToolEmitter { command: &[String], cwd: PathBuf, source: ExecCommandSource, - interaction_input: Option, process_id: Option, ) -> Self { let parsed_cmd = parse_command(command); @@ -142,7 +141,7 @@ impl ToolEmitter { command: command.to_vec(), cwd, source, - interaction_input, + interaction_input: None, // TODO(jif) drop this field in the protocol. parsed_cmd, process_id, } diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c7230e54d..abaaf4a7a 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,9 +1,8 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandOutputDeltaEvent; use crate::protocol::ExecCommandSource; -use crate::protocol::ExecOutputStream; +use crate::protocol::TerminalInteractionEvent; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ToolInvocation; @@ -189,7 +188,6 @@ impl ToolHandler for UnifiedExecHandler { &command, cwd.clone(), ExecCommandSource::UnifiedExecStartup, - None, Some(process_id.clone()), ); emitter.emit(event_ctx, ToolEventStage::Begin).await; @@ -218,7 +216,7 @@ impl ToolHandler for UnifiedExecHandler { "failed to parse write_stdin arguments: {err:?}" )) })?; - manager + let response = manager .write_stdin(WriteStdinRequest { process_id: &args.session_id.to_string(), input: &args.chars, @@ -228,7 +226,18 @@ impl ToolHandler for UnifiedExecHandler { .await .map_err(|err| { FunctionCallError::RespondToModel(format!("write_stdin failed: {err:?}")) - })? + })?; + + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: args.session_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + + response } other => { return Err(FunctionCallError::RespondToModel(format!( @@ -237,18 +246,6 @@ impl ToolHandler for UnifiedExecHandler { } }; - // Emit a delta event with the chunk of output we just produced, if any. - if !response.output.is_empty() { - let delta = ExecCommandOutputDeltaEvent { - call_id: response.event_call_id.clone(), - stream: ExecOutputStream::Stdout, - chunk: response.output.as_bytes().to_vec(), - }; - session - .send_event(turn.as_ref(), EventMsg::ExecCommandOutputDelta(delta)) - .await; - } - let content = format_response(&response); Ok(ToolOutput::Function { diff --git a/codex-rs/core/src/unified_exec/async_watcher.rs b/codex-rs/core/src/unified_exec/async_watcher.rs new file mode 100644 index 000000000..7412d2972 --- /dev/null +++ b/codex-rs/core/src/unified_exec/async_watcher.rs @@ -0,0 +1,180 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::time::Duration; +use tokio::time::Instant; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::exec::ExecToolCallOutput; +use crate::exec::StreamOutput; +use crate::protocol::EventMsg; +use crate::protocol::ExecCommandOutputDeltaEvent; +use crate::protocol::ExecCommandSource; +use crate::protocol::ExecOutputStream; +use crate::tools::events::ToolEmitter; +use crate::tools::events::ToolEventCtx; +use crate::tools::events::ToolEventStage; + +use super::CommandTranscript; +use super::UnifiedExecContext; +use super::session::UnifiedExecSession; + +/// Spawn a background task that continuously reads from the PTY, appends to the +/// shared transcript, and emits ExecCommandOutputDelta events on UTF‑8 +/// boundaries. +pub(crate) fn start_streaming_output( + session: &UnifiedExecSession, + context: &UnifiedExecContext, + transcript: Arc>, +) { + let mut receiver = session.output_receiver(); + let session_ref = Arc::clone(&context.session); + let turn_ref = Arc::clone(&context.turn); + let call_id = context.call_id.clone(); + let cancellation_token = session.cancellation_token(); + + tokio::spawn(async move { + let mut pending: Vec = Vec::new(); + loop { + tokio::select! { + _ = cancellation_token.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + pending.extend_from_slice(&chunk); + while let Some(prefix) = split_valid_utf8_prefix(&mut pending) { + { + let mut guard = transcript.lock().await; + guard.append(&prefix); + } + + let event = ExecCommandOutputDeltaEvent { + call_id: call_id.clone(), + stream: ExecOutputStream::Stdout, + chunk: prefix, + }; + session_ref + .send_event(turn_ref.as_ref(), EventMsg::ExecCommandOutputDelta(event)) + .await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + }; + } + }); +} + +/// Spawn a background watcher that waits for the PTY to exit and then emits a +/// single ExecCommandEnd event with the aggregated transcript. +#[allow(clippy::too_many_arguments)] +pub(crate) fn spawn_exit_watcher( + session: Arc, + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: String, + transcript: Arc>, + started_at: Instant, +) { + let exit_token = session.cancellation_token(); + + tokio::spawn(async move { + exit_token.cancelled().await; + + let exit_code = session.exit_code().unwrap_or(-1); + let duration = Instant::now().saturating_duration_since(started_at); + emit_exec_end_for_unified_exec( + session_ref, + turn_ref, + call_id, + command, + cwd, + Some(process_id), + transcript, + String::new(), + exit_code, + duration, + ) + .await; + }); +} + +/// Emit an ExecCommandEnd event for a unified exec session, using the transcript +/// as the primary source of aggregated_output and falling back to the provided +/// text when the transcript is empty. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn emit_exec_end_for_unified_exec( + session_ref: Arc, + turn_ref: Arc, + call_id: String, + command: Vec, + cwd: PathBuf, + process_id: Option, + transcript: Arc>, + fallback_output: String, + exit_code: i32, + duration: Duration, +) { + let aggregated_output = resolve_aggregated_output(&transcript, fallback_output).await; + let output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(aggregated_output.clone()), + stderr: StreamOutput::new(String::new()), + aggregated_output: StreamOutput::new(aggregated_output), + duration, + timed_out: false, + }; + let event_ctx = ToolEventCtx::new(session_ref.as_ref(), turn_ref.as_ref(), &call_id, None); + let emitter = ToolEmitter::unified_exec( + &command, + cwd, + ExecCommandSource::UnifiedExecStartup, + process_id, + ); + emitter + .emit(event_ctx, ToolEventStage::Success(output)) + .await; +} + +fn split_valid_utf8_prefix(buffer: &mut Vec) -> Option> { + if buffer.is_empty() { + return None; + } + + let len = buffer.len(); + let mut split = len; + while split > 0 { + if std::str::from_utf8(&buffer[..split]).is_ok() { + let prefix = buffer[..split].to_vec(); + buffer.drain(..split); + return Some(prefix); + } + + if len - split > 4 { + break; + } + split -= 1; + } + + // If no valid UTF-8 prefix was found, emit the first byte so the stream + // keeps making progress and the transcript reflects all bytes. + let byte = buffer.drain(..1).collect(); + Some(byte) +} + +async fn resolve_aggregated_output( + transcript: &Arc>, + fallback: String, +) -> String { + let guard = transcript.lock().await; + if guard.data.is_empty() { + return fallback; + } + + String::from_utf8_lossy(&guard.data).to_string() +} diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 02a0f9ead..0d86b69fd 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -34,6 +34,7 @@ use tokio::sync::Mutex; use crate::codex::Session; use crate::codex::TurnContext; +mod async_watcher; mod errors; mod session; mod session_manager; @@ -51,6 +52,24 @@ pub(crate) const MAX_UNIFIED_EXEC_SESSIONS: usize = 64; // Send a warning message to the models when it reaches this number of sessions. pub(crate) const WARNING_UNIFIED_EXEC_SESSIONS: usize = 60; +#[derive(Debug, Default)] +pub(crate) struct CommandTranscript { + pub data: Vec, +} + +impl CommandTranscript { + pub fn append(&mut self, bytes: &[u8]) { + self.data.extend_from_slice(bytes); + if self.data.len() > UNIFIED_EXEC_OUTPUT_MAX_BYTES { + let excess = self + .data + .len() + .saturating_sub(UNIFIED_EXEC_OUTPUT_MAX_BYTES); + self.data.drain(..excess); + } + } +} + pub(crate) struct UnifiedExecContext { pub session: Arc, pub turn: Arc, @@ -92,18 +111,14 @@ pub(crate) struct UnifiedExecResponse { pub chunk_id: String, pub wall_time: Duration, pub output: String, + /// Raw bytes returned for this unified exec call before any truncation. + pub raw_output: Vec, pub process_id: Option, pub exit_code: Option, pub original_token_count: Option, pub session_command: Option>, } -#[derive(Default)] -pub(crate) struct UnifiedExecSessionManager { - session_store: Mutex, -} - -// Required for mutex sharing. #[derive(Default)] pub(crate) struct SessionStore { sessions: HashMap, @@ -115,22 +130,27 @@ impl SessionStore { self.reserved_sessions_id.remove(session_id); self.sessions.remove(session_id) } +} - pub(crate) fn clear(&mut self) { - self.reserved_sessions_id.clear(); - self.sessions.clear(); +pub(crate) struct UnifiedExecSessionManager { + session_store: Mutex, +} + +impl Default for UnifiedExecSessionManager { + fn default() -> Self { + Self { + session_store: Mutex::new(SessionStore::default()), + } } } struct SessionEntry { - session: UnifiedExecSession, + session: Arc, session_ref: Arc, turn_ref: Arc, call_id: String, process_id: String, command: Vec, - cwd: PathBuf, - started_at: tokio::time::Instant, last_used: tokio::time::Instant, } diff --git a/codex-rs/core/src/unified_exec/session.rs b/codex-rs/core/src/unified_exec/session.rs index 02465538e..51ebbd356 100644 --- a/codex-rs/core/src/unified_exec/session.rs +++ b/codex-rs/core/src/unified_exec/session.rs @@ -98,19 +98,22 @@ impl UnifiedExecSession { let cancellation_token_clone = cancellation_token.clone(); let output_task = tokio::spawn(async move { loop { - match receiver.recv().await { - Ok(chunk) => { - let mut guard = buffer_clone.lock().await; - guard.push_chunk(chunk); - drop(guard); - notify_clone.notify_waiters(); + tokio::select! { + _ = cancellation_token_clone.cancelled() => break, + result = receiver.recv() => match result { + Ok(chunk) => { + let mut guard = buffer_clone.lock().await; + guard.push_chunk(chunk); + drop(guard); + notify_clone.notify_waiters(); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + cancellation_token_clone.cancel(); + break; + } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - cancellation_token_clone.cancel(); - break; - } - } + }; } }); @@ -136,6 +139,14 @@ impl UnifiedExecSession { } } + pub(super) fn output_receiver(&self) -> tokio::sync::broadcast::Receiver> { + self.session.output_receiver() + } + + pub(super) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() + } + pub(super) fn has_exited(&self) -> bool { self.session.has_exited() } @@ -144,6 +155,12 @@ impl UnifiedExecSession { self.session.exit_code() } + pub(super) fn terminate(&self) { + self.session.terminate(); + self.cancellation_token.cancel(); + self.output_task.abort(); + } + async fn snapshot_output(&self) -> Vec> { let guard = self.output_buffer.lock().await; guard.snapshot() @@ -246,6 +263,6 @@ impl UnifiedExecSession { impl Drop for UnifiedExecSession { fn drop(&mut self) { - self.output_task.abort(); + self.terminate(); } } diff --git a/codex-rs/core/src/unified_exec/session_manager.rs b/codex-rs/core/src/unified_exec/session_manager.rs index af706b4b2..fa64eb4bb 100644 --- a/codex-rs/core/src/unified_exec/session_manager.rs +++ b/codex-rs/core/src/unified_exec/session_manager.rs @@ -13,18 +13,12 @@ use tokio_util::sync::CancellationToken; use crate::bash::extract_bash_command; use crate::codex::Session; use crate::codex::TurnContext; -use crate::exec::ExecToolCallOutput; -use crate::exec::StreamOutput; use crate::exec_env::create_env; use crate::exec_policy::create_exec_approval_requirement_for_command; use crate::protocol::BackgroundEventEvent; use crate::protocol::EventMsg; -use crate::protocol::ExecCommandSource; use crate::sandboxing::ExecEnv; use crate::sandboxing::SandboxPermissions; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventStage; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; @@ -33,6 +27,7 @@ use crate::truncate::TruncationPolicy; use crate::truncate::approx_token_count; use crate::truncate::formatted_truncate_text; +use super::CommandTranscript; use super::ExecCommandRequest; use super::MAX_UNIFIED_EXEC_SESSIONS; use super::SessionEntry; @@ -43,6 +38,9 @@ use super::UnifiedExecResponse; use super::UnifiedExecSessionManager; use super::WARNING_UNIFIED_EXEC_SESSIONS; use super::WriteStdinRequest; +use super::async_watcher::emit_exec_end_for_unified_exec; +use super::async_watcher::spawn_exit_watcher; +use super::async_watcher::start_streaming_output; use super::clamp_yield_time; use super::generate_chunk_id; use super::resolve_max_tokens; @@ -135,17 +133,23 @@ impl UnifiedExecSessionManager { .await; let session = match session { - Ok(session) => session, + Ok(session) => Arc::new(session), Err(err) => { self.release_process_id(&request.process_id).await; return Err(err); } }; + let transcript = Arc::new(tokio::sync::Mutex::new(CommandTranscript::default())); + start_streaming_output(&session, context, Arc::clone(&transcript)); + let max_tokens = resolve_max_tokens(request.max_output_tokens); let yield_time_ms = clamp_yield_time(request.yield_time_ms); let start = Instant::now(); + // For the initial exec_command call, we both stream output to events + // (via start_streaming_output above) and collect a snapshot here for + // the tool response body. let OutputHandles { output_buffer, output_notify, @@ -163,36 +167,44 @@ impl UnifiedExecSessionManager { let text = String::from_utf8_lossy(&collected).to_string(); let output = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); - let has_exited = session.has_exited(); let exit_code = session.exit_code(); + let has_exited = session.has_exited() || exit_code.is_some(); let chunk_id = generate_chunk_id(); let process_id = request.process_id.clone(); if has_exited { + // Short‑lived command: emit ExecCommandEnd immediately using the + // same helper as the background watcher, so all end events share + // one implementation. self.release_process_id(&request.process_id).await; let exit = exit_code.unwrap_or(-1); - Self::emit_exec_end_from_context( - context, - &request.command, + emit_exec_end_for_unified_exec( + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + request.command.clone(), cwd, + Some(process_id), + Arc::clone(&transcript), output.clone(), exit, wall_time, - // We always emit the process ID in order to keep consistency between the Begin - // event and the End event. - Some(process_id), ) .await; session.check_for_sandbox_denial_with_text(&text).await?; } else { - // Only store session if not exited. + // Long‑lived command: persist the session so write_stdin can reuse + // it, and register a background watcher that will emit + // ExecCommandEnd when the PTY eventually exits (even if no further + // tool calls are made). self.store_session( - session, + Arc::clone(&session), context, &request.command, cwd.clone(), start, process_id, + Arc::clone(&transcript), ) .await; @@ -205,6 +217,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id: if has_exited { None } else { @@ -238,6 +251,8 @@ impl UnifiedExecSessionManager { if !request.input.is_empty() { Self::send_input(&writer_tx, request.input.as_bytes()).await?; + // Give the remote process a brief window to react so that we are + // more likely to capture its output in the poll below. tokio::time::sleep(Duration::from_millis(100)).await; } @@ -259,16 +274,20 @@ impl UnifiedExecSessionManager { let original_token_count = approx_token_count(&text); let chunk_id = generate_chunk_id(); + // After polling, refresh_session_state tells us whether the PTY is + // still alive or has exited and been removed from the store; we thread + // that through so the handler can tag TerminalInteraction with an + // appropriate process_id and exit_code. let status = self.refresh_session_state(process_id.as_str()).await; - let (process_id, exit_code, completion_entry, event_call_id) = match status { + let (process_id, exit_code, event_call_id) = match status { SessionStatus::Alive { exit_code, call_id, process_id, - } => (Some(process_id), exit_code, None, call_id), + } => (Some(process_id), exit_code, call_id), SessionStatus::Exited { exit_code, entry } => { let call_id = entry.call_id.clone(); - (None, exit_code, Some(*entry), call_id) + (None, exit_code, call_id) } SessionStatus::Unknown => { return Err(UnifiedExecError::UnknownSessionId { @@ -282,6 +301,7 @@ impl UnifiedExecSessionManager { chunk_id, wall_time, output, + raw_output: collected, process_id, exit_code, original_token_count: Some(original_token_count), @@ -292,12 +312,6 @@ impl UnifiedExecSessionManager { Self::emit_waiting_status(&session_ref, &turn_ref, &session_command).await; } - if let (Some(exit), Some(entry)) = (response.exit_code, completion_entry) { - let total_duration = Instant::now().saturating_duration_since(entry.started_at); - Self::emit_exec_end_from_entry(entry, response.output.clone(), exit, total_duration) - .await; - } - Ok(response) } @@ -371,28 +385,27 @@ impl UnifiedExecSessionManager { #[allow(clippy::too_many_arguments)] async fn store_session( &self, - session: UnifiedExecSession, + session: Arc, context: &UnifiedExecContext, command: &[String], cwd: PathBuf, started_at: Instant, process_id: String, + transcript: Arc>, ) { let entry = SessionEntry { - session, + session: Arc::clone(&session), session_ref: Arc::clone(&context.session), turn_ref: Arc::clone(&context.turn), call_id: context.call_id.clone(), process_id: process_id.clone(), command: command.to_vec(), - cwd, - started_at, last_used: started_at, }; let number_sessions = { let mut store = self.session_store.lock().await; Self::prune_sessions_if_needed(&mut store); - store.sessions.insert(process_id, entry); + store.sessions.insert(process_id.clone(), entry); store.sessions.len() }; @@ -405,73 +418,18 @@ impl UnifiedExecSessionManager { ) .await; }; - } - async fn emit_exec_end_from_entry( - entry: SessionEntry, - aggregated_output: String, - exit_code: i32, - duration: Duration, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - entry.session_ref.as_ref(), - entry.turn_ref.as_ref(), - &entry.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - &entry.command, - entry.cwd, - ExecCommandSource::UnifiedExecStartup, - None, - Some(entry.process_id.clone()), - ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; - } - - async fn emit_exec_end_from_context( - context: &UnifiedExecContext, - command: &[String], - cwd: PathBuf, - aggregated_output: String, - exit_code: i32, - duration: Duration, - process_id: Option, - ) { - let output = ExecToolCallOutput { - exit_code, - stdout: StreamOutput::new(aggregated_output.clone()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - }; - let event_ctx = ToolEventCtx::new( - context.session.as_ref(), - context.turn.as_ref(), - &context.call_id, - None, - ); - let emitter = ToolEmitter::unified_exec( - command, + spawn_exit_watcher( + Arc::clone(&session), + Arc::clone(&context.session), + Arc::clone(&context.turn), + context.call_id.clone(), + command.to_vec(), cwd, - ExecCommandSource::UnifiedExecStartup, - None, process_id, + transcript, + started_at, ); - emitter - .emit(event_ctx, ToolEventStage::Success(output)) - .await; } async fn emit_waiting_status( @@ -567,7 +525,7 @@ impl UnifiedExecSessionManager { cancellation_token: &CancellationToken, deadline: Instant, ) -> Vec { - const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(25); + const POST_EXIT_OUTPUT_GRACE: Duration = Duration::from_millis(50); let mut collected: Vec = Vec::with_capacity(4096); let mut exit_signal_received = cancellation_token.is_cancelled(); @@ -634,7 +592,9 @@ impl UnifiedExecSessionManager { .collect(); if let Some(session_id) = Self::session_id_to_prune_from_meta(&meta) { - store.remove(&session_id); + if let Some(entry) = store.remove(&session_id) { + entry.session.terminate(); + } return true; } @@ -671,8 +631,17 @@ impl UnifiedExecSessionManager { } pub(crate) async fn terminate_all_sessions(&self) { - let mut sessions = self.session_store.lock().await; - sessions.clear(); + let entries: Vec = { + let mut sessions = self.session_store.lock().await; + let entries: Vec = + sessions.sessions.drain().map(|(_, entry)| entry).collect(); + sessions.reserved_sessions_id.clear(); + entries + }; + + for entry in entries { + entry.session.terminate(); + } } } diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 6a62e35df..5def7aadb 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -648,16 +648,16 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { }) .await?; - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => Some(ev.clone()), + let event = wait_for_event_match(&codex, |msg| match msg { + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => Some(ev.clone()), _ => None, }) .await; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + let text = event.stdout; assert!( text.contains("HELLO-UEXEC"), - "delta chunk missing expected text: {text:?}" + "delta chunk missing expected text: {text:?}", ); wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; @@ -665,7 +665,116 @@ async fn unified_exec_emits_output_delta_for_exec_command() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { +async fn unified_exec_full_lifecycle_with_background_end_event() -> 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 call_id = "uexec-full-lifecycle"; + let args = json!({ + "cmd": "printf 'HELLO-FULL-LIFECYCLE'", + "yield_time_ms": 50, + }); + + 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"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-1", "finished"), + ev_completed("resp-2"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "exercise full unified exec lifecycle".into(), + }], + 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, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut saw_delta_with_marker = 0; + + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev), + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == call_id => { + let text = String::from_utf8_lossy(&ev.chunk); + if text.contains("HELLO-FULL-LIFECYCLE") { + saw_delta_with_marker += 1; + } + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => { + assert!( + end_event.is_none(), + "expected a single ExecCommandEnd event for this call id" + ); + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); + assert_eq!(begin_event.call_id, call_id); + assert!( + begin_event.process_id.is_some(), + "begin event should include a process_id for a long-lived session" + ); + + assert_eq!( + saw_delta_with_marker, 0, + "no ExecCommandOutputDelta should be sent for early exit commands" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include process_id emitted by background watcher" + ); + assert!( + end_event.aggregated_output.contains("HELLO-FULL-LIFECYCLE"), + "aggregated_output should contain the full PTY transcript; got {:?}", + end_event.aggregated_output + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_emits_terminal_interaction_for_write_stdin() -> Result<()> { skip_if_no_network!(Ok(())); skip_if_sandbox!(Ok(())); skip_if_windows!(Ok(())); @@ -740,27 +849,210 @@ async fn unified_exec_emits_output_delta_for_write_stdin() -> Result<()> { }) .await?; - // Expect a delta event corresponding to the write_stdin call. - let delta = wait_for_event_match(&codex, |msg| match msg { - EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { - let text = String::from_utf8_lossy(&ev.chunk); - if text.contains("WSTDIN-MARK") { - Some(ev.clone()) - } else { - None - } - } - _ => None, - }) - .await; + let mut terminal_interaction = None; - let text = String::from_utf8_lossy(&delta.chunk).to_string(); + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_interaction = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let delta = terminal_interaction.expect("expected TerminalInteraction event"); + assert_eq!(delta.process_id, "1000"); + let expected_stdin = stdin_args + .get("chars") + .and_then(Value::as_str) + .expect("stdin chars"); + assert_eq!(delta.stdin, expected_stdin); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unified_exec_terminal_interaction_captures_delayed_output() -> 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 open_call_id = "uexec-delayed-open"; + let open_args = json!({ + "cmd": "sleep 5 && echo MARKER1 && sleep 5 && echo MARKER2", + "yield_time_ms": 10, + }); + + // Poll stdin three times: first for no output, second after the first marker, + // and a final long poll to capture the second marker. + let first_poll_call_id = "uexec-delayed-poll-1"; + let first_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10, + }); + + let second_poll_call_id = "uexec-delayed-poll-2"; + let second_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 6000, + }); + + let third_poll_call_id = "uexec-delayed-poll-3"; + let third_poll_args = json!({ + "chars": "", + "session_id": 1000, + "yield_time_ms": 10000, + }); + + let responses = vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + open_call_id, + "exec_command", + &serde_json::to_string(&open_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + first_poll_call_id, + "write_stdin", + &serde_json::to_string(&first_poll_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_function_call( + second_poll_call_id, + "write_stdin", + &serde_json::to_string(&second_poll_args)?, + ), + ev_completed("resp-3"), + ]), + sse(vec![ + ev_response_created("resp-4"), + ev_function_call( + third_poll_call_id, + "write_stdin", + &serde_json::to_string(&third_poll_args)?, + ), + ev_completed("resp-4"), + ]), + sse(vec![ + ev_response_created("resp-5"), + ev_assistant_message("msg-1", "complete"), + ev_completed("resp-5"), + ]), + ]; + mount_sse_sequence(&server, responses).await; + + let session_model = session_configured.model.clone(); + + codex + .submit(Op::UserTurn { + items: vec![UserInput::Text { + text: "delayed terminal interaction output".into(), + }], + 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, + }) + .await?; + + let mut begin_event = None; + let mut end_event = None; + let mut terminal_events = Vec::new(); + let mut delta_text = String::new(); + + // Consume all events for this turn so we can assert on each stage. + loop { + let msg = wait_for_event(&codex, |_| true).await; + match msg { + EventMsg::ExecCommandBegin(ev) if ev.call_id == open_call_id => { + begin_event = Some(ev); + } + EventMsg::ExecCommandOutputDelta(ev) if ev.call_id == open_call_id => { + delta_text.push_str(&String::from_utf8_lossy(&ev.chunk)); + } + EventMsg::TerminalInteraction(ev) if ev.call_id == open_call_id => { + terminal_events.push(ev); + } + EventMsg::ExecCommandEnd(ev) if ev.call_id == open_call_id => { + end_event = Some(ev); + } + EventMsg::TaskComplete(_) => break, + _ => {} + } + } + + let begin_event = begin_event.expect("expected ExecCommandBegin event"); assert!( - text.contains("WSTDIN-MARK"), - "stdin delta chunk missing expected text: {text:?}" + begin_event.process_id.is_some(), + "begin event should include process_id for a live session" + ); + + // We expect three terminal interactions matching the three write_stdin calls. + assert_eq!( + terminal_events.len(), + 3, + "expected three terminal interactions; got {terminal_events:?}" + ); + + for event in &terminal_events { + assert_eq!(event.call_id, open_call_id); + assert_eq!(event.process_id, "1000"); + } + assert_eq!( + terminal_events + .iter() + .map(|ev| ev.stdin.as_str()) + .collect::>(), + vec!["", "", ""], + "terminal interactions should reflect the three stdin polls" + ); + + assert!( + delta_text.contains("MARKER1") && delta_text.contains("MARKER2"), + "streamed deltas should contain both markers; got {delta_text:?}" + ); + + let end_event = end_event.expect("expected ExecCommandEnd event"); + assert_eq!(end_event.call_id, open_call_id); + assert_eq!(end_event.exit_code, 0); + assert!( + end_event.process_id.is_some(), + "end event should include the process_id" + ); + assert!( + end_event.aggregated_output.contains("MARKER1") + && end_event.aggregated_output.contains("MARKER2"), + "aggregated output should include both markers in order; got {:?}", + end_event.aggregated_output ); - wait_for_event(&codex, |event| matches!(event, EventMsg::TaskComplete(_))).await; Ok(()) } diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 64a5358f3..6eec8b71f 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -566,6 +566,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::WebSearchBegin(_) | EventMsg::ExecApprovalRequest(_) | EventMsg::ApplyPatchApprovalRequest(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::GetHistoryEntryResponse(_) | EventMsg::McpListToolsResponse(_) diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index 23dff015e..03c51662b 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -48,6 +48,7 @@ use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskStartedEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::WebSearchEndEvent; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -72,6 +73,7 @@ pub struct EventProcessorWithJsonOutput { struct RunningCommand { command: String, item_id: String, + aggregated_output: String, } #[derive(Debug, Clone)] @@ -109,6 +111,10 @@ impl EventProcessorWithJsonOutput { EventMsg::AgentReasoning(ev) => self.handle_reasoning_event(ev), EventMsg::ExecCommandBegin(ev) => self.handle_exec_command_begin(ev), EventMsg::ExecCommandEnd(ev) => self.handle_exec_command_end(ev), + EventMsg::TerminalInteraction(ev) => self.handle_terminal_interaction(ev), + EventMsg::ExecCommandOutputDelta(ev) => { + self.handle_output_chunk(&ev.call_id, &ev.chunk) + } EventMsg::McpToolCallBegin(ev) => self.handle_mcp_tool_call_begin(ev), EventMsg::McpToolCallEnd(ev) => self.handle_mcp_tool_call_end(ev), EventMsg::PatchApplyBegin(ev) => self.handle_patch_apply_begin(ev), @@ -172,6 +178,16 @@ impl EventProcessorWithJsonOutput { vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { item })] } + fn handle_output_chunk(&mut self, _call_id: &str, _chunk: &[u8]) -> Vec { + //TODO see how we want to process them + vec![] + } + + fn handle_terminal_interaction(&mut self, _ev: &TerminalInteractionEvent) -> Vec { + //TODO see how we want to process them + vec![] + } + fn handle_agent_message(&self, payload: &AgentMessageEvent) -> Vec { let item = ThreadItem { id: self.get_next_item_id(), @@ -214,6 +230,7 @@ impl EventProcessorWithJsonOutput { RunningCommand { command: command_string.clone(), item_id: item_id.clone(), + aggregated_output: String::new(), }, ); @@ -366,7 +383,11 @@ impl EventProcessorWithJsonOutput { } fn handle_exec_command_end(&mut self, ev: &ExecCommandEndEvent) -> Vec { - let Some(RunningCommand { command, item_id }) = self.running_commands.remove(&ev.call_id) + let Some(RunningCommand { + command, + item_id, + aggregated_output, + }) = self.running_commands.remove(&ev.call_id) else { warn!( call_id = ev.call_id, @@ -379,12 +400,17 @@ impl EventProcessorWithJsonOutput { } else { CommandExecutionStatus::Failed }; + let aggregated_output = if ev.aggregated_output.is_empty() { + aggregated_output + } else { + ev.aggregated_output.clone() + }; let item = ThreadItem { id: item_id, details: ThreadItemDetails::CommandExecution(CommandExecutionItem { command, - aggregated_output: ev.aggregated_output.clone(), + aggregated_output, exit_code: Some(ev.exit_code), status, }), @@ -455,6 +481,21 @@ impl EventProcessorWithJsonOutput { items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); } + if !self.running_commands.is_empty() { + for (_, running) in self.running_commands.drain() { + let item = ThreadItem { + id: running.item_id, + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: running.command, + aggregated_output: running.aggregated_output, + exit_code: None, + status: CommandExecutionStatus::Completed, + }), + }; + items.push(ThreadEvent::ItemCompleted(ItemCompletedEvent { item })); + } + } + if let Some(error) = self.last_critical_error.take() { items.push(ThreadEvent::TurnFailed(TurnFailedEvent { error })); } else { diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index 7b2d3c18b..2b3673f5a 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -48,6 +48,8 @@ use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::CodexErrorInfo; +use codex_protocol::protocol::ExecCommandOutputDeltaEvent; +use codex_protocol::protocol::ExecOutputStream; use mcp_types::CallToolResult; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -699,6 +701,93 @@ fn exec_command_end_success_produces_completed_command_item() { ); } +#[test] +fn command_execution_output_delta_updates_item_progress() { + let mut ep = EventProcessorWithJsonOutput::new(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo delta".to_string(), + ]; + let cwd = std::env::current_dir().unwrap(); + let parsed_cmd = Vec::new(); + + let begin = event( + "d1", + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command: command.clone(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.clone(), + source: ExecCommandSource::Agent, + interaction_input: None, + }), + ); + let out_begin = ep.collect_thread_events(&begin); + assert_eq!( + out_begin, + vec![ThreadEvent::ItemStarted(ItemStartedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: None, + status: CommandExecutionStatus::InProgress, + }), + }, + })] + ); + + let delta = event( + "d2", + EventMsg::ExecCommandOutputDelta(ExecCommandOutputDeltaEvent { + call_id: "delta-1".to_string(), + stream: ExecOutputStream::Stdout, + chunk: b"partial output\n".to_vec(), + }), + ); + let out_delta = ep.collect_thread_events(&delta); + assert_eq!(out_delta, Vec::::new()); + + let end = event( + "d3", + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "delta-1".to_string(), + process_id: Some("42".to_string()), + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: String::new(), + stderr: String::new(), + aggregated_output: String::new(), + exit_code: 0, + duration: Duration::from_millis(3), + formatted_output: String::new(), + }), + ); + let out_end = ep.collect_thread_events(&end); + assert_eq!( + out_end, + vec![ThreadEvent::ItemCompleted(ItemCompletedEvent { + item: ThreadItem { + id: "item_0".to_string(), + details: ThreadItemDetails::CommandExecution(CommandExecutionItem { + command: "bash -lc 'echo delta'".to_string(), + aggregated_output: String::new(), + exit_code: Some(0), + status: CommandExecutionStatus::Completed, + }), + }, + })] + ); +} + #[test] fn exec_command_end_failure_produces_failed_command_item() { let mut ep = EventProcessorWithJsonOutput::new(None); diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index aa895d8dd..908cba1cc 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -282,6 +282,7 @@ async fn run_codex_tool_session_inner( | EventMsg::McpListToolsResponse(_) | EventMsg::ListCustomPromptsResponse(_) | EventMsg::ExecCommandBegin(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 89b5fd315..1e40618f0 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -518,6 +518,9 @@ pub enum EventMsg { /// Incremental chunk of output from a running command. ExecCommandOutputDelta(ExecCommandOutputDeltaEvent), + /// Terminal interaction for an in-progress command (stdin sent and stdout observed). + TerminalInteraction(TerminalInteractionEvent), + ExecCommandEnd(ExecCommandEndEvent), /// Notification that the agent attached a local image via the view_image tool. @@ -1455,6 +1458,17 @@ pub struct ExecCommandOutputDeltaEvent { pub chunk: Vec, } +#[serde_as] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] +pub struct TerminalInteractionEvent { + /// Identifier for the ExecCommandBegin that produced this chunk. + pub call_id: String, + /// Process id associated with the running command. + pub process_id: String, + /// Stdin sent to the running session. + pub stdin: String, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct BackgroundEventEvent { pub message: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1d5ad24a0..dd623ed58 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -46,6 +46,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget; use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; +use codex_core::protocol::TerminalInteractionEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TokenUsageInfo; use codex_core::protocol::TurnAbortReason; @@ -825,6 +826,10 @@ impl ChatWidget { // TODO: Handle streaming exec output if/when implemented } + fn on_terminal_interaction(&mut self, _ev: TerminalInteractionEvent) { + // TODO: Handle once design is ready + } + fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { self.add_to_history(history_cell::new_patch_event( event.changes, @@ -1022,11 +1027,7 @@ impl ChatWidget { } let (command, parsed, source) = match running { Some(rc) => (rc.command, rc.parsed_cmd, rc.source), - None => ( - vec![ev.call_id.clone()], - Vec::new(), - ExecCommandSource::Agent, - ), + None => (ev.command.clone(), ev.parsed_cmd.clone(), ev.source), }; let is_unified_exec_interaction = matches!(source, ExecCommandSource::UnifiedExecInteraction); @@ -1043,7 +1044,7 @@ impl ChatWidget { command, parsed, source, - None, + ev.interaction_input.clone(), self.config.animations, ))); } @@ -1789,6 +1790,7 @@ impl ChatWidget { match msg { EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) + | EventMsg::TerminalInteraction(_) | EventMsg::ExecCommandOutputDelta(_) => {} _ => { tracing::trace!("handle_codex_event: {:?}", msg); @@ -1846,6 +1848,7 @@ impl ChatWidget { self.on_elicitation_request(ev); } EventMsg::ExecCommandBegin(ev) => self.on_exec_command_begin(ev), + EventMsg::TerminalInteraction(delta) => self.on_terminal_interaction(delta), EventMsg::ExecCommandOutputDelta(delta) => self.on_exec_command_output_delta(delta), EventMsg::PatchApplyBegin(ev) => self.on_patch_apply_begin(ev), EventMsg::PatchApplyEnd(ev) => self.on_patch_apply_end(ev), diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 5cc5321f3..fc1d25edf 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -1175,6 +1175,49 @@ fn exec_history_cell_shows_working_then_failed() { assert!(blob.to_lowercase().contains("bloop"), "expected error text"); } +#[test] +fn exec_end_without_begin_uses_event_command() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + "echo orphaned".to_string(), + ]; + let parsed_cmd = codex_core::parse_command::parse_command(&command); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + chat.handle_codex_event(Event { + id: "call-orphan".to_string(), + msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: "call-orphan".to_string(), + process_id: None, + turn_id: "turn-1".to_string(), + command, + cwd, + parsed_cmd, + source: ExecCommandSource::Agent, + interaction_input: None, + stdout: "done".to_string(), + stderr: String::new(), + aggregated_output: "done".to_string(), + exit_code: 0, + duration: std::time::Duration::from_millis(5), + formatted_output: "done".to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected finalized exec cell to flush"); + let blob = lines_to_single_string(&cells[0]); + assert!( + blob.contains("• Ran echo orphaned"), + "expected command text to come from event: {blob:?}" + ); + assert!( + !blob.contains("call-orphan"), + "call id should not be rendered when event has the command: {blob:?}" + ); +} + #[test] fn exec_history_shows_unified_exec_startup_commands() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None); diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index dbaf4b81f..cd98107ca 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -94,10 +94,8 @@ impl ExecCommandSession { pub fn exit_code(&self) -> Option { self.exit_code.lock().ok().and_then(|guard| *guard) } -} -impl Drop for ExecCommandSession { - fn drop(&mut self) { + pub fn terminate(&self) { if let Ok(mut killer_opt) = self.killer.lock() { if let Some(mut killer) = killer_opt.take() { let _ = killer.kill(); @@ -122,6 +120,12 @@ impl Drop for ExecCommandSession { } } +impl Drop for ExecCommandSession { + fn drop(&mut self) { + self.terminate(); + } +} + #[derive(Debug)] pub struct SpawnedPty { pub session: ExecCommandSession,