mirror of
https://github.com/openai/codex.git
synced 2026-03-27 10:36:36 +03:00
Compare commits
3 Commits
pakrym/fix
...
dev/jlicci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87746dbe7c | ||
|
|
59f5840a47 | ||
|
|
6bbcb7f6fe |
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
171
codex-rs/exec/tests/suite/prompt_stdin.rs
Normal file
171
codex-rs/exec/tests/suite/prompt_stdin.rs
Normal 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."));
|
||||
}
|
||||
Reference in New Issue
Block a user