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.
This commit is contained in:
Rasmus Rygaard
2026-04-14 09:53:17 -07:00
committed by GitHub
parent 81c0bcc921
commit d013576f8b
5 changed files with 81 additions and 8 deletions

View File

@@ -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::<JSONRPCMessage>(line) {

View File

@@ -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<PathBuf>,
/// Optional model override when starting/resuming a thread.
#[arg(long)]
model: Option<String>,
@@ -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(

View File

@@ -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<Mutex<()>>,
prompt: Arc<Mutex<PromptState>>,
color: bool,
jsonl_file: Option<Arc<Mutex<File>>>,
}
impl Output {
pub fn new() -> Self {
pub fn new(jsonl_file: Option<File>) -> 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);
}
}

View File

@@ -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::<JSONRPCMessage>(line) else {