feat: codex exec writes only the final message to stdout (#4644)

This updates `codex exec` so that, by default, most of the agent's
activity is written to stderr so that only the final agent message is
written to stdout. This makes it easier to pipe `codex exec` into
another tool without extra filtering.

I introduced `#![deny(clippy::print_stdout)]` to help enforce this
change and renamed the `ts_println!()` macro to `ts_msg()` because (1)
it no longer calls `println!()` and (2), `ts_eprintln!()` seemed too
long of a name.

While here, this also adds `-o` as an alias for `--output-last-message`.

Fixes https://github.com/openai/codex/issues/1670
This commit is contained in:
Michael Bolin
2025-10-03 09:22:12 -07:00
committed by GitHub
parent 5af08e0719
commit 042d4d55d9
6 changed files with 99 additions and 67 deletions

View File

@@ -72,7 +72,7 @@ pub struct Cli {
pub include_plan_tool: bool,
/// Specifies file where the last message from the agent should be written.
#[arg(long = "output-last-message")]
#[arg(long = "output-last-message", short = 'o', value_name = "FILE")]
pub last_message_file: Option<PathBuf>,
/// Initial instructions for the agent. If not provided as an argument (or

View File

@@ -60,6 +60,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
show_raw_agent_reasoning: bool,
last_message_path: Option<PathBuf>,
last_total_token_usage: Option<codex_core::protocol::TokenUsageInfo>,
final_message: Option<String>,
}
impl EventProcessorWithHumanOutput {
@@ -84,6 +85,7 @@ impl EventProcessorWithHumanOutput {
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
last_message_path,
last_total_token_usage: None,
final_message: None,
}
} else {
Self {
@@ -99,6 +101,7 @@ impl EventProcessorWithHumanOutput {
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
last_message_path,
last_total_token_usage: None,
final_message: None,
}
}
}
@@ -109,11 +112,10 @@ struct PatchApplyBegin {
auto_approved: bool,
}
// Timestamped println helper. The timestamp is styled with self.dimmed.
#[macro_export]
macro_rules! ts_println {
/// Timestamped helper. The timestamp is styled with self.dimmed.
macro_rules! ts_msg {
($self:ident, $($arg:tt)*) => {{
println!($($arg)*);
eprintln!($($arg)*);
}};
}
@@ -128,7 +130,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
session_configured_event: &SessionConfiguredEvent,
) {
const VERSION: &str = env!("CARGO_PKG_VERSION");
ts_println!(
ts_msg!(
self,
"OpenAI Codex v{} (research preview)\n--------",
VERSION
@@ -141,15 +143,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
));
for (key, value) in entries {
println!("{} {}", format!("{key}:").style(self.bold), value);
eprintln!("{} {}", format!("{key}:").style(self.bold), value);
}
println!("--------");
eprintln!("--------");
// Echo the prompt that will be sent to the agent so it is visible in the
// transcript/logs before any events come in. Note the prompt may have been
// read from stdin, so it may not be visible in the terminal otherwise.
ts_println!(self, "{}\n{}", "user".style(self.cyan), prompt);
ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt);
}
fn process_event(&mut self, event: Event) -> CodexStatus {
@@ -157,21 +159,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
match msg {
EventMsg::Error(ErrorEvent { message }) => {
let prefix = "ERROR:".style(self.red);
ts_println!(self, "{prefix} {message}");
ts_msg!(self, "{prefix} {message}");
}
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
ts_println!(self, "{}", message.style(self.dimmed));
ts_msg!(self, "{}", message.style(self.dimmed));
}
EventMsg::StreamError(StreamErrorEvent { message }) => {
ts_println!(self, "{}", message.style(self.dimmed));
ts_msg!(self, "{}", message.style(self.dimmed));
}
EventMsg::TaskStarted(_) => {
// Ignore.
}
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
let last_message = last_agent_message.as_deref();
if let Some(output_file) = self.last_message_path.as_deref() {
handle_last_message(last_agent_message.as_deref(), output_file);
handle_last_message(last_message, output_file);
}
self.final_message = last_agent_message;
return CodexStatus::InitiateShutdown;
}
EventMsg::TokenCount(ev) => {
@@ -182,11 +188,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
if !self.show_agent_reasoning {
return CodexStatus::Running;
}
println!();
eprintln!();
}
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
if self.show_raw_agent_reasoning {
ts_println!(
ts_msg!(
self,
"{}\n{}",
"thinking".style(self.italic).style(self.magenta),
@@ -195,7 +201,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
}
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
ts_println!(
ts_msg!(
self,
"{}\n{}",
"codex".style(self.italic).style(self.magenta),
@@ -203,7 +209,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
);
}
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
print!(
eprint!(
"{}\n{} in {}",
"exec".style(self.italic).style(self.magenta),
escape_command(&command).style(self.bold),
@@ -227,20 +233,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
match exit_code {
0 => {
let title = format!(" succeeded{duration}:");
ts_println!(self, "{}", title.style(self.green));
ts_msg!(self, "{}", title.style(self.green));
}
_ => {
let title = format!(" exited {exit_code}{duration}:");
ts_println!(self, "{}", title.style(self.red));
ts_msg!(self, "{}", title.style(self.red));
}
}
println!("{}", truncated_output.style(self.dimmed));
eprintln!("{}", truncated_output.style(self.dimmed));
}
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _,
invocation,
}) => {
ts_println!(
ts_msg!(
self,
"{} {}",
"tool".style(self.magenta),
@@ -265,7 +271,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
format_mcp_invocation(&invocation)
);
ts_println!(self, "{}", title.style(title_style));
ts_msg!(self, "{}", title.style(title_style));
if let Ok(res) = result {
let val: serde_json::Value = res.into();
@@ -273,13 +279,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
println!("{}", line.style(self.dimmed));
eprintln!("{}", line.style(self.dimmed));
}
}
}
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {}
EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
ts_println!(self, "🌐 Searched: {query}");
ts_msg!(self, "🌐 Searched: {query}");
}
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id,
@@ -296,7 +302,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
},
);
ts_println!(
ts_msg!(
self,
"{}",
"file update".style(self.magenta).style(self.italic),
@@ -312,9 +318,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
format_file_change(change),
path.to_string_lossy()
);
println!("{}", header.style(self.magenta));
eprintln!("{}", header.style(self.magenta));
for line in content.lines() {
println!("{}", line.style(self.green));
eprintln!("{}", line.style(self.green));
}
}
FileChange::Delete { content } => {
@@ -323,9 +329,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
format_file_change(change),
path.to_string_lossy()
);
println!("{}", header.style(self.magenta));
eprintln!("{}", header.style(self.magenta));
for line in content.lines() {
println!("{}", line.style(self.red));
eprintln!("{}", line.style(self.red));
}
}
FileChange::Update {
@@ -342,20 +348,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
} else {
format!("{} {}", format_file_change(change), path.to_string_lossy())
};
println!("{}", header.style(self.magenta));
eprintln!("{}", header.style(self.magenta));
// Colorize diff lines. We keep file header lines
// (--- / +++) without extra coloring so they are
// still readable.
for diff_line in unified_diff.lines() {
if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
println!("{}", diff_line.style(self.green));
eprintln!("{}", diff_line.style(self.green));
} else if diff_line.starts_with('-')
&& !diff_line.starts_with("---")
{
println!("{}", diff_line.style(self.red));
eprintln!("{}", diff_line.style(self.red));
} else {
println!("{diff_line}");
eprintln!("{diff_line}");
}
}
}
@@ -392,18 +398,18 @@ impl EventProcessor for EventProcessorWithHumanOutput {
};
let title = format!("{label} exited {exit_code}{duration}:");
ts_println!(self, "{}", title.style(title_style));
ts_msg!(self, "{}", title.style(title_style));
for line in output.lines() {
println!("{}", line.style(self.dimmed));
eprintln!("{}", line.style(self.dimmed));
}
}
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
ts_println!(
ts_msg!(
self,
"{}",
"file update:".style(self.magenta).style(self.italic)
);
println!("{unified_diff}");
eprintln!("{unified_diff}");
}
EventMsg::ExecApprovalRequest(_) => {
// Should we exit?
@@ -413,7 +419,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::AgentReasoning(agent_reasoning_event) => {
if self.show_agent_reasoning {
ts_println!(
ts_msg!(
self,
"{}\n{}",
"thinking".style(self.italic).style(self.magenta),
@@ -432,40 +438,40 @@ impl EventProcessor for EventProcessorWithHumanOutput {
rollout_path: _,
} = session_configured_event;
ts_println!(
ts_msg!(
self,
"{} {}",
"codex session".style(self.magenta).style(self.bold),
conversation_id.to_string().style(self.dimmed)
);
ts_println!(self, "model: {}", model);
println!();
ts_msg!(self, "model: {}", model);
eprintln!();
}
EventMsg::PlanUpdate(plan_update_event) => {
let UpdatePlanArgs { explanation, plan } = plan_update_event;
// Header
ts_println!(self, "{}", "Plan update".style(self.magenta));
ts_msg!(self, "{}", "Plan update".style(self.magenta));
// Optional explanation
if let Some(explanation) = explanation
&& !explanation.trim().is_empty()
{
ts_println!(self, "{}", explanation.style(self.italic));
ts_msg!(self, "{}", explanation.style(self.italic));
}
// Pretty-print the plan items with simple status markers.
for item in plan {
match item.status {
StepStatus::Completed => {
ts_println!(self, " {} {}", "".style(self.green), item.step);
ts_msg!(self, " {} {}", "".style(self.green), item.step);
}
StepStatus::InProgress => {
ts_println!(self, " {} {}", "".style(self.cyan), item.step);
ts_msg!(self, " {} {}", "".style(self.cyan), item.step);
}
StepStatus::Pending => {
ts_println!(
ts_msg!(
self,
" {} {}",
"".style(self.dimmed),
@@ -485,7 +491,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
// Currently ignored in exec output.
}
EventMsg::ViewImageToolCall(view) => {
ts_println!(
ts_msg!(
self,
"{} {}",
"viewed image".style(self.magenta),
@@ -494,13 +500,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
TurnAbortReason::Interrupted => {
ts_println!(self, "task interrupted");
ts_msg!(self, "task interrupted");
}
TurnAbortReason::Replaced => {
ts_println!(self, "task aborted: replaced by a new task");
ts_msg!(self, "task aborted: replaced by a new task");
}
TurnAbortReason::ReviewEnded => {
ts_println!(self, "task aborted: review ended");
ts_msg!(self, "task aborted: review ended");
}
},
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
@@ -517,13 +523,25 @@ impl EventProcessor for EventProcessorWithHumanOutput {
fn print_final_output(&mut self) {
if let Some(usage_info) = &self.last_total_token_usage {
ts_println!(
self,
eprintln!(
"{}\n{}",
"tokens used".style(self.magenta).style(self.italic),
format_with_separators(usage_info.total_token_usage.blended_total())
);
}
// 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.
#[allow(clippy::print_stdout)]
if let Some(message) = &self.final_message {
if message.ends_with('\n') {
print!("{message}");
} else {
println!("{message}");
}
}
}
}

View File

@@ -428,6 +428,7 @@ impl EventProcessor for EventProcessorWithJsonOutput {
});
}
#[allow(clippy::print_stdout)]
fn process_event(&mut self, event: Event) -> CodexStatus {
let aggregated = self.collect_thread_events(&event);
for conv_event in aggregated {

View File

@@ -1,3 +1,9 @@
// - In the default output mode, it is paramount that the only thing written to
// stdout is the final message (if any).
// - In --json mode, stdout must be valid JSONL, one event per line.
// For both modes, any other output must be written to stderr.
#![deny(clippy::print_stdout)]
mod cli;
mod event_processor;
mod event_processor_with_human_output;

View File

@@ -229,14 +229,14 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
assert!(output.status.success(), "resume run failed: {output:?}");
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
assert!(
stdout.contains("model: gpt-5-high"),
"stdout missing model override: {stdout}"
stderr.contains("model: gpt-5-high"),
"stderr missing model override: {stderr}"
);
assert!(
stdout.contains("sandbox: workspace-write"),
"stdout missing sandbox override: {stdout}"
stderr.contains("sandbox: workspace-write"),
"stderr missing sandbox override: {stderr}"
);
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)

View File

@@ -10,12 +10,18 @@ In non-interactive mode, Codex does not ask for command or edit approvals. By de
Use `codex exec --full-auto` to allow file edits. Use `codex exec --sandbox danger-full-access` to allow edits and networked commands.
### Default output mode
By default, Codex streams its activity to stderr and only writes the final message from the agent to stdout. This makes it easier to pipe `codex exec` into another tool without extra filtering.
To write the output of `codex exec` to a file, in addition to using a shell redirect like `>`, there is also a dedicated flag to specify an output file: `-o`/`--output-last-message`.
### JSON output mode
`codex exec` supports a `--json` mode that streams events to stdout as JSON Lines (JSONL) while the agent runs.
Supported event types:
- `thread.started` - when a thread is started or resumed.
- `turn.started` - when a turn starts. A turn encompasses all events between the user message and the assistant response.
- `turn.completed` - when a turn completes; includes token usage.
@@ -23,6 +29,7 @@ Supported event types:
- `item.started`/`item.updated`/`item.completed` - when a thread item is added/updated/completed.
Supported item types:
- `assistant_message` - assistant message.
- `reasoning` - a summary of the assistant's thinking.
- `command_execution` - assistant executing a command.
@@ -33,6 +40,7 @@ Supported item types:
Typically, an `assistant_message` is added at the end of the turn.
Sample output:
```jsonl
{"type":"thread.started","thread_id":"0199a213-81c0-7800-8aa1-bbab2a035a53"}
{"type":"turn.started"}
@@ -54,13 +62,13 @@ Sample schema:
```json
{
"type": "object",
"properties": {
"project_name": { "type": "string" },
"programming_languages": { "type": "array", "items": { "type": "string" } }
},
"required": ["project_name", "programming_languages"],
"additionalProperties": false
"type": "object",
"properties": {
"project_name": { "type": "string" },
"programming_languages": { "type": "array", "items": { "type": "string" } }
},
"required": ["project_name", "programming_languages"],
"additionalProperties": false
}
```
@@ -77,17 +85,16 @@ Combine `--output-schema` with `-o` to only print the final JSON output. You can
Codex requires a Git repository to avoid destructive changes. To disable this check, use `codex exec --skip-git-repo-check`.
### Resuming non-interactive sessions
Resume a previous non-interactive session with `codex exec resume <SESSION_ID>` or `codex exec resume --last`. This preserves conversation context so you can ask follow-up questions or give new tasks to the agent.
Resume a previous non-interactive session with `codex exec resume <SESSION_ID>` or `codex exec resume --last`. This preserves conversation context so you can ask follow-up questions or give new tasks to the agent.
```shell
codex exec "Review the change, look for use-after-free issues"
codex exec resume --last "Fix use-after-free issues"
```
Only the conversation context is preserved; you must still provide flags to customize Codex behavior.
Only the conversation context is preserved; you must still provide flags to customize Codex behavior.
```shell
codex exec --model gpt-5-codex --json "Review the change, look for use-after-free issues"