Skip to content

Commit e180cd6

Browse files
committed
feat: codex exec writes only the final message to stdout
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 #1670
1 parent 231c36f commit e180cd6

File tree

6 files changed

+90
-67
lines changed

6 files changed

+90
-67
lines changed

codex-rs/exec/src/cli.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ pub struct Cli {
7272
pub include_plan_tool: bool,
7373

7474
/// Specifies file where the last message from the agent should be written.
75-
#[arg(long = "output-last-message")]
75+
#[arg(long = "output-last-message", short = 'o', value_name = "FILE")]
7676
pub last_message_file: Option<PathBuf>,
7777

7878
/// Initial instructions for the agent. If not provided as an argument (or

codex-rs/exec/src/event_processor_with_human_output.rs

Lines changed: 60 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,10 @@ struct PatchApplyBegin {
108108
auto_approved: bool,
109109
}
110110

111-
// Timestamped println helper. The timestamp is styled with self.dimmed.
112-
#[macro_export]
113-
macro_rules! ts_println {
111+
/// Timestamped helper. The timestamp is styled with self.dimmed.
112+
macro_rules! ts_msg {
114113
($self:ident, $($arg:tt)*) => {{
115-
println!($($arg)*);
114+
eprintln!($($arg)*);
116115
}};
117116
}
118117

@@ -127,7 +126,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
127126
session_configured_event: &SessionConfiguredEvent,
128127
) {
129128
const VERSION: &str = env!("CARGO_PKG_VERSION");
130-
ts_println!(
129+
ts_msg!(
131130
self,
132131
"OpenAI Codex v{} (research preview)\n--------",
133132
VERSION
@@ -140,37 +139,48 @@ impl EventProcessor for EventProcessorWithHumanOutput {
140139
));
141140

142141
for (key, value) in entries {
143-
println!("{} {}", format!("{key}:").style(self.bold), value);
142+
eprintln!("{} {}", format!("{key}:").style(self.bold), value);
144143
}
145144

146-
println!("--------");
145+
eprintln!("--------");
147146

148147
// Echo the prompt that will be sent to the agent so it is visible in the
149148
// transcript/logs before any events come in. Note the prompt may have been
150149
// read from stdin, so it may not be visible in the terminal otherwise.
151-
ts_println!(self, "{}\n{}", "user".style(self.cyan), prompt);
150+
ts_msg!(self, "{}\n{}", "user".style(self.cyan), prompt);
152151
}
153152

154153
fn process_event(&mut self, event: Event) -> CodexStatus {
155154
let Event { id: _, msg } = event;
156155
match msg {
157156
EventMsg::Error(ErrorEvent { message }) => {
158157
let prefix = "ERROR:".style(self.red);
159-
ts_println!(self, "{prefix} {message}");
158+
ts_msg!(self, "{prefix} {message}");
160159
}
161160
EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => {
162-
ts_println!(self, "{}", message.style(self.dimmed));
161+
ts_msg!(self, "{}", message.style(self.dimmed));
163162
}
164163
EventMsg::StreamError(StreamErrorEvent { message }) => {
165-
ts_println!(self, "{}", message.style(self.dimmed));
164+
ts_msg!(self, "{}", message.style(self.dimmed));
166165
}
167166
EventMsg::TaskStarted(_) => {
168167
// Ignore.
169168
}
170169
EventMsg::TaskComplete(TaskCompleteEvent { last_agent_message }) => {
170+
let last_message = last_agent_message.as_deref();
171171
if let Some(output_file) = self.last_message_path.as_deref() {
172-
handle_last_message(last_agent_message.as_deref(), output_file);
172+
handle_last_message(last_message, output_file);
173173
}
174+
175+
#[allow(clippy::print_stdout)]
176+
if let Some(message) = last_message {
177+
if message.ends_with('\n') {
178+
print!("{message}");
179+
} else {
180+
println!("{message}");
181+
}
182+
}
183+
174184
return CodexStatus::InitiateShutdown;
175185
}
176186
EventMsg::TokenCount(ev) => {
@@ -181,11 +191,11 @@ impl EventProcessor for EventProcessorWithHumanOutput {
181191
if !self.show_agent_reasoning {
182192
return CodexStatus::Running;
183193
}
184-
println!();
194+
eprintln!();
185195
}
186196
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
187197
if self.show_raw_agent_reasoning {
188-
ts_println!(
198+
ts_msg!(
189199
self,
190200
"{}\n{}",
191201
"thinking".style(self.italic).style(self.magenta),
@@ -194,15 +204,15 @@ impl EventProcessor for EventProcessorWithHumanOutput {
194204
}
195205
}
196206
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
197-
ts_println!(
207+
ts_msg!(
198208
self,
199209
"{}\n{}",
200210
"codex".style(self.italic).style(self.magenta),
201211
message,
202212
);
203213
}
204214
EventMsg::ExecCommandBegin(ExecCommandBeginEvent { command, cwd, .. }) => {
205-
print!(
215+
eprint!(
206216
"{}\n{} in {}",
207217
"exec".style(self.italic).style(self.magenta),
208218
escape_command(&command).style(self.bold),
@@ -226,20 +236,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
226236
match exit_code {
227237
0 => {
228238
let title = format!(" succeeded{duration}:");
229-
ts_println!(self, "{}", title.style(self.green));
239+
ts_msg!(self, "{}", title.style(self.green));
230240
}
231241
_ => {
232242
let title = format!(" exited {exit_code}{duration}:");
233-
ts_println!(self, "{}", title.style(self.red));
243+
ts_msg!(self, "{}", title.style(self.red));
234244
}
235245
}
236-
println!("{}", truncated_output.style(self.dimmed));
246+
eprintln!("{}", truncated_output.style(self.dimmed));
237247
}
238248
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
239249
call_id: _,
240250
invocation,
241251
}) => {
242-
ts_println!(
252+
ts_msg!(
243253
self,
244254
"{} {}",
245255
"tool".style(self.magenta),
@@ -264,21 +274,21 @@ impl EventProcessor for EventProcessorWithHumanOutput {
264274
format_mcp_invocation(&invocation)
265275
);
266276

267-
ts_println!(self, "{}", title.style(title_style));
277+
ts_msg!(self, "{}", title.style(title_style));
268278

269279
if let Ok(res) = result {
270280
let val: serde_json::Value = res.into();
271281
let pretty =
272282
serde_json::to_string_pretty(&val).unwrap_or_else(|_| val.to_string());
273283

274284
for line in pretty.lines().take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL) {
275-
println!("{}", line.style(self.dimmed));
285+
eprintln!("{}", line.style(self.dimmed));
276286
}
277287
}
278288
}
279289
EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id: _ }) => {}
280290
EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: _, query }) => {
281-
ts_println!(self, "🌐 Searched: {query}");
291+
ts_msg!(self, "🌐 Searched: {query}");
282292
}
283293
EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
284294
call_id,
@@ -295,7 +305,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
295305
},
296306
);
297307

298-
ts_println!(
308+
ts_msg!(
299309
self,
300310
"{}",
301311
"file update".style(self.magenta).style(self.italic),
@@ -311,9 +321,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
311321
format_file_change(change),
312322
path.to_string_lossy()
313323
);
314-
println!("{}", header.style(self.magenta));
324+
eprintln!("{}", header.style(self.magenta));
315325
for line in content.lines() {
316-
println!("{}", line.style(self.green));
326+
eprintln!("{}", line.style(self.green));
317327
}
318328
}
319329
FileChange::Delete { content } => {
@@ -322,9 +332,9 @@ impl EventProcessor for EventProcessorWithHumanOutput {
322332
format_file_change(change),
323333
path.to_string_lossy()
324334
);
325-
println!("{}", header.style(self.magenta));
335+
eprintln!("{}", header.style(self.magenta));
326336
for line in content.lines() {
327-
println!("{}", line.style(self.red));
337+
eprintln!("{}", line.style(self.red));
328338
}
329339
}
330340
FileChange::Update {
@@ -341,20 +351,20 @@ impl EventProcessor for EventProcessorWithHumanOutput {
341351
} else {
342352
format!("{} {}", format_file_change(change), path.to_string_lossy())
343353
};
344-
println!("{}", header.style(self.magenta));
354+
eprintln!("{}", header.style(self.magenta));
345355

346356
// Colorize diff lines. We keep file header lines
347357
// (--- / +++) without extra coloring so they are
348358
// still readable.
349359
for diff_line in unified_diff.lines() {
350360
if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
351-
println!("{}", diff_line.style(self.green));
361+
eprintln!("{}", diff_line.style(self.green));
352362
} else if diff_line.starts_with('-')
353363
&& !diff_line.starts_with("---")
354364
{
355-
println!("{}", diff_line.style(self.red));
365+
eprintln!("{}", diff_line.style(self.red));
356366
} else {
357-
println!("{diff_line}");
367+
eprintln!("{diff_line}");
358368
}
359369
}
360370
}
@@ -391,18 +401,18 @@ impl EventProcessor for EventProcessorWithHumanOutput {
391401
};
392402

393403
let title = format!("{label} exited {exit_code}{duration}:");
394-
ts_println!(self, "{}", title.style(title_style));
404+
ts_msg!(self, "{}", title.style(title_style));
395405
for line in output.lines() {
396-
println!("{}", line.style(self.dimmed));
406+
eprintln!("{}", line.style(self.dimmed));
397407
}
398408
}
399409
EventMsg::TurnDiff(TurnDiffEvent { unified_diff }) => {
400-
ts_println!(
410+
ts_msg!(
401411
self,
402412
"{}",
403413
"file update:".style(self.magenta).style(self.italic)
404414
);
405-
println!("{unified_diff}");
415+
eprintln!("{unified_diff}");
406416
}
407417
EventMsg::ExecApprovalRequest(_) => {
408418
// Should we exit?
@@ -412,7 +422,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
412422
}
413423
EventMsg::AgentReasoning(agent_reasoning_event) => {
414424
if self.show_agent_reasoning {
415-
ts_println!(
425+
ts_msg!(
416426
self,
417427
"{}\n{}",
418428
"thinking".style(self.italic).style(self.magenta),
@@ -431,41 +441,41 @@ impl EventProcessor for EventProcessorWithHumanOutput {
431441
rollout_path: _,
432442
} = session_configured_event;
433443

434-
ts_println!(
444+
ts_msg!(
435445
self,
436446
"{} {}",
437447
"codex session".style(self.magenta).style(self.bold),
438448
conversation_id.to_string().style(self.dimmed)
439449
);
440450

441-
ts_println!(self, "model: {}", model);
442-
println!();
451+
ts_msg!(self, "model: {}", model);
452+
eprintln!();
443453
}
444454
EventMsg::PlanUpdate(plan_update_event) => {
445455
let UpdatePlanArgs { explanation, plan } = plan_update_event;
446456

447457
// Header
448-
ts_println!(self, "{}", "Plan update".style(self.magenta));
458+
ts_msg!(self, "{}", "Plan update".style(self.magenta));
449459

450460
// Optional explanation
451461
if let Some(explanation) = explanation
452462
&& !explanation.trim().is_empty()
453463
{
454-
ts_println!(self, "{}", explanation.style(self.italic));
464+
ts_msg!(self, "{}", explanation.style(self.italic));
455465
}
456466

457467
// Pretty-print the plan items with simple status markers.
458468
for item in plan {
459469
use codex_core::plan_tool::StepStatus;
460470
match item.status {
461471
StepStatus::Completed => {
462-
ts_println!(self, " {} {}", "✓".style(self.green), item.step);
472+
ts_msg!(self, " {} {}", "✓".style(self.green), item.step);
463473
}
464474
StepStatus::InProgress => {
465-
ts_println!(self, " {} {}", "→".style(self.cyan), item.step);
475+
ts_msg!(self, " {} {}", "→".style(self.cyan), item.step);
466476
}
467477
StepStatus::Pending => {
468-
ts_println!(
478+
ts_msg!(
469479
self,
470480
" {} {}",
471481
"•".style(self.dimmed),
@@ -485,7 +495,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
485495
// Currently ignored in exec output.
486496
}
487497
EventMsg::ViewImageToolCall(view) => {
488-
ts_println!(
498+
ts_msg!(
489499
self,
490500
"{} {}",
491501
"viewed image".style(self.magenta),
@@ -494,13 +504,13 @@ impl EventProcessor for EventProcessorWithHumanOutput {
494504
}
495505
EventMsg::TurnAborted(abort_reason) => match abort_reason.reason {
496506
TurnAbortReason::Interrupted => {
497-
ts_println!(self, "task interrupted");
507+
ts_msg!(self, "task interrupted");
498508
}
499509
TurnAbortReason::Replaced => {
500-
ts_println!(self, "task aborted: replaced by a new task");
510+
ts_msg!(self, "task aborted: replaced by a new task");
501511
}
502512
TurnAbortReason::ReviewEnded => {
503-
ts_println!(self, "task aborted: review ended");
513+
ts_msg!(self, "task aborted: review ended");
504514
}
505515
},
506516
EventMsg::ShutdownComplete => return CodexStatus::Shutdown,
@@ -517,8 +527,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
517527

518528
fn print_final_output(&mut self) {
519529
if let Some(usage_info) = &self.last_total_token_usage {
520-
ts_println!(
521-
self,
530+
eprintln!(
522531
"{}\n{}",
523532
"tokens used".style(self.magenta).style(self.italic),
524533
format_with_separators(usage_info.total_token_usage.blended_total())

codex-rs/exec/src/event_processor_with_jsonl_output.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,7 @@ impl EventProcessor for EventProcessorWithJsonOutput {
428428
});
429429
}
430430

431+
#[allow(clippy::print_stdout)]
431432
fn process_event(&mut self, event: Event) -> CodexStatus {
432433
let aggregated = self.collect_thread_events(&event);
433434
for conv_event in aggregated {

codex-rs/exec/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
// - In the default output mode, it is paramount that the only thing written to
2+
// stdout is the final message (if any).
3+
// - In --json mode, stdout must be valid JSONL, one event per line.
4+
// For both modes, any other output must be written to stderr.
5+
#![deny(clippy::print_stdout)]
6+
17
mod cli;
28
mod event_processor;
39
mod event_processor_with_human_output;

codex-rs/exec/tests/suite/resume.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,14 +229,14 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> {
229229

230230
assert!(output.status.success(), "resume run failed: {output:?}");
231231

232-
let stdout = String::from_utf8(output.stdout)?;
232+
let stderr = String::from_utf8(output.stderr)?;
233233
assert!(
234-
stdout.contains("model: gpt-5-high"),
235-
"stdout missing model override: {stdout}"
234+
stderr.contains("model: gpt-5-high"),
235+
"stderr missing model override: {stderr}"
236236
);
237237
assert!(
238-
stdout.contains("sandbox: workspace-write"),
239-
"stdout missing sandbox override: {stdout}"
238+
stderr.contains("sandbox: workspace-write"),
239+
"stderr missing sandbox override: {stderr}"
240240
);
241241

242242
let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2)

0 commit comments

Comments
 (0)