diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 392ebb0cd6d..8cff14f929a 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -11,11 +11,17 @@ pub struct Cli { pub command: Option, /// Optional image(s) to attach to the initial prompt. - #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] + #[arg( + long = "image", + short = 'i', + value_name = "FILE", + value_delimiter = ',', + num_args = 1.. + )] pub images: Vec, /// Model the agent should use. - #[arg(long, short = 'm')] + #[arg(long, short = 'm', global = true)] pub model: Option, /// Use open-source provider. @@ -37,7 +43,7 @@ pub struct Cli { pub config_profile: Option, /// Convenience alias for low-friction sandboxed automatic execution (-a on-request, --sandbox workspace-write). - #[arg(long = "full-auto", default_value_t = false)] + #[arg(long = "full-auto", default_value_t = false, global = true)] pub full_auto: bool, /// Skip all confirmation prompts and execute commands without sandboxing. @@ -46,6 +52,7 @@ pub struct Cli { long = "dangerously-bypass-approvals-and-sandbox", alias = "yolo", default_value_t = false, + global = true, conflicts_with = "full_auto" )] pub dangerously_bypass_approvals_and_sandbox: bool, @@ -55,7 +62,7 @@ pub struct Cli { pub cwd: Option, /// Allow running Codex outside a Git repository. - #[arg(long = "skip-git-repo-check", default_value_t = false)] + #[arg(long = "skip-git-repo-check", global = true, default_value_t = false)] pub skip_git_repo_check: bool, /// Additional directories that should be writable alongside the primary workspace. @@ -74,7 +81,12 @@ pub struct Cli { pub color: Color, /// Print events to stdout as JSONL. - #[arg(long = "json", alias = "experimental-json", default_value_t = false)] + #[arg( + long = "json", + alias = "experimental-json", + default_value_t = false, + global = true + )] pub json: bool, /// Specifies file where the last message from the agent should be written. @@ -107,6 +119,16 @@ pub struct ResumeArgs { #[arg(long = "last", default_value_t = false)] pub last: bool, + /// Optional image(s) to attach to the prompt sent after resuming. + #[arg( + long = "image", + short = 'i', + value_name = "FILE", + value_delimiter = ',', + num_args = 1 + )] + pub images: Vec, + /// Prompt to send after resuming the session. If `-` is used, read from stdin. #[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 93a481b630e..701c7b7972b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -335,6 +335,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let prompt_text = resolve_prompt(prompt_arg); let mut items: Vec = imgs .into_iter() + .chain(args.images.into_iter()) .map(|path| UserInput::LocalImage { path }) .collect(); items.push(UserInput::Text { diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index e37b38606a2..b6b750dc3e1 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use anyhow::Context; use core_test_support::test_codex_exec::test_codex_exec; +use pretty_assertions::assert_eq; use serde_json::Value; use std::path::Path; use std::string::ToString; @@ -69,6 +70,39 @@ fn extract_conversation_id(path: &std::path::Path) -> String { .to_string() } +fn last_user_image_count(path: &std::path::Path) -> usize { + let content = std::fs::read_to_string(path).unwrap_or_default(); + let mut last_count = 0; + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + let Ok(item): Result = serde_json::from_str(line) else { + continue; + }; + if item.get("type").and_then(|t| t.as_str()) != Some("response_item") { + continue; + } + let Some(payload) = item.get("payload") else { + continue; + }; + if payload.get("type").and_then(|t| t.as_str()) != Some("message") { + continue; + } + if payload.get("role").and_then(|r| r.as_str()) != Some("user") { + continue; + } + let Some(content_items) = payload.get("content").and_then(|v| v.as_array()) else { + continue; + }; + last_count = content_items + .iter() + .filter(|entry| entry.get("type").and_then(|t| t.as_str()) == Some("input_image")) + .count(); + } + last_count +} + #[test] fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { let test = test_codex_exec(); @@ -177,6 +211,41 @@ fn exec_resume_last_accepts_prompt_after_flag_in_json_mode() -> anyhow::Result<( Ok(()) } +#[test] +fn exec_resume_accepts_global_flags_after_subcommand() -> anyhow::Result<()> { + let test = test_codex_exec(); + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse"); + + // Seed a session. + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("echo seed-resume-session") + .assert() + .success(); + + // Resume while passing global flags after the subcommand to ensure clap accepts them. + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("resume") + .arg("--last") + .arg("--json") + .arg("--model") + .arg("gpt-5.2-codex") + .arg("--config") + .arg("reasoning_level=xhigh") + .arg("--dangerously-bypass-approvals-and-sandbox") + .arg("--skip-git-repo-check") + .arg("echo resume-with-global-flags-after-subcommand") + .assert() + .success(); + + Ok(()) +} + #[test] fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { let test = test_codex_exec(); @@ -309,3 +378,64 @@ fn exec_resume_preserves_cli_configuration_overrides() -> anyhow::Result<()> { assert!(content.contains(&marker2)); Ok(()) } + +#[test] +fn exec_resume_accepts_images_after_subcommand() -> anyhow::Result<()> { + let test = test_codex_exec(); + let fixture = + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/cli_responses_fixture.sse"); + + let marker = format!("resume-image-{}", Uuid::new_v4()); + let prompt = format!("echo {marker}"); + + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg(&prompt) + .assert() + .success(); + + let image_path = test.cwd_path().join("resume_image.png"); + let image_path_2 = test.cwd_path().join("resume_image_2.png"); + let image_bytes: &[u8] = &[ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, + 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, + 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, + 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + ]; + std::fs::write(&image_path, image_bytes)?; + std::fs::write(&image_path_2, image_bytes)?; + + let marker2 = format!("resume-image-2-{}", Uuid::new_v4()); + let prompt2 = format!("echo {marker2}"); + test.cmd() + .env("CODEX_RS_SSE_FIXTURE", &fixture) + .env("OPENAI_BASE_URL", "http://unused.local") + .arg("--skip-git-repo-check") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg("resume") + .arg("--last") + .arg("--image") + .arg(&image_path) + .arg("--image") + .arg(&image_path_2) + .arg(&prompt2) + .assert() + .success(); + + let sessions_dir = test.home_path().join("sessions"); + let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2) + .expect("no session file found after resume with images"); + let image_count = last_user_image_count(&resumed_path); + assert_eq!( + image_count, 2, + "resume prompt should include both attached images" + ); + + Ok(()) +}