diff --git a/codex-rs/README.md b/codex-rs/README.md index ad8a0506f..6307668f3 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -50,7 +50,7 @@ You can enable notifications by configuring a script that is run whenever the ag ### `codex exec` to run Codex programmatically/non-interactively -To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. +To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. If you provide both a prompt argument and piped stdin, Codex appends stdin as a `` block after the prompt so patterns like `echo "my output" | codex exec "Summarize this concisely"` work naturally. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk. ### Experimenting with the Codex Sandbox diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index c40237160..2b5932dd5 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -105,7 +105,8 @@ pub struct Cli { pub last_message_file: Option, /// Initial instructions for the agent. If not provided as an argument (or - /// if `-` is used), instructions are read from stdin. + /// if `-` is used), instructions are read from stdin. If stdin is piped and + /// a prompt is also provided, stdin is appended as a `` block. #[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)] pub prompt: Option, } diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 0a5362851..84f1685d9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -120,6 +120,18 @@ enum InitialOperation { }, } +enum StdinPromptBehavior { + /// Read stdin only when there is no positional prompt, which is the legacy + /// `codex exec` behavior for `codex exec` with piped input. + RequiredIfPiped, + /// Always treat stdin as the prompt, used for the explicit `codex exec -` + /// sentinel and similar forced-stdin call sites. + Forced, + /// If stdin is piped alongside a positional prompt, treat stdin as + /// additional context to append rather than as the primary prompt. + OptionalAppend, +} + struct RequestIdSequencer { next: i64, } @@ -615,7 +627,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> { ) } (None, root_prompt, imgs) => { - let prompt_text = resolve_prompt(root_prompt); + let prompt_text = resolve_root_prompt(root_prompt); let mut items: Vec = imgs .into_iter() .map(|path| UserInput::LocalImage { path }) @@ -1528,46 +1540,92 @@ fn decode_utf16( String::from_utf16(&units).map_err(|_| PromptDecodeError::InvalidUtf16 { encoding }) } +fn read_prompt_from_stdin(behavior: StdinPromptBehavior) -> Option { + let stdin_is_terminal = std::io::stdin().is_terminal(); + + match behavior { + StdinPromptBehavior::RequiredIfPiped if stdin_is_terminal => { + eprintln!( + "No prompt provided. Either specify one as an argument or pipe the prompt into stdin." + ); + std::process::exit(1); + } + StdinPromptBehavior::RequiredIfPiped => { + eprintln!("Reading prompt from stdin..."); + } + StdinPromptBehavior::Forced => {} + StdinPromptBehavior::OptionalAppend if stdin_is_terminal => return None, + StdinPromptBehavior::OptionalAppend => { + eprintln!("Reading additional input from stdin..."); + } + } + + let mut bytes = Vec::new(); + if let Err(e) = std::io::stdin().read_to_end(&mut bytes) { + eprintln!("Failed to read prompt from stdin: {e}"); + std::process::exit(1); + } + + let buffer = match decode_prompt_bytes(&bytes) { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to read prompt from stdin: {e}"); + std::process::exit(1); + } + }; + + if buffer.trim().is_empty() { + match behavior { + StdinPromptBehavior::OptionalAppend => None, + StdinPromptBehavior::RequiredIfPiped | StdinPromptBehavior::Forced => { + eprintln!("No prompt provided via stdin."); + std::process::exit(1); + } + } + } else { + Some(buffer) + } +} + +fn prompt_with_stdin_context(prompt: &str, stdin_text: &str) -> String { + let mut combined = format!("{prompt}\n\n\n{stdin_text}"); + if !stdin_text.ends_with('\n') { + combined.push('\n'); + } + combined.push_str(""); + combined +} + fn resolve_prompt(prompt_arg: Option) -> String { match prompt_arg { Some(p) if p != "-" => p, maybe_dash => { - let force_stdin = matches!(maybe_dash.as_deref(), Some("-")); - - if std::io::stdin().is_terminal() && !force_stdin { - eprintln!( - "No prompt provided. Either specify one as an argument or pipe the prompt into stdin." - ); - std::process::exit(1); - } - - if !force_stdin { - eprintln!("Reading prompt from stdin..."); - } - - let mut bytes = Vec::new(); - if let Err(e) = std::io::stdin().read_to_end(&mut bytes) { - eprintln!("Failed to read prompt from stdin: {e}"); - std::process::exit(1); - } - - let buffer = match decode_prompt_bytes(&bytes) { - Ok(s) => s, - Err(e) => { - eprintln!("Failed to read prompt from stdin: {e}"); - std::process::exit(1); - } + let behavior = if matches!(maybe_dash.as_deref(), Some("-")) { + StdinPromptBehavior::Forced + } else { + StdinPromptBehavior::RequiredIfPiped }; - - if buffer.trim().is_empty() { - eprintln!("No prompt provided via stdin."); - std::process::exit(1); - } - buffer + let Some(prompt) = read_prompt_from_stdin(behavior) else { + unreachable!("required stdin prompt should produce content"); + }; + prompt } } } +fn resolve_root_prompt(prompt_arg: Option) -> String { + match prompt_arg { + Some(prompt) if prompt != "-" => { + if let Some(stdin_text) = read_prompt_from_stdin(StdinPromptBehavior::OptionalAppend) { + prompt_with_stdin_context(&prompt, &stdin_text) + } else { + prompt + } + } + maybe_dash => resolve_prompt(maybe_dash), + } +} + fn build_review_request(args: &ReviewArgs) -> anyhow::Result { let target = if args.uncommitted { ReviewTarget::UncommittedChanges @@ -1778,6 +1836,26 @@ mod tests { assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 }); } + #[test] + fn prompt_with_stdin_context_wraps_stdin_block() { + let combined = prompt_with_stdin_context("Summarize this concisely", "my output"); + + assert_eq!( + combined, + "Summarize this concisely\n\n\nmy output\n" + ); + } + + #[test] + fn prompt_with_stdin_context_preserves_trailing_newline() { + let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n"); + + assert_eq!( + combined, + "Summarize this concisely\n\n\nmy output\n" + ); + } + #[test] fn lagged_event_warning_message_is_explicit() { assert_eq!( diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index d86d07977..c6fa0f9fd 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -6,6 +6,7 @@ mod ephemeral; mod mcp_required_exit; mod originator; mod output_schema; +mod prompt_stdin; mod resume; mod sandbox; mod server_error_exit; diff --git a/codex-rs/exec/tests/suite/prompt_stdin.rs b/codex-rs/exec/tests/suite/prompt_stdin.rs new file mode 100644 index 000000000..1f9244b6c --- /dev/null +++ b/codex-rs/exec/tests/suite/prompt_stdin.rs @@ -0,0 +1,171 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; +use predicates::str::contains; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_appends_piped_stdin_to_prompt_argument() -> anyhow::Result<()> { + let test = test_codex_exec(); + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + // echo "my output" | codex exec --skip-git-repo-check -C -m gpt-5.1 "Summarize this concisely" + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("-C") + .arg(test.cwd_path()) + .arg("-m") + .arg("gpt-5.1") + .arg("Summarize this concisely") + .write_stdin("my output\n") + .assert() + .success(); + + let request = response_mock.single_request(); + assert!( + request.has_message_with_input_texts("user", |texts| { + texts == ["Summarize this concisely\n\n\nmy output\n".to_string()] + }), + "request should include a user message with the prompt plus piped stdin context" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_ignores_empty_piped_stdin_when_prompt_argument_is_present() -> anyhow::Result<()> { + let test = test_codex_exec(); + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + // printf "" | codex exec --skip-git-repo-check -C -m gpt-5.1 "Summarize this concisely" + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("-C") + .arg(test.cwd_path()) + .arg("-m") + .arg("gpt-5.1") + .arg("Summarize this concisely") + .write_stdin("") + .assert() + .success(); + + let request = response_mock.single_request(); + assert!( + request.has_message_with_input_texts("user", |texts| texts + == ["Summarize this concisely".to_string()]), + "request should preserve the prompt when stdin is empty" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_dash_prompt_reads_stdin_as_the_prompt() -> anyhow::Result<()> { + let test = test_codex_exec(); + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + // echo "prompt from stdin" | codex exec --skip-git-repo-check -C -m gpt-5.1 - + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("-C") + .arg(test.cwd_path()) + .arg("-m") + .arg("gpt-5.1") + .arg("-") + .write_stdin("prompt from stdin\n") + .assert() + .success(); + + let request = response_mock.single_request(); + assert!( + request.has_message_with_input_texts("user", |texts| { + texts == ["prompt from stdin\n".to_string()] + }), + "dash prompt should preserve the existing forced-stdin behavior" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn exec_without_prompt_argument_reads_piped_stdin_as_the_prompt() -> anyhow::Result<()> { + let test = test_codex_exec(); + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp1"), + responses::ev_assistant_message("m1", "fixture hello"), + responses::ev_completed("resp1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + // echo "prompt from stdin" | codex exec --skip-git-repo-check -C -m gpt-5.1 + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("-C") + .arg(test.cwd_path()) + .arg("-m") + .arg("gpt-5.1") + .write_stdin("prompt from stdin\n") + .assert() + .success(); + + let request = response_mock.single_request(); + assert!( + request.has_message_with_input_texts("user", |texts| { + texts == ["prompt from stdin\n".to_string()] + }), + "missing prompt argument should preserve the existing piped-stdin prompt behavior" + ); + + Ok(()) +} + +#[test] +fn exec_without_prompt_argument_rejects_empty_piped_stdin() { + let test = test_codex_exec(); + + // printf "" | codex exec --skip-git-repo-check -C + test.cmd() + .arg("--skip-git-repo-check") + .arg("-C") + .arg(test.cwd_path()) + .write_stdin("") + .assert() + .code(1) + .stderr(contains("No prompt provided via stdin.")); +} + +#[test] +fn exec_dash_prompt_rejects_empty_piped_stdin() { + let test = test_codex_exec(); + + // printf "" | codex exec --skip-git-repo-check -C - + test.cmd() + .arg("--skip-git-repo-check") + .arg("-C") + .arg(test.cwd_path()) + .arg("-") + .write_stdin("") + .assert() + .code(1) + .stderr(contains("No prompt provided via stdin.")); +}