diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 936cd4ef98..49f3b20ca4 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -84,6 +84,7 @@ use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; use crate::protocol::ExecCommandBeginEvent; use crate::protocol::ExecCommandEndEvent; +use crate::protocol::ExecCommandSummary; use crate::protocol::FileChange; use crate::protocol::InputItem; use crate::protocol::Op; @@ -422,6 +423,7 @@ impl Session { call_id: &str, output: &ExecToolCallOutput, is_apply_patch: bool, + cwd: &std::path::Path, ) { let ExecToolCallOutput { stdout, @@ -443,12 +445,46 @@ impl Session { success: *exit_code == 0, }) } else { + // Build an optional summary for non-zero exit codes to reduce log noise downstream + let summary = if *exit_code != 0 { + let stderr_tail: String = output + .stderr + .text + .chars() + .rev() + .take(1024) + .collect::() + .chars() + .rev() + .collect(); + let stdout_tail: String = output + .stdout + .text + .chars() + .rev() + .take(1024) + .collect::() + .chars() + .rev() + .collect(); + Some(ExecCommandSummary { + cwd: cwd.to_path_buf(), + stderr_tail, + stdout_tail, + stdout_truncated_after_lines: output.stdout.truncated_after_lines, + stderr_truncated_after_lines: output.stderr.truncated_after_lines, + }) + } else { + None + }; + EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: call_id.to_string(), stdout, stderr, duration: *duration, exit_code: *exit_code, + summary, }) }; @@ -518,6 +554,7 @@ impl Session { &call_id, borrowed, is_apply_patch, + &begin_ctx.cwd, ) .await; diff --git a/codex-rs/core/src/protocol.rs b/codex-rs/core/src/protocol.rs index 4972f10d98..8ad776f6c2 100644 --- a/codex-rs/core/src/protocol.rs +++ b/codex-rs/core/src/protocol.rs @@ -595,6 +595,26 @@ pub struct ExecCommandEndEvent { pub exit_code: i32, /// The duration of the command execution. pub duration: Duration, + /// Optional concise summary to aid logs when a command fails. + /// Includes cwd and tails of streams; present primarily for non-zero exits. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ExecCommandSummary { + /// The working directory used for the command. + pub cwd: std::path::PathBuf, + /// Last characters of stderr (up to implementation-defined limit). + pub stderr_tail: String, + /// Last characters of stdout (up to implementation-defined limit). + pub stdout_tail: String, + /// If output was truncated after a number of lines, this carries that value. + #[serde(skip_serializing_if = "Option::is_none")] + pub stdout_truncated_after_lines: Option, + /// If output was truncated after a number of lines, this carries that value. + #[serde(skip_serializing_if = "Option::is_none")] + pub stderr_truncated_after_lines: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/codex-rs/core/tests/exec_summary.rs b/codex-rs/core/tests/exec_summary.rs new file mode 100644 index 0000000000..4e033b8172 --- /dev/null +++ b/codex-rs/core/tests/exec_summary.rs @@ -0,0 +1,52 @@ +#![expect(clippy::unwrap_used)] + +use std::time::Duration; + +use codex_core::protocol::{ExecCommandEndEvent, ExecCommandSummary}; + +#[test] +fn exec_end_event_summary_serialization_roundtrip_some() { + let event = ExecCommandEndEvent { + call_id: "call-123".into(), + stdout: "out".into(), + stderr: "err".into(), + exit_code: 1, + duration: Duration::from_secs(1), + summary: Some(ExecCommandSummary { + cwd: std::path::PathBuf::from("/tmp"), + stderr_tail: "e".into(), + stdout_tail: "o".into(), + stdout_truncated_after_lines: Some(10), + stderr_truncated_after_lines: None, + }), + }; + + let json = serde_json::to_string(&event).unwrap(); + let de: ExecCommandEndEvent = serde_json::from_str(&json).unwrap(); + + assert_eq!(de.call_id, event.call_id); + assert_eq!(de.exit_code, 1); + assert!(de.summary.is_some()); + let s = de.summary.unwrap(); + assert_eq!(s.cwd, std::path::PathBuf::from("/tmp")); + assert_eq!(s.stdout_truncated_after_lines, Some(10)); + assert_eq!(s.stderr_truncated_after_lines, None); +} + +#[test] +fn exec_end_event_summary_serialization_roundtrip_none() { + let event = ExecCommandEndEvent { + call_id: "call-456".into(), + stdout: String::new(), + stderr: String::new(), + exit_code: 0, + duration: Duration::from_millis(0), + summary: None, + }; + + let json = serde_json::to_string(&event).unwrap(); + let de: ExecCommandEndEvent = serde_json::from_str(&json).unwrap(); + + assert_eq!(de.call_id, event.call_id); + assert!(de.summary.is_none()); +} 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 1d35dcb73f..91e37bc915 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -278,6 +278,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { stderr, duration, exit_code, + .. }) => { let exec_command = self.call_id_to_command.remove(&call_id); let (duration, call) = if let Some(ExecCommandBegin { command, .. }) = exec_command diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3d312ffce0..a7712cb8cb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -501,6 +501,7 @@ impl ChatWidget<'_> { duration: _, stdout, stderr, + .. }) => { // Compute summary before moving stdout into the history cell. let cmd = self.running_commands.remove(&call_id);