Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions codex-rs/exec/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,17 @@ pub struct Cli {
pub command: Option<Command>,

/// 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<PathBuf>,

/// Model the agent should use.
#[arg(long, short = 'm')]
#[arg(long, short = 'm', global = true)]
pub model: Option<String>,

/// Use open-source provider.
Expand All @@ -37,7 +43,7 @@ pub struct Cli {
pub config_profile: Option<String>,

/// 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.
Expand All @@ -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,
Expand All @@ -55,7 +62,7 @@ pub struct Cli {
pub cwd: Option<PathBuf>,

/// 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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<PathBuf>,

/// 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<String>,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/exec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let prompt_text = resolve_prompt(prompt_arg);
let mut items: Vec<UserInput> = imgs
.into_iter()
.chain(args.images.into_iter())
.map(|path| UserInput::LocalImage { path })
.collect();
items.push(UserInput::Text {
Expand Down
130 changes: 130 additions & 0 deletions codex-rs/exec/tests/suite/resume.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Value, _> = 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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(())
}
Loading