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 a3a29852b..ff2699eed 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -41,6 +41,7 @@ use owo_colors::Style; use serde::Deserialize; use shlex::try_join; use std::collections::HashMap; +use std::io::IsTerminal; use std::io::Write; use std::path::PathBuf; use std::time::Duration; @@ -869,12 +870,17 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); } - // If the user has not piped the final message to a file, they will see - // it twice: once written to stderr as part of the normal event - // processing, and once here on stdout. We print the token summary above - // to help break up the output visually in that case. + // In interactive terminals we already emitted the final assistant + // message on stderr during event processing. Preserve stdout emission + // only for non-interactive use so pipes and scripts still receive the + // final message. #[allow(clippy::print_stdout)] - if let Some(message) = &self.final_message { + if should_print_final_message_to_stdout( + self.final_message.as_deref(), + std::io::stdout().is_terminal(), + std::io::stderr().is_terminal(), + ) && let Some(message) = &self.final_message + { if message.ends_with('\n') { print!("{message}"); } else { @@ -1025,6 +1031,14 @@ impl EventProcessorWithHumanOutput { } } +fn should_print_final_message_to_stdout( + final_message: Option<&str>, + stdout_is_terminal: bool, + stderr_is_terminal: bool, +) -> bool { + final_message.is_some() && !(stdout_is_terminal && stderr_is_terminal) +} + struct AgentJobProgressStats { processed: usize, total: usize, @@ -1192,3 +1206,41 @@ fn format_mcp_invocation(invocation: &McpInvocation) -> String { format!("{fq_tool_name}({args_str})") } } + +#[cfg(test)] +mod tests { + use super::should_print_final_message_to_stdout; + use pretty_assertions::assert_eq; + + #[test] + fn suppresses_final_stdout_message_when_both_streams_are_terminals() { + assert_eq!( + should_print_final_message_to_stdout(Some("hello"), true, true), + false + ); + } + + #[test] + fn prints_final_stdout_message_when_stdout_is_not_terminal() { + assert_eq!( + should_print_final_message_to_stdout(Some("hello"), false, true), + true + ); + } + + #[test] + fn prints_final_stdout_message_when_stderr_is_not_terminal() { + assert_eq!( + should_print_final_message_to_stdout(Some("hello"), true, false), + true + ); + } + + #[test] + fn does_not_print_when_message_is_missing() { + assert_eq!( + should_print_final_message_to_stdout(None, false, false), + false + ); + } +}