diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b733477935..328bf78bf9 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -122,6 +122,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, } @@ -640,7 +652,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 }) @@ -1588,46 +1600,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