From d013576f8bd8f03144881a3756332e3e1079283c Mon Sep 17 00:00:00 2001 From: Rasmus Rygaard Date: Tue, 14 Apr 2026 09:53:17 -0700 Subject: [PATCH] Redirect debug client output to a file (#17234) In the app-server debug client, allow redirecting output to a file in addition to just stdout. Shell redirecting works OK but is a bit weird with the interactive mode of the debug client since a bunch of newlines get dumped into the shell. With async messages from MCPs starting it's also tricky to actually type in a prompt. --- codex-rs/debug-client/README.md | 7 ++-- codex-rs/debug-client/src/client.rs | 4 +-- codex-rs/debug-client/src/main.rs | 19 +++++++++- codex-rs/debug-client/src/output.rs | 55 ++++++++++++++++++++++++++++- codex-rs/debug-client/src/reader.rs | 4 +-- 5 files changed, 81 insertions(+), 8 deletions(-) diff --git a/codex-rs/debug-client/README.md b/codex-rs/debug-client/README.md index bd310d905..447ed1236 100644 --- a/codex-rs/debug-client/README.md +++ b/codex-rs/debug-client/README.md @@ -12,7 +12,8 @@ Start the app-server client (it will spawn `codex app-server` itself): ``` cargo run -p codex-debug-client -- \ --codex-bin codex \ - --approval-policy on-request + --approval-policy on-request \ + --output-file /tmp/app-server-server-json.jsonl ``` You can resume a specific thread: @@ -29,6 +30,7 @@ cargo run -p codex-debug-client -- --thread-id thr_123 - `--approval-policy `: `untrusted`, `on-failure` (deprecated), `on-request`, `never`. - `--auto-approve`: auto-approve command/file-change approvals (default: decline). - `--final-only`: only show completed assistant messages and tool items. +- `--output-file `: write raw server JSONL to this file instead of stdout. - `--model `: optional model override for thread start/resume. - `--model-provider `: optional provider override. - `--cwd `: optional working directory override. @@ -46,7 +48,8 @@ Type a line to send it as a new turn. Commands are prefixed with `:`: The prompt shows the active thread id. Client messages (help, errors, approvals) print to stderr; raw server JSON prints to stdout so you can pipe/record it -unless `--final-only` is set. +unless `--final-only` is set. Pass `--output-file ` to record raw server +JSONL to a file instead of stdout. ## Notes diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 2ada10e37..8962a1f02 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -299,8 +299,8 @@ impl AppServerClient { } let line = buffer.trim_end_matches(['\n', '\r']); - if !line.is_empty() && !self.filtered_output { - let _ = output.server_line(line); + if !line.is_empty() { + let _ = output.server_json_line(line, self.filtered_output); } let message = match serde_json::from_str::(line) { diff --git a/codex-rs/debug-client/src/main.rs b/codex-rs/debug-client/src/main.rs index 6350331d6..a09d9d04f 100644 --- a/codex-rs/debug-client/src/main.rs +++ b/codex-rs/debug-client/src/main.rs @@ -4,8 +4,10 @@ mod output; mod reader; mod state; +use std::fs::File; use std::io; use std::io::BufRead; +use std::path::PathBuf; use std::sync::mpsc; use anyhow::Context; @@ -50,6 +52,10 @@ struct Cli { #[arg(long, default_value_t = false)] final_only: bool, + /// Write raw server JSONL to this file instead of stdout. + #[arg(long, value_name = "PATH")] + output_file: Option, + /// Optional model override when starting/resuming a thread. #[arg(long)] model: Option, @@ -65,7 +71,18 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); - let output = Output::new(); + let jsonl_file = cli + .output_file + .as_ref() + .map(File::create) + .transpose() + .with_context(|| { + let Some(path) = cli.output_file.as_ref() else { + return "open output file".to_string(); + }; + format!("open output file {}", path.display()) + })?; + let output = Output::new(jsonl_file); let approval_policy = parse_approval_policy(&cli.approval_policy)?; let mut client = AppServerClient::spawn( diff --git a/codex-rs/debug-client/src/output.rs b/codex-rs/debug-client/src/output.rs index ca3ac9d9c..f164a3ade 100644 --- a/codex-rs/debug-client/src/output.rs +++ b/codex-rs/debug-client/src/output.rs @@ -1,4 +1,5 @@ #![allow(clippy::expect_used)] +use std::fs::File; use std::io; use std::io::IsTerminal; use std::io::Write; @@ -24,19 +25,41 @@ pub struct Output { lock: Arc>, prompt: Arc>, color: bool, + jsonl_file: Option>>, } impl Output { - pub fn new() -> Self { + pub fn new(jsonl_file: Option) -> Self { let no_color = std::env::var_os("NO_COLOR").is_some(); let color = !no_color && io::stdout().is_terminal() && io::stderr().is_terminal(); Self { lock: Arc::new(Mutex::new(())), prompt: Arc::new(Mutex::new(PromptState::default())), color, + jsonl_file: jsonl_file.map(|file| Arc::new(Mutex::new(file))), } } + pub fn server_json_line(&self, line: &str, filtered_output: bool) -> io::Result<()> { + let _guard = self.lock.lock().expect("output lock poisoned"); + + if let Some(file) = self.jsonl_file.as_ref() { + let mut file = file.lock().expect("jsonl file lock poisoned"); + writeln!(file, "{line}")?; + file.flush()?; + } + + if self.jsonl_file.is_none() && !filtered_output { + self.clear_prompt_line_locked()?; + let mut stdout = io::stdout(); + writeln!(stdout, "{line}")?; + stdout.flush()?; + self.redraw_prompt_locked()?; + } + + Ok(()) + } + pub fn server_line(&self, line: &str) -> io::Result<()> { let _guard = self.lock.lock().expect("output lock poisoned"); self.clear_prompt_line_locked()?; @@ -120,3 +143,33 @@ impl Output { Ok(()) } } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn server_json_line_writes_to_configured_file() { + let path = std::env::temp_dir().join(format!( + "codex-debug-client-output-{}.jsonl", + std::process::id() + )); + let file = File::create(&path).expect("create output file"); + let output = Output::new(Some(file)); + + output + .server_json_line(r#"{"id":1}"#, false) + .expect("write unfiltered line"); + output + .server_json_line(r#"{"id":2}"#, true) + .expect("write filtered line"); + + assert_eq!( + fs::read_to_string(&path).expect("read output file"), + "{\"id\":1}\n{\"id\":2}\n" + ); + let _ = fs::remove_file(path); + } +} diff --git a/codex-rs/debug-client/src/reader.rs b/codex-rs/debug-client/src/reader.rs index ed401bb10..cb5164163 100644 --- a/codex-rs/debug-client/src/reader.rs +++ b/codex-rs/debug-client/src/reader.rs @@ -67,8 +67,8 @@ pub fn start_reader( } let line = buffer.trim_end_matches(['\n', '\r']); - if !line.is_empty() && !filtered_output { - let _ = output.server_line(line); + if !line.is_empty() { + let _ = output.server_json_line(line, filtered_output); } let Ok(message) = serde_json::from_str::(line) else {