Compare commits

...

3 Commits

Author SHA1 Message Date
Joe Liccini
87746dbe7c Add stdin prompt regression coverage
Cover the legacy piped-stdin path, empty-stdin failures, and document the CLI forms exercised by each test.

Co-authored-by: Codex <noreply@openai.com>
2026-03-26 17:32:59 -07:00
Joe Liccini
59f5840a47 Fix clippy lint in prompt resolution
Co-authored-by: Codex <noreply@openai.com>
2026-03-26 17:05:15 -07:00
Joe Liccini
6bbcb7f6fe Support stdin from pipe as part of codex exec 2026-03-26 17:05:15 -07:00
5 changed files with 285 additions and 34 deletions

View File

@@ -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 `<stdin>` 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

View File

@@ -105,7 +105,8 @@ pub struct Cli {
pub last_message_file: Option<PathBuf>,
/// 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 `<stdin>` block.
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
pub prompt: Option<String>,
}

View File

@@ -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<UserInput> = 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<String> {
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() {
return match behavior {
StdinPromptBehavior::OptionalAppend => None,
StdinPromptBehavior::RequiredIfPiped | StdinPromptBehavior::Forced => {
eprintln!("No prompt provided via stdin.");
std::process::exit(1);
}
};
}
Some(buffer)
}
fn prompt_with_stdin_context(prompt: &str, stdin_text: &str) -> String {
let mut combined = format!("{prompt}\n\n<stdin>\n{stdin_text}");
if !stdin_text.ends_with('\n') {
combined.push('\n');
}
combined.push_str("</stdin>");
combined
}
fn resolve_prompt(prompt_arg: Option<String>) -> 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>) -> 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<ReviewRequest> {
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<stdin>\nmy output\n</stdin>"
);
}
#[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<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn lagged_event_warning_message_is_explicit() {
assert_eq!(

View File

@@ -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;

View File

@@ -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 <cwd> -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<stdin>\nmy output\n</stdin>".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 <cwd> -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 <cwd> -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 <cwd> -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 <cwd>
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 <cwd> -
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."));
}